From fde935273929acce191512f685a6856c5fe2dfba Mon Sep 17 00:00:00 2001 From: dantovska Date: Tue, 9 Jun 2026 16:10:22 +0300 Subject: [PATCH 001/166] RI-7390 Promote dev-vectorSet flag to regular vectorSet flag (#6036) * feat(RI-7390): promote dev-vectorSet feature flag to vectorSet Rename the flag end-to-end (backend config, KnownFeatures enum, registry and strategy registration; frontend enum, slice default, selector and all consumers; E2E specs). Bump features-config.json to version 3.92. Flag stays default-off, so E2E specs that exercise Vector Set UI keep their explicit { vectorSet: true } override. * feat(RI-7390): enable vectorSet and prodMode for 10% rollout Flip both flags to flag: true with perc: [[0, 10]] in features-config.json and bump version to 3.93. --- redisinsight/api/config/features-config.json | 12 ++++++------ .../api/src/modules/feature/constants/index.ts | 2 +- .../src/modules/feature/constants/known-features.ts | 4 ++-- .../providers/feature-flag/feature-flag.provider.ts | 2 +- redisinsight/ui/src/constants/featureFlags.ts | 2 +- .../pages/browser/components/add-key/AddKey.spec.tsx | 4 ++-- .../components/add-key/constants/key-type-options.ts | 4 ++-- .../filter-key-type/FilterKeyType.spec.tsx | 4 ++-- .../browser/components/filter-key-type/constants.ts | 4 ++-- .../dynamic-type-details/DynamicTypeDetails.tsx | 6 +++--- redisinsight/ui/src/slices/app/features.ts | 6 +++--- .../parallel/browser/vector-set/add-elements.spec.ts | 2 +- .../browser/vector-set/add-key-manual.spec.ts | 2 +- .../browser/vector-set/add-key-sample-data.spec.ts | 2 +- .../browser/vector-set/element-actions.spec.ts | 2 +- .../tests/parallel/browser/vector-set/gating.spec.ts | 4 ++-- .../browser/vector-set/similarity-search.spec.ts | 2 +- 17 files changed, 32 insertions(+), 32 deletions(-) diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 574e4d3cea..00ed5b28a1 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 3.91, + "version": 3.93, "features": { "redisDataIntegration": { "flag": true, @@ -139,17 +139,17 @@ "flag": false, "perc": [[0, 100]] }, - "dev-vectorSet": { - "flag": false, - "perc": [[0, 100]] + "vectorSet": { + "flag": true, + "perc": [[0, 10]] }, "dev-array": { "flag": false, "perc": [[0, 100]] }, "prodMode": { - "flag": false, - "perc": [[0, 100]] + "flag": true, + "perc": [[0, 10]] } } } diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts index e4494e873f..505801f5d6 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -36,7 +36,7 @@ export enum KnownFeatures { AzureEntraId = 'azureEntraId', DevAzureEntraId = 'dev-azureEntraId', DevBrowser = 'dev-browser', - DevVectorSet = 'dev-vectorSet', + VectorSet = 'vectorSet', DevArray = 'dev-array', ProdMode = 'prodMode', } diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index d5991a6d72..0fe36e4490 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -83,8 +83,8 @@ export const knownFeatures: Record = { name: KnownFeatures.DevBrowser, storage: FeatureStorage.Database, }, - [KnownFeatures.DevVectorSet]: { - name: KnownFeatures.DevVectorSet, + [KnownFeatures.VectorSet]: { + name: KnownFeatures.VectorSet, storage: FeatureStorage.Database, }, [KnownFeatures.DevArray]: { diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts index a87fbece7f..3b0f495998 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts @@ -100,7 +100,7 @@ export class FeatureFlagProvider { new CommonFlagStrategy(this.featuresConfigService, this.settingsService), ); this.strategies.set( - KnownFeatures.DevVectorSet, + KnownFeatures.VectorSet, new CommonFlagStrategy(this.featuresConfigService, this.settingsService), ); this.strategies.set( diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index 50e78e4956..6ec49b23e6 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -12,7 +12,7 @@ export enum FeatureFlags { databaseManagement = 'databaseManagement', customTutorials = 'customTutorials', vectorSearchV2 = 'vectorSearchV2', - devVectorSet = 'dev-vectorSet', + vectorSet = 'vectorSet', devArray = 'dev-array', azureEntraId = 'azureEntraId', devBrowser = 'dev-browser', diff --git a/redisinsight/ui/src/pages/browser/components/add-key/AddKey.spec.tsx b/redisinsight/ui/src/pages/browser/components/add-key/AddKey.spec.tsx index 39688d273e..0ccd112b11 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/AddKey.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/add-key/AddKey.spec.tsx @@ -41,7 +41,7 @@ jest.mock('uiSrc/slices/instances/instances', () => ({ })) /** - * Build a fresh store with the `devVectorSet` feature flag pre-seeded so the + * Build a fresh store with the `vectorSet` feature flag pre-seeded so the * Vector Set option's `isEnabledSelector` (which reads the flag from the * features slice) resolves correctly. We seed the store rather than spying * on the selector because the option config holds an import-time reference @@ -50,7 +50,7 @@ jest.mock('uiSrc/slices/instances/instances', () => ({ const renderWithVectorSetFlag = (enabled: boolean) => { const storeState = set( cloneDeep(initialStateDefault), - `app.features.featureFlags.features.${FeatureFlags.devVectorSet}`, + `app.features.featureFlags.features.${FeatureFlags.vectorSet}`, { flag: enabled }, ) return render( diff --git a/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts b/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts index f851312a75..5ef0b24739 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts +++ b/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts @@ -1,6 +1,6 @@ import { GROUP_TYPES_COLORS, KeyTypes } from 'uiSrc/constants' import { CommandsVersions } from 'uiSrc/constants/commandsVersions' -import { isDevVectorSetEnabledSelector } from 'uiSrc/slices/app/features' +import { isVectorSetEnabledSelector } from 'uiSrc/slices/app/features' import { AddKeyTypeOption } from '../AddKey.types' export const ADD_KEY_TYPE_OPTIONS: AddKeyTypeOption[] = [ @@ -44,6 +44,6 @@ export const ADD_KEY_TYPE_OPTIONS: AddKeyTypeOption[] = [ value: KeyTypes.VectorSet, color: GROUP_TYPES_COLORS[KeyTypes.VectorSet], minVersion: CommandsVersions.VECTOR_SET.since, - isEnabledSelector: isDevVectorSetEnabledSelector, + isEnabledSelector: isVectorSetEnabledSelector, }, ] diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx index 7afeb924bd..cc49e9f2e5 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx @@ -201,7 +201,7 @@ describe('FilterKeyType', () => { })) const initialStoreState = set( cloneDeep(initialStateDefault), - `app.features.featureFlags.features.${FeatureFlags.devVectorSet}`, + `app.features.featureFlags.features.${FeatureFlags.vectorSet}`, { flag: true }, ) const { queryByText } = render(, { @@ -232,7 +232,7 @@ describe('FilterKeyType', () => { })) const initialStoreState = set( cloneDeep(initialStateDefault), - `app.features.featureFlags.features.${FeatureFlags.devVectorSet}`, + `app.features.featureFlags.features.${FeatureFlags.vectorSet}`, { flag: true }, ) const { queryByText } = render(, { diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts b/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts index 98955bcb53..f62ea6f380 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts @@ -7,7 +7,7 @@ import { import { CommandsVersions } from 'uiSrc/constants/commandsVersions' import { isDevArrayEnabledSelector, - isDevVectorSetEnabledSelector, + isVectorSetEnabledSelector, } from 'uiSrc/slices/app/features' import { RedisDefaultModules } from 'uiSrc/slices/interfaces' import { FilterKeyTypeOption } from './FilterKeyType.types' @@ -60,7 +60,7 @@ export const FILTER_KEY_TYPE_OPTIONS: FilterKeyTypeOption[] = [ value: KeyTypes.VectorSet, color: GROUP_TYPES_COLORS[KeyTypes.VectorSet], minVersion: CommandsVersions.VECTOR_SET.since, - isEnabledSelector: isDevVectorSetEnabledSelector, + isEnabledSelector: isVectorSetEnabledSelector, }, { text: 'Graph', diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx index 81e227ac8c..3bf3a02ba0 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx @@ -7,7 +7,7 @@ import { } from 'uiSrc/constants' import { KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { isDevVectorSetEnabledSelector } from 'uiSrc/slices/app/features' +import { isVectorSetEnabledSelector } from 'uiSrc/slices/app/features' import { isTruncatedString } from 'uiSrc/utils' import TooLongKeyNameDetails from 'uiSrc/pages/browser/modules/key-details/components/too-long-key-name-details/TooLongKeyNameDetails' import ModulesTypeDetails from '../modules-type-details/ModulesTypeDetails' @@ -30,7 +30,7 @@ export interface Props extends KeyDetailsHeaderProps { const DynamicTypeDetails = (props: Props) => { const { keyType: selectedKeyType, keyProp } = props - const isDevVectorSet = useAppSelector(isDevVectorSetEnabledSelector) + const isVectorSet = useAppSelector(isVectorSetEnabledSelector) const TypeDetails: any = { [KeyTypes.ZSet]: , @@ -40,7 +40,7 @@ const DynamicTypeDetails = (props: Props) => { [KeyTypes.List]: , [KeyTypes.ReJSON]: , [KeyTypes.Stream]: , - ...(isDevVectorSet && { + ...(isVectorSet && { [KeyTypes.VectorSet]: , }), } diff --git a/redisinsight/ui/src/slices/app/features.ts b/redisinsight/ui/src/slices/app/features.ts index 78048530d1..6bfd0d7c8d 100644 --- a/redisinsight/ui/src/slices/app/features.ts +++ b/redisinsight/ui/src/slices/app/features.ts @@ -65,7 +65,7 @@ export const initialState: StateAppFeatures = { [FeatureFlags.vectorSearchV2]: { flag: false, }, - [FeatureFlags.devVectorSet]: { + [FeatureFlags.vectorSet]: { flag: false, }, [FeatureFlags.devArray]: { @@ -230,13 +230,13 @@ export const isAzureEntraIdEnabledSelector = (state: RootState): boolean => { return azureEntraIdEnabled && envDependentEnabled } -export const isDevVectorSetEnabledSelector = (state: RootState): boolean => { +export const isVectorSetEnabledSelector = (state: RootState): boolean => { if (isDevelopment) { return true } const features = state.app.features.featureFlags.features - return features[FeatureFlags.devVectorSet]?.flag ?? false + return features[FeatureFlags.vectorSet]?.flag ?? false } export const isDevArrayEnabledSelector = (state: RootState): boolean => { diff --git a/tests/e2e-playwright/tests/parallel/browser/vector-set/add-elements.spec.ts b/tests/e2e-playwright/tests/parallel/browser/vector-set/add-elements.spec.ts index 3447543594..6947dccb92 100644 --- a/tests/e2e-playwright/tests/parallel/browser/vector-set/add-elements.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/vector-set/add-elements.spec.ts @@ -5,7 +5,7 @@ import { TEST_KEY_PREFIX, VectorSetKeyFactory, toFp32EscapedString } from 'e2eSr import { DatabaseInstance } from 'e2eSrc/types'; import { seedVectorSet } from './helpers'; -test.use({ featureFlags: { 'dev-vectorSet': true } }); +test.use({ featureFlags: { vectorSet: true } }); test.describe('Browser > Vector Set > Add Elements', () => { let database: DatabaseInstance; diff --git a/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-manual.spec.ts b/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-manual.spec.ts index 938f9e6d74..bc54eeca0d 100644 --- a/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-manual.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-manual.spec.ts @@ -3,7 +3,7 @@ import { StandaloneV880ConfigFactory } from 'e2eSrc/test-data/databases'; import { TEST_KEY_PREFIX, VectorSetKeyFactory } from 'e2eSrc/test-data/browser'; import { DatabaseInstance } from 'e2eSrc/types'; -test.use({ featureFlags: { 'dev-vectorSet': true } }); +test.use({ featureFlags: { vectorSet: true } }); test.describe('Browser > Vector Set > Add Key (manual)', () => { let database: DatabaseInstance; diff --git a/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-sample-data.spec.ts b/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-sample-data.spec.ts index a2a285261a..74edcc69d8 100644 --- a/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-sample-data.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/vector-set/add-key-sample-data.spec.ts @@ -4,7 +4,7 @@ import { DatabaseInstance } from 'e2eSrc/types'; const VEC2WORD_KEY = 'vec2word'; -test.use({ featureFlags: { 'dev-vectorSet': true } }); +test.use({ featureFlags: { vectorSet: true } }); test.describe('Browser > Vector Set > Add Key (sample data)', () => { let database: DatabaseInstance; diff --git a/tests/e2e-playwright/tests/parallel/browser/vector-set/element-actions.spec.ts b/tests/e2e-playwright/tests/parallel/browser/vector-set/element-actions.spec.ts index 21309f272a..6e36d9b506 100644 --- a/tests/e2e-playwright/tests/parallel/browser/vector-set/element-actions.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/vector-set/element-actions.spec.ts @@ -4,7 +4,7 @@ import { TEST_KEY_PREFIX, VectorSetKeyFactory } from 'e2eSrc/test-data/browser'; import { DatabaseInstance } from 'e2eSrc/types'; import { seedVectorSet } from './helpers'; -test.use({ featureFlags: { 'dev-vectorSet': true } }); +test.use({ featureFlags: { vectorSet: true } }); test.describe('Browser > Vector Set > Element actions', () => { let database: DatabaseInstance; diff --git a/tests/e2e-playwright/tests/parallel/browser/vector-set/gating.spec.ts b/tests/e2e-playwright/tests/parallel/browser/vector-set/gating.spec.ts index 08c8630d5e..9a221957df 100644 --- a/tests/e2e-playwright/tests/parallel/browser/vector-set/gating.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/vector-set/gating.spec.ts @@ -4,7 +4,7 @@ import { DatabaseInstance } from 'e2eSrc/types'; test.describe('Browser > Vector Set > Gating > Redis below 8.0, flag on', () => { // V8 factory points at redis:8.0-M02, which reports redis_version:7.9.225. - test.use({ featureFlags: { 'dev-vectorSet': true } }); + test.use({ featureFlags: { vectorSet: true } }); let database: DatabaseInstance; @@ -45,7 +45,7 @@ test.describe('Browser > Vector Set > Gating > Redis below 8.0, flag on', () => }); test.describe('Browser > Vector Set > Gating > Redis 8.8.0, flag off', () => { - test.use({ featureFlags: { 'dev-vectorSet': false } }); + test.use({ featureFlags: { vectorSet: false } }); let database: DatabaseInstance; diff --git a/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts b/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts index 69d0b655b5..0d2b3db3a6 100644 --- a/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts @@ -4,7 +4,7 @@ import { TEST_KEY_PREFIX, VectorSetKeyFactory } from 'e2eSrc/test-data/browser'; import { DatabaseInstance } from 'e2eSrc/types'; import { seedVectorSet } from './helpers'; -test.use({ featureFlags: { 'dev-vectorSet': true } }); +test.use({ featureFlags: { vectorSet: true } }); test.describe('Browser > Vector Set > Similarity search', () => { let database: DatabaseInstance; From 89c43a801ea76acdc1620ed4e55c550f190568a6 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Thu, 11 Jun 2026 12:51:39 +0300 Subject: [PATCH 002/166] build(desktop): pin electron-builder to 26.14.0 (snap fix + Wayland) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-builder 26.15.0 replaced the Go app-builder-bin snap builder with a pure-TS rewrite (#9829) that ships a broken snap: the launcher references $SNAP/desktop-init.sh that is never staged, and even past that the bundled NSS/NSPR libs are unreachable (libnspr4.so: cannot open shared object file) because SNAP_DESKTOP_RUNTIME / LD_LIBRARY_PATH are not wired up. 26.15.1-.3 all carry the regression. The snap Wayland fix that motivated moving off 26.0.12 (#9337/#9320) shipped in 26.2.0 — well before the 26.15.0 rewrite. Pinning to 26.14.0 keeps that fix (DISABLE_WAYLAND / allowNativeWayland handling) and the proven Go-based snap builder, which correctly sets SNAP_DESKTOP_RUNTIME and LD_LIBRARY_PATH. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- yarn.lock | 483 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 422 insertions(+), 63 deletions(-) diff --git a/package.json b/package.json index 698ffc1c7c..c90c44fad2 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "deep-object-diff": "^1.1.9", "dotenv": "^16.4.5", "electron": "^40.10.2", - "electron-builder": "^26.15.2", + "electron-builder": "26.14.0", "electron-builder-notarize": "^1.5.2", "electron-debug": "^3.2.0", "electron-devtools-installer": "^3.2.0", diff --git a/yarn.lock b/yarn.lock index 3cf82a2b3c..005709950c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"7zip-bin@~5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.2.0.tgz#7a03314684dd6572b7dfa89e68ce31d60286854d" + integrity sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A== + "@adobe/css-tools@^4.4.0": version "4.4.4" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.4.tgz#2856c55443d3d461693f32d2b96fb6ea92e1ffa9" @@ -1255,7 +1260,26 @@ minimist "^1.2.6" plist "^3.0.5" -"@electron/rebuild@^4.0.1", "@electron/rebuild@^4.0.4": +"@electron/rebuild@4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@electron/rebuild/-/rebuild-4.0.3.tgz#f022f7e66874920fd16a4d802b8605885cb549d3" + integrity sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA== + dependencies: + "@malept/cross-spawn-promise" "^2.0.0" + debug "^4.1.1" + detect-libc "^2.0.1" + got "^11.7.0" + graceful-fs "^4.2.11" + node-abi "^4.2.0" + node-api-version "^0.2.1" + node-gyp "^11.2.0" + ora "^5.1.0" + read-binary-file-arch "^1.0.6" + semver "^7.3.5" + tar "^7.5.6" + yargs "^17.0.1" + +"@electron/rebuild@^4.0.1": version "4.0.4" resolved "https://registry.yarnpkg.com/@electron/rebuild/-/rebuild-4.0.4.tgz#a61331d9ae3b8e2c7eddca8e446fcd7fcd60e4ce" integrity sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg== @@ -2117,6 +2141,24 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@npmcli/agent@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-3.0.0.tgz#1685b1fbd4a1b7bb4f930cbb68ce801edfe7aa44" + integrity sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q== + dependencies: + agent-base "^7.1.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.1" + lru-cache "^10.0.1" + socks-proxy-agent "^8.0.3" + +"@npmcli/fs@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-4.0.0.tgz#a1eb1aeddefd2a4a347eca0fab30bc62c0e1c0f2" + integrity sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q== + dependencies: + semver "^7.3.5" + "@open-draft/deferred-promise@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" @@ -4632,6 +4674,11 @@ resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz#00d1dd940b218dff2e49309d410d8bb212159225" integrity sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw== +"@xmldom/xmldom@^0.9.10": + version "0.9.10" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz#a0ad5a26fe8aa996310870726e1704977f769dee" + integrity sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -4657,6 +4704,11 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abbrev@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-3.0.1.tgz#8ac8b3b5024d31464fe2a5feeea9f4536bf44025" + integrity sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg== + abbrev@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" @@ -4722,9 +4774,9 @@ ajv-keywords@^5.1.0: fast-deep-equal "^3.1.3" ajv@^6.12.4, ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -4802,17 +4854,22 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" -app-builder-lib@26.15.2: - version "26.15.2" - resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-26.15.2.tgz#b434f93aba1a391d265f00fe52272b2c75bf9e7a" - integrity sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ== +app-builder-bin@5.0.0-alpha.12: + version "5.0.0-alpha.12" + resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz#2daf82f8badc698e0adcc95ba36af4ff0650dc80" + integrity sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w== + +app-builder-lib@26.14.0: + version "26.14.0" + resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-26.14.0.tgz#10380e8de39e5efb1ff8fe63672edfdaf34579df" + integrity sha512-eRW38gaj9uadOOKBaiRxxWe4b/yUtPlSTDLUpoGAiUSxfDSoMujUqijIkJzYC9Ag7/eeAwguYhNgtIbRtFDiIQ== dependencies: "@electron/asar" "3.4.1" "@electron/fuses" "^1.8.0" "@electron/get" "^3.0.0" "@electron/notarize" "2.5.0" "@electron/osx-sign" "1.3.3" - "@electron/rebuild" "^4.0.4" + "@electron/rebuild" "4.0.3" "@electron/universal" "2.0.3" "@malept/flatpak-bundler" "^0.4.0" "@noble/hashes" "^2.2.0" @@ -4821,15 +4878,15 @@ app-builder-lib@26.15.2: ajv "^8.18.0" asn1js "^3.0.10" async-exit-hook "^2.0.1" - builder-util "26.15.0" - builder-util-runtime "9.7.0" + builder-util "26.14.0" + builder-util-runtime "9.6.3" chromium-pickle-js "^0.2.0" ci-info "4.3.1" debug "^4.3.4" dotenv "^16.4.5" dotenv-expand "^11.0.6" ejs "^3.1.8" - electron-publish "26.15.1" + electron-publish "26.14.0" fs-extra "^10.1.0" hosted-git-info "^4.1.0" isbinaryfile "^5.0.0" @@ -5293,6 +5350,15 @@ bignumber.js@^9.0.0: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6" integrity sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig== +bl@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@~3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -5389,6 +5455,14 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + buffer@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" @@ -5405,21 +5479,23 @@ builder-util-runtime@9.3.1: debug "^4.3.4" sax "^1.2.4" -builder-util-runtime@9.7.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz#c86fba303684e877daee15c29eede81987166fef" - integrity sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw== +builder-util-runtime@9.6.3: + version "9.6.3" + resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.6.3.tgz#bf5707c6c15a5ec87495fbdd370ea0b92629b267" + integrity sha512-W/bhEWeAetNaSyuCG6rpbd8cd3wp+ifpwOtso1tSkJCJrcHPlmlDdkqFHj3zRAzgvyJcDp/5fWRicSQbg23jdA== dependencies: debug "^4.3.4" sax "^1.2.4" -builder-util@26.15.0: - version "26.15.0" - resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-26.15.0.tgz#7266a93664970cb6746e26adef616e78a306b2c0" - integrity sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA== +builder-util@26.14.0: + version "26.14.0" + resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-26.14.0.tgz#00d15eb8e27fa6b8b7133ae6dd2414f73866c224" + integrity sha512-VG0MjpQgIzmkbQs3CIg0mxlTUyKmUEr+JHgS6IjV5LPh4z4CwLW1s9NBWW3Ak6xmR17pHBdpn7miPAcHCcFq3Q== dependencies: + "7zip-bin" "~5.2.0" "@types/debug" "^4.1.6" - builder-util-runtime "9.7.0" + app-builder-bin "5.0.0-alpha.12" + builder-util-runtime "9.6.3" chalk "^4.1.2" cross-spawn "^7.0.6" debug "^4.3.4" @@ -5453,6 +5529,24 @@ cac@^6.7.14: resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== +cacache@^19.0.1: + version "19.0.1" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-19.0.1.tgz#3370cc28a758434c85c2585008bd5bdcff17d6cd" + integrity sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ== + dependencies: + "@npmcli/fs" "^4.0.0" + fs-minipass "^3.0.0" + glob "^10.2.2" + lru-cache "^10.0.1" + minipass "^7.0.3" + minipass-collect "^2.0.1" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + p-map "^7.0.2" + ssri "^12.0.0" + tar "^7.4.3" + unique-filename "^4.0.0" + cacheable-lookup@^5.0.3: version "5.0.4" resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" @@ -5675,6 +5769,13 @@ clean-css@^5.2.2: dependencies: source-map "~0.6.0" +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + cli-cursor@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38" @@ -5682,6 +5783,11 @@ cli-cursor@^5.0.0: dependencies: restore-cursor "^5.0.0" +cli-spinners@^2.5.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== + cli-truncate@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" @@ -5737,6 +5843,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + clsx@^1.0.4, clsx@^1.1.1: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" @@ -6634,6 +6745,13 @@ deepmerge@^4.2.2, deepmerge@^4.3.1: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +defaults@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" + integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== + dependencies: + clone "^1.0.2" + defer-to-connect@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" @@ -6679,6 +6797,11 @@ dequal@^2.0.0: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +detect-libc@^2.0.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -6739,13 +6862,13 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -dmg-builder@26.15.2: - version "26.15.2" - resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-26.15.2.tgz#c97463ae348c4ff6dc8d48b3168e52730ec06cdd" - integrity sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA== +dmg-builder@26.14.0: + version "26.14.0" + resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-26.14.0.tgz#eae22f02ea4cb1e37f526dd829bb7c81cf83191e" + integrity sha512-2MmJniyT5STSnedsuKR6YX4QKQIn8p6igK75hW1R8zl4bXGfNcP2DZwO2Mb5AwAsPsisKDcBR3pd0s7DXnT9qQ== dependencies: - app-builder-lib "26.15.2" - builder-util "26.15.0" + app-builder-lib "26.14.0" + builder-util "26.14.0" fs-extra "^10.1.0" js-yaml "^4.1.0" @@ -6939,17 +7062,17 @@ electron-builder-notarize@^1.5.2: js-yaml "^3.14.0" read-pkg-up "^7.0.0" -electron-builder@^26.15.2: - version "26.15.2" - resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-26.15.2.tgz#b6e8e1846afb4d0c7d17ac0ab2fbb24b0331eaa1" - integrity sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A== +electron-builder@26.14.0: + version "26.14.0" + resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-26.14.0.tgz#913aa3dee043cf95670acf3b49a3d70fcf8345ea" + integrity sha512-C/uyWCbfgETn3efNHuQce8S23D/9GqFfsEYgP8Zy/5+Fo5w6HZ78FcEdKRIQ7YT+uoHROEQNwrg/+dkd0fCpqw== dependencies: - app-builder-lib "26.15.2" - builder-util "26.15.0" - builder-util-runtime "9.7.0" + app-builder-lib "26.14.0" + builder-util "26.14.0" + builder-util-runtime "9.6.3" chalk "^4.1.2" ci-info "^4.2.0" - dmg-builder "26.15.2" + dmg-builder "26.14.0" fs-extra "^10.1.0" lazy-val "^1.0.5" simple-update-notifier "2.0.0" @@ -7029,15 +7152,15 @@ electron-notarize@^1.1.1: debug "^4.1.1" fs-extra "^9.0.1" -electron-publish@26.15.1: - version "26.15.1" - resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-26.15.1.tgz#4db8d5b7c4485d1f17aedb32b6d13e8e8f9689d6" - integrity sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg== +electron-publish@26.14.0: + version "26.14.0" + resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-26.14.0.tgz#fd75fe74057d0a531586d3215270e0698a1dc44b" + integrity sha512-dw0zAn6j5yxILnCnmpAl5khHX41TagDfI8Hl+KSeqdvAu2/87rl5zou7R3/U56Q0Rws5oD/BBbhlQ9yt3JcZ/g== dependencies: "@types/fs-extra" "^9.0.11" aws4 "^1.13.2" - builder-util "26.15.0" - builder-util-runtime "9.7.0" + builder-util "26.14.0" + builder-util-runtime "9.6.3" chalk "^4.1.2" form-data "^4.0.5" fs-extra "^10.1.0" @@ -7110,6 +7233,13 @@ emoticon@^3.2.0: resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-3.2.0.tgz#c008ca7d7620fac742fe1bf4af8ff8fed154ae7f" integrity sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg== +encoding@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.1.0: version "1.4.5" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" @@ -8264,6 +8394,13 @@ fs-extra@^9.0.0, fs-extra@^9.0.1: jsonfile "^6.0.1" universalify "^2.0.0" +fs-minipass@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54" + integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw== + dependencies: + minipass "^7.0.3" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -8436,7 +8573,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^10.0.0, glob@^10.3.10, glob@^10.4.1: +glob@^10.0.0, glob@^10.2.2, glob@^10.3.10, glob@^10.4.1: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -8559,7 +8696,7 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -got@^11.8.5: +got@^11.7.0, got@^11.8.5: version "11.8.6" resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== @@ -8913,7 +9050,7 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" -http-cache-semantics@^4.0.0: +http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== @@ -8964,7 +9101,7 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@0.6, iconv-lite@0.6.3: +iconv-lite@0.6, iconv-lite@0.6.3, 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== @@ -8983,7 +9120,7 @@ identity-obj-proxy@^3.0.0: dependencies: harmony-reflect "^1.4.6" -ieee754@^1.2.1: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -9055,7 +9192,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -9089,6 +9226,11 @@ interpret@^3.1.1: resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== +ip-address@^10.1.1: + version "10.2.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + is-alphabetical@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" @@ -9245,6 +9387,11 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + is-map@^2.0.2, is-map@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" @@ -9369,6 +9516,11 @@ is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed dependencies: which-typed-array "^1.1.16" +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + is-weakmap@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" @@ -9437,9 +9589,9 @@ isexe@^2.0.0: integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== isexe@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" - integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== + version "3.1.5" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.5.tgz#42e368f68d5e10dadfee4fda7b550bc2d8892dc9" + integrity sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w== isexe@^4.0.0: version "4.0.0" @@ -10408,6 +10560,14 @@ lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.18. resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + log-update@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/log-update/-/log-update-6.1.0.tgz#1a04ff38166f94647ae1af562f4bd6a15b1b7cd4" @@ -10453,7 +10613,7 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== -lru-cache@^10.2.0: +lru-cache@^10.0.1, lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== @@ -10501,6 +10661,23 @@ make-error@^1.1.1, make-error@^1.3.6: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +make-fetch-happen@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz#d74c3ecb0028f08ab604011e0bc6baed483fcdcd" + integrity sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ== + dependencies: + "@npmcli/agent" "^3.0.0" + cacache "^19.0.1" + http-cache-semantics "^4.1.1" + minipass "^7.0.2" + minipass-fetch "^4.0.0" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^1.0.0" + proc-log "^5.0.0" + promise-retry "^2.0.1" + ssri "^12.0.0" + makeerror@1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" @@ -11114,12 +11291,58 @@ minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.4, minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +minipass-collect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863" + integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw== + dependencies: + minipass "^7.0.3" + +minipass-fetch@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-4.0.1.tgz#f2d717d5a418ad0b1a7274f9b913515d3e78f9e5" + integrity sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ== + dependencies: + minipass "^7.0.3" + minipass-sized "^1.0.3" + minizlib "^3.0.1" + optionalDependencies: + encoding "^0.1.13" + +minipass-flush@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.7.tgz#145c383d5ae294b36030aa80d4e872d08bebcb73" + integrity sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA== + dependencies: + minipass "^3.0.0" + +minipass-pipeline@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" + integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + dependencies: + minipass "^3.0.0" + +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" -minizlib@^3.1.0: +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.0.4, minipass@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +minizlib@^3.0.1, minizlib@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c" integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw== @@ -11270,6 +11493,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -11330,6 +11558,22 @@ node-gyp-build-optional-packages@5.0.7: resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz#5d2632bbde0ab2f6e22f1bbac2199b07244ae0b3" integrity sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w== +node-gyp@^11.2.0: + version "11.5.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-11.5.0.tgz#82661b5f40647a7361efe918e3cea76d297fcc56" + integrity sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ== + dependencies: + env-paths "^2.2.0" + exponential-backoff "^3.1.1" + graceful-fs "^4.2.6" + make-fetch-happen "^14.0.3" + nopt "^8.0.0" + proc-log "^5.0.0" + semver "^7.3.5" + tar "^7.4.3" + tinyglobby "^0.2.12" + which "^5.0.0" + node-gyp@^12.2.0: version "12.4.0" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-12.4.0.tgz#2d017b6ea1ca9294dbbee75be533728f49257024" @@ -11372,6 +11616,13 @@ nopt@^4.0.1: abbrev "1" osenv "^0.1.4" +nopt@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-8.1.0.tgz#b11d38caf0f8643ce885818518064127f602eae3" + integrity sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A== + dependencies: + abbrev "^3.0.0" + nopt@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-9.0.0.tgz#6bff0836b2964d24508b6b41b5a9a49c4f4a1f96" @@ -11509,7 +11760,7 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -onetime@^5.1.2: +onetime@^5.1.0, onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -11557,6 +11808,21 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +ora@^5.1.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" @@ -11643,6 +11909,11 @@ p-locate@^6.0.0: dependencies: p-limit "^4.0.0" +p-map@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-7.0.4.tgz#b81814255f542e252d5729dca4d66e5ec14935b8" + integrity sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ== + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -11871,7 +12142,7 @@ pkijs@^3.4.0: pvutils "^1.1.3" tslib "^2.8.1" -plist@3.1.0, plist@^3.0.5, plist@^3.1.0: +plist@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9" integrity sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ== @@ -11880,6 +12151,15 @@ plist@3.1.0, plist@^3.0.5, plist@^3.1.0: base64-js "^1.5.1" xmlbuilder "^15.1.1" +plist@^3.0.5, plist@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.1.tgz#fa6099e1e3cf6ea180258ebe6378ea3878c2c841" + integrity sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA== + dependencies: + "@xmldom/xmldom" "^0.9.10" + base64-js "^1.5.1" + xmlbuilder "^15.1.1" + polished@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" @@ -12201,6 +12481,11 @@ prismjs@~1.27.0, prismjs@~1.30.0: resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw== +proc-log@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-5.0.0.tgz#e6c93cf37aef33f835c53485f314f50ea906a9d8" + integrity sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ== + proc-log@^6.0.0: version "6.1.0" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-6.1.0.tgz#18519482a37d5198e231133a70144a50f21f0215" @@ -12801,6 +13086,15 @@ readable-stream@^2.0.2, readable-stream@^2.3.5, readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.4.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@~1.0.31: version "1.0.34" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" @@ -13178,6 +13472,14 @@ responselike@^2.0.0: dependencies: lowercase-keys "^2.0.0" +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + restore-cursor@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-5.1.0.tgz#0766d95699efacb14150993f55baf0953ea1ebe7" @@ -13304,7 +13606,7 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@^5.0.1: +safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -13722,6 +14024,11 @@ slide@~1.1.3: resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" integrity sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw== +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + snake-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" @@ -13755,6 +14062,23 @@ socket.io-parser@~4.2.4: "@socket.io/component-emitter" "~3.1.0" debug "~4.4.1" +socks-proxy-agent@^8.0.3: + version "8.0.5" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee" + integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== + dependencies: + agent-base "^7.1.2" + debug "^4.3.4" + socks "^2.8.3" + +socks@^2.8.3: + version "2.8.9" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.9.tgz#aa5f130ca0f88a43fa44faf4869c50d22aa27752" + integrity sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw== + dependencies: + ip-address "^10.1.1" + smart-buffer "^4.2.0" + sort-keys-length@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188" @@ -13869,6 +14193,13 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +ssri@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-12.0.0.tgz#bcb4258417c702472f8191981d3c8a771fee6832" + integrity sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ== + dependencies: + minipass "^7.0.3" + stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -14059,6 +14390,13 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -14312,7 +14650,7 @@ tar-mini@^0.2.0: resolved "https://registry.yarnpkg.com/tar-mini/-/tar-mini-0.2.0.tgz#2b2cdc215f5b83b0ab8ce363dc9ded22de51849b" integrity sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ== -tar@^7.5.4, tar@^7.5.7: +tar@^7.4.3, tar@^7.5.4, tar@^7.5.6, tar@^7.5.7: version "7.5.16" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.16.tgz#f11e063afed4554f758049d082909e37d6b53ced" integrity sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w== @@ -14858,6 +15196,20 @@ unified@^9.2.0: trough "^1.0.0" vfile "^4.0.0" +unique-filename@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-4.0.0.tgz#a06534d370e7c977a939cd1d11f7f0ab8f1fed13" + integrity sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ== + dependencies: + unique-slug "^5.0.0" + +unique-slug@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-5.0.0.tgz#ca72af03ad0dbab4dad8aa683f633878b1accda8" + integrity sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg== + dependencies: + imurmurhash "^0.1.4" + unist-builder@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-2.0.3.tgz#77648711b5d86af0942f334397a33c5e91516436" @@ -15094,7 +15446,7 @@ utf8-byte-length@^1.0.1: resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61" integrity sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA== -util-deprecate@^1.0.2, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -15349,6 +15701,13 @@ watchpack@^2.4.4: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== + dependencies: + defaults "^1.0.3" + web-namespaces@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" @@ -15735,7 +16094,7 @@ yargs@^16.1.0, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.3.1, yargs@^17.5.1, yargs@^17.6.2, yargs@^17.7.2: +yargs@^17.0.1, yargs@^17.3.1, yargs@^17.5.1, yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== From 450981a9375f26669b2f9589ee3b094e4bd59d3d Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Fri, 12 Jun 2026 12:14:10 +0300 Subject: [PATCH 003/166] fix(snap): migrate snap to core24 to fix launch on modern Linux The Linux snap failed to launch on modern systems: native modules built on the ubuntu-24.04 CI runner (glibc 2.39) couldn't load on the core20 base (glibc 2.31) -> "libm.so.6: version GLIBC_2.38 not found" loading better_sqlite3.node. The old gnome-3-28-1804 platform also lacked a working GPU driver for current hardware. Migrate the snap to base core24 via electron-builder's `snapcraft.core24`: - base core24 -> glibc 2.39, matching the build toolchain (fixes the fatal load) - GNOME extension (default) -> gnome-46-2404 + mesa-2404/gpu-2404 (modern GPU) - XDG_SESSION_TYPE=x11 -> forces XWayland, restoring the old allowNativeWayland:false behavior (Electron 40 removed ELECTRON_OZONE_PLATFORM_HINT and snapcraft core24 rejects '=' in an app command, so this env var is the supported mechanism) - plain browser-support plug -> auto-connects on install (snapd only blocks auto-connect for allow-sandbox:true); electron-builder adds --no-sandbox itself - network / home / password-manager-service plugs preserved CI: core24 builds via the snapcraft CLI in an LXD container, so the x64 Linux job installs snapcraft + lxd, grants the LXD socket, and allows iptables FORWARD (the runner's Docker sets it to DROP, blocking the build container's network). Gated to runs where the snap target is actually built. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pipeline-build-linux.yml | 25 ++++++++++++++++++++++ electron-builder.json | 21 +++++++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pipeline-build-linux.yml b/.github/workflows/pipeline-build-linux.yml index 75e34ea337..af02c3d38b 100644 --- a/.github/workflows/pipeline-build-linux.yml +++ b/.github/workflows/pipeline-build-linux.yml @@ -119,6 +119,31 @@ jobs: sudo apt-get install -qy ruby ruby-dev build-essential sudo gem install --no-document fpm + # The snap target builds on core24 via snapcraft + the GNOME extension, + # which requires an isolated build environment (LXD). x64 only — snap is x64. + - name: Install snapcraft + LXD (for core24 snap build) + if: >- + matrix.arch == 'x64' + && steps.resolve-targets.outputs.should_build == 'true' + && (steps.resolve-targets.outputs.mode == 'default' + || contains(steps.resolve-targets.outputs.custom_targets, 'snap')) + run: | + sudo snap install snapcraft --classic + sudo snap install lxd + sudo lxd waitready --timeout=60 + sudo lxd init --auto + sudo usermod -aG lxd "$USER" + # The build step runs in a shell started before the group change, so the + # new 'lxd' group isn't active there and snapcraft's pylxd can't reach the + # socket ("LXD requires additional permissions"). Grant the socket directly + # so snapcraft can connect without a re-login. + sudo chmod 0666 /var/snap/lxd/common/lxd/unix.socket + # GitHub runners run Docker, which sets the iptables FORWARD policy to DROP. + # That blocks the LXD bridge's NAT traffic, so the snapcraft build container + # has no internet ("A network related operation failed..."). Allow forwarding + # so the container can fetch the base, GNOME extension, and stage-packages. + sudo iptables -P FORWARD ACCEPT + - name: Build linux packages (production) if: vars.ENV == 'production' && steps.resolve-targets.outputs.mode == 'default' run: yarn package:prod --linux ${{ matrix.defaultTargets }} diff --git a/electron-builder.json b/electron-builder.json index 20cfbe3d5e..fa4cadadd3 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -136,11 +136,22 @@ "rpm": { "fpm": ["--rpm-digest", "sha256"] }, - "snap": { - "plugs": ["default", "password-manager-service"], - "confinement": "strict", - "stagePackages": ["default"], - "allowNativeWayland": false + "snapcraft": { + "base": "core24", + "core24": { + "confinement": "strict", + "useLXD": true, + "stagePackages": ["default"], + "environment": { + "XDG_SESSION_TYPE": "x11" + }, + "plugs": [ + "network", + "home", + "password-manager-service", + "browser-support" + ] + } }, "flatpak": { "runtimeVersion": "20.08", From 90490ebf545c901a81d3f9331e5d8730685b4d19 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 15 Jun 2026 10:04:58 +0300 Subject: [PATCH 004/166] build(desktop): bump Electron to 41.7.2 (#6063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why 41 and not 42 Electron 42 ships ABI 146. better-sqlite3@12.10.1 fixed the code paths for Electron 42, but the WiseLibs release does not yet publish prebuilt .node assets for ABI 146 — only ABI 145 (Electron 41) and below. Targeting 42 today would silently force every dev install and CI job into a local C++ build (node-gyp), which is exactly what the prebuilt pipeline is here to avoid. Electron 41 is the highest version with verified prebuilt better-sqlite3 coverage for all our targets (macOS x64/arm64, Linux x64/arm64 glibc + musl, Windows x64). Once WiseLibs publishes the ABI 146 assets, the follow-up to 42 is a one-line bump. --- package.json | 5 +-- redisinsight/api/package.json | 2 +- redisinsight/api/yarn.lock | 14 +++--- redisinsight/package.json | 2 +- redisinsight/yarn.lock | 14 +++--- yarn.lock | 82 ++++++++++++++--------------------- 6 files changed, 51 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index c90c44fad2..0a704333f5 100644 --- a/package.json +++ b/package.json @@ -105,8 +105,7 @@ "react-router-dom/react-router/path-to-regexp": "^1.9.0", "**/form-data": "^4.0.4", "@types/react": "18.2.1", - "@types/react-dom": "18.2.1", - "yauzl": "^3.3.1" + "@types/react-dom": "18.2.1" }, "devDependencies": { "@aivenio/tsc-output-parser": "2.1.1", @@ -173,7 +172,7 @@ "csv-stringify": "^6.4.0", "deep-object-diff": "^1.1.9", "dotenv": "^16.4.5", - "electron": "^40.10.2", + "electron": "^41.7.2", "electron-builder": "26.14.0", "electron-builder-notarize": "^1.5.2", "electron-debug": "^3.2.0", diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index f5ab992f9c..b7ea9dc43b 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -77,7 +77,7 @@ "@types/json-bigint": "^1.0.4", "adm-zip": "^0.5.9", "axios": "^1.16.0", - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.10.1", "body-parser": "^1.20.3", "busboy": "^1.6.0", "class-transformer": "^0.5.1", diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index 3378869080..bc91055bb4 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -3314,10 +3314,10 @@ bcrypt-pbkdf@^1.0.2: dependencies: tweetnacl "^0.14.3" -better-sqlite3@^12.8.0: - version "12.8.0" - resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.8.0.tgz#ec9ccd4a426a35f3b9355c147af6c92a6ddd6862" - integrity sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ== +better-sqlite3@^12.10.1: + version "12.10.1" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.1.tgz#1fedf77460210c83d5140fb700c81700964a1a24" + integrity sha512-HfFtzCqnSfwB3+HroF6PSKzyh+7RfNMGPCzHFUZXRlvrPCb4P3cvxKZNN43Sr7IrkofqQZM+gIvffGpA8VvqgA== dependencies: bindings "^1.5.0" prebuild-install "^7.1.1" @@ -6776,9 +6776,9 @@ nock@^13.3.0: propagate "^2.0.0" node-abi@^3.3.0: - version "3.40.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.40.0.tgz#51d8ed44534f70ff1357dfbc3a89717b1ceac1b4" - integrity sha512-zNy02qivjjRosswoYmPi8hIKJRr8MpQyeKT6qlcq/OnOgA3Rhoae+IYOqsM9V5+JnHWmxKnWOT2GxvtqdtOCXA== + version "3.92.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.92.0.tgz#18e2214677499b8dda81ffcd095afc763d5a9802" + integrity sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ== dependencies: semver "^7.3.5" diff --git a/redisinsight/package.json b/redisinsight/package.json index 023f6c2e1b..3dc0a4461e 100644 --- a/redisinsight/package.json +++ b/redisinsight/package.json @@ -19,7 +19,7 @@ "**/cpu-features": "file:./api/stubs/cpu-features" }, "dependencies": { - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.10.1", "keytar": "^7.9.0", "tunnel-ssh": "^5.1.2" } diff --git a/redisinsight/yarn.lock b/redisinsight/yarn.lock index 1d164a2e63..d2a93e1e9b 100644 --- a/redisinsight/yarn.lock +++ b/redisinsight/yarn.lock @@ -21,10 +21,10 @@ bcrypt-pbkdf@^1.0.2: dependencies: tweetnacl "^0.14.3" -better-sqlite3@^12.8.0: - version "12.8.0" - resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.8.0.tgz#ec9ccd4a426a35f3b9355c147af6c92a6ddd6862" - integrity sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ== +better-sqlite3@^12.10.1: + version "12.10.1" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.1.tgz#1fedf77460210c83d5140fb700c81700964a1a24" + integrity sha512-HfFtzCqnSfwB3+HroF6PSKzyh+7RfNMGPCzHFUZXRlvrPCb4P3cvxKZNN43Sr7IrkofqQZM+gIvffGpA8VvqgA== dependencies: bindings "^1.5.0" prebuild-install "^7.1.1" @@ -161,9 +161,9 @@ napi-build-utils@^1.0.1: integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== node-abi@^3.3.0: - version "3.45.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.45.0.tgz#f568f163a3bfca5aacfce1fbeee1fa2cc98441f5" - integrity sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ== + version "3.92.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.92.0.tgz#18e2214677499b8dda81ffcd095afc763d5a9802" + integrity sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ== dependencies: semver "^7.3.5" diff --git a/yarn.lock b/yarn.lock index 005709950c..ac25738498 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1191,6 +1191,11 @@ uuid "^8.3.0" vfile "^4.2.0" +"@electron-internal/extract-zip@^1.0.1": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz#debf68f415ed7a8416d568cd03cda98109c0eab4" + integrity sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ== + "@electron/asar@3.4.1", "@electron/asar@^3.3.1": version "3.4.1" resolved "https://registry.yarnpkg.com/@electron/asar/-/asar-3.4.1.tgz#4e9196a4b54fba18c56cd8d5cac67c5bdc588065" @@ -1209,10 +1214,10 @@ fs-extra "^9.0.1" minimist "^1.2.5" -"@electron/get@^2.0.0": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@electron/get/-/get-2.0.3.tgz#fba552683d387aebd9f3fcadbcafc8e12ee4f960" - integrity sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ== +"@electron/get@^3.0.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@electron/get/-/get-3.1.0.tgz#22c5a0bd917ab201badeb77bc4ad18cba54cb4ec" + integrity sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ== dependencies: debug "^4.1.1" env-paths "^2.2.0" @@ -1224,20 +1229,19 @@ optionalDependencies: global-agent "^3.0.0" -"@electron/get@^3.0.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@electron/get/-/get-3.1.0.tgz#22c5a0bd917ab201badeb77bc4ad18cba54cb4ec" - integrity sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ== +"@electron/get@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@electron/get/-/get-5.0.0.tgz#3c7ec0e26480ce51a487d54c8a10233460531021" + integrity sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA== dependencies: debug "^4.1.1" - env-paths "^2.2.0" - fs-extra "^8.1.0" - got "^11.8.5" + env-paths "^3.0.0" + graceful-fs "^4.2.11" progress "^2.0.3" - semver "^6.2.0" + semver "^7.6.3" sumchecker "^3.0.1" optionalDependencies: - global-agent "^3.0.0" + undici "^7.24.4" "@electron/notarize@2.3.2", "@electron/notarize@2.5.0": version "2.3.2" @@ -4247,13 +4251,6 @@ dependencies: "@types/yargs-parser" "*" -"@types/yauzl@^2.9.1": - version "2.10.3" - resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" - integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== - dependencies: - "@types/node" "*" - "@typescript-eslint/eslint-plugin@7.16.1": version "7.16.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz#f5f5da52db674b1f2cdb9d5f3644e5b2ec750465" @@ -7194,14 +7191,14 @@ electron-updater@^6.6.2: semver "^7.6.3" tiny-typed-emitter "^2.1.0" -electron@^40.10.2: - version "40.10.2" - resolved "https://registry.yarnpkg.com/electron/-/electron-40.10.2.tgz#78d09d50ff3c24feebe98be9ccefb789de31ce89" - integrity sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g== +electron@^41.7.2: + version "41.7.2" + resolved "https://registry.yarnpkg.com/electron/-/electron-41.7.2.tgz#5f8fb6c657b86b89b7e21d65b6cf07a7ff74d6fc" + integrity sha512-oYbZNimoMlAy6Fp/o5x2vTggptuPsIbnPKCb6jGf1PJStXH7Gkw0MUQrBliHmDqKLW7yeE+SPsvFNILVN0ORMQ== dependencies: - "@electron/get" "^2.0.0" + "@electron-internal/extract-zip" "^1.0.1" + "@electron/get" "^5.0.0" "@types/node" "^24.9.0" - extract-zip "^2.0.1" emittery@^0.13.1: version "0.13.1" @@ -7300,6 +7297,11 @@ env-paths@^2.2.0, env-paths@^2.2.1: resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== +env-paths@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-3.0.0.tgz#2f1e89c2f6dbd3408e1b1711dd82d62e317f58da" + integrity sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A== + envinfo@^7.7.3: version "7.8.1" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475" @@ -8099,17 +8101,6 @@ extend@^3.0.0, extend@^3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -extract-zip@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" - integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== - dependencies: - debug "^4.1.1" - get-stream "^5.1.0" - yauzl "^2.10.0" - optionalDependencies: - "@types/yauzl" "^2.9.1" - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -12081,11 +12072,6 @@ pe-library@^0.4.1: resolved "https://registry.yarnpkg.com/pe-library/-/pe-library-0.4.1.tgz#e269be0340dcb13aa6949d743da7d658c3e2fbea" integrity sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw== -pend@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" - integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== - php-serialize@^5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/php-serialize/-/php-serialize-5.1.3.tgz#95b6e1d9195bd9959a180b2870cccd72ef3ee105" @@ -15135,6 +15121,11 @@ undici@^6.25.0: resolved "https://registry.yarnpkg.com/undici/-/undici-6.26.0.tgz#333a35b7f519c48d2dc6aeb38e4e91d9274e0652" integrity sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A== +undici@^7.24.4: + version "7.27.2" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.27.2.tgz#f8fae968ee68377cfc61713d9cd152773716804f" + integrity sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA== + unherit@^1.0.4: version "1.1.3" resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" @@ -16117,13 +16108,6 @@ yarn-deduplicate@^6.0.2: semver "^7.5.0" tslib "^2.5.0" -yauzl@^2.10.0, yauzl@^3.3.1: - version "3.4.0" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.4.0.tgz#88b2a21455f37ca7dccf2eeb33bacb4392322719" - integrity sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw== - dependencies: - pend "~1.2.0" - yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" From 79a62af433543cfe1b0e771b8010beae56d9a8b8 Mon Sep 17 00:00:00 2001 From: DimoHG Date: Mon, 6 Jul 2026 16:10:42 +0300 Subject: [PATCH 005/166] fix(copilot): block HTML exfiltration vectors in AI chat rendering (RED-194228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redis Copilot answers are rendered via react-jsx-parser with raw HTML passed through (allowDangerousHtml). The only blacklisted tags were `iframe` and `script`, so an AI response containing `` (or other passive network tags) rendered as a live element and issued an outbound request on load. Because message content can be influenced by untrusted data through indirect prompt injection (a malicious instruction stored in a database field that Copilot later summarizes), an attacker could smuggle stolen data into an `` URL and exfiltrate it to an attacker-controlled host — the core of VDP-4596 / HackerOne #3680497. Block every tag able to trigger an outbound request (img, image, picture, source, video, audio, track, object, embed, link, svg, input) and strip the `style` attribute (CSS `background-image: url(...)` is another beacon). Scoped to the AI-chat render path only; tutorials use a separate renderer and legitimately display images. Co-Authored-By: Claude Opus 4.8 --- .../markdown-message/MarkdownMessage.spec.tsx | 63 +++++++++++++++++-- .../markdown-message/MarkdownMessage.tsx | 32 +++++++++- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx index ed27a9b328..57fb061c16 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { render, act, screen } from 'uiSrc/utils/test-utils' +import { render, act, screen, waitFor } from 'uiSrc/utils/test-utils' import MarkdownMessage from './MarkdownMessage' @@ -8,11 +8,64 @@ describe('MarkdownMessage', () => { expect(render(1)).toBeTruthy() }) - it('should render 2', async () => { - await act(() => { - render(1) + it('should render plain markdown content', async () => { + render(Hello **world**) + + await waitFor(() => { + expect(screen.getByText(/world/i)).toBeInTheDocument() }) + }) + + describe('security', () => { + // RED-194228 / VDP-4596: message content can be influenced by untrusted + // data (indirect prompt injection), so tags able to trigger an outbound + // request must never render — otherwise they exfiltrate data on load. + it('should not render tags from AI content', async () => { + const { container } = render( + + {'A bike. '} + , + ) + + await waitFor(() => { + expect(screen.getByText(/A bike\./)).toBeInTheDocument() + }) + + expect(container.querySelector('img')).toBeNull() + }) + + it('should not render other passive network tags from AI content', async () => { + const { container } = render( + + {'' + + '' + + '' + + ''} + , + ) - screen.debug(undefined, 100_000) + await act(async () => {}) + + expect(container.querySelector('video')).toBeNull() + expect(container.querySelector('object')).toBeNull() + expect(container.querySelector('embed')).toBeNull() + expect(container.querySelector('iframe')).toBeNull() + }) + + it('should not render an inline style that could beacon out via CSS', async () => { + const { container } = render( + + { + 'text' + } + , + ) + + await act(async () => {}) + + // No rendered element may carry a `style` attribute (stripped, or the + // whole message falls back to escaped text) — either way nothing loads. + expect(container.querySelector('[style]')).toBeNull() + }) }) }) diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx index 1bdc8aaf48..2c55432401 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx @@ -10,6 +10,35 @@ export interface CodeProps { lang: string } +/** + * Copilot answers are plain markdown (text, tables, code, links). They never + * contain images or embedded media. Because message content can be influenced + * by untrusted data (e.g. indirect prompt injection via values stored in the + * database), we block every tag able to trigger an outbound request on render + * — otherwise a crafted `` would silently + * exfiltrate data as soon as the browser loads it. See RED-194228 / VDP-4596. + */ +const BLACKLISTED_TAGS = [ + 'iframe', + 'script', + 'img', + 'image', + 'picture', + 'source', + 'video', + 'audio', + 'track', + 'object', + 'embed', + 'link', + 'svg', + 'input', +] + +// Strip event handlers (default) plus `style`, which can beacon out via +// CSS `background-image: url(https://attacker/...)`. +const BLACKLISTED_ATTRS: Array = [/^on.+/i, 'style'] + export interface Props { onRunCommand?: (query: string) => void modules?: AdditionalRedisModule[] @@ -65,7 +94,8 @@ const MarkdownMessage = (props: Props) => { // @ts-ignore setParseAsIs(true)} From 4821e4ffc0d31260dce1055f38e23adb003c96da Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Thu, 9 Jul 2026 09:54:19 +0300 Subject: [PATCH 006/166] Make host/port editable for non-managed databases (#6155) --- .../api/src/constants/error-messages.ts | 2 + .../modules/database/database.service.spec.ts | 158 ++++++++++++++++++ .../src/modules/database/database.service.ts | 58 +++++++ .../api/database/PATCH-databases-id.test.ts | 90 ++++++++++ .../src/pages/home/components/form/DbInfo.tsx | 10 +- .../ManualConnectionWrapper.tsx | 3 + .../ManualConnectionForm.tsx | 4 + .../ManualConnectionFrom.spec.tsx | 43 ++++- .../forms/EditConnection.tsx | 12 +- .../ui/src/utils/instance/instanceProvider.ts | 11 ++ .../tests/instance/instanceProvider.spec.ts | 44 ++++- tests/e2e-playwright/TEST_PLAN.md | 6 + tests/e2e-playwright/helpers/api.ts | 1 + .../databases/components/AddDatabaseDialog.ts | 9 + .../parallel/databases/edit/host.spec.ts | 78 +++++++++ tests/e2e-playwright/types/database.ts | 11 ++ 16 files changed, 528 insertions(+), 12 deletions(-) create mode 100644 tests/e2e-playwright/tests/parallel/databases/edit/host.spec.ts diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index 1769fe932a..ffe08a6bcb 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -27,6 +27,8 @@ export default { UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', WRONG_DATABASE_TYPE: 'Wrong database type.', + HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE: + 'Host and port cannot be changed for a database managed by a cloud provider.', CONNECTION_TIMEOUT: 'The connection has timed out, please check the connection details.', DB_CONNECTION_TIMEOUT: diff --git a/redisinsight/api/src/modules/database/database.service.spec.ts b/redisinsight/api/src/modules/database/database.service.spec.ts index 075bcb0f06..6f6975d60a 100644 --- a/redisinsight/api/src/modules/database/database.service.spec.ts +++ b/redisinsight/api/src/modules/database/database.service.spec.ts @@ -1,4 +1,5 @@ import { + BadRequestException, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; @@ -361,6 +362,163 @@ describe('DatabaseService', () => { ), ).rejects.toThrow(NotFoundException); }); + + describe('managed databases endpoint guard', () => { + it('should throw BadRequest when changing host of a cloud-managed database', async () => { + databaseRepository.get.mockResolvedValueOnce( + mockDatabaseWithCloudDetails, + ); + + await expect( + service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ), + ).rejects.toThrow( + new BadRequestException( + ERROR_MESSAGES.HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE, + ), + ); + expect(databaseRepository.update).not.toHaveBeenCalled(); + }); + + it('should throw BadRequest when changing port of an Azure-managed database', async () => { + databaseRepository.get.mockResolvedValueOnce( + mockDatabaseWithProviderDetails, + ); + + await expect( + service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { port: 6380 }), + true, + ), + ).rejects.toThrow( + new BadRequestException( + ERROR_MESSAGES.HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE, + ), + ); + expect(databaseRepository.update).not.toHaveBeenCalled(); + }); + + it('should allow updating other fields of a managed database when the endpoint is unchanged', async () => { + databaseRepository.get.mockResolvedValueOnce( + mockDatabaseWithCloudDetails, + ); + databaseRepository.update.mockReturnValue({ + ...mockDatabaseWithCloudDetails, + name: 'new-name', + }); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { + name: 'new-name', + host: mockDatabaseWithCloudDetails.host, + port: mockDatabaseWithCloudDetails.port, + }), + true, + ); + + expect(databaseRepository.update).toHaveBeenCalled(); + }); + + it('should allow changing host of a non-managed database', async () => { + databaseRepository.update.mockReturnValue({ + ...mockDatabase, + host: 'new-host', + }); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ); + + expect(databaseRepository.update).toHaveBeenCalled(); + }); + }); + + describe('endpoint name sync', () => { + const HOST = '127.0.100.1'; + const PORT = 6379; + + // Fresh object per call: merge mutates the returned database, and the + // shared mockDatabase can be mutated by other update tests. + const defaultNamedDatabase = () => ({ + ...mockDatabase, + host: HOST, + port: PORT, + name: `${HOST}:${PORT}`, + }); + + it('should sync the name to the new endpoint when the name was the default host:port', async () => { + databaseRepository.get.mockResolvedValueOnce(defaultNamedDatabase()); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ); + + expect(databaseFactory.createDatabaseModel).toHaveBeenCalledWith( + mockSessionMetadata, + expect.objectContaining({ + host: 'new-host', + name: `new-host:${PORT}`, + }), + ); + }); + + it('should not override an explicit name provided in the update', async () => { + databaseRepository.get.mockResolvedValueOnce(defaultNamedDatabase()); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { + host: 'new-host', + name: 'custom-name', + }), + true, + ); + + expect(databaseFactory.createDatabaseModel).toHaveBeenCalledWith( + mockSessionMetadata, + expect.objectContaining({ host: 'new-host', name: 'custom-name' }), + ); + }); + + it('should not change a custom name when the endpoint changes', async () => { + databaseRepository.get.mockResolvedValueOnce({ + ...mockDatabase, + host: HOST, + port: PORT, + name: 'my-custom-alias', + }); + + await service.update( + mockSessionMetadata, + mockDatabase.id, + classToClass(UpdateDatabaseDto, { host: 'new-host' }), + true, + ); + + expect(databaseFactory.createDatabaseModel).toHaveBeenCalledWith( + mockSessionMetadata, + expect.objectContaining({ + host: 'new-host', + name: 'my-custom-alias', + }), + ); + }); + }); }); describe('test', () => { diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index a41fa63211..f17198c030 100644 --- a/redisinsight/api/src/modules/database/database.service.ts +++ b/redisinsight/api/src/modules/database/database.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, InternalServerErrorException, Logger, @@ -85,6 +86,39 @@ export class DatabaseService { ); } + /** + * Checks whether the endpoint (host/port) in the dto differs from the stored one. + * Unlike isEndpointAffected, this compares values so an unchanged host/port + * present in the payload is not treated as a change. + */ + static isEndpointChanged( + dto: UpdateDatabaseDto, + database: Database, + ): boolean { + return ( + (dto.host !== undefined && dto.host !== database.host) || + (dto.port !== undefined && dto.port !== database.port) + ); + } + + /** + * A database is considered managed when its endpoint is owned by a cloud + * provider (Redis Cloud subscription or Azure). For such databases the + * host/port are tied to provider metadata (cloudDetails/providerDetails) that + * would become stale if the endpoint were edited manually. + */ + static isManagedDatabase(database: Database): boolean { + return !!database.cloudDetails?.cloudId || !!database.providerDetails; + } + + /** + * Whether the database name is still the default "host:port" derived from its + * current endpoint (i.e. the user never set a custom alias). + */ + static hasDefaultEndpointName(database: Database): boolean { + return database.name === `${database.host}:${database.port}`; + } + private async merge( database: Database, dto: UpdateDatabaseDto, @@ -246,10 +280,34 @@ export class DatabaseService { this.logger.debug(`Updating database: ${id}`, sessionMetadata); const oldDatabase = await this.get(sessionMetadata, id, true); + if ( + DatabaseService.isEndpointChanged(dto, oldDatabase) && + DatabaseService.isManagedDatabase(oldDatabase) + ) { + throw new BadRequestException( + ERROR_MESSAGES.HOST_PORT_NOT_EDITABLE_FOR_MANAGED_DATABASE, + ); + } + + // When the name is still the default "host:port" and the endpoint changes, + // keep the name in sync with the new endpoint. Computed before merge (which + // mutates oldDatabase) and skipped when the caller sets a name explicitly or + // the user has a custom alias. + const syncedName = + dto.name === undefined && + DatabaseService.isEndpointChanged(dto, oldDatabase) && + DatabaseService.hasDefaultEndpointName(oldDatabase) + ? `${dto.host ?? oldDatabase.host}:${dto.port ?? oldDatabase.port}` + : undefined; + let database: Database; try { database = await this.merge(oldDatabase, dto); + if (syncedName !== undefined) { + database.name = syncedName; + } + if (DatabaseService.isConnectionAffected(dto)) { if (DatabaseService.isEndpointAffected(dto)) { database.provider = undefined; diff --git a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts index c60ed029f2..54648b04a5 100644 --- a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts @@ -9,6 +9,7 @@ import { _, it, validateApiCall, + before, after, } from '../deps'; import { Joi } from '../../helpers/test'; @@ -253,6 +254,95 @@ describe(`PATCH /databases/:id`, () => { }, ].map(mainCheckFn); }); + describe('Managed databases (cloud) endpoint guard', () => { + const managedHostPortMessage = + 'Host and port cannot be changed for a database managed by a cloud provider.'; + // Dedicated managed instance so we never mutate the shared TEST_INSTANCE_ID + // (its cloudDetails would otherwise leak into later tests). + const MANAGED_ID = 'cloud0000-0000-4000-8000-managed000001'; + const managedEndpoint = () => endpoint(MANAGED_ID); + const managedName = constants.getRandomString(); + + // Seed once directly via the repository: cloudDetails marks the database as + // managed, and the guard rejects endpoint changes before any connection, so + // no real connectivity is required. + before(async () => { + const rep = await localDb.getRepository(localDb.repositories.DATABASE); + await rep.save({ + id: MANAGED_ID, + name: 'cloud-managed-db', + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: false, + verifyServerCert: false, + modules: '[]', + version: '7.0', + cloudDetails: { + cloudId: constants.TEST_CLOUD_ID, + subscriptionType: 'fixed', + }, + }); + }); + + after(async () => { + const rep = await localDb.getRepository(localDb.repositories.DATABASE); + await rep.delete(MANAGED_ID); + }); + + [ + { + name: 'Should reject host change for a cloud-managed database', + endpoint: managedEndpoint, + data: { + host: constants.getRandomString(), + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + message: managedHostPortMessage, + }, + after: async () => { + // endpoint must remain unchanged + const db = await localDb.getInstanceById(MANAGED_ID); + expect(db?.host).to.eq(constants.TEST_REDIS_HOST); + expect(db?.port).to.eq(constants.TEST_REDIS_PORT); + }, + }, + { + name: 'Should reject port change for a cloud-managed database', + endpoint: managedEndpoint, + data: { + port: 1234, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + message: managedHostPortMessage, + }, + after: async () => { + const db = await localDb.getInstanceById(MANAGED_ID); + expect(db?.port).to.eq(constants.TEST_REDIS_PORT); + }, + }, + { + name: 'Should allow non-endpoint change (name) for a cloud-managed database', + endpoint: managedEndpoint, + data: { + name: managedName, + }, + responseBody: { + name: managedName, + }, + after: async () => { + const db = await localDb.getInstanceById(MANAGED_ID); + expect(db?.name).to.eq(managedName); + }, + }, + ].map(mainCheckFn); + }); describe('TAGS', () => { const newTagsDto1 = [ { diff --git a/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx b/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx index f1cd52e092..9c2a4cbfd3 100644 --- a/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx +++ b/redisinsight/ui/src/pages/home/components/form/DbInfo.tsx @@ -26,6 +26,7 @@ export interface Props { db: Nullable modules: AdditionalRedisModule[] isFromCloud: boolean + isManaged?: boolean } export const ListGroupItemLabelValue = ({ @@ -91,8 +92,14 @@ const DbInfo = (props: Props) => { db, modules, isFromCloud, + isManaged = false, } = props + // The endpoint is editable in the form for non-managed, non-cloud databases, + // so it is hidden from this read-only summary in that case and shown here + // otherwise (cloud/managed databases keep a read-only endpoint). + const isEndpointEditable = !isManaged && !isFromCloud + const { server } = useAppSelector(appInfoSelector) const dbInfo: DbInfoLabelValue[] = [ @@ -112,6 +119,7 @@ const DbInfo = (props: Props) => { label: 'Host:', value: host, dataTestId: 'db-info-host', + hide: isEndpointEditable && !nodes?.length, additionalContent: !!nodes?.length && ( ), @@ -120,7 +128,7 @@ const DbInfo = (props: Props) => { label: 'Port:', value: port, dataTestId: 'db-info-port', - hide: server?.buildType !== BuildType.RedisStack && !isFromCloud, + hide: server?.buildType !== BuildType.RedisStack && isEndpointEditable, }, { label: 'Database Index:', diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx index 5c36bb1931..fe1db2d1a0 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx @@ -18,6 +18,7 @@ import { transformQueryParamsObject, getDiffKeysOfObjectValues, isAzureDatabase, + isManagedDatabase, } from 'uiSrc/utils' import { BuildType } from 'uiSrc/constants/env' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' @@ -313,6 +314,7 @@ const ManualConnectionWrapper = (props: Props) => { ) const isFromAzure = isAzureDatabase(editedInstance) + const isManaged = isManagedDatabase(editedInstance) return ( { onAliasEdited={onAliasEdited} onClickBack={onClickBack} isFromAzure={isFromAzure} + isManaged={isManaged} /> ) } diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx index 175d35d12f..af0b6da5e2 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionForm.tsx @@ -46,6 +46,7 @@ export interface Props { isEditMode: boolean isCloneMode: boolean isFromAzure?: boolean + isManaged?: boolean setIsCloneMode: (value: boolean) => void onSubmit: (values: DbConnectionInfo) => void onTestConnection: (values: DbConnectionInfo) => void @@ -74,6 +75,7 @@ const ManualConnectionForm = (props: Props) => { isCloneMode, setIsCloneMode, isFromAzure = false, + isManaged = false, } = props const { @@ -275,6 +277,7 @@ const ManualConnectionForm = (props: Props) => { nameFromProvider={nameFromProvider} nodes={nodes} isFromCloud={isFromCloud} + isManaged={isManaged} /> @@ -288,6 +291,7 @@ const ManualConnectionForm = (props: Props) => { isEditMode={isEditMode} isFromCloud={isFromCloud} isFromAzure={isFromAzure} + isManaged={isManaged} formik={formik} onKeyDown={onKeyDown} onHostNamePaste={onHostNamePaste} diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx index 1269b0caf6..09fe135917 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/ManualConnectionFrom.spec.tsx @@ -106,6 +106,32 @@ describe('InstanceForm', () => { ).toBeTruthy() }) + it('should show the host input when editing a non-managed database', () => { + render( + , + ) + + expect(screen.getByTestId('host')).toBeInTheDocument() + }) + + it('should hide the host input when editing a managed database', () => { + render( + , + ) + + expect(screen.queryByTestId('host')).not.toBeInTheDocument() + }) + it('should render DatabaseForm', () => { expect( render( @@ -1328,12 +1354,13 @@ describe('InstanceForm', () => { })) }) - it('should disable connection fields when editing Azure database', () => { + it('should hide the endpoint and disable credentials when editing Azure database', () => { render( { />, ) - // Host is not shown in edit mode (shown as info above form) + // Endpoint (host/port) is hidden for managed databases and shown as + // read-only info above the form expect(screen.queryByTestId('host')).not.toBeInTheDocument() - // Port, username, password should be disabled - expect(screen.getByTestId('port')).toBeDisabled() + expect(screen.queryByTestId('port')).not.toBeInTheDocument() + // Username and password remain visible but disabled for Azure expect(screen.getByTestId('username')).toBeDisabled() expect(screen.getByTestId('password')).toBeDisabled() }) @@ -1369,7 +1397,7 @@ describe('InstanceForm', () => { expect(screen.getByTestId('password')).toBeDisabled() }) - it('should not disable connection fields for non-Azure database in edit mode', () => { + it('should show and enable the endpoint for a non-managed database in edit mode', () => { render( { />, ) - // Host is not shown in edit mode (shown as info above form) - expect(screen.queryByTestId('host')).not.toBeInTheDocument() - // Port, username, password should NOT be disabled for non-Azure databases + // Host and port are editable for non-managed databases in edit mode + expect(screen.getByTestId('host')).not.toBeDisabled() expect(screen.getByTestId('port')).not.toBeDisabled() expect(screen.getByTestId('username')).not.toBeDisabled() expect(screen.getByTestId('password')).not.toBeDisabled() diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx index f0aafe5be0..74fb8ffc80 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/manual-connection-form/forms/EditConnection.tsx @@ -24,6 +24,7 @@ export interface Props { isCloneMode: boolean isFromCloud: boolean isFromAzure?: boolean + isManaged?: boolean formik: FormikProps onKeyDown: (event: React.KeyboardEvent) => void onHostNamePaste: (content: string) => boolean @@ -39,6 +40,7 @@ const EditConnection = (props: Props) => { isEditMode, isFromCloud, isFromAzure = false, + isManaged = false, formik, onKeyDown, onHostNamePaste, @@ -50,6 +52,12 @@ const EditConnection = (props: Props) => { // For Azure databases in edit/clone mode, disable connection fields const readOnlyFields = isFromAzure && isEditMode ? AZURE_READONLY_FIELDS : [] + // The endpoint (host/port) is editable when adding, cloning, or editing a + // non-managed database. For cloud-managed databases it stays read-only since + // the endpoint is tied to provider metadata (see isManagedDatabase). + const showEndpointFields = + (!isEditMode || isCloneMode || !isManaged) && !isFromCloud + return (
{ formik={formik} showFields={{ alias: true, - host: (!isEditMode || isCloneMode) && !isFromCloud, - port: !isFromCloud, + host: showEndpointFields, + port: showEndpointFields, timeout: true, }} autoFocus={!isCloneMode && isEditMode} diff --git a/redisinsight/ui/src/utils/instance/instanceProvider.ts b/redisinsight/ui/src/utils/instance/instanceProvider.ts index 281f1178e4..c0d9e920d0 100644 --- a/redisinsight/ui/src/utils/instance/instanceProvider.ts +++ b/redisinsight/ui/src/utils/instance/instanceProvider.ts @@ -12,3 +12,14 @@ export const isAzureDatabase = ( return instance.providerDetails.provider === AZURE_PROVIDER } + +/** + * A database is "managed" when its endpoint is owned by a cloud provider + * (Redis Cloud subscription or Azure). For such databases the host/port are + * tied to provider metadata that would become stale if edited manually, so the + * endpoint must stay read-only. Mirrors DatabaseService.isManagedDatabase on + * the backend. + */ +export const isManagedDatabase = ( + instance: Nullable>, +): boolean => isAzureDatabase(instance) || !!instance?.cloudDetails?.cloudId diff --git a/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts b/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts index e8c35c7c5f..2909a8b583 100644 --- a/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts +++ b/redisinsight/ui/src/utils/tests/instance/instanceProvider.spec.ts @@ -1,4 +1,4 @@ -import { isAzureDatabase } from 'uiSrc/utils' +import { isAzureDatabase, isManagedDatabase } from 'uiSrc/utils' import { DBInstanceFactory } from 'uiSrc/mocks/factories/database/DBInstance.factory' // "as any" is used for providerDetails because Instance extends Partial @@ -58,3 +58,45 @@ describe('isAzureDatabase', () => { expect(isAzureDatabase({})).toBe(false) }) }) + +describe('isManagedDatabase', () => { + it('should return true for an Azure database', () => { + const instance = DBInstanceFactory.build({ + providerDetails: { + provider: 'azure', + authType: 'entraId', + } as any, + }) + + expect(isManagedDatabase(instance)).toBe(true) + }) + + it('should return true when the database has cloudDetails with a cloudId', () => { + const instance = DBInstanceFactory.build({ + cloudDetails: { cloudId: 12345 } as any, + }) + + expect(isManagedDatabase(instance)).toBe(true) + }) + + it('should return false for a plain database without cloud/provider metadata', () => { + const instance = DBInstanceFactory.build({ + providerDetails: undefined, + cloudDetails: undefined, + }) + + expect(isManagedDatabase(instance)).toBe(false) + }) + + it('should return false when cloudDetails has no cloudId', () => { + const instance = DBInstanceFactory.build({ + cloudDetails: {} as any, + }) + + expect(isManagedDatabase(instance)).toBe(false) + }) + + it('should return false when instance is null', () => { + expect(isManagedDatabase(null)).toBe(false) + }) +}) diff --git a/tests/e2e-playwright/TEST_PLAN.md b/tests/e2e-playwright/TEST_PLAN.md index 684d2f4afd..598ecd79a5 100644 --- a/tests/e2e-playwright/TEST_PLAN.md +++ b/tests/e2e-playwright/TEST_PLAN.md @@ -144,6 +144,12 @@ The test plan is organized by feature area. Tests are grouped for parallel execu | ✅ | main | Development DB > should show DEV label in databases list and instance header | | ✅ | main | Unspecified DB > should not render an environment badge in list or header | +### 1.2.1 Edit Database +| Status | Group | Test Case | +|--------|-------|-----------| +| ✅ | main | Host field is editable when editing a non-managed database | +| ✅ | main | Host field is read-only when editing a cloud-managed database | + ### 1.3 Clone Database | Status | Group | Test Case | |--------|-------|-----------| diff --git a/tests/e2e-playwright/helpers/api.ts b/tests/e2e-playwright/helpers/api.ts index e659ff6bc8..1d3c0b43f8 100644 --- a/tests/e2e-playwright/helpers/api.ts +++ b/tests/e2e-playwright/helpers/api.ts @@ -43,6 +43,7 @@ export class ApiHelper { password: config.password || null, db: config.db ?? 0, ...(config.environment ? { environment: config.environment } : {}), + ...(config.cloudDetails ? { cloudDetails: config.cloudDetails } : {}), }, }); diff --git a/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts b/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts index 68a647b974..cc767f6cac 100644 --- a/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts +++ b/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts @@ -25,6 +25,11 @@ export class AddDatabaseDialog { readonly testConnectionButton: Locator; readonly dialog: Locator; + // Read-only endpoint info (shown in edit mode instead of editable fields + // for cloud/managed databases) + readonly dbInfoHost: Locator; + readonly dbInfoPort: Locator; + // Additional settings readonly timeoutInput: Locator; readonly selectLogicalDatabaseCheckbox: Locator; @@ -81,6 +86,10 @@ export class AddDatabaseDialog { this.cancelButton = page.getByRole('button', { name: 'Cancel' }); this.testConnectionButton = page.getByRole('button', { name: 'Test Connection' }); + // Read-only endpoint info + this.dbInfoHost = page.getByTestId('db-info-host'); + this.dbInfoPort = page.getByTestId('db-info-port'); + // Connection settings form this.databaseAliasInput = page.getByPlaceholder('Enter Database Alias'); this.hostInput = page.getByPlaceholder('Enter Hostname / IP address / Connection URL'); diff --git a/tests/e2e-playwright/tests/parallel/databases/edit/host.spec.ts b/tests/e2e-playwright/tests/parallel/databases/edit/host.spec.ts new file mode 100644 index 0000000000..e9357b1dd5 --- /dev/null +++ b/tests/e2e-playwright/tests/parallel/databases/edit/host.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from 'e2eSrc/fixtures/base'; +import { StandaloneConfigFactory, StandaloneEmptyConfigFactory } from 'e2eSrc/test-data/databases'; +import { DatabaseInstance } from 'e2eSrc/types'; +import { faker } from '@faker-js/faker'; + +/** + * Databases > Edit > Host field + * + * Guards the host-editability rules end-to-end: + * - Non-managed databases expose an editable host field in edit mode. + * - Cloud-managed databases (carrying cloudDetails) keep the endpoint + * read-only: the host field is hidden and shown only as read-only info. + * + * The backend guard that rejects endpoint changes for managed databases is + * covered by the API integration tests (PATCH /databases/:id); here we verify + * the user-facing form wiring that the unit tests cannot exercise full-stack. + */ +test.describe('Databases > Edit > Host field', () => { + let standaloneDb: DatabaseInstance; + let managedDb: DatabaseInstance | undefined; + + test.beforeAll(async ({ apiHelper }) => { + standaloneDb = await apiHelper.createDatabase(StandaloneConfigFactory.build()); + + // A managed database is a real (reachable) database that additionally + // carries cloudDetails. Use the isolated "empty" instance to avoid the + // cloud uniqueness check colliding with other tests' databases on the + // primary standalone endpoint. + try { + const managedConfig = StandaloneEmptyConfigFactory.build({ + name: `test-managed-${faker.string.alphanumeric(8)}`, + cloudDetails: { cloudId: faker.number.int({ min: 100000, max: 999999 }), subscriptionType: 'fixed' }, + }); + managedDb = await apiHelper.createDatabase(managedConfig); + } catch { + // Managed fixture unavailable in this environment - the managed test is skipped + } + }); + + test.afterAll(async ({ apiHelper }) => { + for (const db of [standaloneDb, managedDb]) { + if (db?.id) { + await apiHelper.deleteDatabase(db.id).catch(() => {}); + } + } + }); + + test.beforeEach(async ({ databasesPage }) => { + await databasesPage.goto(); + }); + + test('should expose an editable host field when editing a non-managed database', async ({ databasesPage }) => { + const { databaseList, addDatabaseDialog } = databasesPage; + + await databaseList.expectDatabaseVisible(standaloneDb.name, { searchFirst: true }); + await databaseList.edit(standaloneDb.name); + + await expect(addDatabaseDialog.dialog).toBeVisible(); + await expect(addDatabaseDialog.hostInput).toBeVisible(); + await expect(addDatabaseDialog.hostInput).toHaveValue(standaloneDb.host); + await expect(addDatabaseDialog.hostInput).toBeEditable(); + }); + + test('should keep the host field read-only when editing a cloud-managed database', async ({ databasesPage }) => { + test.skip(!managedDb, 'Managed database fixture unavailable in this environment'); + + const { databaseList, addDatabaseDialog } = databasesPage; + + await databaseList.expectDatabaseVisible(managedDb!.name, { searchFirst: true }); + await databaseList.edit(managedDb!.name); + + await expect(addDatabaseDialog.dialog).toBeVisible(); + // Endpoint is not editable for managed databases: the host input is absent, + // and the endpoint is surfaced as read-only info instead. + await expect(addDatabaseDialog.hostInput).toHaveCount(0); + await expect(addDatabaseDialog.dbInfoHost).toBeVisible(); + }); +}); diff --git a/tests/e2e-playwright/types/database.ts b/tests/e2e-playwright/types/database.ts index a9bf62e510..eeb703056a 100644 --- a/tests/e2e-playwright/types/database.ts +++ b/tests/e2e-playwright/types/database.ts @@ -30,11 +30,22 @@ export interface RedisConnectionConfig { environment?: Environment; } +/** + * Cloud (Redis Cloud) subscription metadata that marks a database as managed. + * When present on a created database, the app treats its endpoint as owned by + * the cloud provider and keeps host/port read-only. + */ +export interface CloudDatabaseDetailsConfig { + cloudId: number; + subscriptionType: 'fixed' | 'flexible'; +} + /** * Configuration for adding a database via UI */ export interface AddDatabaseConfig extends RedisConnectionConfig { name: string; + cloudDetails?: CloudDatabaseDetailsConfig; } /** From ccde3a9cc57155225cc555dd2fae93a67922f508 Mon Sep 17 00:00:00 2001 From: DimoHG Date: Thu, 9 Jul 2026 10:56:57 +0300 Subject: [PATCH 007/166] fix(copilot): preserve markdown links, block '} + , + ) + + await waitFor(() => { + expect(screen.getByText(/Marker text\./)).toBeInTheDocument() + }) + + expect(container.querySelector('style')).toBeNull() + }) + + it('should not render a raw element that could load external resources', async () => { + const { container } = render( + + {'Marker text. ' + + ''} + , + ) + + await waitFor(() => { + expect(screen.getByText(/Marker text\./)).toBeInTheDocument() + }) + + expect(container.querySelector('link')).toBeNull() + }) }) }) diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx index 2c55432401..c79bfc537d 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx @@ -30,14 +30,20 @@ const BLACKLISTED_TAGS = [ 'track', 'object', 'embed', - 'link', + 'style', 'svg', 'input', ] // Strip event handlers (default) plus `style`, which can beacon out via // CSS `background-image: url(https://attacker/...)`. -const BLACKLISTED_ATTRS: Array = [/^on.+/i, 'style'] +const BLACKLISTED_ATTRS: Array = [/^on.+/i, /^style$/i] + +// Case-sensitive strip of raw HTML elements to prevent external +// resource loading while preserving the PascalCase component emitted by +// the markdown formatter. JsxParser's blacklistedTags is case-insensitive, so +// blacklisting `link` would also drop legitimate — handle it here. +const LOWERCASE_LINK_TAG = /]*\/?>|<\/link\s*>/g export interface Props { onRunCommand?: (query: string) => void @@ -97,7 +103,7 @@ const MarkdownMessage = (props: Props) => { blacklistedTags={BLACKLISTED_TAGS} blacklistedAttrs={BLACKLISTED_ATTRS} autoCloseVoidElements - jsx={content} + jsx={content.replace(LOWERCASE_LINK_TAG, '')} onError={() => setParseAsIs(true)} /> ) From 0064f61d860c1b242000513c764978ec80d76772 Mon Sep 17 00:00:00 2001 From: Krum Tyukenov Date: Thu, 9 Jul 2026 12:20:13 +0300 Subject: [PATCH 008/166] feat(ui): copy changes for WhatsNew feature (#6182) --- .../whats-new/components/feature-card/FeatureCard.tsx | 11 +++++++---- .../constants/content/whats-new/versions/v3.4.1.ts | 2 +- .../constants/content/whats-new/versions/v3.6.0.ts | 6 +++--- redisinsight/ui/src/i18n/locales/bg.json | 1 + redisinsight/ui/src/i18n/locales/en.json | 1 + 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/redisinsight/ui/src/components/whats-new/components/feature-card/FeatureCard.tsx b/redisinsight/ui/src/components/whats-new/components/feature-card/FeatureCard.tsx index 78e54dbc06..b63b3319e9 100644 --- a/redisinsight/ui/src/components/whats-new/components/feature-card/FeatureCard.tsx +++ b/redisinsight/ui/src/components/whats-new/components/feature-card/FeatureCard.tsx @@ -1,6 +1,7 @@ import React from 'react' import { useTranslation } from 'uiSrc/i18n' +import { RiTooltip } from 'uiSrc/components' import { Row } from 'uiSrc/components/base/layout/flex' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { Text } from 'uiSrc/components/base/text' @@ -24,10 +25,12 @@ const FeatureCard = ({ card, isActive = true, onLinkClick }: Props) => { )} {!isActive && ( - + + + )} diff --git a/redisinsight/ui/src/constants/content/whats-new/versions/v3.4.1.ts b/redisinsight/ui/src/constants/content/whats-new/versions/v3.4.1.ts index cb99fc3869..fd2bfeae21 100644 --- a/redisinsight/ui/src/constants/content/whats-new/versions/v3.4.1.ts +++ b/redisinsight/ui/src/constants/content/whats-new/versions/v3.4.1.ts @@ -10,7 +10,7 @@ export const version341: WhatsNewVersion = { id: 'search-workspace', title: 'Dedicated Search workspace', body: 'A new Search workspace with full index lifecycle support: create indexes from sample or existing data, query indexed data with an assisted editor, and save queries to a Query Library for reuse.', - location: 'Search workspace in the left navigation', + location: 'Search workspace in the main database navigation', featureFlag: FeatureFlags.vectorSearchV2, }, { diff --git a/redisinsight/ui/src/constants/content/whats-new/versions/v3.6.0.ts b/redisinsight/ui/src/constants/content/whats-new/versions/v3.6.0.ts index f4469a2760..a7fef50234 100644 --- a/redisinsight/ui/src/constants/content/whats-new/versions/v3.6.0.ts +++ b/redisinsight/ui/src/constants/content/whats-new/versions/v3.6.0.ts @@ -9,21 +9,21 @@ export const version360: WhatsNewVersion = { { id: 'vector-sets', title: 'Vector Sets support', - body: 'Full support for Vector Sets, the Redis 8 vector-native data type: create them manually or from a bundled sample dataset, add elements, and run similarity search end-to-end.', + body: 'Create Vector Sets (Redis 8) manually or from the bundled vec2word sample, add elements with attributes, and run similarity search in the GUI. Handy for prototyping semantic search.', location: 'Browser — add a key of type Vector Set', featureFlag: FeatureFlags.vectorSet, }, { id: 'dev-vs-prod-mode', title: 'Dev vs Production database mode', - body: 'Classify databases by environment with clear visual indicators, and require type-to-confirm for destructive actions on production databases.', + body: 'Tag connections as dev or production. Production shows a PROD badge and requires type-to-confirm before destructive actions. Makes it harder to run destructive actions against the wrong database.', location: "Database list — edit a database's connection settings", featureFlag: FeatureFlags.prodMode, }, { id: 'geodata-workbench', title: 'Geodata Workbench plugin', - body: 'Renders Redis GEO command results as an interactive map, density heatmap, or details card — auto-selected per command.', + body: 'GEO results render as a map, heatmap, or details card, auto-selected per command. Verify GEOSEARCH output visually instead of reading raw coordinates.', location: 'Workbench — run a GEO command (e.g. GEOSEARCH)', }, ], diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 1b47d21503..b017f30ed2 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -377,6 +377,7 @@ "settings.workbench.pipeline.title": "Pipeline режим", "whatsNew.button.gotIt": "Разбрах", "whatsNew.card.comingSoon": "Очаквайте скоро", + "whatsNew.card.tooltip": "Функцията се въвежда поетапно.", "whatsNew.card.locationLabel": "Къде да го намерите:", "whatsNew.menuItem": "Какво ново?", "whatsNew.releaseDate": "Издадена на {{date}}", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 1f8c026014..2f23969001 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -377,6 +377,7 @@ "settings.workbench.pipeline.title": "Pipeline Mode", "whatsNew.button.gotIt": "Got it", "whatsNew.card.comingSoon": "Coming soon", + "whatsNew.card.tooltip": "The feature is rolled out gradually.", "whatsNew.card.locationLabel": "Where to find it:", "whatsNew.menuItem": "What's new?", "whatsNew.releaseDate": "Released {{date}}", From 8f2d230064d6dfce77592bcb579104f53969fa70 Mon Sep 17 00:00:00 2001 From: Vasko Atanasov Date: Thu, 9 Jul 2026 14:11:57 +0300 Subject: [PATCH 009/166] RI-8228 Markdown value encoding (all key types) (#6175) * feat(ui): add Markdown value-encoding format Adds Markdown to the value formatter chain so stored values render as sanitized GitHub-Flavored Markdown for every key type. Rendering goes through a synchronous MarkdownViewer component (unified pipeline plus a DOMPurify pass over the final HTML, then a hardened JsxParser), keeping untrusted values inert; editing keeps the raw markdown source. The Array view gains the format selector next to Add Elements on both the View and Search tabs, inline markdown rendering in value cells, and expandable rows that show the full formatted value for any encoding. Link sanitization now also adds rel="noopener noreferrer" alongside target="_blank". Also removes redundant jest.mock() calls that shadowed the shared moduleNameMapper stubs for unified/unist-util-visit - the split mock instances broke the markdown plugin specs on fresh macOS installs. References: #RI-8228 * refactor(ui): trim narrating comments in markdown value components Keeps only the comments code cannot express: the JSX symbol-wrap round-trip, the plugin type casts, and the DOMPurify hook dependency. References: #RI-8228 * fix(ui): render markdown as sanitized HTML, not parsed JSX react-jsx-parser evaluated {...} expressions embedded in raw HTML, so a value like
{"".constructor.constructor("...")()}
executed code that DOMPurify does not neutralize. The viewer registers no custom components, so it renders the DOMPurify-sanitized HTML directly via dangerouslySetInnerHTML and drops the JSX parser and brace-wrapping entirely. Adds a regression test plus the payload to the e2e XSS case. References: #RI-8228 * fix(ui): block remote-loading elements in markdown value viewer Untrusted Redis values could render img/video/audio/svg/source, all of which fetch remote resources on view - leaking the viewer's IP and enabling tracking. DOMPurify now forbids those plus embedding/input tags via FORBID_TAGS, matching the hostile-input posture already applied to links. Extends the unit and e2e XSS coverage to images and media. References: #RI-8228 * fix(ui): render Markdown inline for every key type on selection Markdown only rendered rich when a caller passed expanded=true (String) or special-cased it (Array), so Hash/List/Set/ZSet/Stream showed raw source in collapsed cells until each row was expanded. The formatter now returns the MarkdownViewer whenever it is not building a tooltip, so picking Markdown renders it inline everywhere, consistent with String. Drops the now-redundant Array cell special-case. Renames the markdown e2e to value-markdown and adds List and Hash inline-render coverage. References: #RI-8228 * fix(ui): drive value-format selector from Redux, not local state ArrayDetails keeps the View and Search tabs mounted together, so each rendered its own KeyDetailsHeaderFormatter with a private typeSelected copy that only updated on its own change - changing the format on one tab left the other's selector stale while cells rendered the new format. The selector now reads viewFormat straight from the store, so every mounted instance stays in sync. References: #RI-8228 --- .../markdown-viewer/MarkdownViewer.spec.tsx | 307 ++++++++++++++++++ .../markdown-viewer/MarkdownViewer.styles.ts | 133 ++++++++ .../markdown-viewer/MarkdownViewer.tsx | 84 +++++ .../markdown-viewer/MarkdownViewer.types.ts | 4 + .../src/components/markdown-viewer/index.ts | 2 + redisinsight/ui/src/constants/keys.ts | 1 + .../KeyDetailsHeaderFormatter.spec.tsx | 1 + .../KeyDetailsHeaderFormatter.tsx | 6 +- .../key-details-header-formatter/constants.ts | 4 + .../ArrayDetailsTable.spec.tsx | 46 +++ .../components/ArrayExpandedValue.spec.tsx | 57 ++++ .../components/ArrayExpandedValue.styles.ts | 20 ++ .../components/ArrayExpandedValue.tsx | 43 +++ .../components/ArrayExpandedValue.types.ts | 9 + .../components/ArrayValueCell.spec.tsx | 23 ++ .../array-details-table/components/index.ts | 1 + .../search-tab/SearchTab.spec.tsx | 6 + .../array-details/search-tab/SearchTab.tsx | 3 + .../array-details/view-tab/ViewTab.spec.tsx | 56 +++- .../array-details/view-tab/ViewTab.styles.ts | 9 - .../array-details/view-tab/ViewTab.tsx | 38 ++- .../KeyDetailsSubheader.spec.tsx | 25 +- .../KeyDetailsSubheader.tsx | 4 +- .../StringDetailsValue.spec.tsx | 18 + .../StringDetailsValue.tsx | 1 + .../services/formatter/MarkdownToJsxString.ts | 20 +- .../ui/src/utils/formatters/markdown/index.ts | 2 + .../formatters/markdown/rehypeWrapSymbols.ts | 18 + .../formatters/markdown/remarkSanitize.ts | 1 + .../src/utils/formatters/valueFormatters.tsx | 11 + .../markdown/rehypeWrapSymbols.spec.ts | 61 ++++ .../formatters/markdown/remarkImage.spec.ts | 1 - .../formatters/markdown/remarkLink.spec.ts | 2 - .../markdown/remarkRedisCode.spec.ts | 2 - .../markdown/remarkRedisUpload.spec.ts | 2 - .../markdown/remarkSanitize.spec.ts | 5 +- .../tests/formatters/valueFormatters.spec.ts | 35 ++ .../pages/browser/components/KeyDetails.ts | 11 + .../key-details/value-markdown.spec.ts | 182 +++++++++++ 39 files changed, 1193 insertions(+), 61 deletions(-) create mode 100644 redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.spec.tsx create mode 100644 redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.styles.ts create mode 100644 redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.tsx create mode 100644 redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.types.ts create mode 100644 redisinsight/ui/src/components/markdown-viewer/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts create mode 100644 redisinsight/ui/src/utils/formatters/markdown/rehypeWrapSymbols.ts create mode 100644 redisinsight/ui/src/utils/tests/formatters/markdown/rehypeWrapSymbols.spec.ts create mode 100644 tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts diff --git a/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.spec.tsx b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.spec.tsx new file mode 100644 index 0000000000..677337a737 --- /dev/null +++ b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.spec.tsx @@ -0,0 +1,307 @@ +import React from 'react' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' +import remarkRehype from 'remark-rehype' +import rehypeStringify from 'rehype-stringify' +import { faker } from '@faker-js/faker' + +import { render, screen } from 'uiSrc/utils/test-utils' +import { remarkSanitize } from 'uiSrc/utils/formatters/markdown' + +import { MarkdownViewer } from './MarkdownViewer' +import { MarkdownViewerProps } from './MarkdownViewer.types' + +// The unified pipeline is mocked via moduleNameMapper (shared jest.fn stubs), so +// these tests control the serialized HTML the component would emit and cover how +// the real DOMPurify sanitize hardens it before it is rendered. Full markdown +// conversion is covered by e2e. +interface PipelineOptions { + html?: string + shouldThrow?: boolean +} + +const setupPipeline = ({ + html = '', + shouldThrow = false, +}: PipelineOptions = {}) => { + const use = jest.fn() + const processSync = jest.fn(() => { + if (shouldThrow) { + throw new Error('markdown parse failed') + } + return html + }) + const chain = { use, processSync } + use.mockReturnValue(chain) + ;(unified as unknown as jest.Mock).mockReturnValue(chain) + return { use, processSync } +} + +const testWindow = window as unknown as { __pwned?: boolean } + +describe('MarkdownViewer', () => { + const defaultProps: MarkdownViewerProps = { + value: faker.lorem.sentence(), + } + + const renderComponent = (propsOverride?: Partial) => { + const props = { ...defaultProps, ...propsOverride } + + return render() + } + + beforeEach(() => { + jest.clearAllMocks() + delete testWindow.__pwned + }) + + it('should render container with the default data-testid', () => { + setupPipeline({ html: '

hello

' }) + renderComponent() + + expect(screen.getByTestId('markdown-viewer')).toBeInTheDocument() + }) + + it('should render container with a custom data-testid', () => { + setupPipeline({ html: '

hello

' }) + renderComponent({ 'data-testid': 'custom-markdown' }) + + expect(screen.getByTestId('custom-markdown')).toBeInTheDocument() + }) + + it('should run the pipeline with the required plugins in order and pass the value', () => { + const value = '# Title' + const { use, processSync } = setupPipeline({ html: '

Title

' }) + + renderComponent({ value }) + + // remark-rehype's jest mock has no default export, so its slot asserts + // undefined plus the options object. + expect(use.mock.calls).toEqual([ + [remarkParse], + [remarkSanitize], + [remarkGfm], + [remarkRehype, { allowDangerousHtml: true }], + [rehypeStringify, { allowDangerousHtml: true }], + ]) + expect(processSync).toBeCalledWith(value) + }) + + it('should render representative GFM pipeline output', () => { + setupPipeline({ + html: + '

Title

' + + '

bold

' + + '
  • first item
' + + '' + + '
name
redis
' + + '
const x = 1
' + + '

Redis

', + }) + renderComponent() + + const container = screen.getByTestId('markdown-viewer') + expect(container.querySelector('h1')).toHaveTextContent('Title') + expect(container.querySelector('strong')).toHaveTextContent('bold') + expect(container.querySelector('ul li')).toHaveTextContent('first item') + expect(container.querySelector('table th')).toHaveTextContent('name') + expect(container.querySelector('table td')).toHaveTextContent('redis') + expect(container.querySelector('pre code')).toHaveTextContent('const x = 1') + expect(container.querySelector('a')).toHaveAttribute( + 'href', + 'https://redis.io', + ) + }) + + it('should render plain text as a paragraph, unchanged', () => { + const value = 'just some plain text' + setupPipeline({ html: `

${value}

` }) + renderComponent({ value }) + + const text = screen.getByText(value) + expect(text.tagName).toBe('P') + }) + + it('should render {, } and > characters literally', () => { + // Rendered as HTML, not parsed as JSX, so braces are literal text. + setupPipeline({ html: '

values {a: 1} > threshold

' }) + renderComponent({ value: 'values {a: 1} > threshold' }) + + expect(screen.getByTestId('markdown-viewer')).toHaveTextContent( + 'values {a: 1} > threshold', + ) + }) + + it('should not evaluate JSX expressions embedded in raw HTML', () => { + // DOMPurify keeps `{...}` as inert text; a JSX parser would execute it. + setupPipeline({ + html: '
{"".constructor.constructor("window.__pwned = true")()}
', + }) + renderComponent({ value: 'irrelevant' }) + + const container = screen.getByTestId('markdown-viewer') + expect(container).toHaveTextContent( + '{"".constructor.constructor("window.__pwned = true")()}', + ) + expect(testWindow.__pwned).toBeUndefined() + }) + + it('should preserve target="_blank" on external links', () => { + setupPipeline({ + html: '

site

', + }) + renderComponent() + + const link = screen.getByText('site') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should add target="_blank" and rel to absolute links that lack it', () => { + // DOMPurify's afterSanitizeAttributes hook (registered by remarkSanitize) + // marks absolute links to open in a new tab and hardens them against + // reverse tabnabbing. + setupPipeline({ html: '

site

' }) + renderComponent() + + const link = screen.getByText('site') + expect(link).toHaveAttribute('href', 'https://redis.io') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should strip javascript: hrefs from links', () => { + setupPipeline({ + html: '

click

', + }) + renderComponent() + + const link = screen.getByText('click') + expect(link.hasAttribute('href')).toBe(false) + expect(testWindow.__pwned).toBeUndefined() + }) + + it('should strip relative hrefs from links', () => { + setupPipeline({ html: '

local

' }) + renderComponent() + + const link = screen.getByText('local') + expect(link.hasAttribute('href')).toBe(false) + }) + + describe('hardening', () => { + it('should not render script elements or execute them', () => { + setupPipeline({ + html: '

before

', + }) + renderComponent() + + const container = screen.getByTestId('markdown-viewer') + expect(container.querySelector('script')).toBeNull() + expect(container.querySelector('p')).toHaveTextContent('before') + expect(testWindow.__pwned).toBeUndefined() + }) + + it('should strip on* attributes', () => { + setupPipeline({ + html: '

text

', + }) + renderComponent() + + const paragraph = screen.getByText('text') + expect(paragraph.hasAttribute('onclick')).toBe(false) + expect(testWindow.__pwned).toBeUndefined() + }) + + it('should not render images that could load remote resources', () => { + setupPipeline({ + html: + '

before

' + + 'tracker' + + '

after

', + }) + renderComponent() + + const container = screen.getByTestId('markdown-viewer') + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('p')).toHaveTextContent('before') + }) + + it('should not render media or embedding elements', () => { + setupPipeline({ + html: + '' + + '' + + '' + + '

safe

', + }) + renderComponent() + + const container = screen.getByTestId('markdown-viewer') + expect(container.querySelector('video')).toBeNull() + expect(container.querySelector('audio')).toBeNull() + expect(container.querySelector('svg')).toBeNull() + expect(container.querySelector('p')).toHaveTextContent('safe') + }) + + it('should strip style attributes', () => { + setupPipeline({ + html: '

styled

', + }) + renderComponent() + + const paragraph = screen.getByText('styled') + expect(paragraph.hasAttribute('style')).toBe(false) + }) + + it('should not render iframe and link elements', () => { + setupPipeline({ + html: + '' + + '' + + '

safe

', + }) + renderComponent() + + const container = screen.getByTestId('markdown-viewer') + expect(container.querySelector('iframe')).toBeNull() + expect(container.querySelector('link')).toBeNull() + expect(container.querySelector('p')).toHaveTextContent('safe') + }) + + it('should keep rendering surrounding content when a script is embedded', () => { + // DOMPurify strips the script and keeps the surrounding nodes. + setupPipeline({ + html: + '

Title

' + + '' + + '

after

', + }) + renderComponent() + + const container = screen.getByTestId('markdown-viewer') + expect(container.querySelector('script')).toBeNull() + expect(container.querySelector('h1')).toHaveTextContent('Title') + expect(container.querySelector('p')).toHaveTextContent('after') + expect(testWindow.__pwned).toBeUndefined() + }) + }) + + it('should render an empty value without crashing', () => { + const { processSync } = setupPipeline({ html: '' }) + renderComponent({ value: '' }) + + expect(screen.getByTestId('markdown-viewer')).toBeInTheDocument() + expect(processSync).toBeCalledWith('') + }) + + it('should fall back to the raw value as plain text when the pipeline throws', () => { + const value = '# Title *raw*' + setupPipeline({ shouldThrow: true }) + renderComponent({ value }) + + const container = screen.getByTestId('markdown-viewer') + expect(container).toHaveTextContent(value) + expect(container.querySelector('h1')).toBeNull() + }) +}) diff --git a/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.styles.ts b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.styles.ts new file mode 100644 index 0000000000..01a134afbb --- /dev/null +++ b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.styles.ts @@ -0,0 +1,133 @@ +import { HTMLAttributes } from 'react' +import styled from 'styled-components' + +import { CommonProps } from 'uiSrc/components/base/theme/types' + +export const Container = styled.div< + CommonProps & HTMLAttributes +>` + font-size: ${({ theme }) => theme.core.font.fontSize.s14}; + color: ${({ theme }) => theme.semantic.color.text.neutral800}; + line-height: 1.5; + overflow-wrap: break-word; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: ${({ theme }) => theme.core.space.space200} 0 + ${({ theme }) => theme.core.space.space100}; + font-weight: ${({ theme }) => theme.core.font.fontWeight.semiBold}; + } + + h1 { + font-size: ${({ theme }) => theme.core.font.fontSize.s20}; + } + + h2 { + font-size: ${({ theme }) => theme.core.font.fontSize.s18}; + } + + h3 { + font-size: ${({ theme }) => theme.core.font.fontSize.s16}; + } + + h4, + h5, + h6 { + font-size: ${({ theme }) => theme.core.font.fontSize.s14}; + } + + p { + margin: ${({ theme }) => theme.core.space.space100} 0; + } + + ul, + ol { + margin: ${({ theme }) => theme.core.space.space100} 0; + padding-left: ${({ theme }) => theme.core.space.space300}; + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + + code { + padding: 0 ${({ theme }) => theme.core.space.space050}; + font-family: ${({ theme }) => + theme.core.font.fontFamily.sourceCodeProRegular}; + font-size: ${({ theme }) => theme.core.font.fontSize.s13}; + background-color: ${({ theme }) => + theme.semantic.color.background.neutral300}; + border-radius: ${({ theme }) => theme.core.space.space050}; + } + + pre { + margin: ${({ theme }) => theme.core.space.space100} 0; + padding: ${({ theme }) => theme.core.space.space150}; + background-color: ${({ theme }) => + theme.semantic.color.background.neutral300}; + border-radius: ${({ theme }) => theme.core.space.space050}; + overflow-x: auto; + + code { + padding: 0; + background-color: transparent; + } + } + + blockquote { + margin: ${({ theme }) => theme.core.space.space100} 0; + padding-left: ${({ theme }) => theme.core.space.space150}; + border-left: 2px solid + ${({ theme }) => theme.semantic.color.border.neutral500}; + color: ${({ theme }) => theme.semantic.color.text.neutral600}; + } + + table { + margin: ${({ theme }) => theme.core.space.space100} 0; + border-collapse: collapse; + } + + th, + td { + padding: ${({ theme }) => theme.core.space.space050} + ${({ theme }) => theme.core.space.space150}; + border: 1px solid ${({ theme }) => theme.semantic.color.border.neutral500}; + } + + th { + font-weight: ${({ theme }) => theme.core.font.fontWeight.semiBold}; + background-color: ${({ theme }) => + theme.semantic.color.background.neutral300}; + } + + a { + color: ${({ theme }) => theme.semantic.color.text.informative400}; + + &:hover { + text-decoration: underline; + } + } + + hr { + margin: ${({ theme }) => theme.core.space.space150} 0; + border: none; + border-top: 1px solid + ${({ theme }) => theme.semantic.color.border.neutral500}; + } +` diff --git a/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.tsx b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.tsx new file mode 100644 index 0000000000..34a0514de3 --- /dev/null +++ b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from 'react' +import { unified } from 'unified' +import type { Plugin } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' +import remarkRehype from 'remark-rehype' +import rehypeStringify from 'rehype-stringify' +import DOMPurify from 'dompurify' + +import { remarkSanitize } from 'uiSrc/utils/formatters/markdown' +import { Nullable } from 'uiSrc/utils' + +import { MarkdownViewerProps } from './MarkdownViewer.types' +import * as S from './MarkdownViewer.styles' + +// Untrusted values get no elements that load remote resources (img/media leak +// the viewer's IP and enable tracking), embed/script content, or take input. +// DOMPurify drops on* handlers by default; style is the attribute it keeps. +const FORBIDDEN_TAGS = [ + 'img', + 'video', + 'audio', + 'source', + 'svg', + 'math', + 'iframe', + 'object', + 'embed', + 'link', + 'style', + 'meta', + 'base', + 'form', + 'input', + 'textarea', + 'select', + 'button', +] +const SANITIZE_CONFIG = { FORBID_TAGS: FORBIDDEN_TAGS, FORBID_ATTR: ['style'] } + +// The custom plugin types its tree as DOM nodes, so it is cast to unist Plugin. +const markdownToSafeHtml = (value: string): string => { + const html = String( + unified() + .use(remarkParse) + .use(remarkSanitize as unknown as Plugin) + .use(remarkGfm) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeStringify, { allowDangerousHtml: true }) + .processSync(value), + ) + + // Absolute-only links, target=_blank and rel=noopener come from the global + // DOMPurify hooks remarkSanitize registers at import; it must stay imported. + return DOMPurify.sanitize(html, SANITIZE_CONFIG) +} + +export const MarkdownViewer = ({ + value, + 'data-testid': dataTestId = 'markdown-viewer', +}: MarkdownViewerProps) => { + const html: Nullable = useMemo(() => { + try { + return markdownToSafeHtml(value) + } catch { + return null + } + }, [value]) + + if (html === null) { + return {value} + } + + // The value is untrusted, so it is rendered as DOMPurify-sanitized HTML rather + // than parsed as JSX: a JSX parser would evaluate `{...}` expressions embedded + // in raw HTML, which sanitization does not neutralize. + return ( + + ) +} diff --git a/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.types.ts b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.types.ts new file mode 100644 index 0000000000..9d553b1fc3 --- /dev/null +++ b/redisinsight/ui/src/components/markdown-viewer/MarkdownViewer.types.ts @@ -0,0 +1,4 @@ +export interface MarkdownViewerProps { + value: string + 'data-testid'?: string +} diff --git a/redisinsight/ui/src/components/markdown-viewer/index.ts b/redisinsight/ui/src/components/markdown-viewer/index.ts new file mode 100644 index 0000000000..a28f1cfddc --- /dev/null +++ b/redisinsight/ui/src/components/markdown-viewer/index.ts @@ -0,0 +1,2 @@ +export { MarkdownViewer } from './MarkdownViewer' +export type { MarkdownViewerProps } from './MarkdownViewer.types' diff --git a/redisinsight/ui/src/constants/keys.ts b/redisinsight/ui/src/constants/keys.ts index 88edec1cc2..68742ef78c 100644 --- a/redisinsight/ui/src/constants/keys.ts +++ b/redisinsight/ui/src/constants/keys.ts @@ -158,6 +158,7 @@ export enum KeyValueFormat { Vector32Bit = 'Vector 32-bit', Vector64Bit = 'Vector 64-bit', DateTime = 'DateTime', + Markdown = 'Markdown', } export const DATETIME_FORMATTER_DEFAULT = 'HH:mm:ss d MMM yyyy' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.spec.tsx index 3769483760..9cdef1c49d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.spec.tsx @@ -25,6 +25,7 @@ describe('KeyValueFormatter', () => { 'Binary', 'HEX', 'JSON', + 'Markdown', 'Msgpack', 'Pickle', 'Protobuf', diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx index c92ceb022a..240142ef05 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/KeyDetailsHeaderFormatter.tsx @@ -45,7 +45,6 @@ const KeyDetailsHeaderFormatter = (props: Props) => { const { value: keyValue } = useAppSelector(stringDataSelector) const [isSelectOpen, setIsSelectOpen] = useState(false) - const [typeSelected, setTypeSelected] = useState(viewFormat) const [options, setOptions] = useState([]) const dispatch = useAppDispatch() @@ -66,7 +65,7 @@ const KeyDetailsHeaderFormatter = (props: Props) => { content={ !isStringFormattingEnabled ? TEXT_DISABLED_STRING_FORMATTING - : typeSelected + : viewFormat } position="top" anchorClassName="flex-row" @@ -114,7 +113,6 @@ const KeyDetailsHeaderFormatter = (props: Props) => { }, }) - setTypeSelected(value) setIsSelectOpen(false) dispatch(setViewFormat(value)) } @@ -137,7 +135,7 @@ const KeyDetailsHeaderFormatter = (props: Props) => { } return option.inputDisplay as JSX.Element }} - value={typeSelected} + value={viewFormat} onChange={(value: any) => onChangeType(value)} data-testid="select-format-key-value" /> diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/constants.ts index 4580c7707a..030045467a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/constants.ts @@ -21,6 +21,10 @@ export const KEY_VALUE_FORMATTER_OPTIONS = [ text: 'JSON', value: KeyValueFormat.JSON, }, + { + text: 'Markdown', + value: KeyValueFormat.Markdown, + }, { text: 'Msgpack', value: KeyValueFormat.Msgpack, diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx index f7b268f085..892b4a2555 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx @@ -17,13 +17,16 @@ import keysReducer, { setSelectedKeyRefreshDisabled, } from 'uiSrc/slices/browser/keys' import { stringToBuffer } from 'uiSrc/utils' +import { KeyValueFormat } from 'uiSrc/constants' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { arrayElementFactory, arrayElementWithValueFactory, } from 'uiSrc/mocks/factories/browser/array/arrayElement.factory' import { ArrayDetailsTable } from './ArrayDetailsTable' +import { ArrayExpandedValue } from './components' // Store whose selected key is set but whose array `data.keyName` is still // empty — the Search-tab / pre-View-load condition the edit key must survive. @@ -496,6 +499,49 @@ describe('ArrayDetailsTable', () => { expect(await screen.findByTestId('expanded-7')).toBeInTheDocument() }) + it('renders the expanded value surface in a Markdown-format sub-row', async () => { + const user = userEvent.setup() + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.viewFormat = KeyValueFormat.Markdown + render( + element.value != null} + renderExpandedRow={(row) => ( + + )} + />, + { store: mockStore(state) }, + ) + + // Collapsed: the Markdown cell renders the rich viewer inline; the + // expanded sub-row surface is not mounted yet. + expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(1) + expect( + screen.queryByTestId('array-expanded-value-7'), + ).not.toBeInTheDocument() + + await user.click(screen.getByTestId('array-details-table-index-7')) + + // Expanded: the sub-row mounts its own full-value surface alongside the + // inline cell viewer. + expect( + await screen.findByTestId('array-expanded-value-7'), + ).toBeInTheDocument() + expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(2) + }) + it('renders no expand affordance when expansion props are omitted', () => { render( { + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.viewFormat = viewFormat + return render(, { + store: mockStore(state), + }) +} + +describe('ArrayExpandedValue', () => { + it('routes a Markdown-format value through the markdown viewer', () => { + renderExpanded(stringToBuffer('# Heading'), KeyValueFormat.Markdown) + + // The expanded value is handed to MarkdownViewer (its container), not the + // plain-text branch. The unified pipeline is stubbed in jsdom, so the rich + // HTML render itself is covered by the e2e / live verification. + expect(screen.getByTestId('array-expanded-value-7')).toBeInTheDocument() + expect(screen.getByTestId('markdown-viewer')).toBeInTheDocument() + }) + + it('renders the full text for a Unicode-format value', () => { + renderExpanded( + stringToBuffer('first line\nsecond line'), + KeyValueFormat.Unicode, + ) + + const container = screen.getByTestId('array-expanded-value-7') + expect(container).toHaveTextContent('first line') + expect(container).toHaveTextContent('second line') + // Plain text must not go through the markdown/JSON rich viewers. + expect(screen.queryByTestId('markdown-viewer')).not.toBeInTheDocument() + expect(screen.queryByTestId('value-as-json')).not.toBeInTheDocument() + }) + + it('renders the JSON tree for a JSON-format value', () => { + renderExpanded(stringToBuffer('{"name":"redis"}'), KeyValueFormat.JSON) + + expect(screen.getByTestId('array-expanded-value-7')).toBeInTheDocument() + expect(screen.getByTestId('value-as-json')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts new file mode 100644 index 0000000000..0d0023b42b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts @@ -0,0 +1,20 @@ +import styled from 'styled-components' +import { Col } from 'uiSrc/components/base/layout/flex' + +export const Container = styled(Col)` + padding: ${({ theme }) => + `${theme.core.space.space100} ${theme.core.space.space150}`}; + min-width: 0; + overflow-wrap: anywhere; +` + +// Text formats (Unicode/ASCII/…) come back as a raw string; preserve their +// newlines and wrap long lines instead of overflowing the row horizontally. +// Kept off the container so the rich viewers (markdown/JSON), which set their +// own layout, don't inherit whitespace preservation. +export const PlainText = styled.div` + white-space: break-spaces; + overflow-wrap: anywhere; + font-size: ${({ theme }) => theme.core.font.fontSize.s14}; + color: ${({ theme }) => theme.semantic.color.text.neutral800}; +` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx new file mode 100644 index 0000000000..41f9d6f90e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +import { useAppSelector } from 'uiSrc/slices/hooks' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { KeyValueCompressor } from 'uiSrc/constants' +import { formattingBuffer, Nullable } from 'uiSrc/utils' +import { decompressingBuffer } from 'uiSrc/utils/decompressors' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' + +import { ArrayExpandedValueProps } from './ArrayExpandedValue.types' +import * as S from './ArrayExpandedValue.styles' + +const TEST_ID_PREFIX = 'array-expanded-value' + +// Reads compressor/viewFormat from the same selectors the table cells use, +// so the expanded surface always matches the cells' format. +export const ArrayExpandedValue = ({ + index, + value, +}: ArrayExpandedValueProps) => { + const { compressor = null } = useAppSelector( + connectedInstanceSelector, + ) as unknown as { compressor: Nullable } + const { viewFormat } = useAppSelector(selectedKeySelector) + + const { value: decompressed } = decompressingBuffer(value, compressor) + const { value: formatted } = formattingBuffer( + decompressed as RedisResponseBuffer, + viewFormat, + { expanded: true }, + ) + + return ( + + {typeof formatted === 'string' ? ( + {formatted} + ) : ( + formatted + )} + + ) +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts new file mode 100644 index 0000000000..430013489f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts @@ -0,0 +1,9 @@ +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' + +export interface ArrayExpandedValueProps { + /** Slot index — used only to build a stable, unique test id per row. */ + index: string + /** Populated slot's raw value buffer. The View tab only expands populated + * rows (`getIsRowExpandable` rejects empty slots), so this is never null. */ + value: RedisResponseBuffer +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx index 8be5cd032b..a56be56c4c 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx @@ -38,3 +38,26 @@ describe('ArrayValueCell — open editor Save lock', () => { expect(screen.getByTestId('apply-btn')).toBeDisabled() }) }) + +describe('ArrayValueCell — inline value rendering', () => { + it('renders Markdown values as rich markdown directly in the cell', () => { + renderCell({ + isEditing: false, + viewFormat: KeyValueFormat.Markdown, + value: stringToBuffer('# Title'), + }) + expect(screen.getByTestId('markdown-viewer')).toBeInTheDocument() + }) + + it('keeps non-Markdown formats compact without a markdown viewer', () => { + renderCell({ + isEditing: false, + viewFormat: KeyValueFormat.JSON, + value: stringToBuffer('{"a":1}'), + }) + expect(screen.queryByTestId('markdown-viewer')).not.toBeInTheDocument() + expect( + screen.getByTestId('array-details-table-value-1'), + ).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts index 3abd524428..a8d2f183e8 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts @@ -1,2 +1,3 @@ export { ArrayIndexCell } from './ArrayIndexCell' export { ArrayValueCell } from './ArrayValueCell' +export { ArrayExpandedValue } from './ArrayExpandedValue' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx index 98738592f7..28f386bf15 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx @@ -55,6 +55,12 @@ describe('SearchTab', () => { expect(screen.getByTestId('array-search-form')).toBeInTheDocument() }) + it('renders the value-format selector', () => { + renderTab() + + expect(screen.getByTestId('select-format-key-value')).toBeInTheDocument() + }) + it('disables the search form while the key is locked for editing', () => { // isRefreshDisabled is set by the active table while a value editor is open // or an ARSET is in flight; the query form must not reload the table then. diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx index 7b0d0c4087..1a434525ae 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx @@ -2,12 +2,14 @@ import React, { useEffect, useRef, useState } from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' import { selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { KeyTypes } from 'uiSrc/constants' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { bufferToString, isEqualBuffers } from 'uiSrc/utils' import { ArrayDetailsTable } from '../array-details-table' import { ArraySearchForm } from '../array-search-form' import { ContextOption } from '../array-search-form/ArraySearchForm.types' +import { KeyDetailsSubheader } from '../../key-details-subheader/KeyDetailsSubheader' import { useArraySearchQuery, useArrayElementActions } from '../hooks' import { DEFAULT_CONTEXT } from '../constants' import * as S from '../tabs.styles' @@ -90,6 +92,7 @@ const SearchTab = ({ keyProp, isActive }: SearchTabProps) => { onReset={handleReset} disabled={!isArrayKeyReady || isRefreshDisabled} /> + {isArrayKeyReady && } {/* Keep the tab blank until the user runs a search, then let ArrayDetailsTable own the loading / error / empty states. Gate on diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx index a8b6f703be..e3f054324d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx @@ -1,5 +1,6 @@ import React from 'react' import { cloneDeep } from 'lodash' +import userEvent from '@testing-library/user-event' import { fireEvent, initialStateDefault, @@ -8,12 +9,15 @@ import { screen, waitFor, } from 'uiSrc/utils/test-utils' -import { KeyTypes } from 'uiSrc/constants' +import { KeyTypes, KeyValueFormat } from 'uiSrc/constants' import { stringToBuffer } from 'uiSrc/utils' import { apiService } from 'uiSrc/services' import { initialState as initialStateArray } from 'uiSrc/slices/browser/array' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' -import { arrayElementWithValueFactory } from 'uiSrc/mocks/factories/browser/array/arrayElement.factory' +import { + arrayElementFactory, + arrayElementWithValueFactory, +} from 'uiSrc/mocks/factories/browser/array/arrayElement.factory' import ViewTab from './ViewTab' jest.mock('uiSrc/services', () => ({ @@ -59,6 +63,54 @@ const renderView = ( } describe('ViewTab', () => { + it('renders the value-format selector alongside Add Elements', () => { + renderView(keyBuffer, {}, [ + arrayElementWithValueFactory.build({ index: '7' }), + ]) + + expect(screen.getByTestId('select-format-key-value')).toBeInTheDocument() + expect(screen.getByTestId(ADD_BTN)).toBeInTheDocument() + }) + + it('expands a populated row into the full formatted value', async () => { + const user = userEvent.setup() + const state = buildState([ + arrayElementWithValueFactory.build({ + index: '7', + value: stringToBuffer('# Heading'), + }), + ]) + state.browser.keys.selectedKey.viewFormat = KeyValueFormat.Markdown + const store = mockStore(state) + store.clearActions() + render(, { store }) + + // Collapsed rows already render the Markdown viewer inline; the expanded + // sub-row surface is not mounted yet. + expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(1) + expect( + screen.queryByTestId('array-expanded-value-7'), + ).not.toBeInTheDocument() + + await user.click(screen.getByTestId('array-details-table-index-7')) + + expect( + await screen.findByTestId('array-expanded-value-7'), + ).toBeInTheDocument() + expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(2) + }) + + it('does not expand an empty slot', async () => { + const user = userEvent.setup() + renderView(keyBuffer, {}, [arrayElementFactory.build({ index: '3' })]) + + await user.click(screen.getByTestId('array-details-table-index-3')) + + expect( + screen.queryByTestId('array-expanded-value-3'), + ).not.toBeInTheDocument() + }) + it('renders a per-row delete affordance for a populated element', () => { renderView(keyBuffer, {}, [ arrayElementWithValueFactory.build({ index: '7' }), diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts deleted file mode 100644 index 7da8416730..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.styles.ts +++ /dev/null @@ -1,9 +0,0 @@ -import styled from 'styled-components' -import { FlexItem } from 'uiSrc/components/base/layout/flex' - -/** Subheader strip hosting the right-aligned "Add Elements" action, mirroring - * VectorSetKeySubheader so the array view matches the other key types. */ -export const SubheaderContainer = styled(FlexItem)` - padding: ${({ theme }) => - `${theme.core?.space.space150} ${theme.core?.space.space200} 0`}; -` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx index 001952f7d1..859659d9df 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx @@ -1,20 +1,21 @@ import React, { useEffect, useRef, useState } from 'react' -import AutoSizer from 'react-virtualized-auto-sizer' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { selectedKeySelector } from 'uiSrc/slices/browser/keys' import { deleteArrayRange } from 'uiSrc/slices/browser/array' +import { KeyTypes } from 'uiSrc/constants' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { bufferToString, isEqualBuffers } from 'uiSrc/utils' -import { Row } from 'uiSrc/components/base/layout/flex' import { AddItemsAction } from 'uiSrc/pages/browser/modules/key-details/components/key-details-actions' import { ArrayDetailsTable } from '../array-details-table' +import { ArrayExpandedValue } from '../array-details-table/components' import { ArrayRangeForm } from '../array-range-form' import { ArrayAddForm } from '../array-add-form' +import { KeyDetailsSubheader } from '../../key-details-subheader/KeyDetailsSubheader' import { AddKeysContainer } from '../../common/AddKeysContainer.styled' import { useArrayRangeQuery, useArrayElementActions } from '../hooks' import * as S from '../tabs.styles' -import * as LS from './ViewTab.styles' import { ViewTabProps } from './ViewTab.types' const ADD_ELEMENTS_TITLE = 'Add Elements' @@ -102,6 +103,14 @@ const ViewTab = ({ } } + const Actions = ({ width }: { width: number }) => ( + + ) + return ( <> {isArrayKeyReady && ( - - - {({ width = 0 }) => ( - - - - )} - - + )} {!loading && ( @@ -144,6 +141,15 @@ const ViewTab = ({ deleteConfig={deleteConfig} selectionConfig={selectionConfig} bulkDeleteConfig={bulkDeleteConfig} + expandRowOnClick + getIsRowExpandable={(element) => element.value != null} + renderExpandedRow={(row) => ( + + )} /> )} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.spec.tsx index 89f9ed8ad8..4fd9eef80d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.spec.tsx @@ -1,14 +1,37 @@ import React from 'react' import { instance, mock } from 'ts-mockito' -import { render } from 'uiSrc/utils/test-utils' +import { render, screen } from 'uiSrc/utils/test-utils' +import { KeyTypes } from 'uiSrc/constants' import { KeyDetailsSubheader, Props } from './KeyDetailsSubheader' const mockedProps = mock() +const MockActions = () =>
+ describe('KeyDetailsSubheader', () => { it('should render', () => { expect( render(), ).toBeTruthy() }) + + it('renders the value formatter for a supported key type', () => { + render() + expect(screen.getByTestId('select-format-key-value')).toBeInTheDocument() + }) + + it('omits the trailing divider when no Actions are provided', () => { + render() + expect(screen.getByTestId('select-format-key-value')).toBeInTheDocument() + expect(screen.queryByRole('separator')).not.toBeInTheDocument() + }) + + it('renders the divider between the formatter and the Actions', () => { + render( + , + ) + expect(screen.getByTestId('select-format-key-value')).toBeInTheDocument() + expect(screen.getByTestId('mock-actions')).toBeInTheDocument() + expect(screen.getByRole('separator')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx index ae9c67ebe6..d9e4d933f0 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx @@ -24,7 +24,9 @@ export const KeyDetailsSubheader = ({ keyType, Actions }: Props) => ( - + {!isUndefined(Actions) && ( + + )} )} {!isUndefined(Actions) && } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.spec.tsx index 0d76c36f9b..bf6bc5f68c 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.spec.tsx @@ -218,6 +218,24 @@ describe('StringDetailsValue', () => { ) }) + it('Should render the markdown viewer when viewFormat is Markdown', () => { + const stringDataSelectorMock = jest.fn().mockReturnValue({ + value: fullValue, + }) + const selectedKeySelectorMock = jest.fn().mockReturnValue({ + viewFormat: KeyValueFormat.Markdown, + }) + ;(selectedKeySelector as jest.Mock).mockImplementation( + selectedKeySelectorMock, + ) + ;(stringDataSelector as jest.Mock).mockImplementation( + stringDataSelectorMock, + ) + + render() + expect(screen.getByTestId('markdown-viewer')).toBeInTheDocument() + }) + it('Should not add "..." in the end of the full value', async () => { const stringDataSelectorMock = jest.fn().mockReturnValue({ value: fullValue, diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.tsx index ced87ff336..bf2edde8e4 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/string-details/string-details-value/StringDetailsValue.tsx @@ -257,6 +257,7 @@ const StringDetailsValue = (props: Props) => { const renderValue = (value: string) => { const textEl = ( isEditable && setIsEdit(true)} diff --git a/redisinsight/ui/src/services/formatter/MarkdownToJsxString.ts b/redisinsight/ui/src/services/formatter/MarkdownToJsxString.ts index 9920d2772c..2cbb979368 100644 --- a/redisinsight/ui/src/services/formatter/MarkdownToJsxString.ts +++ b/redisinsight/ui/src/services/formatter/MarkdownToJsxString.ts @@ -3,12 +3,12 @@ import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import remarkGfm from 'remark-gfm' import rehypeStringify from 'rehype-stringify' -import { visit } from 'unist-util-visit' import { remarkRedisUpload, remarkLink, rehypeLinks, + rehypeWrapSymbols, remarkImage, remarkCode, remarkSanitize, @@ -29,7 +29,7 @@ class MarkdownToJsxString implements IFormatter { .use(remarkLink) // Customise links .use(remarkRehype, { allowDangerousHtml: true }) // Pass raw HTML strings through. .use(rehypeLinks, config ? { history: config.history } : undefined) // Customise links - .use(MarkdownToJsxString.rehypeWrapSymbols) // Wrap special symbols inside curly braces for JSX parse + .use(rehypeWrapSymbols) // Wrap special symbols inside curly braces for JSX parse .use(rehypeStringify, { allowDangerousHtml: true }) // Serialize the raw HTML strings .process(data) .then((file) => { @@ -38,22 +38,6 @@ class MarkdownToJsxString implements IFormatter { .catch((error) => reject(error)) }) } - - private static rehypeWrapSymbols( - symbols: string[] = ['{', '}', '>'], - ): (tree: Node) => void { - return (tree: any) => { - visit(tree, 'text', (node) => { - const { value } = node - if (value) { - node.value = value.replace( - new RegExp(`[${symbols.join()}]`, 'g'), - '{"$&"}', - ) - } - }) - } - } } export default MarkdownToJsxString diff --git a/redisinsight/ui/src/utils/formatters/markdown/index.ts b/redisinsight/ui/src/utils/formatters/markdown/index.ts index 40ce3f1aac..6acbca6170 100644 --- a/redisinsight/ui/src/utils/formatters/markdown/index.ts +++ b/redisinsight/ui/src/utils/formatters/markdown/index.ts @@ -1,4 +1,5 @@ import { rehypeLinks } from './rehypeLinks' +import { rehypeWrapSymbols } from './rehypeWrapSymbols' import { remarkImage } from './remarkImage' import { remarkLink } from './remarkLink' import { remarkCode } from './remarkCode' @@ -8,6 +9,7 @@ import { escapeJsxAttribute } from './escapeJsxAttribute' export { rehypeLinks, + rehypeWrapSymbols, remarkImage, remarkLink, remarkCode, diff --git a/redisinsight/ui/src/utils/formatters/markdown/rehypeWrapSymbols.ts b/redisinsight/ui/src/utils/formatters/markdown/rehypeWrapSymbols.ts new file mode 100644 index 0000000000..1304cb8ef3 --- /dev/null +++ b/redisinsight/ui/src/utils/formatters/markdown/rehypeWrapSymbols.ts @@ -0,0 +1,18 @@ +import { visit } from 'unist-util-visit' + +// Wraps characters that JSX treats as syntax in text nodes as {"$&"} string +// expressions, so the serialized HTML string survives JsxParser and the +// characters render literally. +export const rehypeWrapSymbols = + (symbols: string[] = ['{', '}', '>']): ((tree: Node) => void) => + (tree: any) => { + visit(tree, 'text', (node) => { + const { value } = node + if (value) { + node.value = value.replace( + new RegExp(`[${symbols.join()}]`, 'g'), + '{"$&"}', + ) + } + }) + } diff --git a/redisinsight/ui/src/utils/formatters/markdown/remarkSanitize.ts b/redisinsight/ui/src/utils/formatters/markdown/remarkSanitize.ts index 7925bfba86..7a79956ce4 100644 --- a/redisinsight/ui/src/utils/formatters/markdown/remarkSanitize.ts +++ b/redisinsight/ui/src/utils/formatters/markdown/remarkSanitize.ts @@ -16,6 +16,7 @@ DOMPurify.addHook('afterSanitizeAttributes', (node: Element) => { } node.setAttribute('target', '_blank') + node.setAttribute('rel', 'noopener noreferrer') } }) diff --git a/redisinsight/ui/src/utils/formatters/valueFormatters.tsx b/redisinsight/ui/src/utils/formatters/valueFormatters.tsx index b1aac1b37f..9841e49a57 100644 --- a/redisinsight/ui/src/utils/formatters/valueFormatters.tsx +++ b/redisinsight/ui/src/utils/formatters/valueFormatters.tsx @@ -1,3 +1,4 @@ +import React from 'react' import { encode } from 'msgpackr' // eslint-disable-next-line import/order import { Buffer } from 'buffer' @@ -9,6 +10,7 @@ import JSONBigInt from 'json-bigint' import { store } from 'uiSrc/slices/store' import JSONViewer from 'uiSrc/components/json-viewer/JSONViewer' +import { MarkdownViewer } from 'uiSrc/components/markdown-viewer' import { DATETIME_FORMATTER_DEFAULT, KeyValueFormat, @@ -238,6 +240,15 @@ const formattingBuffer = ( } return { value, isValid: false } } + case KeyValueFormat.Markdown: { + const value = bufferToUTF8(reply) + // Selecting Markdown renders it wherever a value is shown, so it does not + // depend on a row being expanded. Tooltips still get the raw source. + if (props?.tooltip) { + return { value, isValid: true } + } + return { value: , isValid: true } + } default: return { value: bufferToUnicode(reply), isValid: true } } diff --git a/redisinsight/ui/src/utils/tests/formatters/markdown/rehypeWrapSymbols.spec.ts b/redisinsight/ui/src/utils/tests/formatters/markdown/rehypeWrapSymbols.spec.ts new file mode 100644 index 0000000000..da2ce0771c --- /dev/null +++ b/redisinsight/ui/src/utils/tests/formatters/markdown/rehypeWrapSymbols.spec.ts @@ -0,0 +1,61 @@ +import { visit } from 'unist-util-visit' +import { rehypeWrapSymbols } from 'uiSrc/utils/formatters/markdown' + +// unist-util-visit is already stubbed via moduleNameMapper; jest.mock() here +// would shadow that shared instance with a per-spec automock the production +// import never sees. + +const mockVisitWith = (node: { value?: string }) => { + ;(visit as jest.Mock).mockImplementation( + (_tree: any, _name: string, callback: (node: any) => void) => { + callback(node) + }, + ) +} + +describe('rehypeWrapSymbols', () => { + it('should visit text nodes', () => { + mockVisitWith({ value: '' }) + + const tree = {} as Node + rehypeWrapSymbols()(tree) + + expect(visit).toBeCalledWith(tree, 'text', expect.any(Function)) + }) + + it('should wrap {, } and > as JSX string expressions', () => { + const node = { value: 'values {a: 1} > threshold' } + mockVisitWith(node) + + rehypeWrapSymbols()({} as Node) + + expect(node.value).toBe('values {"{"}a: 1{"}"} {">"} threshold') + }) + + it('should leave text without special symbols unchanged', () => { + const node = { value: 'plain text without specials' } + mockVisitWith(node) + + rehypeWrapSymbols()({} as Node) + + expect(node.value).toBe('plain text without specials') + }) + + it('should leave empty values untouched', () => { + const node = { value: '' } + mockVisitWith(node) + + rehypeWrapSymbols()({} as Node) + + expect(node.value).toBe('') + }) + + it('should wrap only the given symbols when a custom list is passed', () => { + const node = { value: 'a > b < c' } + mockVisitWith(node) + + rehypeWrapSymbols(['<'])({} as Node) + + expect(node.value).toBe('a > b {"<"} c') + }) +}) diff --git a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkImage.spec.ts b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkImage.spec.ts index ba8b1e219e..838af9afdd 100644 --- a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkImage.spec.ts +++ b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkImage.spec.ts @@ -2,7 +2,6 @@ import { visit } from 'unist-util-visit' import { RESOURCES_BASE_URL } from 'uiSrc/services/resourcesService' import { remarkImage } from 'uiSrc/utils/formatters/markdown' -jest.mock('unist-util-visit') const TUTORIAL_PATH = 'static/custom-tutorials/tutorial-id' const testCases = [ { diff --git a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkLink.spec.ts b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkLink.spec.ts index 9a1524ac4b..a2e020c88a 100644 --- a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkLink.spec.ts +++ b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkLink.spec.ts @@ -1,8 +1,6 @@ import { visit } from 'unist-util-visit' import { remarkLink } from 'uiSrc/utils/formatters/markdown' -jest.mock('unist-util-visit') - describe('remarkLink', () => { it('should not modify codeNode if title is not Redis Cloud', () => { const codeNode = { diff --git a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisCode.spec.ts b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisCode.spec.ts index b2ba05c841..7c38426e8f 100644 --- a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisCode.spec.ts +++ b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisCode.spec.ts @@ -1,8 +1,6 @@ import { visit } from 'unist-util-visit' import { remarkCode } from 'uiSrc/utils/formatters/markdown' -jest.mock('unist-util-visit') - const visitMock = visit as jest.Mock const setupVisitMock = (node: Record) => { diff --git a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisUpload.spec.ts b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisUpload.spec.ts index 35e86048b8..f18992c73e 100644 --- a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisUpload.spec.ts +++ b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkRedisUpload.spec.ts @@ -1,8 +1,6 @@ import { visit } from 'unist-util-visit' import { remarkRedisUpload } from 'uiSrc/utils/formatters/markdown' -jest.mock('unist-util-visit') - const getValue = (label: string, path: string) => `` diff --git a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkSanitize.spec.ts b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkSanitize.spec.ts index c69278d06f..6d34ea0c9f 100644 --- a/redisinsight/ui/src/utils/tests/formatters/markdown/remarkSanitize.spec.ts +++ b/redisinsight/ui/src/utils/tests/formatters/markdown/remarkSanitize.spec.ts @@ -1,13 +1,12 @@ import { visit } from 'unist-util-visit' import { remarkSanitize } from 'uiSrc/utils/formatters/markdown' -jest.mock('unist-util-visit') - const testCases = [ { input: '', output: '' }, { input: '', - output: '', + output: + '', }, { input: '', output: '' }, { input: '', output: '' }, diff --git a/redisinsight/ui/src/utils/tests/formatters/valueFormatters.spec.ts b/redisinsight/ui/src/utils/tests/formatters/valueFormatters.spec.ts index 41a473b3de..670894d4d8 100644 --- a/redisinsight/ui/src/utils/tests/formatters/valueFormatters.spec.ts +++ b/redisinsight/ui/src/utils/tests/formatters/valueFormatters.spec.ts @@ -1,7 +1,9 @@ +import React from 'react' import { format } from 'date-fns' import { encode } from 'msgpackr' import { serialize } from 'php-serialize' import { DATETIME_FORMATTER_DEFAULT, KeyValueFormat } from 'uiSrc/constants' +import { MarkdownViewer } from 'uiSrc/components/markdown-viewer' import { anyToBuffer, bufferToSerializedFormat, @@ -412,4 +414,37 @@ describe('formattingBuffer', () => { }) }) }) + + describe(KeyValueFormat.Markdown, () => { + const source = '# Title' + const input = stringToBuffer(source) + + it('should render a MarkdownViewer element when expanded', () => { + const { value, isValid } = formattingBuffer( + input, + KeyValueFormat.Markdown, + { expanded: true }, + ) + + expect(isValid).toEqual(true) + expect(React.isValidElement(value)).toEqual(true) + expect(React.isValidElement(value) && value.type).toEqual(MarkdownViewer) + }) + + it('should render a MarkdownViewer element even when not expanded', () => { + const { value, isValid } = formattingBuffer( + input, + KeyValueFormat.Markdown, + ) + + expect(isValid).toEqual(true) + expect(React.isValidElement(value) && value.type).toEqual(MarkdownViewer) + }) + + it('should return the raw markdown source inside a tooltip', () => { + expect( + formattingBuffer(input, KeyValueFormat.Markdown, { tooltip: true }), + ).toEqual({ value: source, isValid: true }) + }) + }) }) diff --git a/tests/e2e-playwright/pages/browser/components/KeyDetails.ts b/tests/e2e-playwright/pages/browser/components/KeyDetails.ts index cb3ee56b93..c97a42c9ad 100644 --- a/tests/e2e-playwright/pages/browser/components/KeyDetails.ts +++ b/tests/e2e-playwright/pages/browser/components/KeyDetails.ts @@ -67,6 +67,9 @@ export class KeyDetails { readonly addJsonFieldButton: Locator; readonly changeEditorTypeButton: Locator; + // Markdown-specific + readonly markdownViewer: Locator; + constructor(page: Page) { this.page = page; @@ -135,6 +138,9 @@ export class KeyDetails { this.jsonContent = page.getByTestId('json-details'); this.addJsonFieldButton = page.getByRole('button', { name: 'Add field' }); this.changeEditorTypeButton = page.getByRole('button', { name: 'Change editor type' }); + + // Markdown-specific - the rendered (sanitized) markdown output + this.markdownViewer = page.getByTestId('markdown-viewer'); } async isVisible(): Promise { @@ -883,4 +889,9 @@ export class KeyDetails { const targetValue = scalarValues.nth(fieldIndex); return await targetValue.innerText(); } + + // Markdown methods + async waitForMarkdownViewer(): Promise { + await this.markdownViewer.waitFor({ state: 'visible' }); + } } diff --git a/tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts b/tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts new file mode 100644 index 0000000000..3f101d5175 --- /dev/null +++ b/tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts @@ -0,0 +1,182 @@ +import { test, expect } from 'e2eSrc/fixtures/base'; +import { StandaloneConfigFactory } from 'e2eSrc/test-data/databases'; +import { StringKeyFactory, ListKeyFactory, HashKeyFactory, TEST_KEY_PREFIX } from 'e2eSrc/test-data/browser'; +import { DatabaseInstance } from 'e2eSrc/types'; + +const MARKDOWN_FORMAT = 'Markdown'; + +// A markdown document exercising the pieces the viewer must render: heading, +// bold, a GFM table, a fenced code block and an external link. The link is a +// markdown-native [text](url) link: sanitizing the final serialized HTML runs +// the DOMPurify href hook on every anchor, so native links get target="_blank" +// and rel="noopener noreferrer" too (not just raw-HTML ones). The last line +// carries {, } and > to prove the DOMPurify round-trip renders those literally. +const RENDERED_MARKDOWN = [ + '# Heading Markdown Test', + '', + 'This has **bold text** and a [Redis link](https://redis.io).', + '', + '| Feature | Status |', + '| ------- | ------ |', + '| Markdown | ready |', + '', + '```js', + 'const answer = 42', + '```', + '', + 'Config {a: 1} applies when value > 5.', +].join('\n'); +const LITERAL_SYMBOLS_TEXT = 'Config {a: 1} applies when value > 5.'; + +// Untrusted value from Redis mixing safe markdown with the dangerous parts the +// sanitizer must defuse: a script tag, an event-handler attribute, a javascript: +// link, a remote image (tracking pixel / IP leak), and a raw-HTML element +// carrying a JSX expression (inert as HTML text, but a JSX parser would execute +// it). The safe heading must still render. +const XSS_MARKDOWN = [ + '# Safe Heading', + '', + '', + '![tracker](https://evil.example/pixel.png)', + '[raw js link](javascript:alert(1))', + '
{"".constructor.constructor("window.__xssPwned = true")()}
', +].join('\n'); + +/** + * Browser > Key Details - Markdown value format + * + * The Markdown value format renders a value through the real sanitized markdown + * pipeline (unified + remark/rehype + DOMPurify). Jest globally mocks that + * pipeline, so this e2e is the only coverage that runs it end to end - including + * the XSS sanitization, which matters because Redis values are untrusted input. + * Selecting Markdown renders inline for every key type (String, List, Hash, ...) + * without expanding a row. + */ +test.describe('Browser > Key Details - Markdown value format', () => { + let database: DatabaseInstance; + + test.beforeAll(async ({ apiHelper }) => { + const config = StandaloneConfigFactory.build({ name: 'test-key-details-markdown-db' }); + database = await apiHelper.createDatabase(config); + }); + + test.afterAll(async ({ apiHelper }) => { + if (database?.id) { + await apiHelper.deleteDatabase(database.id); + } + }); + + test.beforeEach(async ({ browserPage }) => { + await browserPage.goto(database.id); + }); + + test.afterEach(async ({ apiHelper }) => { + await apiHelper.deleteKeysByPattern(database.id, `${TEST_KEY_PREFIX}*`); + }); + + test('should render a markdown String value through the sanitized pipeline', async ({ apiHelper, browserPage }) => { + const keyData = StringKeyFactory.build({ value: RENDERED_MARKDOWN }); + await apiHelper.createStringKey(database.id, keyData.keyName, keyData.value); + + // Open the key in the details panel. + await browserPage.keyList.searchKeys(keyData.keyName); + await browserPage.keyList.clickKey(keyData.keyName); + await browserPage.keyDetails.waitForKeyDetails(); + + // Switch the value format to Markdown and wait for the rendered viewer. + await browserPage.keyDetails.changeValueFormat(MARKDOWN_FORMAT); + await browserPage.keyDetails.waitForMarkdownViewer(); + const viewer = browserPage.keyDetails.markdownViewer; + + // Heading renders as a real

, not raw "# " markdown text. + await expect(viewer.getByRole('heading', { level: 1, name: 'Heading Markdown Test' })).toBeVisible(); + + // Inline formatting renders as elements. + await expect(viewer.locator('strong', { hasText: 'bold text' })).toBeVisible(); + + // Markdown-native external link renders, is forced to open in a new tab and + // is hardened against reverse tabnabbing. + const link = viewer.getByRole('link', { name: 'Redis link' }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', /noopener/); + await expect(link).toHaveAttribute('href', 'https://redis.io'); + + // GFM table renders with header and body cells. + await expect(viewer.locator('table')).toBeVisible(); + await expect(viewer.locator('th', { hasText: 'Feature' })).toBeVisible(); + await expect(viewer.locator('td', { hasText: 'ready' })).toBeVisible(); + + // Fenced code block renders inside
.
+    await expect(viewer.locator('pre code')).toContainText('const answer = 42');
+
+    // { } and > survive the DOMPurify round-trip and render literally.
+    await expect(viewer).toContainText(LITERAL_SYMBOLS_TEXT);
+    await expect(viewer).not.toContainText('>');
+
+    // Raw markdown syntax is not shown verbatim.
+    await expect(viewer).not.toContainText('# Heading');
+    await expect(viewer).not.toContainText('**bold text**');
+  });
+
+  test('should render markdown inline in List element cells on selection', async ({ apiHelper, browserPage, page }) => {
+    const keyData = ListKeyFactory.build({
+      elements: [RENDERED_MARKDOWN, '## Second **element**'],
+    });
+    await apiHelper.createListKey(database.id, keyData.keyName, keyData.elements);
+
+    await browserPage.keyList.searchKeys(keyData.keyName);
+    await browserPage.keyList.clickKey(keyData.keyName);
+    await browserPage.keyDetails.waitForKeyDetails();
+    await browserPage.keyDetails.changeValueFormat(MARKDOWN_FORMAT);
+
+    // Each element cell renders its value as markdown inline, with no row
+    // expansion (the heading appears only when the value is rendered, not raw).
+    await expect(browserPage.keyDetails.markdownViewer.first()).toBeVisible();
+    await expect(page.getByRole('heading', { level: 1, name: 'Heading Markdown Test' })).toBeVisible();
+    await expect(page.locator('strong', { hasText: 'bold text' })).toBeVisible();
+  });
+
+  test('should render markdown inline in Hash value cells on selection', async ({ apiHelper, browserPage, page }) => {
+    const keyData = HashKeyFactory.build({
+      fields: [{ field: 'readme', value: RENDERED_MARKDOWN }],
+    });
+    await apiHelper.createHashKey(database.id, keyData.keyName, keyData.fields);
+
+    await browserPage.keyList.searchKeys(keyData.keyName);
+    await browserPage.keyList.clickKey(keyData.keyName);
+    await browserPage.keyDetails.waitForKeyDetails();
+    await browserPage.keyDetails.changeValueFormat(MARKDOWN_FORMAT);
+
+    // The value cell renders as markdown inline, with no row expansion.
+    await expect(browserPage.keyDetails.markdownViewer.first()).toBeVisible();
+    await expect(page.getByRole('heading', { level: 1, name: 'Heading Markdown Test' })).toBeVisible();
+    await expect(page.locator('strong', { hasText: 'bold text' })).toBeVisible();
+  });
+
+  test('should render markdown but keep an XSS payload inert', async ({ apiHelper, browserPage, page }) => {
+    const keyData = StringKeyFactory.build({ value: XSS_MARKDOWN });
+    await apiHelper.createStringKey(database.id, keyData.keyName, keyData.value);
+
+    // Open the key and switch to Markdown.
+    await browserPage.keyList.searchKeys(keyData.keyName);
+    await browserPage.keyList.clickKey(keyData.keyName);
+    await browserPage.keyDetails.waitForKeyDetails();
+    await browserPage.keyDetails.changeValueFormat(MARKDOWN_FORMAT);
+    await browserPage.keyDetails.waitForMarkdownViewer();
+    const viewer = browserPage.keyDetails.markdownViewer;
+
+    // The safe heading still renders alongside the defused payload.
+    await expect(viewer.getByRole('heading', { level: 1, name: 'Safe Heading' })).toBeVisible();
+
+    // No injected script executed.
+    const xssPwned = await page.evaluate(() => (window as Window & { __xssPwned?: boolean }).__xssPwned);
+    expect(xssPwned).toBeUndefined();
+
+    // No dangerous nodes/attributes survived sanitization.
+    await expect(viewer.locator('script')).toHaveCount(0);
+    await expect(viewer.locator('[onerror]')).toHaveCount(0);
+    await expect(viewer.locator('a[href^="javascript:"]')).toHaveCount(0);
+    await expect(viewer.locator('img')).toHaveCount(0);
+  });
+});

From 28943760a62bb7e8a1e7206e172d2732809ddae5 Mon Sep 17 00:00:00 2001
From: Vasko Atanasov 
Date: Thu, 9 Jul 2026 15:20:48 +0300
Subject: [PATCH 010/166] fix(ui): drop redundant row expand in array View tab
 (#6185)

Array values render inline on format selection (Markdown rich, other
formats compact), so the per-row expand chevron and its sub-panel added
nothing but a redundant duplicate. Removes the View-tab expand wiring
and the ArrayExpandedValue component. The Search tab's context band
(neighbouring elements around a match) is a separate feature and stays.

References: #RI-8228
---
 .../ArrayDetailsTable.spec.tsx                | 46 ---------------
 .../components/ArrayExpandedValue.spec.tsx    | 57 -------------------
 .../components/ArrayExpandedValue.styles.ts   | 20 -------
 .../components/ArrayExpandedValue.tsx         | 43 --------------
 .../components/ArrayExpandedValue.types.ts    |  9 ---
 .../array-details-table/components/index.ts   |  1 -
 .../array-details/view-tab/ViewTab.spec.tsx   | 31 +---------
 .../array-details/view-tab/ViewTab.tsx        | 11 ----
 8 files changed, 3 insertions(+), 215 deletions(-)
 delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.spec.tsx
 delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts
 delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx
 delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts

diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx
index 892b4a2555..f7b268f085 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx
@@ -17,16 +17,13 @@ import keysReducer, {
   setSelectedKeyRefreshDisabled,
 } from 'uiSrc/slices/browser/keys'
 import { stringToBuffer } from 'uiSrc/utils'
-import { KeyValueFormat } from 'uiSrc/constants'
 import { ArrayDataElement } from 'uiSrc/slices/interfaces/array'
-import { RedisResponseBuffer } from 'uiSrc/slices/interfaces'
 import {
   arrayElementFactory,
   arrayElementWithValueFactory,
 } from 'uiSrc/mocks/factories/browser/array/arrayElement.factory'
 
 import { ArrayDetailsTable } from './ArrayDetailsTable'
-import { ArrayExpandedValue } from './components'
 
 // Store whose selected key is set but whose array `data.keyName` is still
 // empty — the Search-tab / pre-View-load condition the edit key must survive.
@@ -499,49 +496,6 @@ describe('ArrayDetailsTable', () => {
     expect(await screen.findByTestId('expanded-7')).toBeInTheDocument()
   })
 
-  it('renders the expanded value surface in a Markdown-format sub-row', async () => {
-    const user = userEvent.setup()
-    const state = cloneDeep(initialStateDefault)
-    state.browser.keys.selectedKey.viewFormat = KeyValueFormat.Markdown
-    render(
-       element.value != null}
-        renderExpandedRow={(row) => (
-          
-        )}
-      />,
-      { store: mockStore(state) },
-    )
-
-    // Collapsed: the Markdown cell renders the rich viewer inline; the
-    // expanded sub-row surface is not mounted yet.
-    expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(1)
-    expect(
-      screen.queryByTestId('array-expanded-value-7'),
-    ).not.toBeInTheDocument()
-
-    await user.click(screen.getByTestId('array-details-table-index-7'))
-
-    // Expanded: the sub-row mounts its own full-value surface alongside the
-    // inline cell viewer.
-    expect(
-      await screen.findByTestId('array-expanded-value-7'),
-    ).toBeInTheDocument()
-    expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(2)
-  })
-
   it('renders no expand affordance when expansion props are omitted', () => {
     render(
        {
-  const state = cloneDeep(initialStateDefault)
-  state.browser.keys.selectedKey.viewFormat = viewFormat
-  return render(, {
-    store: mockStore(state),
-  })
-}
-
-describe('ArrayExpandedValue', () => {
-  it('routes a Markdown-format value through the markdown viewer', () => {
-    renderExpanded(stringToBuffer('# Heading'), KeyValueFormat.Markdown)
-
-    // The expanded value is handed to MarkdownViewer (its container), not the
-    // plain-text branch. The unified pipeline is stubbed in jsdom, so the rich
-    // HTML render itself is covered by the e2e / live verification.
-    expect(screen.getByTestId('array-expanded-value-7')).toBeInTheDocument()
-    expect(screen.getByTestId('markdown-viewer')).toBeInTheDocument()
-  })
-
-  it('renders the full text for a Unicode-format value', () => {
-    renderExpanded(
-      stringToBuffer('first line\nsecond line'),
-      KeyValueFormat.Unicode,
-    )
-
-    const container = screen.getByTestId('array-expanded-value-7')
-    expect(container).toHaveTextContent('first line')
-    expect(container).toHaveTextContent('second line')
-    // Plain text must not go through the markdown/JSON rich viewers.
-    expect(screen.queryByTestId('markdown-viewer')).not.toBeInTheDocument()
-    expect(screen.queryByTestId('value-as-json')).not.toBeInTheDocument()
-  })
-
-  it('renders the JSON tree for a JSON-format value', () => {
-    renderExpanded(stringToBuffer('{"name":"redis"}'), KeyValueFormat.JSON)
-
-    expect(screen.getByTestId('array-expanded-value-7')).toBeInTheDocument()
-    expect(screen.getByTestId('value-as-json')).toBeInTheDocument()
-  })
-})
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts
deleted file mode 100644
index 0d0023b42b..0000000000
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.styles.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import styled from 'styled-components'
-import { Col } from 'uiSrc/components/base/layout/flex'
-
-export const Container = styled(Col)`
-  padding: ${({ theme }) =>
-    `${theme.core.space.space100} ${theme.core.space.space150}`};
-  min-width: 0;
-  overflow-wrap: anywhere;
-`
-
-// Text formats (Unicode/ASCII/…) come back as a raw string; preserve their
-// newlines and wrap long lines instead of overflowing the row horizontally.
-// Kept off the container so the rich viewers (markdown/JSON), which set their
-// own layout, don't inherit whitespace preservation.
-export const PlainText = styled.div`
-  white-space: break-spaces;
-  overflow-wrap: anywhere;
-  font-size: ${({ theme }) => theme.core.font.fontSize.s14};
-  color: ${({ theme }) => theme.semantic.color.text.neutral800};
-`
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx
deleted file mode 100644
index 41f9d6f90e..0000000000
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react'
-
-import { useAppSelector } from 'uiSrc/slices/hooks'
-import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances'
-import { selectedKeySelector } from 'uiSrc/slices/browser/keys'
-import { KeyValueCompressor } from 'uiSrc/constants'
-import { formattingBuffer, Nullable } from 'uiSrc/utils'
-import { decompressingBuffer } from 'uiSrc/utils/decompressors'
-import { RedisResponseBuffer } from 'uiSrc/slices/interfaces'
-
-import { ArrayExpandedValueProps } from './ArrayExpandedValue.types'
-import * as S from './ArrayExpandedValue.styles'
-
-const TEST_ID_PREFIX = 'array-expanded-value'
-
-// Reads compressor/viewFormat from the same selectors the table cells use,
-// so the expanded surface always matches the cells' format.
-export const ArrayExpandedValue = ({
-  index,
-  value,
-}: ArrayExpandedValueProps) => {
-  const { compressor = null } = useAppSelector(
-    connectedInstanceSelector,
-  ) as unknown as { compressor: Nullable }
-  const { viewFormat } = useAppSelector(selectedKeySelector)
-
-  const { value: decompressed } = decompressingBuffer(value, compressor)
-  const { value: formatted } = formattingBuffer(
-    decompressed as RedisResponseBuffer,
-    viewFormat,
-    { expanded: true },
-  )
-
-  return (
-    
-      {typeof formatted === 'string' ? (
-        {formatted}
-      ) : (
-        formatted
-      )}
-    
-  )
-}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts
deleted file mode 100644
index 430013489f..0000000000
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayExpandedValue.types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { RedisResponseBuffer } from 'uiSrc/slices/interfaces'
-
-export interface ArrayExpandedValueProps {
-  /** Slot index — used only to build a stable, unique test id per row. */
-  index: string
-  /** Populated slot's raw value buffer. The View tab only expands populated
-   *  rows (`getIsRowExpandable` rejects empty slots), so this is never null. */
-  value: RedisResponseBuffer
-}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts
index a8d2f183e8..3abd524428 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/index.ts
@@ -1,3 +1,2 @@
 export { ArrayIndexCell } from './ArrayIndexCell'
 export { ArrayValueCell } from './ArrayValueCell'
-export { ArrayExpandedValue } from './ArrayExpandedValue'
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx
index e3f054324d..21f929522a 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
 import { cloneDeep } from 'lodash'
-import userEvent from '@testing-library/user-event'
 import {
   fireEvent,
   initialStateDefault,
@@ -14,10 +13,7 @@ import { stringToBuffer } from 'uiSrc/utils'
 import { apiService } from 'uiSrc/services'
 import { initialState as initialStateArray } from 'uiSrc/slices/browser/array'
 import { ArrayDataElement } from 'uiSrc/slices/interfaces/array'
-import {
-  arrayElementFactory,
-  arrayElementWithValueFactory,
-} from 'uiSrc/mocks/factories/browser/array/arrayElement.factory'
+import { arrayElementWithValueFactory } from 'uiSrc/mocks/factories/browser/array/arrayElement.factory'
 import ViewTab from './ViewTab'
 
 jest.mock('uiSrc/services', () => ({
@@ -72,8 +68,7 @@ describe('ViewTab', () => {
     expect(screen.getByTestId(ADD_BTN)).toBeInTheDocument()
   })
 
-  it('expands a populated row into the full formatted value', async () => {
-    const user = userEvent.setup()
+  it('renders Markdown values inline in the row without expansion', () => {
     const state = buildState([
       arrayElementWithValueFactory.build({
         index: '7',
@@ -85,30 +80,10 @@ describe('ViewTab', () => {
     store.clearActions()
     render(, { store })
 
-    // Collapsed rows already render the Markdown viewer inline; the expanded
-    // sub-row surface is not mounted yet.
-    expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(1)
+    expect(screen.getByTestId('markdown-viewer')).toBeInTheDocument()
     expect(
       screen.queryByTestId('array-expanded-value-7'),
     ).not.toBeInTheDocument()
-
-    await user.click(screen.getByTestId('array-details-table-index-7'))
-
-    expect(
-      await screen.findByTestId('array-expanded-value-7'),
-    ).toBeInTheDocument()
-    expect(screen.getAllByTestId('markdown-viewer')).toHaveLength(2)
-  })
-
-  it('does not expand an empty slot', async () => {
-    const user = userEvent.setup()
-    renderView(keyBuffer, {}, [arrayElementFactory.build({ index: '3' })])
-
-    await user.click(screen.getByTestId('array-details-table-index-3'))
-
-    expect(
-      screen.queryByTestId('array-expanded-value-3'),
-    ).not.toBeInTheDocument()
   })
 
   it('renders a per-row delete affordance for a populated element', () => {
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx
index 859659d9df..32e5975fe9 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx
@@ -4,12 +4,10 @@ import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks'
 import { selectedKeySelector } from 'uiSrc/slices/browser/keys'
 import { deleteArrayRange } from 'uiSrc/slices/browser/array'
 import { KeyTypes } from 'uiSrc/constants'
-import { RedisResponseBuffer } from 'uiSrc/slices/interfaces'
 import { bufferToString, isEqualBuffers } from 'uiSrc/utils'
 import { AddItemsAction } from 'uiSrc/pages/browser/modules/key-details/components/key-details-actions'
 
 import { ArrayDetailsTable } from '../array-details-table'
-import { ArrayExpandedValue } from '../array-details-table/components'
 import { ArrayRangeForm } from '../array-range-form'
 import { ArrayAddForm } from '../array-add-form'
 import { KeyDetailsSubheader } from '../../key-details-subheader/KeyDetailsSubheader'
@@ -141,15 +139,6 @@ const ViewTab = ({
               deleteConfig={deleteConfig}
               selectionConfig={selectionConfig}
               bulkDeleteConfig={bulkDeleteConfig}
-              expandRowOnClick
-              getIsRowExpandable={(element) => element.value != null}
-              renderExpandedRow={(row) => (
-                
-              )}
             />
           
         )}

From ccd7e618251cac2bd6234d441a71e1f32e7aacb4 Mon Sep 17 00:00:00 2001
From: Pavel Angelov 
Date: Thu, 9 Jul 2026 16:01:42 +0300
Subject: [PATCH 011/166] RI-8310: Move array Delete range next to Add Elements
 above the table(#6179)

---
 .../array-range-form/ArrayRangeForm.spec.tsx  | 135 ------------------
 .../array-range-form/ArrayRangeForm.tsx       |  73 +---------
 .../array-range-form/ArrayRangeForm.types.ts  |   7 -
 .../DeleteRangeAction.spec.tsx                | 111 ++++++++++++++
 .../delete-range-action/DeleteRangeAction.tsx |  79 ++++++++++
 .../DeleteRangeAction.types.ts                |  16 +++
 .../delete-range-action/index.ts              |   5 +
 .../array-details/view-tab/ViewTab.spec.tsx   |  33 +++--
 .../array-details/view-tab/ViewTab.tsx        |  47 ++++--
 9 files changed, 282 insertions(+), 224 deletions(-)
 create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.spec.tsx
 create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.tsx
 create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.types.ts
 create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/index.ts

diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.spec.tsx
index 661b2fed9e..9f32300be4 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.spec.tsx
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.spec.tsx
@@ -162,139 +162,4 @@ describe('ArrayRangeForm', () => {
     fireEvent.click(screen.getByTestId('array-range-form-reset'))
     expect(onReset).toHaveBeenCalledTimes(1)
   })
-
-  describe('Delete range', () => {
-    const DELETE_TESTID = 'array-range-form-delete'
-    const DELETE_CONFIRM_TESTID = 'array-range-form-delete-confirm'
-
-    it('renders the delete button only when onDeleteRange is provided', () => {
-      renderComponent()
-
-      expect(screen.queryByTestId(DELETE_TESTID)).not.toBeInTheDocument()
-    })
-
-    it('opens a confirm popover stating the exact window', () => {
-      renderComponent({
-        onDeleteRange: jest.fn(),
-        start: '5',
-        end: '20',
-      })
-
-      fireEvent.click(screen.getByTestId(DELETE_TESTID))
-
-      expect(
-        screen.getByText(
-          'Elements in range 5-20 will be permanently removed from the array.',
-        ),
-      ).toBeInTheDocument()
-    })
-
-    it('calls onDeleteRange on confirm and closes the popover', () => {
-      const onDeleteRange = jest.fn()
-      renderComponent({ onDeleteRange })
-
-      fireEvent.click(screen.getByTestId(DELETE_TESTID))
-      fireEvent.click(screen.getByTestId(DELETE_CONFIRM_TESTID))
-
-      expect(onDeleteRange).toHaveBeenCalledTimes(1)
-      expect(
-        screen.queryByTestId(DELETE_CONFIRM_TESTID),
-      ).not.toBeInTheDocument()
-    })
-
-    it('does not delete before the confirm click', () => {
-      const onDeleteRange = jest.fn()
-      renderComponent({ onDeleteRange })
-
-      fireEvent.click(screen.getByTestId(DELETE_TESTID))
-
-      expect(onDeleteRange).not.toHaveBeenCalled()
-    })
-
-    it.each([
-      ['loading', { loading: true }],
-      ['disabled prop', { disabled: true }],
-      ['invalid start index', { start: '-1' }],
-      ['non-canonical end index', { end: '007' }],
-    ])('disables Delete range on %s', (_, props) => {
-      renderComponent({ onDeleteRange: jest.fn(), ...props })
-
-      expect(screen.getByTestId(DELETE_TESTID)).toBeDisabled()
-    })
-
-    it('stays enabled for an over-cap span (the cap only guards the view query)', () => {
-      // ARDELRANGE accepts any inclusive window — deleting 0..10M without
-      // loading it first is a supported flow, so only Run is span-capped.
-      renderComponent({
-        onDeleteRange: jest.fn(),
-        start: '0',
-        end: '10000000',
-      })
-
-      expect(screen.getByTestId('array-range-form-run')).toBeDisabled()
-      expect(screen.getByTestId(DELETE_TESTID)).not.toBeDisabled()
-    })
-
-    it('stays enabled for a reversed range (deletes the same inclusive window)', () => {
-      renderComponent({ onDeleteRange: jest.fn(), start: '20', end: '5' })
-
-      expect(screen.getByTestId(DELETE_TESTID)).not.toBeDisabled()
-    })
-
-    it('closes an open confirm popover when the key changes', () => {
-      // A confirm left open across a key switch would target the new key
-      // with stale or default bounds.
-      const { rerender } = renderComponent({
-        onDeleteRange: jest.fn(),
-        keyName: 'readings',
-      })
-
-      fireEvent.click(screen.getByTestId(DELETE_TESTID))
-      expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).toBeInTheDocument()
-
-      rerender(
-        ,
-      )
-
-      expect(
-        screen.queryByTestId(DELETE_CONFIRM_TESTID),
-      ).not.toBeInTheDocument()
-    })
-
-    it('closes an open confirm popover when the form becomes disabled', () => {
-      const { rerender } = renderComponent({ onDeleteRange: jest.fn() })
-
-      fireEvent.click(screen.getByTestId(DELETE_TESTID))
-      expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).toBeInTheDocument()
-
-      rerender(
-        ,
-      )
-
-      expect(
-        screen.queryByTestId(DELETE_CONFIRM_TESTID),
-      ).not.toBeInTheDocument()
-    })
-
-    it('disables the confirm button when an index turns invalid while open', () => {
-      const { rerender } = renderComponent({ onDeleteRange: jest.fn() })
-
-      fireEvent.click(screen.getByTestId(DELETE_TESTID))
-      expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).not.toBeDisabled()
-
-      rerender(
-        ,
-      )
-
-      expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).toBeDisabled()
-    })
-  })
 })
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx
index 0ba45623f3..5c2521afc4 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx
@@ -1,15 +1,9 @@
-import React, { useEffect, useMemo, useState } from 'react'
+import React, { useMemo, useState } from 'react'
 
-import { useTranslation } from 'uiSrc/i18n'
 import { RiTooltip } from 'uiSrc/components'
-import ConfirmationPopover from 'uiSrc/components/confirmation-popover'
-import {
-  DestructiveButton,
-  IconButton,
-  PrimaryButton,
-} from 'uiSrc/components/base/forms/buttons'
+import { IconButton, PrimaryButton } from 'uiSrc/components/base/forms/buttons'
 import { FormField } from 'uiSrc/components/base/forms/FormField'
-import { DeleteIcon, ResetIcon } from 'uiSrc/components/base/icons'
+import { ResetIcon } from 'uiSrc/components/base/icons'
 import { FlexItem, Row } from 'uiSrc/components/base/layout/flex'
 import { TextInput } from 'uiSrc/components/base/inputs'
 import { Checkbox } from 'uiSrc/components/base/forms/checkbox/Checkbox'
@@ -34,14 +28,14 @@ import * as S from './ArrayRangeForm.styles'
 /**
  * Range/scan query form for the array View tab. Lays out inputs above a
  * single action row containing a toggleable command preview, an optional
- * reset, an optional destructive Delete range, and the primary Run button —
- * matching the Vector Set similarity-search form pattern so the two
- * verticals feel like siblings.
+ * reset, and the primary Run button — matching the Vector Set
+ * similarity-search form pattern so the two verticals feel like siblings.
+ * The destructive Delete range action lives in the View tab subheader
+ * (`DeleteRangeAction`) next to Add Elements, not in this form.
  *
  * - `Start` / `End` are decimal-string indexes (BigInt-as-string contract).
  * - `Show empty indexes` ON  → ARGETRANGE (returns `null` for gaps).
  * - `Show empty indexes` OFF → ARSCAN (skips gaps; `Limit` caps result size).
- * - `Delete range` → ARDELRANGE over the same [start, end] inputs.
  */
 export const ArrayRangeForm = ({
   keyName,
@@ -54,22 +48,11 @@ export const ArrayRangeForm = ({
   onToggleShowEmpty,
   onRun,
   onReset,
-  onDeleteRange,
   disabled = false,
 }: ArrayRangeFormProps) => {
-  const { t } = useTranslation()
   const [previewVisible, setPreviewVisible] = useState(false)
-  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
   const { containerRef, isWide } = useResponsivePreviewLabel()
 
-  // A delete confirm left open across a key switch (or while the newly
-  // clicked key's type is still unconfirmed) must not carry over: the
-  // inputs reset for the new key, so confirming would run ARDELRANGE
-  // against it with stale or default bounds.
-  useEffect(() => {
-    setDeleteConfirmOpen(false)
-  }, [keyName, disabled])
-
   // Match the backend's @IsArrayIndex validator exactly: accept only
   // canonical decimal strings (no leading zeros, no whitespace, etc.).
   // Loose-acceptance values like "007" or " 7 " would pass `parseArrayIndex`
@@ -118,11 +101,6 @@ export const ArrayRangeForm = ({
     return `ARSCAN ${name} ${start} ${end} LIMIT ${DEFAULT_SCAN_LIMIT}`
   }, [keyName, start, end, showEmpty])
 
-  // No span cap here on purpose: the 1M cap protects the view response
-  // size (ARGETRANGE), while ARDELRANGE accepts any inclusive window —
-  // deleting 0..10M without loading it first is a supported flow.
-  const deleteDisabled = startInvalid || endInvalid || loading || disabled
-
   return (
     
       
@@ -191,43 +169,6 @@ export const ArrayRangeForm = ({
             
           
         )}
-        {onDeleteRange && (
-          
-             setDeleteConfirmOpen(false)}
-              panelPaddingSize="m"
-              title={t('browser.array.delete.range.title')}
-              message={t('browser.array.delete.range.message', { start, end })}
-              button={
-                 setDeleteConfirmOpen((open) => !open)}
-                  disabled={deleteDisabled}
-                  data-testid={`${TEST_ID}-delete`}
-                >
-                  {t('browser.array.delete.range.trigger')}
-                
-              }
-              confirmButton={
-                 {
-                    onDeleteRange()
-                    setDeleteConfirmOpen(false)
-                  }}
-                  data-testid={`${TEST_ID}-delete-confirm`}
-                >
-                  {t('browser.array.delete.range.button')}
-                
-              }
-            />
-          
-        )}
         
            onRun()}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.types.ts
index f1cbda9afe..0919ae6137 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.types.ts
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.types.ts
@@ -18,13 +18,6 @@ export interface ArrayRangeFormProps {
    * actual reset semantics (resetting Redux state alongside form state).
    */
   onReset?: () => void
-  /**
-   * Deletes the inclusive [start, end] window currently in the inputs
-   * (ARDELRANGE). Rendered as a destructive action behind its own confirm
-   * popover; hidden when the handler is absent. Unlike Run, it ignores the
-   * view-only span cap — the delete endpoint accepts any window size.
-   */
-  onDeleteRange?: () => void
   /**
    * Disables the Run / Reset actions in addition to the form's internal
    * range validation. Container passes `true` while the selected key's
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.spec.tsx
new file mode 100644
index 0000000000..4a569d0d9e
--- /dev/null
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.spec.tsx
@@ -0,0 +1,111 @@
+import React from 'react'
+import { fireEvent, render, screen } from 'uiSrc/utils/test-utils'
+
+import { DeleteRangeAction } from './DeleteRangeAction'
+import { DeleteRangeActionProps } from './DeleteRangeAction.types'
+
+const DELETE_TESTID = 'array-delete-range'
+const DELETE_CONFIRM_TESTID = 'array-delete-range-confirm'
+
+const defaultProps: DeleteRangeActionProps = {
+  start: '0',
+  end: '9',
+  onDeleteRange: jest.fn(),
+}
+
+const renderComponent = (props: Partial = {}) =>
+  render()
+
+describe('DeleteRangeAction', () => {
+  it('opens a confirm popover stating the exact window', () => {
+    renderComponent({ start: '5', end: '20' })
+
+    fireEvent.click(screen.getByTestId(DELETE_TESTID))
+
+    expect(
+      screen.getByText(
+        'Elements in range 5-20 will be permanently removed from the array.',
+      ),
+    ).toBeInTheDocument()
+  })
+
+  it('calls onDeleteRange on confirm and closes the popover', () => {
+    const onDeleteRange = jest.fn()
+    renderComponent({ onDeleteRange })
+
+    fireEvent.click(screen.getByTestId(DELETE_TESTID))
+    fireEvent.click(screen.getByTestId(DELETE_CONFIRM_TESTID))
+
+    expect(onDeleteRange).toHaveBeenCalledTimes(1)
+    expect(screen.queryByTestId(DELETE_CONFIRM_TESTID)).not.toBeInTheDocument()
+  })
+
+  it('does not delete before the confirm click', () => {
+    const onDeleteRange = jest.fn()
+    renderComponent({ onDeleteRange })
+
+    fireEvent.click(screen.getByTestId(DELETE_TESTID))
+
+    expect(onDeleteRange).not.toHaveBeenCalled()
+  })
+
+  it.each([
+    ['loading', { loading: true }],
+    ['disabled prop', { disabled: true }],
+    ['invalid start index', { start: '-1' }],
+    ['non-canonical end index', { end: '007' }],
+  ])('disables the trigger on %s', (_, props) => {
+    renderComponent(props)
+
+    expect(screen.getByTestId(DELETE_TESTID)).toBeDisabled()
+  })
+
+  it('stays enabled for an over-cap span (the cap only guards the view query)', () => {
+    // ARDELRANGE accepts any inclusive window — deleting 0..10M without
+    // loading it first is a supported flow, so it is not span-capped.
+    renderComponent({ start: '0', end: '10000000' })
+
+    expect(screen.getByTestId(DELETE_TESTID)).not.toBeDisabled()
+  })
+
+  it('stays enabled for a reversed range (deletes the same inclusive window)', () => {
+    renderComponent({ start: '20', end: '5' })
+
+    expect(screen.getByTestId(DELETE_TESTID)).not.toBeDisabled()
+  })
+
+  it('closes an open confirm popover when the key changes', () => {
+    // A confirm left open across a key switch would target the new key
+    // with stale or default bounds.
+    const { rerender } = renderComponent({ keyName: 'readings' })
+
+    fireEvent.click(screen.getByTestId(DELETE_TESTID))
+    expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).toBeInTheDocument()
+
+    rerender()
+
+    expect(screen.queryByTestId(DELETE_CONFIRM_TESTID)).not.toBeInTheDocument()
+  })
+
+  it('closes an open confirm popover when the action becomes disabled', () => {
+    const { rerender } = renderComponent()
+
+    fireEvent.click(screen.getByTestId(DELETE_TESTID))
+    expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).toBeInTheDocument()
+
+    rerender()
+
+    expect(screen.queryByTestId(DELETE_CONFIRM_TESTID)).not.toBeInTheDocument()
+  })
+
+  it('disables the confirm button when an index turns invalid while open', () => {
+    const { rerender } = renderComponent()
+
+    fireEvent.click(screen.getByTestId(DELETE_TESTID))
+    expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).not.toBeDisabled()
+
+    rerender()
+
+    expect(screen.getByTestId(DELETE_CONFIRM_TESTID)).toBeDisabled()
+  })
+})
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.tsx
new file mode 100644
index 0000000000..d10d8f62b7
--- /dev/null
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.tsx
@@ -0,0 +1,79 @@
+import React, { useEffect, useState } from 'react'
+
+import { useTranslation } from 'uiSrc/i18n'
+import ConfirmationPopover from 'uiSrc/components/confirmation-popover'
+import { DestructiveButton } from 'uiSrc/components/base/forms/buttons'
+import { DeleteIcon } from 'uiSrc/components/base/icons'
+import { parseArrayIndex } from 'uiSrc/utils/arrayIndex'
+
+import { DeleteRangeActionProps } from './DeleteRangeAction.types'
+
+export const DELETE_RANGE_ACTION_TEST_ID = 'array-delete-range'
+
+/**
+ * Destructive "Delete range" action for the array View tab, shown in the
+ * subheader next to "Add Elements". Deletes the inclusive [start, end] window
+ * from the range inputs (ARDELRANGE) behind a confirm popover.
+ *
+ * Not span-capped, unlike the view query — the delete endpoint accepts any
+ * window, so deleting a huge range without loading it first is supported.
+ */
+export const DeleteRangeAction = ({
+  keyName,
+  start,
+  end,
+  loading = false,
+  disabled = false,
+  onDeleteRange,
+}: DeleteRangeActionProps) => {
+  const { t } = useTranslation()
+  const [confirmOpen, setConfirmOpen] = useState(false)
+
+  // Don't carry an open confirm across a key switch: the inputs reset for the
+  // new key, so confirming would delete a stale window from it.
+  useEffect(() => {
+    setConfirmOpen(false)
+  }, [keyName, disabled])
+
+  // Only canonical decimal strings, matching the backend's @IsArrayIndex.
+  const startInvalid = parseArrayIndex(start) !== start
+  const endInvalid = parseArrayIndex(end) !== end
+  const deleteDisabled = startInvalid || endInvalid || loading || disabled
+
+  return (
+     setConfirmOpen(false)}
+      panelPaddingSize="m"
+      title={t('browser.array.delete.range.title')}
+      message={t('browser.array.delete.range.message', { start, end })}
+      button={
+         setConfirmOpen((open) => !open)}
+          disabled={deleteDisabled}
+          data-testid={DELETE_RANGE_ACTION_TEST_ID}
+        >
+          {t('browser.array.delete.range.trigger')}
+        
+      }
+      confirmButton={
+         {
+            onDeleteRange()
+            setConfirmOpen(false)
+          }}
+          data-testid={`${DELETE_RANGE_ACTION_TEST_ID}-confirm`}
+        >
+          {t('browser.array.delete.range.button')}
+        
+      }
+    />
+  )
+}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.types.ts
new file mode 100644
index 0000000000..1a7cf00923
--- /dev/null
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/DeleteRangeAction.types.ts
@@ -0,0 +1,16 @@
+export interface DeleteRangeActionProps {
+  /** Used to close the confirm popover when the selected key changes. */
+  keyName?: string
+  /** Live [start, end] indexes the delete targets (BigInt-as-string). */
+  start: string
+  end: string
+  /** Disables the action while the range query is in flight. */
+  loading?: boolean
+  /**
+   * Disables the trigger on top of the internal index validation — set while
+   * the selected key's array type is not yet confirmed.
+   */
+  disabled?: boolean
+  /** Runs ARDELRANGE over the inclusive [start, end] window. */
+  onDeleteRange: () => void
+}
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/index.ts
new file mode 100644
index 0000000000..8c6ab3d8f5
--- /dev/null
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/delete-range-action/index.ts
@@ -0,0 +1,5 @@
+export {
+  DeleteRangeAction,
+  DELETE_RANGE_ACTION_TEST_ID,
+} from './DeleteRangeAction'
+export type { DeleteRangeActionProps } from './DeleteRangeAction.types'
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx
index 21f929522a..e9116da3aa 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.spec.tsx
@@ -167,10 +167,8 @@ describe('ViewTab', () => {
       arrayElementWithValueFactory.build({ index: '7' }),
     ])
 
-    fireEvent.click(screen.getByTestId('array-range-form-delete'))
-    fireEvent.click(
-      await screen.findByTestId('array-range-form-delete-confirm'),
-    )
+    fireEvent.click(screen.getByTestId('array-delete-range'))
+    fireEvent.click(await screen.findByTestId('array-delete-range-confirm'))
 
     // The form's default range is the live input value the delete targets.
     await waitFor(() =>
@@ -201,10 +199,8 @@ describe('ViewTab', () => {
       await screen.findByTestId('array-bulk-remove-btn-icon'),
     ).toBeInTheDocument()
 
-    fireEvent.click(screen.getByTestId('array-range-form-delete'))
-    fireEvent.click(
-      await screen.findByTestId('array-range-form-delete-confirm'),
-    )
+    fireEvent.click(screen.getByTestId('array-delete-range'))
+    fireEvent.click(await screen.findByTestId('array-delete-range-confirm'))
 
     await waitFor(() =>
       expect(
@@ -213,6 +209,27 @@ describe('ViewTab', () => {
     )
   })
 
+  it('keeps the delete-range confirm open across a non-key re-render', async () => {
+    // The confirm lives in DeleteRangeAction local state, so it survives only
+    // while the Actions render prop keeps a stable identity. A fresh Actions
+    // each render would be a new component type, remounting DeleteRangeAction
+    // and silently dropping an open confirm on any parent update.
+    const { rerender } = renderView(keyBuffer, {}, [
+      arrayElementWithValueFactory.build({ index: '7' }),
+    ])
+
+    fireEvent.click(screen.getByTestId('array-delete-range'))
+    expect(
+      await screen.findByTestId('array-delete-range-confirm'),
+    ).toBeInTheDocument()
+
+    // Re-render that is not a key switch (same bytes, fresh buffer): the open
+    // confirm must not be torn down.
+    rerender()
+
+    expect(screen.getByTestId('array-delete-range-confirm')).toBeInTheDocument()
+  })
+
   it('drops the multi-select when the range is reset', async () => {
     // resetQuery refires the default range with resetData:false, so the current
     // rows stay rendered; the selection must still clear on reset.
diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx
index 32e5975fe9..acb91663d3 100644
--- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx
+++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/view-tab/ViewTab.tsx
@@ -1,15 +1,17 @@
-import React, { useEffect, useRef, useState } from 'react'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
 
 import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks'
 import { selectedKeySelector } from 'uiSrc/slices/browser/keys'
 import { deleteArrayRange } from 'uiSrc/slices/browser/array'
 import { KeyTypes } from 'uiSrc/constants'
 import { bufferToString, isEqualBuffers } from 'uiSrc/utils'
+import { Row } from 'uiSrc/components/base/layout/flex'
 import { AddItemsAction } from 'uiSrc/pages/browser/modules/key-details/components/key-details-actions'
 
 import { ArrayDetailsTable } from '../array-details-table'
 import { ArrayRangeForm } from '../array-range-form'
 import { ArrayAddForm } from '../array-add-form'
+import { DeleteRangeAction } from '../delete-range-action'
 import { KeyDetailsSubheader } from '../../key-details-subheader/KeyDetailsSubheader'
 import { AddKeysContainer } from '../../common/AddKeysContainer.styled'
 import { useArrayRangeQuery, useArrayElementActions } from '../hooks'
@@ -101,12 +103,42 @@ const ViewTab = ({
     }
   }
 
-  const Actions = ({ width }: { width: number }) => (
-    
+  // KeyDetailsSubheader renders the Actions render prop as , so a
+  // fresh function each render is a new component type — React would remount the
+  // subtree and drop DeleteRangeAction's open confirm popover on any parent
+  // update (editing the range, a loading flip, a redux change). Keep Actions'
+  // identity stable and read live values through a ref so it stays dep-free.
+  const latest = {
+    keyName,
+    start,
+    end,
+    rangeLoading,
+    isRefreshDisabled,
+    handleDeleteRange,
+    openAddPanel,
+  }
+  const latestRef = useRef(latest)
+  latestRef.current = latest
+
+  const Actions = useCallback(
+    ({ width }: { width: number }) => (
+      
+        
+        
+      
+    ),
+    [],
   )
 
   return (
@@ -122,7 +154,6 @@ const ViewTab = ({
         onToggleShowEmpty={setShowEmpty}
         onRun={runQuery}
         onReset={handleReset}
-        onDeleteRange={handleDeleteRange}
         disabled={!isArrayKeyReady || isRefreshDisabled}
       />
       {isArrayKeyReady && (

From 8d18682bece4cba96232160efdb92bd6649be96c Mon Sep 17 00:00:00 2001
From: DimoHG 
Date: Thu, 9 Jul 2026 16:23:09 +0300
Subject: [PATCH 012/166] fix(copilot): drop  regex, block background
 attr in AI chat

Address bot review on the previous commit:

- Remove the LOWERCASE_LINK_TAG global string replace. It ran over the whole
  formatted JSX (including fenced code emitted as {JSON.stringify(...)}
  ), so a  inside a code snippet was deleted, corrupting the
  block. Raw  elements are already stripped by remarkSanitize (DOMPurify)
  during formatting, before the PascalCase  component is generated, so
  the regex was redundant as well as harmful.
- Block the legacy `background` URI attribute (/^background$/i): DOMPurify keeps
  it on /
and browsers load it as an image URL, another render-time exfiltration vector. Co-Authored-By: Claude Opus 4.8 --- .../markdown-message/MarkdownMessage.spec.tsx | 15 +++++++++++++ .../markdown-message/MarkdownMessage.tsx | 22 +++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx index 9839be38d0..e82d31714c 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.spec.tsx @@ -89,6 +89,21 @@ describe('MarkdownMessage', () => { expect(container.querySelector('style')).toBeNull() }) + it('should not keep a background attribute that could beacon out via a URL', async () => { + const { container } = render( + + {'Marker text. ' + + '
x
'} +
, + ) + + await waitFor(() => { + expect(screen.getByText(/Marker text\./)).toBeInTheDocument() + }) + + expect(container.querySelector('[background]')).toBeNull() + }) + it('should not render a raw element that could load external resources', async () => { const { container } = render( diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx index c79bfc537d..6614651af0 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/MarkdownMessage.tsx @@ -35,15 +35,19 @@ const BLACKLISTED_TAGS = [ 'input', ] -// Strip event handlers (default) plus `style`, which can beacon out via -// CSS `background-image: url(https://attacker/...)`. -const BLACKLISTED_ATTRS: Array = [/^on.+/i, /^style$/i] +// Strip event handlers (default) plus attributes that can trigger an outbound +// request on render: `style` (CSS `background-image: url(...)`) and the legacy +// `background` image URL supported on ``/`{/* Compact disclosure: chevron + "Options" on the left, the add-row "+" on the right, the option fields below once expanded. */} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.types.ts index 69536cdd2c..7a10e3a1e3 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.types.ts @@ -4,16 +4,6 @@ import { ArraySearchOptions, } from 'uiSrc/slices/interfaces/array' -/** - * Per-match context window shown when a result row is expanded: a toggle plus - * the ±N neighbour count. A display concern, kept separate from `options` so - * it never enters the ARGREP command. - */ -export type ContextOption = { - enabled: boolean - count: number -} - export interface ArraySearchFormProps { /** * Key name rendered in the preview command. Optional so the form can be @@ -32,8 +22,6 @@ export interface ArraySearchFormProps { onChangePredicate: (index: number, patch: Partial) => void onChangeCombinator: (combinator: ArrayCombinator) => void onChangeOptions: (patch: Partial) => void - context: ContextOption - onChangeContext: (patch: Partial) => void onRun: () => void /** * Optional reset hook — restores form defaults and clears prior results. diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/components/InfoHint.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/InfoHint.tsx similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/components/InfoHint.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/InfoHint.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/components/InfoHint.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/InfoHint.types.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/components/InfoHint.types.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/InfoHint.types.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/index.ts new file mode 100644 index 0000000000..b4e020206e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/index.ts @@ -0,0 +1,2 @@ +export { InfoHint } from './InfoHint' +export type { InfoHintProps } from './InfoHint.types' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.constants.ts new file mode 100644 index 0000000000..bf53c0979d --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.constants.ts @@ -0,0 +1,6 @@ +export const ARRAY_CONTEXT_CONTROL_TEST_ID = 'array-context-control' + +export const CONTEXT_LABEL = 'Context' +export const CONTEXT_PREFIX = '±' +export const CONTEXT_HINT = + 'When expanding a match, also show ±N neighbouring elements.' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.spec.tsx new file mode 100644 index 0000000000..edfe01f492 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.spec.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { + fireEvent, + render, + screen, + userEvent, + waitFor, +} from 'uiSrc/utils/test-utils' + +import { ContextControl } from './ContextControl' +import { ContextControlProps } from './ContextControl.types' + +const defaultProps: ContextControlProps = { + context: { enabled: false, count: 5 }, + onChange: jest.fn(), +} + +const renderComponent = (props: Partial = {}) => + render() + +describe('ContextControl', () => { + it('keeps the count input disabled until the toggle is ticked', () => { + const { rerender } = renderComponent() + // Off by default → input present (so layout is stable) but disabled. + expect(screen.getByTestId('array-context-control-count')).toBeDisabled() + + rerender( + , + ) + expect(screen.getByTestId('array-context-control-count')).toBeEnabled() + }) + + it('reports enabled when the toggle is ticked', () => { + const onChange = jest.fn() + renderComponent({ onChange }) + + fireEvent.click(screen.getByTestId('array-context-control-toggle')) + + expect(onChange).toHaveBeenCalledWith({ enabled: true }) + }) + + it('shows the passed count and clamps a typed value above the max to 50', async () => { + const user = userEvent.setup() + renderComponent({ context: { enabled: true, count: 5 } }) + + const input = screen.getByTestId('array-context-control-count') + // redis-ui NumericInput renders a text input, so the DOM value is a string. + expect(input).toHaveValue('5') + + // autoValidate clamps onChange, but the field text only settles to the + // clamped value on blur — so '99' stays verbatim while typing and resolves + // to '50' once the input blurs. + await user.clear(input) + await user.type(input, '99') + await user.tab() + + await waitFor(() => { + expect(input).toHaveValue('50') + }) + }) + + it('reports a new count via onChange', () => { + const onChange = jest.fn() + renderComponent({ context: { enabled: true, count: 5 }, onChange }) + + fireEvent.change(screen.getByTestId('array-context-control-count'), { + target: { value: '8' }, + }) + + expect(onChange).toHaveBeenCalledWith({ count: 8 }) + }) + + it('disables both the toggle and the input when disabled', () => { + renderComponent({ context: { enabled: true, count: 5 }, disabled: true }) + + expect(screen.getByTestId('array-context-control-toggle')).toBeDisabled() + expect(screen.getByTestId('array-context-control-count')).toBeDisabled() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.styles.ts new file mode 100644 index 0000000000..fc615c0e49 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.styles.ts @@ -0,0 +1,16 @@ +import styled from 'styled-components' +import { Checkbox } from 'uiSrc/components/base/forms/checkbox/Checkbox' +import { Row } from 'uiSrc/components/base/layout/flex' + +/** Trim the checkbox label's trailing padding so the InfoHint hugs the text. */ +export const InlineCheckbox = styled(Checkbox)` + & label { + padding-inline-end: 0; + padding-right: 0; + } +` + +/** Compact fixed-width box so the count reads as a small inline field. */ +export const NarrowInputBox = styled(Row)` + width: 110px; +` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.tsx new file mode 100644 index 0000000000..d07a64ed25 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.tsx @@ -0,0 +1,68 @@ +import React from 'react' + +import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' +import { NumericInput } from 'uiSrc/components/base/inputs' +import { Text } from 'uiSrc/components/base/text' + +import { InfoHint } from '../../components/InfoHint' +import { CONTEXT_COUNT_MAX, CONTEXT_COUNT_MIN } from '../../constants' +import { + ARRAY_CONTEXT_CONTROL_TEST_ID as TEST_ID, + CONTEXT_HINT, + CONTEXT_LABEL, + CONTEXT_PREFIX, +} from './ContextControl.constants' +import { ContextControlProps } from './ContextControl.types' +import * as S from './ContextControl.styles' + +/** + * Toggle + ±N neighbour count that controls how a matched row expands. Lives + * in the subheader, not the search form, as it never enters the ARGREP command. + */ +export const ContextControl = ({ + context, + onChange, + disabled = false, +}: ContextControlProps) => ( + + + + + onChange({ enabled: e.target.checked })} + disabled={disabled} + data-testid={`${TEST_ID}-toggle`} + /> + + + + + + + + {CONTEXT_PREFIX} + + {/* Always shown so the row doesn't shift; just disabled while Context is off. */} + + + + onChange({ + count: Math.round(Number(next ?? CONTEXT_COUNT_MIN)), + }) + } + disabled={disabled || !context.enabled} + data-testid={`${TEST_ID}-count`} + /> + + + +) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.types.ts new file mode 100644 index 0000000000..dcfecc000e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.types.ts @@ -0,0 +1,20 @@ +/** + * Per-match context window shown when a result row is expanded: a toggle plus + * the ±N neighbour count. A display concern, kept out of the ARGREP command. + */ +export type ContextOption = { + enabled: boolean + count: number +} + +export interface ContextControlProps { + /** Current toggle + count state (owned by SearchTab). */ + context: ContextOption + /** Patch the context state (partial merge). */ + onChange: (patch: Partial) => void + /** + * Disables the toggle and the count input. Mirrors the Search form's prior + * coupling to `isRefreshDisabled` so behavior is unchanged by the move. + */ + disabled?: boolean +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/index.ts new file mode 100644 index 0000000000..fc2e7fbcb1 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/index.ts @@ -0,0 +1,2 @@ +export { ContextControl } from './ContextControl' +export type { ContextOption, ContextControlProps } from './ContextControl.types' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx index 28f386bf15..21d765357d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.spec.tsx @@ -162,7 +162,7 @@ describe('SearchTab', () => { // Context is off by default — enable it so the row can expand. fireEvent // sidesteps the redis-ui control's `pointer-events: none` wrapper. - fireEvent.click(screen.getByTestId('array-search-form-context-toggle')) + fireEvent.click(screen.getByTestId('array-context-control-toggle')) await user.click(screen.getByTestId('array-details-table-index-7')) @@ -211,7 +211,7 @@ describe('SearchTab', () => { data: [arrayElementWithValueFactory.build({ index: '7' })], }) - fireEvent.click(screen.getByTestId('array-search-form-context-toggle')) + fireEvent.click(screen.getByTestId('array-context-control-toggle')) expect( screen.getByTestId('array-details-table-index-7-expander'), @@ -231,7 +231,7 @@ describe('SearchTab', () => { data: [arrayElementWithValueFactory.build({ index: '7' })], }) - fireEvent.click(screen.getByTestId('array-search-form-context-toggle')) + fireEvent.click(screen.getByTestId('array-context-control-toggle')) await user.click(screen.getByTestId('array-details-table-index-7')) await waitFor(() => @@ -251,12 +251,12 @@ describe('SearchTab', () => { // Enabling Context enables its count input; reset must turn it back off // (context state lives in SearchTab, not the query hook's resetQuery). - fireEvent.click(screen.getByTestId('array-search-form-context-toggle')) - expect(screen.getByTestId('array-search-form-context')).toBeEnabled() + fireEvent.click(screen.getByTestId('array-context-control-toggle')) + expect(screen.getByTestId('array-context-control-count')).toBeEnabled() fireEvent.click(screen.getByTestId('array-search-form-reset')) - expect(screen.getByTestId('array-search-form-context')).toBeDisabled() + expect(screen.getByTestId('array-context-control-count')).toBeDisabled() }) it('drops the multi-select when the search is reset', async () => { @@ -296,7 +296,7 @@ describe('SearchTab', () => { data: [arrayElementWithValueFactory.build({ index: '7' })], }) - fireEvent.click(screen.getByTestId('array-search-form-context-toggle')) + fireEvent.click(screen.getByTestId('array-context-control-toggle')) await user.click(screen.getByTestId('array-details-table-index-7')) expect( await screen.findByTestId('array-context-band-7'), @@ -304,7 +304,7 @@ describe('SearchTab', () => { // Toggling Context off must unmount the band (and stop its fetch), not // leave an already-expanded match still showing it. - fireEvent.click(screen.getByTestId('array-search-form-context-toggle')) + fireEvent.click(screen.getByTestId('array-context-control-toggle')) await waitFor(() => expect( @@ -314,19 +314,28 @@ describe('SearchTab', () => { }) it('resets Context to off when the selected key changes', () => { - const { rerender } = renderTab({ + // Context lives in the subheader (gated on isArrayKeyReady), so move the + // prop and the store's selected key together to keep it visible to assert. + const state = buildState({ loaded: true, loading: false, error: '', data: [arrayElementWithValueFactory.build({ index: '7' })], }) + const store = mockStore(state) + store.clearActions() + const { rerender } = render(, { + store, + }) - fireEvent.click(screen.getByTestId('array-search-form-context-toggle')) + fireEvent.click(screen.getByTestId('array-context-control-toggle')) expect(screen.getByRole('checkbox', { name: 'Context' })).toBeChecked() // The tab stays mounted across key switches; selecting another key resets // Context to its default rather than inheriting the previous key's. - rerender() + const otherKey = stringToBuffer('other-key') + state.browser.keys.selectedKey.data!.name = otherKey + rerender() expect(screen.getByRole('checkbox', { name: 'Context' })).not.toBeChecked() }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx index 1a434525ae..b33c451467 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/SearchTab.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' import { selectedKeySelector } from 'uiSrc/slices/browser/keys' @@ -8,7 +8,7 @@ import { bufferToString, isEqualBuffers } from 'uiSrc/utils' import { ArrayDetailsTable } from '../array-details-table' import { ArraySearchForm } from '../array-search-form' -import { ContextOption } from '../array-search-form/ArraySearchForm.types' +import { ContextControl, ContextOption } from './ContextControl' import { KeyDetailsSubheader } from '../../key-details-subheader/KeyDetailsSubheader' import { useArraySearchQuery, useArrayElementActions } from '../hooks' import { DEFAULT_CONTEXT } from '../constants' @@ -21,15 +21,12 @@ const SearchTab = ({ keyProp, isActive }: SearchTabProps) => { useAppSelector(selectedKeySelector) const keyName = keyProp ? bufferToString(keyProp) : '' - // Context is a display concern (±N neighbours on expand), off by default so - // result rows aren't expandable until the user opts in. + // Display-only ±N neighbours shown when a row expands; off by default. const [context, setContext] = useState(DEFAULT_CONTEXT) const onChangeContext = (patch: Partial) => setContext((c) => ({ ...c, ...patch })) - // Context is SearchTab-owned and the tab stays mounted across key switches, - // so reset it on a real key change — otherwise a new key inherits the - // previous key's toggle/count (the query hook resets only its own state). + // The tab stays mounted across key switches, so reset Context on a new key. const lastKeyRef = useRef(null) useEffect(() => { if (!keyProp) return @@ -57,22 +54,41 @@ const SearchTab = ({ keyProp, isActive }: SearchTabProps) => { loaded, } = useArraySearchQuery(keyProp) - // Every result is a real match — an index-only row (WITHVALUES off) has a - // null value but is still deletable — so empty-slot hiding is off here. The - // delete thunk refreshes all loaded views (incl. this search) afterwards. + // Every result is a real match (index-only rows still delete), so keep empty slots. const { deleteConfig, selectionConfig, bulkDeleteConfig, clearSelection } = useArrayElementActions(keyProp, { elements, hideEmptySlots: false }) - // Context lives here, not in the query hook, so the form's reset must - // restore it too — otherwise reset leaves rows expandable at the old count. - // Reset also drops the multi-select: clearing the results shouldn't leave a - // stale selection that a later search could partially restore. + // Reset the state the query hook doesn't own: Context and the selection. const handleReset = () => { setContext(DEFAULT_CONTEXT) clearSelection() resetQuery() } + // Show Context and the table only after a search; the !keyLoading guard + // avoids flashing the previous key's matches during a switch. + const showResults = !keyLoading && (loaded || loading) + + // Stable identity via ref so the subheader doesn't remount the control and + // steal focus from the count input while typing. + const contextActionsRef = useRef({ + context, + onChangeContext, + isRefreshDisabled, + }) + contextActionsRef.current = { context, onChangeContext, isRefreshDisabled } + + const ContextStartActions = useCallback( + () => ( + + ), + [], + ) + return ( <> { onChangePredicate={updatePredicate} onChangeCombinator={setCombinator} onChangeOptions={updateOptions} - context={context} - onChangeContext={onChangeContext} onRun={runSearch} onReset={handleReset} disabled={!isArrayKeyReady || isRefreshDisabled} /> - {isArrayKeyReady && } + {isArrayKeyReady && ( + + )} - {/* Keep the tab blank until the user runs a search, then let - ArrayDetailsTable own the loading / error / empty states. Gate on - the key not loading too, so a key switch can't flash the previous - key's matches before the hook's reset effect runs. */} - {!keyLoading && (loaded || loading) && ( + {showResults && ( () const MockActions = () =>
+const MockStartActions = () =>
describe('KeyDetailsSubheader', () => { it('should render', () => { @@ -34,4 +35,15 @@ describe('KeyDetailsSubheader', () => { expect(screen.getByTestId('mock-actions')).toBeInTheDocument() expect(screen.getByRole('separator')).toBeInTheDocument() }) + + it('renders StartActions at the start alongside the formatter', () => { + render( + , + ) + expect(screen.getByTestId('mock-start-actions')).toBeInTheDocument() + expect(screen.getByTestId('select-format-key-value')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx index d9e4d933f0..1fd38d5e7d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/key-details-subheader/KeyDetailsSubheader.tsx @@ -11,14 +11,20 @@ import styles from './styles.module.scss' export interface Props { keyType: KeyTypes | ModulesKeyTypes Actions?: (props: { width: number }) => ReactElement + /** Rendered at the start (left) of the row, opposite the formatter and Actions. */ + StartActions?: (props: { width: number }) => ReactElement } -export const KeyDetailsSubheader = ({ keyType, Actions }: Props) => ( +export const KeyDetailsSubheader = ({ + keyType, + Actions, + StartActions, +}: Props) => ( - {({ width = 0 }) => ( -
- + {({ width = 0 }) => { + const formatterGroup = ( + <> {Object.values(KeyTypes).includes(keyType as KeyTypes) && ( <> @@ -30,9 +36,26 @@ export const KeyDetailsSubheader = ({ keyType, Actions }: Props) => ( )} {!isUndefined(Actions) && } - -
- )} + + ) + + return ( +
+ {isUndefined(StartActions) ? ( + + {formatterGroup} + + ) : ( + + + + {formatterGroup} + + + )} +
+ ) + }}
) From a86559a7c341810a4c07ac1f519e3a1a2e5db473 Mon Sep 17 00:00:00 2001 From: dantovska Date: Fri, 10 Jul 2026 10:31:51 +0300 Subject: [PATCH 017/166] E2E: stabilize databases-list row actions and edit-dialog locator (#6184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): fix databases edit-flow failures — search the target row into view before edit (pagination could page it out) and accept the "Edit Database" title in the shared dialog locator * fix(ui): make vector set element test ids format-independent — derive from the raw element name instead of formattingBuffer output (JSX under Markdown/JSON), and reset the persisted view format after each value-markdown e2e test --- .../ElementNameCell/ElementNameCell.spec.tsx | 38 +++++++++++++++++++ .../ElementNameCell/ElementNameCell.tsx | 13 +++++-- .../databases/components/AddDatabaseDialog.ts | 2 +- .../key-details/value-markdown.spec.ts | 10 +++++ .../databases/list/database-list.spec.ts | 2 + 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.spec.tsx new file mode 100644 index 0000000000..917f732467 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.spec.tsx @@ -0,0 +1,38 @@ +import React from 'react' + +import { render, screen } from 'uiSrc/utils/test-utils' +import { KeyValueFormat } from 'uiSrc/constants' +import { stringToBuffer } from 'uiSrc/utils' +import { ElementNameCell } from './ElementNameCell' + +const element = { name: stringToBuffer('element-abc') } + +describe('ElementNameCell', () => { + it('builds the test id from the raw name', () => { + render( + , + ) + + expect( + screen.getByTestId('vector-set-element-value-element-abc'), + ).toBeInTheDocument() + }) + + it('keeps the raw-name test id when the format renders JSX', () => { + render( + , + ) + + expect( + screen.getByTestId('vector-set-element-value-element-abc'), + ).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.tsx index e25f807928..922f5d2f71 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/vector-set-element-list/components/ElementNameCell/ElementNameCell.tsx @@ -1,7 +1,11 @@ import React from 'react' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { createTooltipContent, formattingBuffer } from 'uiSrc/utils' +import { + bufferToString, + createTooltipContent, + formattingBuffer, +} from 'uiSrc/utils' import { TEXT_FAILED_CONVENT_FORMATTER } from 'uiSrc/constants' import { decompressingBuffer } from 'uiSrc/utils/decompressors' import { FormattedValue } from 'uiSrc/pages/browser/modules/key-details/shared' @@ -31,8 +35,11 @@ export const ElementNameCell = ({ viewFormat, ) - const testIdSuffix = - typeof value === 'string' ? value?.substring(0, 200) : value + // Test ids must not depend on the view format: rich formats (Markdown, + // JSON) return JSX from formattingBuffer, not a string. + const testIdSuffix = bufferToString( + decompressedItem as RedisResponseBuffer, + ).substring(0, 200) return ( diff --git a/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts b/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts index cc767f6cac..5c44b28c77 100644 --- a/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts +++ b/tests/e2e-playwright/pages/databases/components/AddDatabaseDialog.ts @@ -75,7 +75,7 @@ export class AddDatabaseDialog { this.page = page; // Dialog controls - this.dialog = page.getByRole('dialog', { name: /add database|connection settings/i }); + this.dialog = page.getByRole('dialog', { name: /add database|edit database|connection settings/i }); this.connectionUrlInput = page.getByPlaceholder(/redis:\/\//i); this.connectionSettingsButton = page.getByTestId('btn-connection-settings'); this.addDatabaseButton = page.getByRole('button', { diff --git a/tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts b/tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts index 3f101d5175..0a77cf6cbd 100644 --- a/tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/key-details/value-markdown.spec.ts @@ -74,6 +74,16 @@ test.describe('Browser > Key Details - Markdown value format', () => { await apiHelper.deleteKeysByPattern(database.id, `${TEST_KEY_PREFIX}*`); }); + // The format choice persists (localStorage + in-memory store) and Electron + // runs the whole suite in one app instance, so reset it before it can leak + // into later specs. Registered after the hook above => runs before it, + // while the key details panel is still open. + test.afterEach(async ({ browserPage }) => { + if (await browserPage.keyDetails.formatDropdown.isVisible()) { + await browserPage.keyDetails.changeValueFormat('Unicode'); + } + }); + test('should render a markdown String value through the sanitized pipeline', async ({ apiHelper, browserPage }) => { const keyData = StringKeyFactory.build({ value: RENDERED_MARKDOWN }); await apiHelper.createStringKey(database.id, keyData.keyName, keyData.value); diff --git a/tests/e2e-playwright/tests/parallel/databases/list/database-list.spec.ts b/tests/e2e-playwright/tests/parallel/databases/list/database-list.spec.ts index 143e688151..e07f2db53f 100644 --- a/tests/e2e-playwright/tests/parallel/databases/list/database-list.spec.ts +++ b/tests/e2e-playwright/tests/parallel/databases/list/database-list.spec.ts @@ -229,6 +229,8 @@ test.describe('Database List', () => { test('should edit database connection', async ({ databasesPage }) => { const { databaseList } = databasesPage; + // Parallel suites can page the row out of the unfiltered list + await databaseList.expectDatabaseVisible(standaloneDb1.name, { searchFirst: true }); await databaseList.edit(standaloneDb1.name); const editDialog = databasesPage.page.getByRole('dialog', { name: /edit database/i }); From 18327ab8ec543e5feb162bcbeaffbbfdc1c9b8b3 Mon Sep 17 00:00:00 2001 From: dantovska Date: Fri, 10 Jul 2026 12:37:02 +0300 Subject: [PATCH 018/166] RI-8290 Expand index view when clicking on View Index (in key details) (#6170) * feat(ui): expand index view when opening it from key details * refactor(ui): keep openIndexPanel param, send key-details telemetry at click time --- .../ViewIndexDataButton.spec.tsx | 30 ++++++++--- .../ViewIndexDataButton.tsx | 25 +++++++-- .../VectorSearchQueryPage.constants.ts | 6 +++ .../VectorSearchQueryPage.spec.tsx | 52 ++++++++++++++++++- .../VectorSearchQueryPage.tsx | 12 ++++- .../vector-search/telemetry.constants.ts | 1 + 6 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.constants.ts diff --git a/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.spec.tsx b/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.spec.tsx index b2182a3d6e..63cb52bec6 100644 --- a/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.spec.tsx @@ -5,7 +5,12 @@ import { cleanup, render, screen, userEvent } from 'uiSrc/utils/test-utils' import { Pages } from 'uiSrc/constants' import { IndexSummary } from 'uiSrc/slices/interfaces/redisearch' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { SearchBrowserSource } from 'uiSrc/pages/vector-search/telemetry.constants' +import { + SearchBrowserSource, + SearchIndexDetailsSource, +} from 'uiSrc/pages/vector-search/telemetry.constants' + +import { OPEN_INDEX_PANEL_PARAM } from 'uiSrc/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.constants' import { ViewIndexDataButton } from './ViewIndexDataButton' import { ViewIndexDataButtonProps } from './ViewIndexDataButton.types' @@ -64,18 +69,19 @@ describe('ViewIndexDataButton', () => { expect(btn).not.toBeDisabled() }) - it('should navigate to the index query page on click', async () => { + it('should navigate to the index query page with the open panel param on click', async () => { const index = buildIndex({ name: 'movies_index' }) renderComponent({ indexes: [index] }) await userEvent.click(screen.getByTestId('view-index-data-btn')) - expect(mockPush).toHaveBeenCalledWith( - Pages.vectorSearchQuery( + expect(mockPush).toHaveBeenCalledWith({ + pathname: Pages.vectorSearchQuery( mockInstanceId, encodeURIComponent('movies_index'), ), - ) + search: `${OPEN_INDEX_PANEL_PARAM}=true`, + }) }) it('should send SEARCH_VIEW_INDEX_CLICKED telemetry on click', async () => { @@ -93,6 +99,13 @@ describe('ViewIndexDataButton', () => { source: SearchBrowserSource.KeyDetails, }, }) + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_INDEX_DETAILS_VIEWED, + eventData: { + databaseId: mockInstanceId, + source: SearchIndexDetailsSource.KeyDetails, + }, + }) }) it('should call onNavigate callback instead of history.push when provided', async () => { @@ -145,12 +158,13 @@ describe('ViewIndexDataButton', () => { screen.getByTestId('view-index-data-item-users_index'), ) - expect(mockPush).toHaveBeenCalledWith( - Pages.vectorSearchQuery( + expect(mockPush).toHaveBeenCalledWith({ + pathname: Pages.vectorSearchQuery( mockInstanceId, encodeURIComponent('users_index'), ), - ) + search: `${OPEN_INDEX_PANEL_PARAM}=true`, + }) }) it('should send SEARCH_VIEW_INDEX_CLICKED telemetry with correct count when menu item is clicked', async () => { diff --git a/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.tsx b/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.tsx index 311e447491..1abd097d6a 100644 --- a/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.tsx +++ b/redisinsight/ui/src/pages/browser/components/view-index-data-button/ViewIndexDataButton.tsx @@ -11,7 +11,11 @@ import { MenuItem, } from 'uiSrc/components/base/layout/menu' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { SearchBrowserSource } from 'uiSrc/pages/vector-search/telemetry.constants' +import { + SearchBrowserSource, + SearchIndexDetailsSource, +} from 'uiSrc/pages/vector-search/telemetry.constants' +import { OPEN_INDEX_PANEL_PARAM } from 'uiSrc/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.constants' import { ViewIndexDataButtonProps } from './ViewIndexDataButton.types' import * as S from './ViewIndexDataButton.styles' @@ -35,13 +39,26 @@ export const ViewIndexDataButton = ({ source: SearchBrowserSource.KeyDetails, }, }) + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_INDEX_DETAILS_VIEWED, + eventData: { + databaseId: instanceId, + source: SearchIndexDetailsSource.KeyDetails, + }, + }) if (onNavigate) { onNavigate(indexName) return } - history.push( - Pages.vectorSearchQuery(instanceId, encodeURIComponent(indexName)), - ) + history.push({ + pathname: Pages.vectorSearchQuery( + instanceId, + encodeURIComponent(indexName), + ), + search: new URLSearchParams({ + [OPEN_INDEX_PANEL_PARAM]: 'true', + }).toString(), + }) }, [history, instanceId, onNavigate, indexes.length], ) diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.constants.ts b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.constants.ts new file mode 100644 index 0000000000..d2f198e451 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.constants.ts @@ -0,0 +1,6 @@ +/** + * Search param that opens the index details side panel on the query page. + * HashRouter (Electron) does not support location.state, so callers + * encode the flag in the search string instead. + */ +export const OPEN_INDEX_PANEL_PARAM = 'openIndexPanel' diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.spec.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.spec.tsx index 9956e6e609..2f92cfcd70 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.spec.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.spec.tsx @@ -5,6 +5,8 @@ import { sendEventTelemetry } from 'uiSrc/telemetry' import { TelemetryEvent } from 'uiSrc/telemetry/events' import { commandExecutionUIFactory } from 'uiSrc/mocks/factories/workbench/commandExectution.factory' import { redisearchListSelector } from 'uiSrc/slices/browser/redisearch' +import { SearchIndexDetailsSource } from 'uiSrc/pages/vector-search/telemetry.constants' +import { OPEN_INDEX_PANEL_PARAM } from './VectorSearchQueryPage.constants' const redisearchListSelectorMock = redisearchListSelector as jest.Mock import { VectorSearchQueryPage } from './VectorSearchQueryPage' @@ -54,8 +56,11 @@ jest.mock('uiSrc/services/commands-history/commandsHistoryService', () => ({ const mockHistoryItems = commandExecutionUIFactory.buildList(2) -const setupRouterMocks = (indexName = 'test-index') => { +const setupRouterMocks = (indexName = 'test-index', search = '') => { reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: mockPush }) + reactRouterDom.useLocation = jest + .fn() + .mockReturnValue({ pathname: 'pathname', search }) reactRouterDom.useParams = jest .fn() .mockReturnValue({ instanceId: mockInstanceId, indexName }) @@ -149,6 +154,51 @@ describe('VectorSearchQueryPage', () => { eventData: { databaseId: mockInstanceId }, }) }) + + it('should send telemetry with query source when the index panel is toggled open', async () => { + await renderComponent() + + fireEvent.click(screen.getByTestId('view-index-btn')) + + expect(sendEventTelemetry).toHaveBeenCalledWith({ + event: TelemetryEvent.SEARCH_INDEX_DETAILS_VIEWED, + eventData: { + databaseId: mockInstanceId, + source: SearchIndexDetailsSource.Query, + }, + }) + }) + }) + + describe('index panel auto-open from key details', () => { + beforeEach(() => { + redisearchListSelectorMock.mockReturnValue({ + data: ['test-index'], + loading: false, + error: '', + }) + }) + + it('should keep the index panel closed without the open panel param', async () => { + await renderComponent() + + const panel = screen.queryByTestId('view-index-panel') + expect(panel).not.toBeInTheDocument() + expect(sendEventTelemetry).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: TelemetryEvent.SEARCH_INDEX_DETAILS_VIEWED, + }), + ) + }) + + it('should open the index panel when the open panel param is present', async () => { + setupRouterMocks('test-index', `?${OPEN_INDEX_PANEL_PARAM}=true`) + + await renderComponent() + + const panel = screen.getByTestId('view-index-panel') + expect(panel).toBeInTheDocument() + }) }) describe('redirect when index does not exist', () => { diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.tsx index 3f4769679e..50a8ca0bf4 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/VectorSearchQueryPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { useHistory, useParams } from 'react-router-dom' +import { useHistory, useLocation, useParams } from 'react-router-dom' import { RiSelectOption } from 'uiSrc/components/base/forms/select/RiSelect' import { Pages } from 'uiSrc/constants' @@ -13,6 +13,7 @@ import { decodeIndexNameFromUrl, } from '../../utils' import { useRedisearchListData } from '../../hooks' +import { OPEN_INDEX_PANEL_PARAM } from './VectorSearchQueryPage.constants' import { VectorSearchQueryPageParams } from './VectorSearchQueryPage.types' import { PageHeader, PageContent } from './components' @@ -21,9 +22,18 @@ import * as S from './VectorSearchQueryPage.styles' export const VectorSearchQueryPage = () => { const { instanceId, indexName } = useParams() const history = useHistory() + const location = useLocation() const [isIndexPanelOpen, setIsIndexPanelOpen] = useState(false) + // Param intentionally kept in the URL; telemetry is sent at click time. + useEffect(() => { + const params = new URLSearchParams(location.search) + if (params.get(OPEN_INDEX_PANEL_PARAM) === 'true') { + setIsIndexPanelOpen(true) + } + }, [location.search]) + const { loading, error, stringData: indexes } = useRedisearchListData() const decodedIndexName = decodeIndexNameFromUrl(indexName) diff --git a/redisinsight/ui/src/pages/vector-search/telemetry.constants.ts b/redisinsight/ui/src/pages/vector-search/telemetry.constants.ts index bc8ccaed03..c1044475b5 100644 --- a/redisinsight/ui/src/pages/vector-search/telemetry.constants.ts +++ b/redisinsight/ui/src/pages/vector-search/telemetry.constants.ts @@ -33,6 +33,7 @@ export enum SearchOnboardingAction { export enum SearchIndexDetailsSource { IndexList = 'index_list', Query = 'query', + KeyDetails = 'key_details', } export enum SearchCommandType { From 6b97178b814e797690359758fac1c47a010272a2 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 10 Jul 2026 14:41:24 +0300 Subject: [PATCH 019/166] RI-8315: Edit array element values in a Monaco drawer (#6181) --- .../ArrayDetailsTable.config.tsx | 52 +- .../ArrayDetailsTable.spec.tsx | 563 +++++++++++++++++- .../array-details-table/ArrayDetailsTable.tsx | 214 ++++++- .../ArrayDetailsTable.types.ts | 4 + .../components/ArrayValueCell.spec.tsx | 51 +- .../components/ArrayValueCell.tsx | 57 +- .../ArrayValueEditorDrawer.spec.tsx | 102 ++++ .../components/ArrayValueEditorDrawer.tsx | 94 +++ .../ArrayValueEditorDrawer.types.ts | 11 + .../RowActionsCell/RowActionsCell.spec.tsx | 200 ++++++- .../RowActionsCell/RowActionsCell.styles.ts | 6 +- .../RowActionsCell/RowActionsCell.tsx | 117 +++- .../RowActionsCell/RowActionsCell.types.ts | 27 +- .../array-details-table/constants.ts | 3 +- .../getArrayElementEditState.spec.ts | 64 ++ .../getArrayElementEditState.ts | 95 +++ .../EditableTextArea.spec.tsx | 37 ++ .../editable-textarea/EditableTextArea.tsx | 12 +- .../editable-textarea/styles.module.scss | 6 + redisinsight/ui/src/slices/browser/array.ts | 13 +- .../ui/src/slices/interfaces/array.ts | 4 + .../ui/src/slices/tests/browser/array.spec.ts | 22 + 22 files changed, 1623 insertions(+), 131 deletions(-) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.types.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.spec.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx index 3e68fdf05f..d8ec1d78ae 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx @@ -70,18 +70,28 @@ const valueColumn: ColumnDef = { } /** - * Delete column, appended only when the consumer passes a `deleteConfig` (via - * `meta`). The header hosts the bulk-delete trigger (shown only while rows are - * selected); each cell hosts the per-row trash. The cell renders nothing for - * empty slots. + * Row-actions column hosting the per-row edit, expand and delete affordances + * (revealed on hover). The header hosts the bulk-delete trigger (shown only + * while rows are selected). Editing wiring is always present in `meta`, so the + * cell derives an `editConfig` from it; `deleteConfig` is forwarded only when + * the consumer enables deletion. */ export const actionsColumn: ColumnDef = { id: 'actions', // Custom so the header renders the bulk trigger raw, not as a column title. isHeaderCustom: true, header: ({ table }) => { - const { bulkDeleteConfig } = table.options.meta as ArrayTableConfig - if (!bulkDeleteConfig) return null + const { bulkDeleteConfig, isValueDrawerOpen, editingIndex, updating } = + table.options.meta as ArrayTableConfig + // Freeze bulk delete during any edit or in-flight write — the selection may + // include the edited element, whose pending ARSET would resurrect it. + if ( + !bulkDeleteConfig || + isValueDrawerOpen || + editingIndex !== null || + updating + ) + return null return }, enableSorting: false, @@ -89,9 +99,33 @@ export const actionsColumn: ColumnDef = { size: ACTIONS_COLUMN_SIZE, sizeUnit: 'px', cell: ({ row, table }: CellContext) => { - const { deleteConfig } = table.options.meta as ArrayTableConfig - if (!deleteConfig) return null - return + const { + compressor, + viewFormat, + editingIndex, + isValueDrawerOpen, + updating, + loading, + onEditElement, + onOpenValueEditor, + deleteConfig, + } = table.options.meta as ArrayTableConfig + return ( + + ) }, } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx index f7b268f085..399738fa3c 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.spec.tsx @@ -15,7 +15,13 @@ import { apiService } from 'uiSrc/services' import keysReducer, { refreshKeyInfoSuccess, setSelectedKeyRefreshDisabled, + setViewFormat, } from 'uiSrc/slices/browser/keys' +import instancesReducer, { + setConnectedInstanceId, +} from 'uiSrc/slices/instances/instances' +import contextReducer, { setBrowserSelectedKey } from 'uiSrc/slices/app/context' +import { KeyValueFormat } from 'uiSrc/constants' import { stringToBuffer } from 'uiSrc/utils' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' import { @@ -25,6 +31,39 @@ import { import { ArrayDetailsTable } from './ArrayDetailsTable' +jest.mock('uiSrc/components/base/code-editor', () => { + const ReactMock = require('react') + return { + __esModule: true, + CodeEditor: (props: any) => + ReactMock.createElement('textarea', { + 'data-testid': 'array-value-code-editor', + value: props.value, + onChange: (e: any) => props.onChange?.(e.target.value), + }), + } +}) + +// Production-write confirmation: auto-confirm by default (matching the no-op +// context), but a test can flip `mockAutoConfirm` off to hold the confirmation +// pending and fire `mockPendingConfirm()` itself. +let mockAutoConfirm = true +let mockPendingConfirm: (() => void) | null = null +jest.mock('uiSrc/components/production-write-confirmation', () => ({ + ...jest.requireActual('uiSrc/components/production-write-confirmation'), + useProductionWriteConfirmation: () => ({ + requestConfirmation: ({ onConfirm }: { onConfirm: () => void }) => { + mockPendingConfirm = onConfirm + if (mockAutoConfirm) onConfirm() + }, + }), +})) + +afterEach(() => { + mockAutoConfirm = true + mockPendingConfirm = null +}) + // Store whose selected key is set but whose array `data.keyName` is still // empty — the Search-tab / pre-View-load condition the edit key must survive. const storeWithSelectedKey = (name: string) => { @@ -133,7 +172,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) expect( screen.getByTestId('array-details-table_value-editor-1'), @@ -167,17 +206,13 @@ describe('ArrayDetailsTable', () => { ) }) - expect( - screen.getByTestId('array-details-table_edit-btn-1'), - ).toBeDisabled() + expect(screen.getByTestId('array-edit-btn-1')).toBeDisabled() }) it('does not offer editing for an empty slot', () => { renderComponent([arrayElementFactory.build({ index: '3' })]) - expect( - screen.queryByTestId('array-details-table_edit-btn-3'), - ).not.toBeInTheDocument() + expect(screen.queryByTestId('array-edit-btn-3')).not.toBeInTheDocument() expect( screen.getByTestId('array-details-table-empty-3'), ).toBeInTheDocument() @@ -195,7 +230,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) fireEvent.change( screen.getByTestId('array-details-table_value-editor-1'), @@ -214,6 +249,53 @@ describe('ArrayDetailsTable', () => { postSpy.mockRestore() }) + it('skips the inline ARSET when the database changed since the editor opened', async () => { + const postSpy = jest + .spyOn(apiService, 'post') + .mockResolvedValue({ status: 200, data: '' }) + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.data = { + name: stringToBuffer('mykey'), + } as any + state.connections.instances.connectedInstance = { id: 'db-1' } as any + const store = mockStore(state) + + render( + , + { store }, + ) + + // Open the editor while connected to db-1 (captured as the write guard). + act(() => { + fireEvent.mouseEnter( + screen.getByTestId('array-details-table_content-value-1'), + ) + }) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) + fireEvent.change( + screen.getByTestId('array-details-table_value-editor-1'), + { target: { value: 'updated' } }, + ) + + // The connection switches to another database before Save is confirmed. + state.connections.instances.connectedInstance = { id: 'db-2' } as any + + await act(async () => { + fireEvent.click(screen.getByTestId('apply-btn')) + }) + + const setCall = postSpy.mock.calls.find(([url]) => + (url as string).includes('array/set-element'), + ) + expect(setCall).toBeFalsy() + + postSpy.mockRestore() + }) + it('uses the selected key name for ARSET even when the View range has not loaded', async () => { const postSpy = jest .spyOn(apiService, 'post') @@ -234,7 +316,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) fireEvent.change( screen.getByTestId('array-details-table_value-editor-1'), { target: { value: 'updated' } }, @@ -261,6 +343,7 @@ describe('ArrayDetailsTable', () => { keys.selectedKey.data = { name: stringToBuffer('mykey') } as any const store = configureStore({ reducer: combineReducers({ + app: (s = initialStateDefault.app) => s, browser: combineReducers({ keys: keysReducer, array: (s = initialStateDefault.browser.array) => s, @@ -288,7 +371,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) expect( screen.getByTestId('array-details-table_value-editor-1'), ).toBeInTheDocument() @@ -333,7 +416,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) fireEvent.change( screen.getByTestId('array-details-table_value-editor-1'), { target: { value: 'first' } }, @@ -348,7 +431,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) expect( screen.getByTestId('array-details-table_value-editor-1'), ).toBeInTheDocument() @@ -382,7 +465,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) expect(store.getActions()).toContainEqual( setSelectedKeyRefreshDisabled(true), ) @@ -414,7 +497,7 @@ describe('ArrayDetailsTable', () => { screen.getByTestId('array-details-table_content-value-1'), ) }) - fireEvent.click(screen.getByTestId('array-details-table_edit-btn-1')) + fireEvent.click(screen.getByTestId('array-edit-btn-1')) expect( screen.getByTestId('array-details-table_value-editor-1'), ).toBeInTheDocument() @@ -464,9 +547,7 @@ describe('ArrayDetailsTable', () => { screen.getAllByTestId('array-details-table_content-value-1')[0], ) }) - fireEvent.click( - screen.getAllByTestId('array-details-table_edit-btn-1')[0], - ) + fireEvent.click(screen.getAllByTestId('array-edit-btn-1')[0]) // The hidden sibling must not re-enable refresh during the edit. const refreshActions = store @@ -476,6 +557,433 @@ describe('ArrayDetailsTable', () => { }) }) + describe('Monaco drawer (expand)', () => { + const withValue = (index: string, text: string) => { + const element = arrayElementWithValueFactory.build({ index }) + element.value = stringToBuffer(text) as typeof element.value + return element + } + + it('opens the drawer seeded with the value on expand', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + + expect(screen.getByTestId('array-value-code-editor')).toHaveValue('hello') + }) + + it('pauses the key-header refresh while the drawer is open', () => { + const store = mockStore(cloneDeep(initialStateDefault)) + render( + , + { store }, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + + expect(store.getActions()).toContainEqual( + setSelectedKeyRefreshDisabled(true), + ) + }) + + it('abandons the open drawer when the tab is hidden', () => { + const element = withValue('1', 'hello') + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + expect(screen.getByTestId('array-value-code-editor')).toBeInTheDocument() + + rerender( + , + ) + + expect( + screen.queryByTestId('array-value-code-editor'), + ).not.toBeInTheDocument() + }) + + it('closes an inline edit on another row when the drawer opens', () => { + render( + , + ) + + // Open inline edit on row 0. + fireEvent.click(screen.getByTestId('array-edit-btn-0')) + expect( + screen.getByTestId('array-details-table_value-editor-0'), + ).toBeInTheDocument() + + // Open the drawer on row 1 — the inline editor on row 0 must close, so a + // later drawer save can't clear it and drop its unsaved text. + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + + expect( + screen.queryByTestId('array-details-table_value-editor-0'), + ).not.toBeInTheDocument() + expect(screen.getByTestId('array-value-code-editor')).toBeInTheDocument() + }) + + it('hides all row edit/expand triggers while the drawer is open', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + expect(screen.getByTestId('array-value-code-editor')).toBeInTheDocument() + + // No second editor can be opened while the drawer is up — a re-open would + // otherwise re-seed the drawer and drop unsaved text. + expect(screen.queryByTestId('array-edit-btn-0')).not.toBeInTheDocument() + expect(screen.queryByTestId('array-expand-btn-0')).not.toBeInTheDocument() + expect(screen.queryByTestId('array-expand-btn-1')).not.toBeInTheDocument() + }) + + it('closes the drawer when the value formatter changes', () => { + // The seed was serialized under the previous format; re-serializing it + // under a new one on Save would write different bytes. + const keys = cloneDeep(initialStateDefault.browser.keys) + keys.selectedKey.data = { name: stringToBuffer('mykey') } as any + keys.selectedKey.viewFormat = KeyValueFormat.Unicode + const store = configureStore({ + reducer: combineReducers({ + app: (s = initialStateDefault.app) => s, + browser: combineReducers({ + keys: keysReducer, + array: (s = initialStateDefault.browser.array) => s, + }), + connections: combineReducers({ + instances: (s = initialStateDefault.connections.instances) => s, + }), + }), + preloadedState: { browser: { keys } }, + middleware: (getDefault) => + getDefault({ serializableCheck: false, immutableCheck: false }), + }) + + render( + , + { store }, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + expect(screen.getByTestId('array-value-code-editor')).toBeInTheDocument() + + act(() => { + store.dispatch(setViewFormat(KeyValueFormat.HEX)) + }) + + expect( + screen.queryByTestId('array-value-code-editor'), + ).not.toBeInTheDocument() + }) + + it('closes the drawer only after the save succeeds (not optimistically)', async () => { + const postSpy = jest + .spyOn(apiService, 'post') + .mockResolvedValue({ status: 200, data: '' }) + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.data = { + name: stringToBuffer('mykey'), + } as any + // Live selection + instance must match for the thunk's success callback + // (which closes the drawer) to fire. + state.app.context.browser.keyList.selectedKey = stringToBuffer( + 'mykey', + ) as any + state.connections.instances.connectedInstance = { id: 'db-1' } as any + const store = mockStore(state) + + render( + , + { store }, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + fireEvent.change(screen.getByTestId('array-value-code-editor'), { + target: { value: 'updated' }, + }) + fireEvent.click(screen.getByTestId('array-value-editor-save-btn')) + + // Still open synchronously after Save — closes only when the ARSET + // success callback runs. + expect(screen.getByTestId('array-value-code-editor')).toBeInTheDocument() + await waitFor(() => { + expect( + screen.queryByTestId('array-value-code-editor'), + ).not.toBeInTheDocument() + }) + + postSpy.mockRestore() + }) + + it('skips the drawer ARSET when the database changed since the drawer opened', async () => { + const postSpy = jest + .spyOn(apiService, 'post') + .mockResolvedValue({ status: 200, data: '' }) + // A real instances reducer so switching the connected database re-renders + // the table — the drawer must guard its save with the database captured + // when it *opened*, not when Save was clicked. + const keys = cloneDeep(initialStateDefault.browser.keys) + keys.selectedKey.data = { name: stringToBuffer('mykey') } as any + const store = configureStore({ + reducer: combineReducers({ + app: (s = initialStateDefault.app) => s, + browser: combineReducers({ + keys: (s = keys) => s, + array: (s = initialStateDefault.browser.array) => s, + }), + connections: combineReducers({ instances: instancesReducer }), + }), + preloadedState: { + connections: { + instances: { + ...initialStateDefault.connections.instances, + connectedInstance: { + ...initialStateDefault.connections.instances.connectedInstance, + id: 'db-1', + }, + }, + }, + }, + middleware: (getDefault) => + getDefault({ serializableCheck: false, immutableCheck: false }), + }) + + render( + , + { store }, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + fireEvent.change(screen.getByTestId('array-value-code-editor'), { + target: { value: 'updated' }, + }) + + // Connection switches before Save is confirmed. + act(() => { + store.dispatch(setConnectedInstanceId('db-2')) + }) + + await act(async () => { + fireEvent.click(screen.getByTestId('array-value-editor-save-btn')) + }) + + const setCall = postSpy.mock.calls.find(([url]) => + (url as string).includes('array/set-element'), + ) + expect(setCall).toBeFalsy() + + postSpy.mockRestore() + }) + + it('dispatches ARSET when the drawer value is saved', async () => { + const postSpy = jest + .spyOn(apiService, 'post') + .mockResolvedValue({ status: 200, data: '' }) + const store = storeWithSelectedKey('mykey') + + render( + , + { store }, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + fireEvent.change(screen.getByTestId('array-value-code-editor'), { + target: { value: 'updated' }, + }) + fireEvent.click(screen.getByTestId('array-value-editor-save-btn')) + + await waitFor(() => { + const setCall = postSpy.mock.calls.find(([url]) => + (url as string).includes('array/set-element'), + ) + expect(setCall).toBeTruthy() + expect((setCall?.[1] as { index: string }).index).toBe('1') + }) + + postSpy.mockRestore() + }) + + it('does not close a reopened drawer when a stale save from the previous session succeeds', async () => { + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.data = { + name: stringToBuffer('mykey'), + } as any + state.app.context.browser.keyList.selectedKey = stringToBuffer( + 'mykey', + ) as any + state.connections.instances.connectedInstance = { id: 'db-1' } as any + const store = mockStore(state) + + let resolvePost: () => void = () => {} + const postSpy = jest.spyOn(apiService, 'post').mockImplementation( + () => + new Promise((r) => { + resolvePost = () => r({ status: 200, data: '' } as any) + }), + ) + + render( + , + { store }, + ) + + // Session 1: expand row 1, save — the ARSET stays in flight. + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + fireEvent.change(screen.getByTestId('array-value-code-editor'), { + target: { value: 'updated' }, + }) + fireEvent.click(screen.getByTestId('array-value-editor-save-btn')) + + // Abandon and reopen on row 2 — a new session. + fireEvent.click(screen.getByTestId('array-value-editor-cancel-btn')) + fireEvent.click(screen.getByTestId('array-expand-btn-2')) + + // Row 1's late success must not close row 2's freshly opened drawer. + await act(async () => { + resolvePost() + }) + expect( + screen.getByLabelText('Save value for index 2'), + ).toBeInTheDocument() + + postSpy.mockRestore() + }) + + it('abandons a pending drawer save when the table unmounts before Confirm', async () => { + mockAutoConfirm = false + const postSpy = jest + .spyOn(apiService, 'post') + .mockResolvedValue({ status: 200, data: '' }) + const state = cloneDeep(initialStateDefault) + state.browser.keys.selectedKey.data = { + name: stringToBuffer('mykey'), + } as any + state.app.context.browser.keyList.selectedKey = stringToBuffer( + 'mykey', + ) as any + state.connections.instances.connectedInstance = { id: 'db-1' } as any + const store = mockStore(state) + + const { unmount } = render( + , + { store }, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + fireEvent.change(screen.getByTestId('array-value-code-editor'), { + target: { value: 'updated' }, + }) + // Save opens the confirmation but leaves it pending. + fireEvent.click(screen.getByTestId('array-value-editor-save-btn')) + + // A key switch tears the table down before the user confirms. + unmount() + await act(async () => { + mockPendingConfirm?.() + }) + + const setCall = postSpy.mock.calls.find(([url]) => + (url as string).includes('array/set-element'), + ) + expect(setCall).toBeFalsy() + + postSpy.mockRestore() + }) + + it('abandons the drawer when the live selection changes while selectedKeyData lags', () => { + // keyName (from selectedKeyData) lags a key switch during fetchKeyInfo, + // so the abandon guard keys off the live app-context selection instead. + // Hold selectedKeyData on 'mykey' and move only the live selection. + const keys = cloneDeep(initialStateDefault.browser.keys) + keys.selectedKey.data = { name: stringToBuffer('mykey') } as any + const store = configureStore({ + reducer: combineReducers({ + app: combineReducers({ context: contextReducer }), + browser: combineReducers({ + keys: (s = keys) => s, + array: (s = initialStateDefault.browser.array) => s, + }), + connections: combineReducers({ + instances: (s = initialStateDefault.connections.instances) => s, + }), + }), + middleware: (getDefault) => + getDefault({ serializableCheck: false, immutableCheck: false }), + }) + store.dispatch(setBrowserSelectedKey(stringToBuffer('mykey'))) + + render( + , + { store }, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-1')) + expect(screen.getByTestId('array-value-code-editor')).toBeInTheDocument() + + // Live selection moves on while selectedKeyData still points at 'mykey'. + act(() => { + store.dispatch(setBrowserSelectedKey(stringToBuffer('otherkey'))) + }) + + expect( + screen.queryByTestId('array-value-code-editor'), + ).not.toBeInTheDocument() + }) + }) + it('renders an expanded panel when a row is expanded via row click', async () => { const user = userEvent.setup() render( @@ -584,4 +1092,25 @@ describe('ArrayDetailsTable', () => { screen.queryByRole('checkbox', { name: /all rows/i }), ).not.toBeInTheDocument() }) + + it('freezes the bulk-delete trigger while a row is being inline-edited', () => { + render( + , + ) + + expect(screen.getByTestId('array-bulk-remove-btn-icon')).toBeInTheDocument() + + // A selection may include the edited row, whose pending ARSET would + // resurrect it, so bulk delete is frozen while an edit is open. + fireEvent.click(screen.getByTestId('array-edit-btn-0')) + + expect( + screen.queryByTestId('array-bulk-remove-btn-icon'), + ).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx index c7be239839..e9d5c2384f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx @@ -9,6 +9,7 @@ import React, { import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { appContextSelectedKey } from 'uiSrc/slices/app/context' import { selectedKeyDataSelector, selectedKeySelector, @@ -22,7 +23,12 @@ import { import { KeyValueCompressor } from 'uiSrc/constants' import { Nullable, stringToSerializedBufferFormat } from 'uiSrc/utils' import { Row, Table } from 'uiSrc/components/base/layout/table' +import { + BrowserConfirmationCommandId, + useProductionWriteConfirmation, +} from 'uiSrc/components/production-write-confirmation' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { ARRAY_TABLE_EMPTY_MESSAGE, @@ -30,6 +36,8 @@ import { SELECTION_COLUMN_CELL_CLASS, SELECTION_COLUMN_WIDTH_REM, } from './constants' +import { getArrayElementEditState } from './getArrayElementEditState' +import { ArrayValueEditorDrawer } from './components/ArrayValueEditorDrawer' import { actionsColumn, arrayColumns, @@ -63,9 +71,12 @@ const ArrayDetailsTable = memo( bulkDeleteConfig, }: ArrayDetailsTableProps) => { const dispatch = useAppDispatch() - const { compressor = null } = useAppSelector( + const { compressor = null, id: connectedInstanceId } = useAppSelector( connectedInstanceSelector, - ) as unknown as { compressor: Nullable } + ) as unknown as { + compressor: Nullable + id?: string + } const { viewFormat } = useAppSelector(selectedKeySelector) const { updating, @@ -85,13 +96,41 @@ const ArrayDetailsTable = memo( const { name: keyName } = useAppSelector(selectedKeyDataSelector) ?? { name: '', } + // The live selection — updated on key click, before fetchKeyInfo. Unlike + // `keyName` (from selectedKeyData) it doesn't lag a switch. + const liveSelectedKey = useAppSelector(appContextSelectedKey) + + const { requestConfirmation } = useProductionWriteConfirmation() - // Index of the row currently being edited; only one row edits at a time. const [editingIndex, setEditingIndex] = useState>(null) + // Row open in the Monaco drawer, plus its open-time seed. Held table-level + // (not per-row) so the drawer shares the inline editor's guards. + const [drawerIndex, setDrawerIndex] = useState>(null) + const [drawerSeed, setDrawerSeed] = useState('') + // Mirrors `drawerIndex` for reads inside the async production-write + // confirmation callback, so a pending save can tell whether the drawer was + // abandoned (closed, or moved to another row) while the dialog was open. + const drawerIndexRef = useRef>(null) + useEffect(() => { + drawerIndexRef.current = drawerIndex + }, [drawerIndex]) // Identifies the current edit session. Bumped whenever an editor opens, so // a still-in-flight save from a previous session can't close an editor the // user has since reopened (which would discard the new input). const editSessionRef = useRef(0) + // Live mirror of the connected database id, so the stable open/apply + // callbacks can read it without a stale closure. + const connectedInstanceIdRef = useRef(connectedInstanceId) + useEffect(() => { + connectedInstanceIdRef.current = connectedInstanceId + }, [connectedInstanceId]) + // Database connected when the inline editor opened. Its Save confirmation + // (in EditableTextArea) can be confirmed after a database switch, so the + // write is guarded with this id to avoid saving into the new database. + const inlineEditInstanceIdRef = useRef(undefined) + // Same guard for the drawer, captured when it opens (not at Save) so a + // switch before the save still skips the write. + const drawerEditInstanceIdRef = useRef(undefined) // Only the visible tab's table drives the editor-driven refresh pause, so // a hidden table can't re-enable refresh while the active one has an editor @@ -101,8 +140,12 @@ const ArrayDetailsTable = memo( // active table reacts to it.) useEffect(() => { if (!isActive) return - dispatch(setSelectedKeyRefreshDisabled(editingIndex !== null || updating)) - }, [isActive, editingIndex, updating, dispatch]) + dispatch( + setSelectedKeyRefreshDisabled( + editingIndex !== null || drawerIndex !== null || updating, + ), + ) + }, [isActive, editingIndex, drawerIndex, updating, dispatch]) // When a table is hidden (tab switch) or unmounts, it releases the shared // flag but still respects an in-flight write (global), so switching to a @@ -112,29 +155,49 @@ const ArrayDetailsTable = memo( dispatch(setSelectedKeyRefreshDisabled(updating)) }, [isActive, updating, dispatch]) - // Abandon an open editor when this table is hidden (tab switch) or the key - // changes, so a background editor can't keep refresh disabled and a stale - // editing state can't carry over. + // Abandon an open editor (inline or drawer) when this table is hidden (tab + // switch) or the key changes, so a background editor can't keep refresh + // disabled, leave a portaled drawer visible over the other tab, or carry + // stale editing state across keys. useEffect(() => { - if (!isActive) setEditingIndex(null) + if (!isActive) { + setEditingIndex(null) + setDrawerIndex(null) + } }, [isActive]) - // Abandon an open editor only on a *real* key change. `keyName` is the - // selected key's name buffer, and the post-ARSET `refreshKeyInfoAction` - // swaps in a new buffer instance for the same key — comparing by value - // (not reference) stops that refresh from closing an editor the user has - // meanwhile reopened on another row. - const prevKeyRef = useRef(keyName) + // Abandon an open editor on a real key change, keyed off the live + // selection — `keyName` lags a switch during fetchKeyInfo, which would let + // a pending drawer save ARSET the old key. Compare by value so a same-key + // info refresh (a fresh buffer for the same key) doesn't close the editor. + const prevKeyRef = useRef(liveSelectedKey) useEffect(() => { - if (isSameKey(prevKeyRef.current, keyName)) return - prevKeyRef.current = keyName + if (isSameKey(prevKeyRef.current, liveSelectedKey)) return + prevKeyRef.current = liveSelectedKey setEditingIndex(null) - }, [keyName]) + setDrawerIndex(null) + }, [liveSelectedKey]) - // Re-enable refresh when the table unmounts entirely (panel close). + // Abandon an open editor when the value formatter changes. The editor seed + // was serialized under the previous format, but a save re-serializes with + // the current `viewFormat` — saving the unchanged seed under a new format + // would write different bytes (e.g. "41" as Unicode vs the byte 0x41 as + // HEX). + const prevFormatRef = useRef(viewFormat) + useEffect(() => { + if (prevFormatRef.current === viewFormat) return + prevFormatRef.current = viewFormat + setEditingIndex(null) + setDrawerIndex(null) + }, [viewFormat]) + + // Re-enable refresh when the table unmounts (panel close), and abandon a + // pending drawer save — otherwise Confirm could ARSET the old key after a + // key switch unmounts the table. useEffect( () => () => { dispatch(setSelectedKeyRefreshDisabled(false)) + drawerIndexRef.current = null }, [dispatch], ) @@ -143,14 +206,25 @@ const ArrayDetailsTable = memo( (index: string, isEditing: boolean) => { // Opening an editor starts a new session; a stale save's callback that // compares against its captured session id will then no-op. - if (isEditing) editSessionRef.current += 1 + if (isEditing) { + editSessionRef.current += 1 + // Capture the connected database to guard a save confirmed later. + inlineEditInstanceIdRef.current = connectedInstanceIdRef.current + // Inline and drawer are one mutually-exclusive edit session — opening + // inline closes any open drawer. + setDrawerIndex(null) + } setEditingIndex(isEditing ? index : null) }, [], ) const handleApplyEditElement = useCallback( - (index: string, value: string) => { + ( + index: string, + value: string, + options?: { startInstanceId?: string; onSuccess?: () => void }, + ) => { const editSession = editSessionRef.current dispatch( updateArrayElementAction( @@ -158,21 +232,86 @@ const ArrayDetailsTable = memo( key: keyName, index, value: stringToSerializedBufferFormat(viewFormat, value), + // Inline saves fall back to the open-time id; the drawer passes + // its own save-time id. + startInstanceId: + options?.startInstanceId ?? inlineEditInstanceIdRef.current, }, - () => { - // Ignore a completion whose editor the user has since closed and - // reopened (a newer session) — closing it would discard the new - // input. handleEditElement's own guard runs for the live session. - if (editSessionRef.current === editSession) { - handleEditElement(index, false) - } - }, + options?.onSuccess ?? + (() => { + // Ignore a completion whose editor the user has since closed + // and reopened (a newer session) — closing it would discard the + // new input. handleEditElement's guard runs for the live session. + if (editSessionRef.current === editSession) { + handleEditElement(index, false) + } + }), ), ) }, [dispatch, keyName, viewFormat, handleEditElement], ) + // Open the Monaco drawer for a row, capturing its serialized value as the + // seed. Guarded like the inline editor: refresh pauses while it's open. + const handleOpenValueEditor = useCallback( + (index: string) => { + const element = elements.find((el) => el.index === index) + if (!element?.value) return + const { serialize } = getArrayElementEditState( + element.value as RedisResponseBuffer, + compressor, + viewFormat, + ) + setDrawerSeed(serialize()) + // Capture the connected database to guard a save confirmed later. + drawerEditInstanceIdRef.current = connectedInstanceIdRef.current + // Opening the drawer starts a new edit session (like inline) so a + // stale save's onSuccess can't close a drawer the user has since + // reopened. + editSessionRef.current += 1 + // Inline and drawer are one mutually-exclusive edit session — opening + // the drawer closes any open inline edit, so a later drawer save can't + // clear a still-open inline editor on another row. + setEditingIndex(null) + setDrawerIndex(index) + }, + [elements, compressor, viewFormat], + ) + + const handleDrawerSave = useCallback( + (value: string) => { + const savedIndex = drawerIndex + const savedInstanceId = drawerEditInstanceIdRef.current + if (savedIndex === null) return + requestConfirmation({ + title: 'Edit value on production database?', + actionDescription: + 'You are about to modify a value on a production database.', + confirmButtonText: 'Save', + commandId: BrowserConfirmationCommandId.EditValue, + disableConfirmationInput: true, + onConfirm: () => { + // Skip if the drawer was abandoned (Cancel, key/format change, tab + // switch) while the confirmation was pending. + if (drawerIndexRef.current !== savedIndex) return + const editSession = editSessionRef.current + handleApplyEditElement(savedIndex, value, { + // Skip the write if the database changed since Save. + startInstanceId: savedInstanceId, + // Close the drawer only on a successful write for the current + // session — a skipped, failed or superseded save leaves the edit + // in place. + onSuccess: () => { + if (editSessionRef.current === editSession) setDrawerIndex(null) + }, + }) + }, + }) + }, + [drawerIndex, requestConfirmation, handleApplyEditElement], + ) + // Pass shared per-cell config via the table's `meta` so the static // column defs in `ArrayDetailsTable.config` don't need to close over // them and can be rebuilt only when their inputs change. @@ -181,8 +320,10 @@ const ArrayDetailsTable = memo( compressor, viewFormat, editingIndex, + isValueDrawerOpen: drawerIndex !== null, onEditElement: handleEditElement, onApplyEditElement: handleApplyEditElement, + onOpenValueEditor: handleOpenValueEditor, updating, loading: readLoading, deleteConfig, @@ -192,8 +333,10 @@ const ArrayDetailsTable = memo( compressor, viewFormat, editingIndex, + drawerIndex, handleEditElement, handleApplyEditElement, + handleOpenValueEditor, updating, readLoading, deleteConfig, @@ -235,7 +378,10 @@ const ArrayDetailsTable = memo( // from `meta`, so rebuilding `columns` on every toggle would needlessly // reset table state (e.g. expanded Search context rows). const hasSelectionColumn = Boolean(selectionConfig) - const hasActionsColumn = Boolean(deleteConfig) + // The actions column hosts the per-row edit + expand triggers (editing is + // always wired on this table) alongside the optional delete trigger, so it + // is always present. + const hasActionsColumn = true const columns = useMemo(() => { const cols = hasSelectionColumn ? [selectionColumn, ...arrayColumns] @@ -280,6 +426,14 @@ const ArrayDetailsTable = memo( {...selectionProps} data-testid={`${TEST_ID}-table`} /> + setDrawerIndex(null)} + /> ) }, diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts index 0ad5725402..4630bd7461 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.types.ts @@ -58,6 +58,10 @@ export interface ArrayTableConfig { onEditElement: (index: string, isEditing: boolean) => void /** Persist an edited value (plain string from the editor) via ARSET. */ onApplyEditElement: (index: string, value: string) => void + /** Open the Monaco drawer editor for a row's value. */ + onOpenValueEditor: (index: string) => void + /** True while the drawer is open, so the actions cell can hide its triggers. */ + isValueDrawerOpen: boolean /** True while an ARSET write is in flight — keeps the editor in its loading * state and blocks a second edit from overlapping the request. */ updating: boolean diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx index a56be56c4c..eb53b46aad 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.spec.tsx @@ -1,10 +1,13 @@ import React from 'react' -import { render, screen } from 'uiSrc/utils/test-utils' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { stringToBuffer } from 'uiSrc/utils' import { KeyValueFormat } from 'uiSrc/constants' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { ArrayValueCell } from './ArrayValueCell' +const TEST_ID_PREFIX = 'array-details-table' + const renderCell = (props: Record = {}) => render( { ).toBeInTheDocument() }) }) + +describe('ArrayValueCell — display', () => { + const baseProps = { + index: '0', + value: stringToBuffer('hello'), + compressor: null, + viewFormat: KeyValueFormat.Unicode, + } + + it('renders the formatted value', () => { + render( + , + ) + + expect(screen.getByTestId(`${TEST_ID_PREFIX}-value-0`)).toHaveTextContent( + 'hello', + ) + }) + + it('renders "Empty" for an empty slot', () => { + render( + , + ) + + expect(screen.getByTestId(`${TEST_ID_PREFIX}-empty-0`)).toBeInTheDocument() + }) + + it('does not render an edit pencil in the value cell (triggers live in the actions column)', () => { + render( + , + ) + + fireEvent.mouseEnter( + screen.getByTestId(`${TEST_ID_PREFIX}_content-value-0`), + ) + + expect( + screen.queryByTestId(`${TEST_ID_PREFIX}_edit-btn-0`), + ).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx index 993b1bb41d..7303520c32 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueCell.tsx @@ -1,40 +1,37 @@ import React from 'react' import { - TEXT_DISABLED_COMPRESSED_VALUE, - TEXT_DISABLED_FORMATTER_EDITING, TEXT_FAILED_CONVENT_FORMATTER, TEXT_INVALID_VALUE, TEXT_UNPRINTABLE_CHARACTERS, } from 'uiSrc/constants' import { - bufferToSerializedFormat, - bufferToString, createTooltipContent, formattingBuffer, - isEqualBuffers, - isFormatEditable, - isNonUnicodeFormatter, - stringToBuffer, stringToSerializedBufferFormat, } from 'uiSrc/utils' -import { decompressingBuffer } from 'uiSrc/utils/decompressors' import { EditableTextArea, FormattedValue, } from 'uiSrc/pages/browser/modules/key-details/shared' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { getArrayElementEditState } from '../getArrayElementEditState' import { ArrayValueCellProps } from './ArrayValueCell.types' import * as S from './ArrayValueCell.styles' const TEST_ID_PREFIX = 'array-details-table' /** - * Renders a populated slot's value (formatted) wrapped in an inline editor for - * in-place edits (ARSET). Empty slots render a muted "Empty" marker and are - * not editable — filling a gap changes ARCOUNT/ARLEN and belongs to append / - * set-at-index, not the value edit. + * Renders a populated slot's value (formatted). When the row is in edit mode + * it hosts the inline editor (ARSET). Empty slots render a muted "Empty" + * marker and are not editable — filling a gap changes ARCOUNT/ARLEN and + * belongs to append / set-at-index, not the value edit. + * + * The edit / expand triggers live in the table's actions column + * (`RowActionsCell`), not here, so the value column stays clear for text — + * hence `hideEditButton` on the editor. Editing is driven from that column via + * the table-level `editingIndex`; this cell only reacts to `isEditing`. */ export const ArrayValueCell = ({ index, @@ -64,46 +61,24 @@ export const ArrayValueCell = ({ // Values flow through the API in `encoding=buffer` mode, so we narrow // RedisString to RedisResponseBuffer at the rendering boundary. const buffer = value as RedisResponseBuffer - const { value: decompressed, isCompressed } = decompressingBuffer( - buffer, - compressor, - ) - const decompressedBuffer = decompressed as RedisResponseBuffer - const { value: formatted, isValid } = formattingBuffer( - decompressedBuffer, - viewFormat, - { expanded: false }, - ) + const { decompressedBuffer, formatted, isValid, isUnprintable, serialize } = + getArrayElementEditState(buffer, compressor, viewFormat) const tooltipContent = createTooltipContent( formatted, decompressedBuffer, viewFormat, ) - - // Compressed payloads and non-round-trippable formats can't be safely - // edited; values with unprintable characters are disabled in the editor. - const isEditable = !isCompressed && isFormatEditable(viewFormat) - const isUnprintable = - !isNonUnicodeFormatter(viewFormat, isValid) && - !isEqualBuffers(decompressedBuffer, stringToBuffer(bufferToString(buffer))) - const editToolTipContent = isCompressed - ? TEXT_DISABLED_COMPRESSED_VALUE - : TEXT_DISABLED_FORMATTER_EDITING - const serializedValue = isEditing - ? bufferToSerializedFormat(viewFormat, decompressedBuffer, 4) - : '' + const serializedValue = isEditing ? serialize() : '' return ( @@ -112,7 +87,7 @@ export const ArrayValueCell = ({ viewFormat, )?.isValid } - editToolTipContent={!isEditable ? editToolTipContent : null} + hideEditButton onEdit={(editing) => onEdit?.(editing)} onDecline={() => onEdit?.(false)} onApply={(editedValue) => onApply?.(editedValue)} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.spec.tsx new file mode 100644 index 0000000000..e1c9130fb9 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.spec.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' + +import { ArrayValueEditorDrawer } from './ArrayValueEditorDrawer' + +jest.mock('uiSrc/components/base/code-editor', () => { + const ReactMock = require('react') + return { + __esModule: true, + CodeEditor: (props: any) => + ReactMock.createElement('textarea', { + 'data-testid': 'array-value-code-editor', + value: props.value, + readOnly: props.options?.readOnly, + onChange: (e: any) => props.onChange?.(e.target.value), + }), + } +}) + +const defaultProps = { + isOpen: true, + index: '0', + initialValue: 'hello', + onSave: jest.fn(), + onClose: jest.fn(), +} + +const renderComponent = (props = {}) => + render() + +describe('ArrayValueEditorDrawer', () => { + beforeEach(() => jest.clearAllMocks()) + + it('renders nothing while closed (no Monaco instance per row)', () => { + renderComponent({ isOpen: false }) + expect( + screen.queryByTestId('array-value-code-editor'), + ).not.toBeInTheDocument() + }) + + it('seeds the editor with initialValue when open', () => { + renderComponent() + expect(screen.getByTestId('array-value-code-editor')).toHaveValue('hello') + // Save is never validation-gated — no disabled state to satisfy first. + expect(screen.getByTestId('array-value-editor-save-btn')).not.toBeDisabled() + }) + + it('calls onSave with the edited value', () => { + const onSave = jest.fn() + renderComponent({ onSave }) + + fireEvent.change(screen.getByTestId('array-value-code-editor'), { + target: { value: 'edited value' }, + }) + fireEvent.click(screen.getByTestId('array-value-editor-save-btn')) + + expect(onSave).toHaveBeenCalledWith('edited value') + }) + + it('disables Save when isSaveDisabled is set', () => { + renderComponent({ isSaveDisabled: true }) + expect(screen.getByTestId('array-value-editor-save-btn')).toBeDisabled() + }) + + it('makes the editor read-only while a save is in flight', () => { + renderComponent({ isSaveDisabled: true }) + expect(screen.getByTestId('array-value-code-editor')).toHaveAttribute( + 'readonly', + ) + }) + + it('calls onClose and not onSave when cancelled', () => { + const onSave = jest.fn() + const onClose = jest.fn() + renderComponent({ onSave, onClose }) + + fireEvent.click(screen.getByTestId('array-value-editor-cancel-btn')) + + expect(onClose).toHaveBeenCalled() + expect(onSave).not.toHaveBeenCalled() + }) + + it('re-seeds the editor from initialValue when reopened', () => { + const { rerender } = renderComponent({ initialValue: 'first' }) + fireEvent.change(screen.getByTestId('array-value-code-editor'), { + target: { value: 'dirty' }, + }) + + rerender( + , + ) + rerender( + , + ) + + expect(screen.getByTestId('array-value-code-editor')).toHaveValue('second') + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.tsx new file mode 100644 index 0000000000..5159124dc0 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react' + +import { + Drawer, + DrawerBody, + DrawerFooter, + DrawerHeader, +} from 'uiSrc/components/base/layout/drawer' +import { CodeEditor } from 'uiSrc/components/base/code-editor' +import { Row } from 'uiSrc/components/base/layout/flex' +import { + PrimaryButton, + SecondaryButton, +} from 'uiSrc/components/base/forms/buttons' + +import { ArrayValueEditorDrawerProps } from './ArrayValueEditorDrawer.types' + +// Fill the drawer height minus its header and footer. +const EDITOR_HEIGHT = 'calc(100vh - 140px)' + +/** + * Right-side drawer with a plaintext Monaco editor for a single array element + * value — more room for large values than the inline editor. A single + * instance lives at the table level; it renders only while open (returns null + * when closed) so no Monaco instance is mounted when nothing is being edited. + */ +export const ArrayValueEditorDrawer = ({ + isOpen, + index, + initialValue, + title = 'Edit value', + isSaveDisabled = false, + onSave, + onClose, +}: ArrayValueEditorDrawerProps) => { + const [value, setValue] = useState(initialValue) + + // Re-seed on open so reopening after a cancel discards the previous edit. + // Don't add other deps, or an in-flight edit could be silently discarded. + useEffect(() => { + if (isOpen) setValue(initialValue) + }, [isOpen, initialValue]) + + if (!isOpen) return null + + return ( + { + if (!open) onClose() + }} + data-testid="array-value-editor-drawer" + > + + + + + + + + Cancel + + onSave(value)} + data-testid="array-value-editor-save-btn" + aria-label={`Save value for index ${index}`} + > + Save + + + + + ) +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.types.ts new file mode 100644 index 0000000000..fd5ecce4ea --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/ArrayValueEditorDrawer.types.ts @@ -0,0 +1,11 @@ +export interface ArrayValueEditorDrawerProps { + isOpen: boolean + index: string + /** Serialized value the editor is re-seeded with on each open. */ + initialValue: string + title?: string + /** Blocks Save while a write / patched-view read is in flight. */ + isSaveDisabled?: boolean + onSave: (value: string) => void + onClose: () => void +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.spec.tsx index d7c1d30475..0e8b53bc3d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.spec.tsx @@ -1,12 +1,20 @@ import React from 'react' import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { KeyValueFormat } from 'uiSrc/constants' +import { stringToBuffer } from 'uiSrc/utils' +import { getConfig } from 'uiSrc/config' import { arrayElementFactory, arrayElementWithValueFactory, } from 'uiSrc/mocks/factories/browser/array/arrayElement.factory' import { RowActionsCell } from './RowActionsCell' -import { ArrayElementDeleteConfig } from './RowActionsCell.types' +import { + ArrayElementDeleteConfig, + ArrayElementEditConfig, +} from './RowActionsCell.types' + +const { truncatedStringPrefix } = getConfig().app const SUFFIX = '-array-element' @@ -22,6 +30,20 @@ const buildConfig = ( ...over, }) +const buildEditConfig = ( + over: Partial = {}, +): ArrayElementEditConfig => ({ + compressor: null, + viewFormat: KeyValueFormat.Unicode, + editingIndex: null, + isValueDrawerOpen: false, + updating: false, + loading: false, + onEditElement: jest.fn(), + onOpenValueEditor: jest.fn(), + ...over, +}) + describe('RowActionsCell', () => { it('shows a delete trigger for a populated row and opens the popover on click', () => { const showPopover = jest.fn() @@ -76,3 +98,179 @@ describe('RowActionsCell', () => { expect(screen.getByTestId('array-remove-btn-3-icon')).toBeInTheDocument() }) }) + +describe('RowActionsCell — edit + expand', () => { + beforeEach(() => jest.clearAllMocks()) + + it('renders edit and expand triggers for a populated editable row', () => { + render( + , + ) + + expect(screen.getByTestId('array-edit-btn-5')).toBeInTheDocument() + expect(screen.getByTestId('array-expand-btn-5')).toBeInTheDocument() + }) + + it('opens inline edit via onEditElement when the pencil is clicked', () => { + const onEditElement = jest.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('array-edit-btn-5')) + expect(onEditElement).toHaveBeenCalledWith('5', true) + }) + + it('opens the value drawer via onOpenValueEditor when expand is clicked', () => { + const onOpenValueEditor = jest.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-5')) + expect(onOpenValueEditor).toHaveBeenCalledWith('5') + }) + + it('hides edit, expand and delete while this row is being edited', () => { + render( + , + ) + + expect(screen.queryByTestId('array-edit-btn-5')).not.toBeInTheDocument() + expect(screen.queryByTestId('array-expand-btn-5')).not.toBeInTheDocument() + // Delete is hidden too: deleting this row would race its pending ARSET. + expect( + screen.queryByTestId('array-remove-btn-5-icon'), + ).not.toBeInTheDocument() + }) + + it('keeps delete for other rows while a different row is inline-edited', () => { + render( + , + ) + + // Only the edited row's delete is frozen; ARDEL leaves a gap without + // shifting indexes, so deleting a different row can't race the edit. + expect(screen.getByTestId('array-remove-btn-5-icon')).toBeInTheDocument() + }) + + it('hides delete while an ARSET is in flight, even on a non-edited row', () => { + render( + , + ) + + // Inline Save closes the editor before its write settles, so `updating` + // covers the window where a delete would race the pending ARSET. + expect( + screen.queryByTestId('array-remove-btn-5-icon'), + ).not.toBeInTheDocument() + }) + + it('hides all row actions (edit, expand, delete) while the drawer is open', () => { + render( + , + ) + + // Freezing deletes too: deleting the edited element would let a later + // drawer Save resurrect it via ARSET. + expect(screen.queryByTestId('array-edit-btn-5')).not.toBeInTheDocument() + expect(screen.queryByTestId('array-expand-btn-5')).not.toBeInTheDocument() + expect( + screen.queryByTestId('array-remove-btn-5-icon'), + ).not.toBeInTheDocument() + }) + + it('stops trigger clicks from bubbling to a row click handler', () => { + const onRowClick = jest.fn() + render( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ +
, + ) + + fireEvent.click(screen.getByTestId('array-expand-btn-5')) + fireEvent.click(screen.getByTestId('array-edit-btn-5')) + + expect(onRowClick).not.toHaveBeenCalled() + }) + + it('disables edit and expand while a write is in flight', () => { + render( + , + ) + + expect(screen.getByTestId('array-edit-btn-5')).toBeDisabled() + expect(screen.getByTestId('array-expand-btn-5')).toBeDisabled() + }) + + it('disables edit and expand for a backend-truncated value', () => { + const element = arrayElementWithValueFactory.build({ index: '5' }) + element.value = stringToBuffer( + `${truncatedStringPrefix} big value…`, + ) as typeof element.value + render() + + expect(screen.getByTestId('array-edit-btn-5')).toBeDisabled() + expect(screen.getByTestId('array-expand-btn-5')).toBeDisabled() + }) + + it('renders no edit or expand triggers when editConfig is omitted (read-only)', () => { + render( + , + ) + + expect(screen.queryByTestId('array-edit-btn-5')).not.toBeInTheDocument() + expect(screen.queryByTestId('array-expand-btn-5')).not.toBeInTheDocument() + expect(screen.getByTestId('array-remove-btn-5-icon')).toBeInTheDocument() + }) + + it('renders no edit or expand triggers for a null-value Search row but keeps delete', () => { + render( + , + ) + + expect(screen.queryByTestId('array-edit-btn-3')).not.toBeInTheDocument() + expect(screen.queryByTestId('array-expand-btn-3')).not.toBeInTheDocument() + expect(screen.getByTestId('array-remove-btn-3-icon')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.styles.ts index 6d790279d1..157d430133 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.styles.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.styles.ts @@ -6,9 +6,13 @@ import { FlexItem } from 'uiSrc/components/base/layout/flex' // out when the pointer moves onto the popover. The reveal rules live in // ArrayDetailsTable's StyledTable (they need the row ancestor). Staying in the // DOM at opacity 0 keeps it focusable for keyboard users. +// `FlexItem` defaults to flex-direction: column, so set row explicitly to lay +// the action icons out side by side, spread evenly across the cell width. export const ActionCell = styled(FlexItem)` display: flex; - justify-content: center; + flex-direction: row; + align-items: center; + justify-content: space-evenly; opacity: 0; transition: opacity 0.1s ease-in; ` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.tsx index 042a4bd026..009fcfcb86 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.tsx @@ -2,51 +2,118 @@ import React from 'react' import { useTranslation } from 'uiSrc/i18n' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { RiTooltip } from 'uiSrc/components' +import { EditIcon, ExtendIcon } from 'uiSrc/components/base/icons' +import { IconButton } from 'uiSrc/components/base/forms/buttons' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { getArrayElementEditState } from '../../getArrayElementEditState' import { RowActionsCellProps } from './RowActionsCell.types' import * as S from './RowActionsCell.styles' +/** + * Right-column row actions — edit (inline editor), expand (Monaco drawer) and + * delete, revealed on row hover. The triggers live here, not over the value, + * so long values aren't hidden behind icons. Editing and the drawer are both + * driven from `ArrayDetailsTable` (via `editConfig`), so they share its + * refresh-pause and abandon-on-tab/key guards. + */ export const RowActionsCell = ({ element, + editConfig, deleteConfig, }: RowActionsCellProps) => { const { t } = useTranslation() - const { - deleting, - suffix, - hideEmptySlots, - closePopover, - showPopover, - handleDeleteElement, - } = deleteConfig + + const { index, value } = element // In the gap-preserving View range a null value is an empty slot with - // nothing to delete (ARDEL returns affected: 0). Search results never carry + // nothing to act on (ARDEL returns affected: 0). Search results never carry // gaps — an index-only match (WITHVALUES off) has a null value but is a real // element — so the consumer disables this guard there. - if (hideEmptySlots && element.value == null) return null + if (deleteConfig?.hideEmptySlots && value == null) return null + + const editState = + editConfig && value != null + ? getArrayElementEditState( + value as RedisResponseBuffer, + editConfig.compressor, + editConfig.viewFormat, + ) + : null + const isEditingThisRow = editConfig?.editingIndex === index + // Hide the triggers while this row is being inline-edited (its editor already + // has controls) and while the drawer is open on any row — otherwise a second + // expand would silently re-seed the open drawer and drop unsaved text. + const showEditActions = + !!editState && !isEditingThisRow && !editConfig?.isValueDrawerOpen + const isEditActionDisabled = + !editState?.isEditable || !!editConfig?.updating || !!editConfig?.loading - const { index } = element - const isOpen = deleting === `${index}${suffix}` + // `updating` too: an inline Save closes the editor before its ARSET settles, + // so a delete in that window would race the write and resurrect the element. + const showDelete = + !!deleteConfig && + !editConfig?.isValueDrawerOpen && + !isEditingThisRow && + !editConfig?.updating + + const isDeletePopoverOpen = + !!deleteConfig && deleteConfig.deleting === `${index}${deleteConfig.suffix}` return ( - handleDeleteElement(index)} - testid={`array-remove-btn-${index}`} - /> + {showEditActions && ( + <> + + { + // Search renders this table with expandRowOnClick — don't let + // the action click also toggle the neighbour band. + e.stopPropagation() + editConfig?.onEditElement(index, true) + }} + data-testid={`array-edit-btn-${index}`} + /> + + + { + e.stopPropagation() + editConfig?.onOpenValueEditor(index) + }} + data-testid={`array-expand-btn-${index}`} + /> + + + )} + + {showDelete && ( + deleteConfig.handleDeleteElement(index)} + testid={`array-remove-btn-${index}`} + /> + )} ) } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.types.ts index 1d94cc2127..a7a0918000 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/components/RowActionsCell/RowActionsCell.types.ts @@ -1,5 +1,27 @@ +import { KeyValueCompressor, KeyValueFormat } from 'uiSrc/constants' +import { Nullable } from 'uiSrc/utils' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' +/** + * Per-row edit wiring the actions cell reads from the table `meta` to render + * the edit (inline) and expand (Monaco drawer) triggers next to delete. Both + * the editing state and the drawer live in `ArrayDetailsTable`; these + * callbacks open them. + */ +export interface ArrayElementEditConfig { + compressor: Nullable + viewFormat: KeyValueFormat + editingIndex: Nullable + /** True while the drawer is open on any row — hides the triggers so a second + * expand can't re-seed the open drawer over unsaved text. */ + isValueDrawerOpen: boolean + updating: boolean + /** Blocks opening an edit so a late read can't overwrite the optimistic patch. */ + loading: boolean + onEditElement: (index: string, isEditing: boolean) => void + onOpenValueEditor: (index: string) => void +} + /** * Per-row delete state shared with the table's actions cell via the table * `meta`. Owned by `useArrayElementActions`; passed down so the static column @@ -21,5 +43,8 @@ export interface ArrayElementDeleteConfig { export interface RowActionsCellProps { element: ArrayDataElement - deleteConfig: ArrayElementDeleteConfig + /** Enables the edit + expand triggers. Omitted in read-only contexts. */ + editConfig?: ArrayElementEditConfig + /** Enables the delete trigger. Omitted when deletion isn't offered. */ + deleteConfig?: ArrayElementDeleteConfig } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts index 11fb3dc2f7..7249437b9a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts @@ -5,7 +5,8 @@ export const ARRAY_TABLE_LOADING_MESSAGE = 'Loading…' // row lines up with the same columns. export const INDEX_COLUMN_SIZE = 140 export const VALUE_COLUMN_SIZE = 420 -export const ACTIONS_COLUMN_SIZE = 48 +// Room for up to three hover actions (edit · expand · delete). +export const ACTIONS_COLUMN_SIZE = 120 // Snug around the 1.8rem checkbox, not redis-ui's default 4.2rem. export const SELECTION_COLUMN_WIDTH_REM = 2.6 export const SELECTION_COLUMN_CELL_CLASS = 'array-selection-cell' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.spec.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.spec.ts new file mode 100644 index 0000000000..3ece8b6a3b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.spec.ts @@ -0,0 +1,64 @@ +import { + KeyValueFormat, + TEXT_DISABLED_ACTION_WITH_TRUNCATED_DATA, + TEXT_UNPRINTABLE_CHARACTERS, +} from 'uiSrc/constants' +import { stringToBuffer } from 'uiSrc/utils' +import { getConfig } from 'uiSrc/config' +import { + RedisResponseBuffer, + RedisResponseBufferType, +} from 'uiSrc/slices/interfaces' + +import { getArrayElementEditState } from './getArrayElementEditState' + +const { truncatedStringPrefix } = getConfig().app + +const buffer = (s: string) => stringToBuffer(s) as RedisResponseBuffer + +describe('getArrayElementEditState', () => { + it('marks a plain unicode value editable with no disabled reason', () => { + const state = getArrayElementEditState( + buffer('hello'), + null, + KeyValueFormat.Unicode, + ) + + expect(state.isEditable).toBe(true) + expect(state.isTruncated).toBe(false) + expect(state.editDisabledReason).toBeNull() + expect(state.serialize()).toBe('hello') + }) + + it('marks a backend-truncated value non-editable with the truncated reason', () => { + const state = getArrayElementEditState( + buffer(`${truncatedStringPrefix} big value`), + null, + KeyValueFormat.Unicode, + ) + + expect(state.isTruncated).toBe(true) + expect(state.isEditable).toBe(false) + expect(state.editDisabledReason).toBe( + TEXT_DISABLED_ACTION_WITH_TRUNCATED_DATA, + ) + }) + + it('marks a value with non-printable bytes non-editable', () => { + // 0xC0 is an invalid UTF-8 lead byte, so it doesn't round-trip through a + // string and back — the definition of unprintable here. + const unprintable = { + type: RedisResponseBufferType.Buffer, + data: [0xc0], + } as RedisResponseBuffer + const state = getArrayElementEditState( + unprintable, + null, + KeyValueFormat.Unicode, + ) + + expect(state.isUnprintable).toBe(true) + expect(state.isEditable).toBe(false) + expect(state.editDisabledReason).toBe(TEXT_UNPRINTABLE_CHARACTERS.content) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.ts new file mode 100644 index 0000000000..bcf79aa77b --- /dev/null +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/getArrayElementEditState.ts @@ -0,0 +1,95 @@ +import { ReactNode } from 'react' + +import { + KeyValueCompressor, + KeyValueFormat, + TEXT_DISABLED_ACTION_WITH_TRUNCATED_DATA, + TEXT_DISABLED_COMPRESSED_VALUE, + TEXT_DISABLED_FORMATTER_EDITING, + TEXT_UNPRINTABLE_CHARACTERS, +} from 'uiSrc/constants' +import { + bufferToSerializedFormat, + bufferToString, + formattingBuffer, + isEqualBuffers, + isFormatEditable, + isNonUnicodeFormatter, + isTruncatedString, + stringToBuffer, + Nullable, +} from 'uiSrc/utils' +import { decompressingBuffer } from 'uiSrc/utils/decompressors' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' + +export interface ArrayElementEditState { + decompressedBuffer: RedisResponseBuffer + formatted: JSX.Element | string + isValid: boolean + isCompressed: boolean + /** Editing a truncated value would save the truncated copy over the real one. */ + isTruncated: boolean + /** Editor stays open but its input is disabled, to avoid silent data loss. */ + isUnprintable: boolean + isEditable: boolean + /** Trigger-tooltip text; null when editable. */ + editDisabledReason: Nullable + serialize: () => string +} + +/** + * Shared display + edit state for one populated array element. Centralised so + * the value cell (display + inline editor) and the actions cell (edit/expand + * triggers + drawer seed) can't drift apart. Callers must guard empty slots + * (`value == null`) — an empty slot has nothing to format or edit. + */ +export const getArrayElementEditState = ( + value: RedisResponseBuffer, + compressor: Nullable, + viewFormat: KeyValueFormat, +): ArrayElementEditState => { + const { value: decompressed, isCompressed } = decompressingBuffer( + value, + compressor, + ) + const decompressedBuffer = decompressed as RedisResponseBuffer + const { value: formatted, isValid } = formattingBuffer( + decompressedBuffer, + viewFormat, + { expanded: false }, + ) + + const isTruncated = isTruncatedString(value) + const isFormatEditableValue = isFormatEditable(viewFormat) + const isUnprintable = + !isNonUnicodeFormatter(viewFormat, isValid) && + !isEqualBuffers(decompressedBuffer, stringToBuffer(bufferToString(value))) + // Unprintable is part of editability: the drawer's Monaco field is fully + // editable, so a Save would re-encode and overwrite the original bytes. + const isEditable = + !isCompressed && !isTruncated && isFormatEditableValue && !isUnprintable + + let editDisabledReason: Nullable = null + if (isCompressed) { + editDisabledReason = TEXT_DISABLED_COMPRESSED_VALUE + } else if (isTruncated) { + editDisabledReason = TEXT_DISABLED_ACTION_WITH_TRUNCATED_DATA + } else if (!isFormatEditableValue) { + editDisabledReason = TEXT_DISABLED_FORMATTER_EDITING + } else if (isUnprintable) { + editDisabledReason = TEXT_UNPRINTABLE_CHARACTERS.content + } + + return { + decompressedBuffer, + formatted, + isValid, + isCompressed, + isTruncated, + isUnprintable, + isEditable, + editDisabledReason, + serialize: () => + bufferToSerializedFormat(viewFormat, decompressedBuffer, 4), + } +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.spec.tsx index cd4c78b3bb..622711d761 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.spec.tsx @@ -80,4 +80,41 @@ describe('EditableTextArea', () => { expect(onDecline).toBeCalled() }) + + it('should show the edit pencil on hover by default', () => { + render( + + + , + ) + + fireEvent.mouseEnter(screen.getByTestId('item_content-value-field')) + + expect(screen.getByTestId('item_edit-btn-field')).toBeInTheDocument() + }) + + it('should not render the edit pencil when hideEditButton is set', () => { + render( + + + , + ) + + fireEvent.mouseEnter(screen.getByTestId('item_content-value-field')) + + expect(screen.queryByTestId('item_edit-btn-field')).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.tsx index 7284abfd13..ef32394b36 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/editable-textarea/EditableTextArea.tsx @@ -28,6 +28,11 @@ export interface Props { disabledTooltipText?: { title: string; content: string } approveText?: { title: string; text: string } editToolTipContent?: React.ReactNode + /** Suppresses the built-in hover edit pencil (and the space reserved for + * it) in the non-editing state. Used where the edit trigger lives outside + * the cell (the array table drives editing from its actions column); + * defaults to false, so all other consumers are unchanged. */ + hideEditButton?: boolean approveByValidation?: (value: string) => boolean onEdit: (isEditing: boolean) => void onUpdateTextAreaHeight?: () => void @@ -51,6 +56,7 @@ const EditableTextArea = (props: Props) => { disabledTooltipText, approveText, editToolTipContent, + hideEditButton = false, approveByValidation = () => true, onEdit, onUpdateTextAreaHeight, @@ -93,7 +99,9 @@ const EditableTextArea = (props: Props) => { if (!isEditing) { return (
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} data-testid={`${testIdPrefix}_content-value-${field}`} @@ -105,7 +113,7 @@ const EditableTextArea = (props: Props) => { > {children} - {isHovering && ( + {!hideEditButton && isHovering && ( void, ) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { + const state = stateInit() + const startInstanceId = state.connections.instances.connectedInstance?.id + // Skip the write if the connected database changed since the edit was + // initiated — e.g. a production-write confirmation confirmed after the + // connection switched. Never write this value into a different database. + if ( + params.startInstanceId != null && + params.startInstanceId !== startInstanceId + ) { + return + } latestEditRequestToken += 1 const requestToken = latestEditRequestToken dispatch(setArrayUpdating(true)) try { - const state = stateInit() - const startInstanceId = state.connections.instances.connectedInstance?.id const { status } = await apiService.post( arrayUrl(state, ApiEndpoints.ARRAY_SET_ELEMENT), { keyName: params.key, index: params.index, value: params.value }, diff --git a/redisinsight/ui/src/slices/interfaces/array.ts b/redisinsight/ui/src/slices/interfaces/array.ts index 50882b9d5f..76b761716e 100644 --- a/redisinsight/ui/src/slices/interfaces/array.ts +++ b/redisinsight/ui/src/slices/interfaces/array.ts @@ -252,6 +252,10 @@ export interface UpdateArrayElementParams { key: RedisString index: string value: RedisString + /** Connected instance id when the edit was initiated. When set, the write is + * skipped if the connected database has since changed (e.g. a production- + * write confirmation confirmed after switching connections). */ + startInstanceId?: string } /** diff --git a/redisinsight/ui/src/slices/tests/browser/array.spec.ts b/redisinsight/ui/src/slices/tests/browser/array.spec.ts index 63c2d7ca82..6189e648c7 100644 --- a/redisinsight/ui/src/slices/tests/browser/array.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/array.spec.ts @@ -934,6 +934,28 @@ describe('array slice', () => { expect(actions).toContainEqual(setArrayUpdating(false)) }) + it('skips the write entirely when the connected database changed since the edit began', async () => { + apiService.post = jest.fn().mockResolvedValue({ status: 200, data: '' }) + const keyedStore = storeWithSelectedKey(mockKey) + + await keyedStore.dispatch( + updateArrayElementAction({ + key: mockKey, + index: '5', + value: 'B', + startInstanceId: 'instance-no-longer-connected', + }), + ) + + // No ARSET, and the updating lock is never acquired — the value must + // not be written into a different database (e.g. a production-write + // confirmation confirmed after switching connections). + expect(apiService.post).not.toHaveBeenCalled() + expect(keyedStore.getActions()).not.toContainEqual( + setArrayUpdating(true), + ) + }) + it('skips the patch and the success callback when the selected key changed mid-write', async () => { apiService.post = jest.fn().mockResolvedValue({ status: 200, data: '' }) // User switched to another key before the POST resolved. From da94693243fa4f7eead263d2f6da1b98d9c8e7e9 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 10 Jul 2026 15:11:28 +0300 Subject: [PATCH 020/166] Polish the array actions column (#6188) --- .../ArrayDetailsTable.config.tsx | 3 +++ .../ArrayDetailsTable.styles.ts | 16 +++++++++++++++- .../array-details-table/constants.ts | 5 +++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx index d8ec1d78ae..4234baee2c 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx @@ -9,6 +9,7 @@ import { RowActionsCell } from './components/RowActionsCell' import { BulkDeleteHeaderCell } from './components/BulkDeleteHeaderCell' import { ArrayTableConfig } from './ArrayDetailsTable.types' import { + ACTIONS_COLUMN_CELL_CLASS, ACTIONS_COLUMN_SIZE, INDEX_COLUMN_SIZE, SELECTION_COLUMN_WIDTH_REM, @@ -98,6 +99,8 @@ export const actionsColumn: ColumnDef = { enableResizing: false, size: ACTIONS_COLUMN_SIZE, sizeUnit: 'px', + // Center the bulk trigger in the header cell (see ArrayDetailsTable.styles). + getHeaderCellProps: () => ({ className: ACTIONS_COLUMN_CELL_CLASS }), cell: ({ row, table }: CellContext) => { const { compressor, diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts index 2594795c62..2b77626f2a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts @@ -3,7 +3,10 @@ import { FlexItem } from 'uiSrc/components/base/layout/flex' import { Table, TableProps } from 'uiSrc/components/base/layout/table' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' -import { SELECTION_COLUMN_CELL_CLASS } from './constants' +import { + ACTIONS_COLUMN_CELL_CLASS, + SELECTION_COLUMN_CELL_CLASS, +} from './constants' export const Container = styled(FlexItem)` display: flex; @@ -45,4 +48,15 @@ export const StyledTable = styled(Table)` width: 100%; justify-content: center; } + + /* Actions column header: trim the side padding so the bulk trigger centers + in the column instead of hugging the padding. */ + th.${ACTIONS_COLUMN_CELL_CLASS} { + padding-left: ${({ theme }) => theme.core.space.space050}; + padding-right: ${({ theme }) => theme.core.space.space050}; + } + th.${ACTIONS_COLUMN_CELL_CLASS} > * { + width: 100%; + justify-content: center; + } ` as unknown as (props: TableProps) => JSX.Element diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts index 7249437b9a..8e3dd50867 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts @@ -5,8 +5,9 @@ export const ARRAY_TABLE_LOADING_MESSAGE = 'Loading…' // row lines up with the same columns. export const INDEX_COLUMN_SIZE = 140 export const VALUE_COLUMN_SIZE = 420 -// Room for up to three hover actions (edit · expand · delete). -export const ACTIONS_COLUMN_SIZE = 120 +// Snug fit for the row hover actions (edit · expand · delete). +export const ACTIONS_COLUMN_SIZE = 60 // Snug around the 1.8rem checkbox, not redis-ui's default 4.2rem. export const SELECTION_COLUMN_WIDTH_REM = 2.6 export const SELECTION_COLUMN_CELL_CLASS = 'array-selection-cell' +export const ACTIONS_COLUMN_CELL_CLASS = 'array-actions-cell' From 20248a8e451d2bc10825877d671fe40eaa8e7ca9 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Mon, 13 Jul 2026 12:43:43 +0300 Subject: [PATCH 021/166] RI-8275 i18n: migrate Workbench (#6189) - migrate workbench strings (RI-8275) - document plural convention (RI-8275) - translate shared query components and Full Screen (RI-8275) Refs RI-8275 --- .ai/skills/i18n/SKILL.md | 24 ++++++++ .../src/components/full-screen/FullScreen.tsx | 38 +++++++----- .../components/query/components/RunButton.tsx | 6 +- .../query/query-actions/QueryActions.tsx | 34 ++++++----- .../query/query-card/QueryCard.spec.tsx | 2 +- .../components/query/query-card/QueryCard.tsx | 8 ++- .../QueryCardCliResultWrapper.tsx | 9 +-- .../QueryCardHeader/QueryCardHeader.tsx | 28 +++++---- .../query-lite-actions/QueryLiteActions.tsx | 26 +++++---- .../query/query-results/QueryResults.tsx | 4 +- .../query/query-tutorials/QueryTutorials.tsx | 4 +- redisinsight/ui/src/i18n/locales/bg.json | 58 ++++++++++++++++++- redisinsight/ui/src/i18n/locales/en.json | 58 ++++++++++++++++++- .../ui/src/pages/workbench/WorkbenchPage.tsx | 7 ++- .../components/query/Query/Query.tsx | 7 ++- .../components/query/Query/constants.ts | 8 +-- .../WbNoResultsMessage.tsx | 18 +++--- .../wb-results/WBResults/WBResults.tsx | 4 +- .../components/wb-view/WBViewWrapper.tsx | 24 ++++---- .../ui/src/pages/workbench/constants.ts | 16 +++-- .../src/pages/workbench/utils/suggestions.ts | 9 ++- 21 files changed, 290 insertions(+), 102 deletions(-) diff --git a/.ai/skills/i18n/SKILL.md b/.ai/skills/i18n/SKILL.md index ecee011a03..76f956b075 100644 --- a/.ai/skills/i18n/SKILL.md +++ b/.ai/skills/i18n/SKILL.md @@ -81,6 +81,29 @@ import { Trans } from 'uiSrc/i18n'; // getTranslatedApiError() fills {{databaseId}} from response.data.resource — no extra code. ``` +## Plurals + +Use i18next's native **`count`-based** plurals — never a hand-rolled `isPlural` branch with +`.single`/`.plural` keys. + +- Add one key per plural form with the i18next suffix: `key_one`, `key_other` (a language may + need more forms — `_few`, `_many` — but `en`/`bg` only use `_one`/`_other`). +- Reference the **base** key (no suffix) and pass `count`; i18next selects the form: + `t('key', { count })` or ``. +- The base key type-checks even though only the suffixed forms are in `en.json` — i18next's + types resolve it from the `_one`/`_other` entries. +- **Write the whole sentence in each form.** Don't interpolate the one differing word as a + fragment — word order, agreement, and the number of plural forms vary by language. +- Renaming a key (e.g. `.single` → `_one`) leaves the old key behind in `bg.json` because + `i18n:extract` doesn't prune — delete the orphan so en/bg parity holds. + +```tsx +// en.json: +// "workbench.runConfirm.body_one": "…This command is part of…" +// "workbench.runConfirm.body_other": "…These commands are part of…" + +``` + ## Keys - **Flat, dotted keys** — `keySeparator` and `nsSeparator` are `false`, so a dot is a literal character, not nesting. `"api.error.code.11000.title"` is a single key. @@ -146,3 +169,4 @@ The backend ships a stable `errorCode` on every user-facing error (see - ✅ Keep en/bg key parity; empty bg is an acceptable "later" placeholder. - ❌ Don't hardcode user-facing strings — add a key. - ❌ Don't hand-edit the locale-file key order — let `i18n:extract` sort. +- ❌ Don't hand-roll plurals with a JS branch — use `count` + `key_one`/`key_other` (see Plurals). diff --git a/redisinsight/ui/src/components/full-screen/FullScreen.tsx b/redisinsight/ui/src/components/full-screen/FullScreen.tsx index 1b2f23934a..01c7d16f6a 100644 --- a/redisinsight/ui/src/components/full-screen/FullScreen.tsx +++ b/redisinsight/ui/src/components/full-screen/FullScreen.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { ExtendIcon, ShrinkIcon } from 'uiSrc/components/base/icons' import { IconButton } from 'uiSrc/components/base/forms/buttons' import { RiTooltip } from 'uiSrc/components' @@ -15,20 +16,27 @@ const FullScreen = ({ onToggleFullScreen, anchorClassName = '', btnTestId = 'toggle-full-screen', -}: Props) => ( - - - -) +}: Props) => { + const { t } = useTranslation() + return ( + + + + ) +} export { FullScreen } diff --git a/redisinsight/ui/src/components/query/components/RunButton.tsx b/redisinsight/ui/src/components/query/components/RunButton.tsx index 882f5d0dcc..ca710b33d5 100644 --- a/redisinsight/ui/src/components/query/components/RunButton.tsx +++ b/redisinsight/ui/src/components/query/components/RunButton.tsx @@ -1,5 +1,6 @@ import React from 'react' import styled from 'styled-components' +import { useTranslation } from 'uiSrc/i18n' import { PlayFilledIcon } from 'uiSrc/components/base/icons' import { EmptyButton } from 'uiSrc/components/base/forms/buttons' @@ -24,6 +25,7 @@ export const RunButton = ({ isLoading?: boolean onSubmit: () => void }) => { + const { t } = useTranslation() return ( { @@ -32,10 +34,10 @@ export const RunButton = ({ loading={isLoading} disabled={isLoading} icon={PlayFilledIcon} - aria-label="submit" + aria-label={t('query.runButton.aria')} data-testid="btn-submit" > - Run + {t('query.runButton.label')} ) } diff --git a/redisinsight/ui/src/components/query/query-actions/QueryActions.tsx b/redisinsight/ui/src/components/query/query-actions/QueryActions.tsx index fb188aeef9..598efc7559 100644 --- a/redisinsight/ui/src/components/query/query-actions/QueryActions.tsx +++ b/redisinsight/ui/src/components/query/query-actions/QueryActions.tsx @@ -1,9 +1,11 @@ import React from 'react' +import { Trans, useTranslation } from 'uiSrc/i18n' import { ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' import { KEYBOARD_SHORTCUTS } from 'uiSrc/constants' import { KeyboardShortcut, RiTooltip } from 'uiSrc/components' import { isGroupMode } from 'uiSrc/utils' +import { isMacOs } from 'uiSrc/utils/dom' import { RiIcon } from 'uiSrc/components/base/icons' @@ -24,6 +26,7 @@ export interface Props { } const QueryActions = (props: Props) => { + const { t } = useTranslation() const { isLoading, activeMode, @@ -34,7 +37,14 @@ const QueryActions = (props: Props) => { } = props const KeyBoardTooltipContent = KEYBOARD_SHORTCUTS?.workbench?.runQuery && ( <> - {KEYBOARD_SHORTCUTS.workbench.runQuery?.label}: + + {t( + isMacOs() + ? 'query.runShortcut.label' + : 'query.runShortcut.labelNonMac', + )} + : + { {onChangeMode && ( { data-testid="btn-change-mode" > - Raw mode + {t('query.actions.rawMode.label')} )} @@ -66,12 +76,10 @@ const QueryActions = (props: Props) => { - Groups the command results into a single window. -
- When grouped, the results can be visualized only in the text - format. - + }} + /> } data-testid="group-results-tooltip" > @@ -82,18 +90,14 @@ const QueryActions = (props: Props) => { data-testid="btn-change-group-mode" > - Group results + {t('query.actions.groupMode.label')}
)} diff --git a/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx index ed4ee17fa0..fd3dfeac2c 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx @@ -138,7 +138,7 @@ describe('QueryCard', () => { it('Should return correct summary string', () => { const summary = { total: 2, success: 1, fail: 1 } - const summaryText = '2 Command(s) - 1 success, 1 error(s)' + const summaryText = '2 Commands - 1 success, 1 error' const summaryString = getSummaryText(summary) diff --git a/redisinsight/ui/src/components/query/query-card/QueryCard.tsx b/redisinsight/ui/src/components/query/query-card/QueryCard.tsx index 73d18b8b52..0461319709 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCard.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCard.tsx @@ -3,6 +3,7 @@ import { useAppSelector } from 'uiSrc/slices/hooks' import cx from 'classnames' import { useParams } from 'react-router-dom' import { isNull } from 'lodash' +import i18n from 'uiSrc/i18n' import { KeyboardKeys as keys } from 'uiSrc/constants/keys' import { LoadingContent } from 'uiSrc/components/base/layout' @@ -112,9 +113,12 @@ export const getSummaryText = ( ) => { if (summary) { const { total, success, fail } = summary - const summaryText = `${total} Command(s) - ${success} success` + const summaryText = i18n.t('query.card.summary.commands', { + count: total, + success, + }) if (!isSilentModeWithoutError(mode, summary?.fail)) { - return `${summaryText}, ${fail} error(s)` + return `${summaryText}${i18n.t('query.card.summary.errors', { count: fail })}` } return summaryText } diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx index 7c4639f26f..b32177281c 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react' import cx from 'classnames' import { isArray } from 'lodash' +import { useTranslation } from 'uiSrc/i18n' import { LoadingContent } from 'uiSrc/components/base/layout' import { CommandExecutionResult } from 'uiSrc/slices/interfaces' import { ResultsMode } from 'uiSrc/slices/interfaces/workbench' @@ -75,6 +76,7 @@ export const getResultText = ( } const QueryCardCliResultWrapper = (props: Props) => { + const { t } = useTranslation() const { result = [], query, @@ -99,16 +101,15 @@ const QueryCardCliResultWrapper = (props: Props) => { <>
{isNotStored && ( - The result is too big to be saved. It will be deleted after the - application is closed. + {t('query.cliResult.tooBig')} )} {isGroupResults(resultsMode) && isArray(result[0]?.response) ? ( diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx index 5d902cf8c5..b7d0eb0916 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx @@ -1,4 +1,6 @@ import React, { useContext } from 'react' + +import { useTranslation } from 'uiSrc/i18n' import cx from 'classnames' import { useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' @@ -99,6 +101,7 @@ const getTruncatedExecutionTimeString = (value: number): string => { } const QueryCardHeader = (props: Props) => { + const { t } = useTranslation() const { isOpen, toggleOpen, @@ -339,7 +342,7 @@ const QueryCardHeader = (props: Props) => { { > {isNumber(executionTime) && ( { )} - + @@ -465,14 +471,14 @@ const QueryCardHeader = (props: Props) => { {!isFullScreen && ( @@ -484,7 +490,7 @@ const QueryCardHeader = (props: Props) => { {!isSilentModeWithoutError(resultsMode, summary?.fail) && ( )} @@ -500,19 +506,19 @@ const QueryCardHeader = (props: Props) => { {isGroupMode(resultsMode) && ( - Group mode + {t('query.card.mode.group')} )} {isSilentMode(resultsMode) && ( - Silent mode + {t('query.card.mode.silent')} )} {isRawMode(mode) && ( - Raw mode + {t('query.card.mode.raw')} )} @@ -522,7 +528,7 @@ const QueryCardHeader = (props: Props) => { > diff --git a/redisinsight/ui/src/components/query/query-lite-actions/QueryLiteActions.tsx b/redisinsight/ui/src/components/query/query-lite-actions/QueryLiteActions.tsx index 2741f434c3..17e212cb24 100644 --- a/redisinsight/ui/src/components/query/query-lite-actions/QueryLiteActions.tsx +++ b/redisinsight/ui/src/components/query/query-lite-actions/QueryLiteActions.tsx @@ -1,7 +1,9 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { KEYBOARD_SHORTCUTS } from 'uiSrc/constants' import { KeyboardShortcut, RiTooltip } from 'uiSrc/components' +import { isMacOs } from 'uiSrc/utils/dom' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { EmptyButton } from 'uiSrc/components/base/forms/buttons' @@ -15,10 +17,18 @@ export interface Props { } const QueryLiteActions = (props: Props) => { + const { t } = useTranslation() const { isLoading, onSubmit, onClear } = props const KeyBoardTooltipContent = KEYBOARD_SHORTCUTS?.workbench?.runQuery && ( <> - {KEYBOARD_SHORTCUTS.workbench.runQuery?.label}: + + {t( + isMacOs() + ? 'query.runShortcut.label' + : 'query.runShortcut.labelNonMac', + )} + : + { position="right" content={ isLoading - ? 'Please wait while the commands are being executed…' - : 'Clear query' + ? t('query.executing') + : t('query.liteActions.clear.tooltip') } data-testid="clear-query-tooltip" > @@ -42,20 +52,16 @@ const QueryLiteActions = (props: Props) => { onClick={onClear} loading={isLoading} disabled={isLoading} - aria-label="clear" + aria-label={t('query.liteActions.clear.aria')} data-testid="btn-clear" > - Clear + {t('query.liteActions.clear.label')} diff --git a/redisinsight/ui/src/components/query/query-results/QueryResults.tsx b/redisinsight/ui/src/components/query/query-results/QueryResults.tsx index 20b8b4c616..194960b3dc 100644 --- a/redisinsight/ui/src/components/query/query-results/QueryResults.tsx +++ b/redisinsight/ui/src/components/query/query-results/QueryResults.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { CodeButtonParams } from 'uiSrc/constants' import { ProfileQueryType } from 'uiSrc/pages/workbench/constants' import { generateProfileQueryForCommand } from 'uiSrc/pages/workbench/utils/profile' @@ -39,6 +40,7 @@ export interface QueryResultsProps { } const QueryResults = (props: QueryResultsProps) => { + const { t } = useTranslation() const { isResultsLoaded, items = [], @@ -91,7 +93,7 @@ const QueryResults = (props: QueryResultsProps) => { disabled={clearing || processing} data-testid="clear-history-btn" > - Clear Results + {t('query.results.clear')} )} diff --git a/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.tsx b/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.tsx index 3ee1fbeceb..f229d9bcd9 100644 --- a/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.tsx +++ b/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { useAppDispatch } from 'uiSrc/slices/hooks' import { useHistory, useParams } from 'react-router-dom' import styled from 'styled-components' @@ -47,6 +48,7 @@ const QueryTutorialsButton = styled(EmptyButton)` ` const QueryTutorials = ({ tutorials, source }: Props) => { + const { t } = useTranslation() const dispatch = useAppDispatch() const history = useHistory() const { instanceId } = useParams<{ instanceId: string }>() @@ -67,7 +69,7 @@ const QueryTutorials = ({ tutorials, source }: Props) => { return (
- Tutorials: + {t('query.tutorials.title')} {tutorials.map(({ id, title }) => ( Когато са групирани, резултатите могат да се визуализират само в текстов формат.", + "query.actions.rawMode.label": "Необработен режим", + "query.actions.rawMode.tooltip": "Активира режима на необработен изход", + "query.card.clearResult.tooltip": "Изчистване на резултата", + "query.card.copyQuery.aria": "Копиране на заявката", + "query.card.delete.aria": "Изтриване на командата", + "query.card.mode.group": "Групов режим", + "query.card.mode.raw": "Необработен режим", + "query.card.mode.silent": "Тих режим", + "query.card.processingTime": "Време за обработка", + "query.card.queryParameters.aria": "Параметри на заявката", + "query.card.rerun.aria": "Повторно изпълнение на командата", + "query.card.rerun.tooltip": "Изпълни отново", + "query.card.summary.commands_one": "{{count}} команда - {{success}} успешни", + "query.card.summary.commands_other": "{{count}} команди - {{success}} успешни", + "query.card.summary.errors_one": ", {{count}} грешка", + "query.card.summary.errors_other": ", {{count}} грешки", + "query.card.toggleCollapse.aria": "Превключи резултата", + "query.cliResult.copy": "Копиране на резултата", + "query.cliResult.tooBig": "Резултатът е твърде голям, за да бъде запазен. Ще бъде изтрит след затваряне на приложението.", + "query.executing": "Моля, изчакайте, докато командите се изпълняват…", + "query.liteActions.clear.aria": "Изчисти заявката", + "query.liteActions.clear.label": "Изчисти", + "query.liteActions.clear.tooltip": "Изчистване на заявката", + "query.results.clear": "Изчистване на резултатите", + "query.runButton.aria": "Изпълни заявката", + "query.runButton.label": "Изпълни", + "query.runShortcut.label": "Изпълнение на командите", + "query.runShortcut.labelNonMac": "Изпълнение", + "query.tutorials.title": "Ръководства:", "settings.advanced.keysToScan.label": "Ключове за сканиране:", "settings.advanced.keysToScan.summary": "Задава броя ключове, сканирани на една итерация. Филтрирането по шаблон при голям брой ключове може да намали производителността.", "settings.advanced.keysToScan.title": "Ключове за сканиране в изглед Списък", @@ -384,5 +418,27 @@ "whatsNew.releaseNotes.link": "Вижте пълните бележки по изданието за {{version}}", "whatsNew.title": "Какво ново", "whatsNew.version.option": "v{{version}}", - "whatsNew.version.optionLatest": "v{{version}} (най-нова)" + "whatsNew.version.optionLatest": "v{{version}} (най-нова)", + "workbench.noResults.button.explore": "Разгледайте", + "workbench.noResults.cliSubtitle": "за Redis команди.", + "workbench.noResults.cliTitle": "Това е нашият усъвършенстван CLI", + "workbench.noResults.hint": "Или щракнете върху иконата в горния десен ъгъл.", + "workbench.noResults.imageAlt": "няма резултати", + "workbench.noResults.summary": "Изпробвайте Работна среда с нашите интерактивни ръководства, за да научите как Redis може да реши вашите случаи на употреба.", + "workbench.noResults.title": "Все още няма резултати за показване", + "workbench.pageTitle": "{{name}} {{db}} - Работна среда", + "workbench.results.clear": "Изчистване на резултатите", + "workbench.runConfirm.body_one": "На път сте да изпълните {{commands}} на {{db}}. Тази команда е част от списъка с опасни команди. Тази операция може да повлияе на стабилността на сървъра.", + "workbench.runConfirm.body_other": "На път сте да изпълните {{commands}} на {{db}}. Тези команди са част от списъка с опасни команди. Тази операция може да повлияе на стабилността на сървъра.", + "workbench.runConfirm.button.run": "Изпълнение на командата", + "workbench.runConfirm.title": "Продължете внимателно в production среда", + "workbench.suggestions.noIndexes.detail": "Създайте индекс", + "workbench.suggestions.noIndexes.documentation": "Вижте [документацията]({{link}}) за подробни инструкции как да създадете индекс.", + "workbench.suggestions.noIndexes.label": "Няма индекси за показване", + "workbench.tutorials.basicUseCases": "Основни случаи на употреба", + "workbench.tutorials.introToSearch": "Въведение в търсенето", + "workbench.tutorials.introToVectorSearch": "Въведение във векторното търсене", + "workbench.viewType.explain": "Обяснение на командата", + "workbench.viewType.profile": "Профилиране на командата", + "workbench.viewType.text": "Текст" } diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 2f23969001..7386ace7d4 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -201,6 +201,9 @@ "browser.array.delete.range.trigger": "Delete range", "browser.array.delete.row.message": "This element will be permanently removed from the array.", "browser.array.delete.row.title": "Delete element", + "common.fullScreen.enter": "Full Screen", + "common.fullScreen.exit": "Exit Full Screen", + "common.fullScreen.openAria": "Open full screen", "common.privacyPolicy": "Privacy Policy", "notification.error.arrayBulkDeleteLimit.message": "You can delete up to {{max}} elements at once. Clear some of the selection and try again.", "notification.error.arrayBulkDeleteLimit.title": "Too many elements selected", @@ -316,6 +319,37 @@ "notification.success.uploadDataBulk.success": "Success", "notification.success.uploadDataBulk.timeTaken": "Time Taken", "notification.success.uploadDataBulk.title": "Action completed", + "query.actions.groupMode.label": "Group results", + "query.actions.groupMode.tooltip": "Groups the command results into a single window.When grouped, the results can be visualized only in the text format.", + "query.actions.rawMode.label": "Raw mode", + "query.actions.rawMode.tooltip": "Enables the raw output mode", + "query.card.clearResult.tooltip": "Clear result", + "query.card.copyQuery.aria": "Copy query", + "query.card.delete.aria": "Delete command", + "query.card.mode.group": "Group mode", + "query.card.mode.raw": "Raw mode", + "query.card.mode.silent": "Silent mode", + "query.card.processingTime": "Processing Time", + "query.card.queryParameters.aria": "Query parameters", + "query.card.rerun.aria": "Re-run command", + "query.card.rerun.tooltip": "Run again", + "query.card.summary.commands_one": "{{count}} Command - {{success}} success", + "query.card.summary.commands_other": "{{count}} Commands - {{success}} success", + "query.card.summary.errors_one": ", {{count}} error", + "query.card.summary.errors_other": ", {{count}} errors", + "query.card.toggleCollapse.aria": "Toggle result", + "query.cliResult.copy": "Copy result", + "query.cliResult.tooBig": "The result is too big to be saved. It will be deleted after the application is closed.", + "query.executing": "Please wait while the commands are being executed…", + "query.liteActions.clear.aria": "Clear query", + "query.liteActions.clear.label": "Clear", + "query.liteActions.clear.tooltip": "Clear query", + "query.results.clear": "Clear Results", + "query.runButton.aria": "Run query", + "query.runButton.label": "Run", + "query.runShortcut.label": "Run commands", + "query.runShortcut.labelNonMac": "Run", + "query.tutorials.title": "Tutorials:", "settings.advanced.keysToScan.label": "Keys to Scan:", "settings.advanced.keysToScan.summary": "Sets the amount of keys to scan per one iteration. Filtering by pattern per a large number of keys may decrease performance.", "settings.advanced.keysToScan.title": "Keys to Scan in List view", @@ -384,5 +418,27 @@ "whatsNew.releaseNotes.link": "See full release notes for {{version}}", "whatsNew.title": "What's New", "whatsNew.version.option": "v{{version}}", - "whatsNew.version.optionLatest": "v{{version}} (Latest)" + "whatsNew.version.optionLatest": "v{{version}} (Latest)", + "workbench.noResults.button.explore": "Explore", + "workbench.noResults.cliSubtitle": "for Redis commands.", + "workbench.noResults.cliTitle": "This is our advanced CLI", + "workbench.noResults.hint": "Or click the icon in the top right corner.", + "workbench.noResults.imageAlt": "no results", + "workbench.noResults.summary": "Try Workbench with our interactive Tutorials to learn how Redis can solve your use cases.", + "workbench.noResults.title": "No results to display yet", + "workbench.pageTitle": "{{name}} {{db}} - Workbench", + "workbench.results.clear": "Clear Results", + "workbench.runConfirm.body_one": "You're about to run {{commands}} on {{db}}. This command is part of the list of dangerous commands. This operation may affect server stability.", + "workbench.runConfirm.body_other": "You're about to run {{commands}} on {{db}}. These commands are part of the list of dangerous commands. This operation may affect server stability.", + "workbench.runConfirm.button.run": "Run command", + "workbench.runConfirm.title": "Proceed with caution in production", + "workbench.suggestions.noIndexes.detail": "Create an index", + "workbench.suggestions.noIndexes.documentation": "See the [documentation]({{link}}) for detailed instructions on how to create an index.", + "workbench.suggestions.noIndexes.label": "No indexes to display", + "workbench.tutorials.basicUseCases": "Basic use cases", + "workbench.tutorials.introToSearch": "Intro to search", + "workbench.tutorials.introToVectorSearch": "Intro to vector search", + "workbench.viewType.explain": "Explain the command", + "workbench.viewType.profile": "Profile the command", + "workbench.viewType.text": "Text" } diff --git a/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx b/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx index 0007a80324..5670ce25ca 100644 --- a/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx +++ b/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx @@ -2,12 +2,14 @@ import React, { useEffect, useState } from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' +import { useTranslation } from 'uiSrc/i18n' import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' import WBViewWrapper from './components/wb-view' const WorkbenchPage = () => { + const { t } = useTranslation() const [isPageViewSent, setIsPageViewSent] = useState(false) const { name: connectedInstanceName, db } = useAppSelector( @@ -17,7 +19,10 @@ const WorkbenchPage = () => { const { instanceId } = useParams<{ instanceId: string }>() setTitle( - `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)} - Workbench`, + t('workbench.pageTitle', { + name: formatLongName(connectedInstanceName, 33, 0, '...'), + db: getDbIndex(db), + }), ) useEffect(() => { diff --git a/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx index bd4dc96e6d..a0d37f28bb 100644 --- a/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx +++ b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx @@ -2,6 +2,7 @@ import React, { useRef } from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { monaco as monacoEditor } from 'react-monaco-editor' +import { useTranslation } from 'uiSrc/i18n' import { MonacoLanguage } from 'uiSrc/constants' import { CodeEditor } from 'uiSrc/components/base/code-editor' import { @@ -23,6 +24,7 @@ import { Props } from './Query.types' import * as S from './Query.styles' const Query = (props: Props) => { + const { t } = useTranslation() const { activeMode, resultsMode, @@ -138,7 +140,10 @@ const Query = (props: Props) => { ) : ( <> ({ + id, + title: t(titleKey), + }))} source="advanced_workbench_editor" /> { + const { t } = useTranslation() const { provider } = useAppSelector(connectedInstanceSelector) const { instanceId } = useParams<{ instanceId: string }>() @@ -50,13 +53,13 @@ const WbNoResultsMessage = () => { className={styles.noResultsTitle} data-testid="wb_no-results__title" > - No results to display yet + {t('workbench.noResults.title')} - This is our advanced CLI + {t('workbench.noResults.cliTitle')} - for Redis commands. + {t('workbench.noResults.cliSubtitle')} @@ -67,7 +70,7 @@ const WbNoResultsMessage = () => { no results @@ -76,8 +79,7 @@ const WbNoResultsMessage = () => { className={styles.noResultsText} data-testid="wb_no-results__summary" > - Try Workbench with our interactive Tutorials to learn how Redis - can solve your use cases. + {t('workbench.noResults.summary')}
@@ -87,12 +89,12 @@ const WbNoResultsMessage = () => { className={styles.exploreBtn} data-testid="no-results-explore-btn" > - Explore + {t('workbench.noResults.button.explore')}
- Or click the icon in the top right corner. + {t('workbench.noResults.hint')} diff --git a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx index 00b7c8e143..6addbed710 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx @@ -1,6 +1,7 @@ import React from 'react' import cx from 'classnames' +import { useTranslation } from 'uiSrc/i18n' import { CodeButtonParams } from 'uiSrc/constants' import { ProfileQueryType } from 'uiSrc/pages/workbench/constants' import { generateProfileQueryForCommand } from 'uiSrc/pages/workbench/utils/profile' @@ -42,6 +43,7 @@ export interface Props { /** @deprecated Use QueryResults from 'uiSrc/components/query/query-results' instead. */ const WBResults = (props: Props) => { + const { t } = useTranslation() const { isResultsLoaded, items = [], @@ -91,7 +93,7 @@ const WBResults = (props: Props) => { disabled={clearing || processing} data-testid="clear-history-btn" > - Clear Results + {t('workbench.results.clear')}
)} diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx index 95482c9f60..468458add1 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx @@ -3,6 +3,7 @@ import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' import { monaco as monacoEditor } from 'react-monaco-editor' +import { Trans, escapeTrans, useTranslation } from 'uiSrc/i18n' import { getMonacoLines, getParsedParamsInQuery, @@ -79,6 +80,7 @@ let state: IState = { /** @deprecated Use useQuery hook from 'pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery' instead. */ const WBViewWrapper = () => { + const { t } = useTranslation() const { instanceId } = useParams<{ instanceId: string }>() const { @@ -283,20 +285,20 @@ const WBViewWrapper = () => { ), ), ) - const isPlural = dangerousCommands.length > 1 requestConfirmation({ - title: 'Proceed with caution in production', + title: t('workbench.runConfirm.title'), actionDescription: ( - <> - You're about to run{' '} - {dangerousCommands.join(', ')} on{' '} - {confirmationText}.{' '} - {isPlural ? 'These commands are' : 'This command is'} part of the - list of dangerous commands. This operation may affect server - stability. - + }} + /> ), - confirmButtonText: 'Run command', + confirmButtonText: t('workbench.runConfirm.button.run'), commandId: dangerousVerbs, tip: , onConfirm: () => { diff --git a/redisinsight/ui/src/pages/workbench/constants.ts b/redisinsight/ui/src/pages/workbench/constants.ts index 883f2503b4..2de6cda0dd 100644 --- a/redisinsight/ui/src/pages/workbench/constants.ts +++ b/redisinsight/ui/src/pages/workbench/constants.ts @@ -1,4 +1,5 @@ import { AllIconsType } from 'uiSrc/components/base/icons/RiIcon' +import i18n from 'uiSrc/i18n' export const WORKBENCH_HISTORY_WRAPPER_NAME = 'WORKBENCH' export const WORKBENCH_HISTORY_MAX_LENGTH = 30 @@ -10,7 +11,6 @@ export enum WBQueryType { export const DEFAULT_TEXT_VIEW_TYPE = { id: 'default__Text', - text: 'Text', name: 'default__Text', value: WBQueryType.Text, iconDark: 'TextViewIconDarkIcon' as AllIconsType, @@ -18,9 +18,9 @@ export const DEFAULT_TEXT_VIEW_TYPE = { internal: true, } -export const VIEW_TYPE_OPTIONS = [DEFAULT_TEXT_VIEW_TYPE] - -export const getViewTypeOptions = () => [...VIEW_TYPE_OPTIONS] +export const getViewTypeOptions = () => [ + { ...DEFAULT_TEXT_VIEW_TYPE, text: i18n.t('workbench.viewType.text') }, +] export const SEARCH_COMMANDS = ['ft.search', 'ft.aggregate'] export const GRAPH_COMMANDS = ['graph.query'] @@ -35,23 +35,21 @@ export enum ProfileQueryType { Explain = 'Explain', } -const PROFILE_VIEW_TYPE_OPTIONS = [ +export const getProfileViewTypeOptions = () => [ { id: ProfileQueryType.Profile, - text: 'Profile the command', + text: i18n.t('workbench.viewType.profile'), name: 'Profile', value: WBQueryType.Text, }, { id: ProfileQueryType.Explain, - text: 'Explain the command', + text: i18n.t('workbench.viewType.explain'), name: 'Explain', value: WBQueryType.Text, }, ] -export const getProfileViewTypeOptions = () => [...PROFILE_VIEW_TYPE_OPTIONS] - export enum ModuleCommandPrefix { RediSearch = 'FT.', JSON = 'JSON.', diff --git a/redisinsight/ui/src/pages/workbench/utils/suggestions.ts b/redisinsight/ui/src/pages/workbench/utils/suggestions.ts index 80254cb366..896600ae2f 100644 --- a/redisinsight/ui/src/pages/workbench/utils/suggestions.ts +++ b/redisinsight/ui/src/pages/workbench/utils/suggestions.ts @@ -19,6 +19,7 @@ import { } from 'uiSrc/pages/workbench/constants' import { getUtmExternalLink } from 'uiSrc/utils/links' import { IRedisCommand } from 'uiSrc/constants' +import i18n from 'uiSrc/i18n' import { generateDetail } from './query' import { buildSuggestion } from './monaco' @@ -39,15 +40,17 @@ const NO_INDEXES_DOC_LINK = getUtmExternalLink( export const getNoIndexesSuggestion = (range: monaco.IRange) => [ { id: EmptySuggestionsIds.NoIndexes, - label: 'No indexes to display', + label: i18n.t('workbench.suggestions.noIndexes.label'), kind: monacoEditor.languages.CompletionItemKind.Issue, insertText: '', insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, range, - detail: 'Create an index', + detail: i18n.t('workbench.suggestions.noIndexes.detail'), documentation: { - value: `See the [documentation](${NO_INDEXES_DOC_LINK}) for detailed instructions on how to create an index.`, + value: i18n.t('workbench.suggestions.noIndexes.documentation', { + link: NO_INDEXES_DOC_LINK, + }), }, }, ] From 7c4044bf80d6a5cedb7b3f992881d5098366a963 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Mon, 13 Jul 2026 12:45:12 +0300 Subject: [PATCH 022/166] chore: drop AI-Made PR label instruction (#6191) --- .ai/skills/pull-requests/SKILL.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.ai/skills/pull-requests/SKILL.md b/.ai/skills/pull-requests/SKILL.md index ee658842c4..7d90078e64 100644 --- a/.ai/skills/pull-requests/SKILL.md +++ b/.ai/skills/pull-requests/SKILL.md @@ -10,10 +10,6 @@ description: >- ## Creating a PR -### Labels - -When creating PRs with AI assistance, always add the **"AI-Made"** label. - ### PR Title Include issue number at the start: From 8270a97807c55e24e5eddfb67685ec45d71e66ae Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Mon, 13 Jul 2026 13:58:28 +0300 Subject: [PATCH 023/166] RI-8275 i18n: migrate Vector Search (#6190) * feat(i18n): migrate vector search list page (RI-8275) * feat(i18n): migrate vector search create-index flow and query page (RI-8275) * feat(i18n): migrate vector search welcome and state screens (RI-8275) --- redisinsight/ui/src/i18n/locales/bg.json | 281 +++++++++++++++++- redisinsight/ui/src/i18n/locales/en.json | 281 +++++++++++++++++- .../create-redisearch-index/constants.ts | 10 +- .../components/command-view/CommandView.tsx | 6 +- .../CreateIndexOnboarding.constants.tsx | 49 +-- .../CreateIndexOnboardingPopover.tsx | 21 +- .../field-type-list/FieldTypeList.tsx | 57 ++-- .../FieldTypeModal.constants.ts | 83 ++++-- .../field-type-modal/FieldTypeModal.tsx | 29 +- .../FieldTypeForm/FieldTypeForm.tsx | 16 +- .../FieldTypeSelect/FieldTypeSelect.tsx | 13 +- .../TextFieldOptions/TextFieldOptions.tsx | 19 +- .../VectorFieldOptions/VectorFieldOptions.tsx | 42 +-- .../hooks/useFieldTypeValidation.ts | 13 +- .../index-details/IndexDetails.columns.tsx | 15 +- .../FieldActionsCell/FieldActionsCell.tsx | 6 +- .../FieldNameCell/FieldNameTooltip.tsx | 26 +- .../FieldTypeCell/FieldTypeTooltip.tsx | 21 +- .../FieldValueCell/FieldValueTooltip.tsx | 26 +- .../IndexInfoSidePanel.tsx | 4 +- .../index-info/IndexInfo.constants.tsx | 13 +- .../components/index-info/IndexInfo.tsx | 29 +- .../components/index-info/IndexInfo.utils.ts | 11 +- .../index-list/IndexList.config.tsx | 248 ++++++++-------- .../components/index-list/IndexList.tsx | 10 +- .../components/index-list/IndexList.types.ts | 4 +- .../components/ActionsCell/ActionsCell.tsx | 6 +- .../components/index-list/constants.ts | 32 -- .../keys-browser/components/Footer.tsx | 45 +-- .../keys-browser/components/Header.tsx | 4 +- .../keys-browser/components/TypeTabs.tsx | 4 +- .../no-search-results/NoSearchResults.tsx | 8 +- .../PickSampleDataModal.constants.ts | 22 +- .../PickSampleDataModal.spec.tsx | 4 +- .../PickSampleDataModal.tsx | 32 +- .../query-editor/EditorLibraryToggle.tsx | 62 ++-- .../query-editor/QueryEditor.constants.ts | 10 - .../query-editor/QueryEditor.types.ts | 6 +- .../query-editor/VectorSearchActions.spec.tsx | 16 +- .../query-editor/VectorSearchActions.tsx | 31 +- .../query-editor/VectorSearchEditor.tsx | 6 +- .../QueryOnboardingPopover.tsx | 17 +- .../query-editor/onboardingSuggestions.ts | 56 ++-- .../QueryLibraryItem.constants.ts | 4 +- .../query-library-item/QueryLibraryItem.tsx | 16 +- .../QueryLibraryItem.types.ts | 4 +- .../query-library-view/QueryLibraryView.tsx | 8 +- .../delete-query-modal/DeleteQueryModal.tsx | 31 +- .../hooks/useQueryLibrary.ts | 6 +- .../rqe-not-available/RqeNotAvailable.tsx | 4 +- .../save-query-modal/SaveQueryModal.tsx | 15 +- .../SearchPageFallback.tsx | 6 +- .../search-page-fallback/constants.ts | 37 +-- .../components/search-page-fallback/index.ts | 4 +- .../SelectKeyOnboardingPopover.tsx | 14 +- .../UpgradeRedisBanner.tsx | 7 +- .../VersionNotSupported.tsx | 4 +- .../welcome-screen/WelcomeScreen.constants.ts | 33 +- .../welcome-screen/WelcomeScreen.spec.tsx | 9 +- .../welcome-screen/WelcomeScreen.tsx | 21 +- .../vector-search/constants/notifications.ts | 40 +-- .../CreateIndexPageProvider.tsx | 10 +- .../hooks/useListContent/useListContent.ts | 14 +- .../ConfirmKeyChangeModal.tsx | 82 ++--- .../components/CreateIndexContent.tsx | 5 +- .../components/CreateIndexFooter.tsx | 14 +- .../components/CreateIndexHeader.tsx | 13 +- .../components/CreateIndexToolbar.tsx | 10 +- .../IndexNameEditor/IndexNameEditor.tsx | 8 +- .../create-index-menu/CreateIndexMenu.tsx | 14 +- .../DeleteIndexConfirmation.tsx | 30 +- .../components/header-title/HeaderTitle.tsx | 11 +- .../components/header-title/HeaderTitle.tsx | 6 +- .../view-index-button/ViewIndexButton.tsx | 23 +- .../VectorSearchQueryPage/hooks/useQuery.ts | 47 +-- .../hooks/useQuery.utils.spec.ts | 2 +- .../hooks/useQuery.utils.ts | 3 +- .../VectorSearchWelcomePage.tsx | 7 +- 78 files changed, 1496 insertions(+), 760 deletions(-) delete mode 100644 redisinsight/ui/src/pages/vector-search/components/index-list/constants.ts diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 5e4fa1ab41..efc578a03b 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -216,10 +216,16 @@ "notification.error.encryption.checkKeychain": "Проверете системния ключодържател или изключете криптирането, за да продължите.", "notification.error.encryption.disableWarning": "Изключването на криптирането ще доведе до съхранение на чувствителна информация локално в чист текст. Въведете отново данните за връзка с базата данни, за да работите с нея.", "notification.error.encryption.title": "Неуспешно декриптиране", + "notification.error.queryLibraryCleanupFailed.message": "Възникна грешка при премахване на запазените заявки за изтрития индекс.", + "notification.error.queryLibraryCleanupFailed.title": "Неуспешно почистване на библиотеката със заявки", + "notification.error.queryLibrarySaveFailed.message": "Възникна грешка при запазване на заявката. Моля, опитайте отново.", + "notification.error.queryLibrarySaveFailed.title": "Неуспешно запазване на заявката", "notification.error.reportIssue": "Ако проблемът продължава, ", "notification.error.reportIssueLink": "докладвайте ни.", "notification.error.title.default": "Опа, нещо се обърка...", "notification.error.tryAgainLater": "Опитайте отново по-късно.", + "notification.error.vectorSearchCreateIndexFailed.message": "Възникна грешка при създаването на индекса. Моля, опитайте отново.", + "notification.error.vectorSearchCreateIndexFailed.title": "Неуспешно създаване на индекс", "notification.infinite.appUpdateAvailable.button.restart": "Рестартирай", "notification.infinite.appUpdateAvailable.description": "С Redis Insight {{version}} получавате достъп до нови полезни функции и оптимизации.", "notification.infinite.appUpdateAvailable.descriptionRestart": "Рестартирайте Redis Insight, за да инсталирате актуализациите.", @@ -293,6 +299,10 @@ "notification.success.messageAction.title": "Съобщението беше {{action}}", "notification.success.noClaimedMessages.message": "Няма съобщения, които надвишават минималното време на бездействие.", "notification.success.noClaimedMessages.title": "Няма заявени съобщения", + "notification.success.queryLibraryDeleted.title": "Заявката е изтрита.", + "notification.success.queryLibrarySaved.action": "Към библиотеката със заявки", + "notification.success.queryLibrarySaved.message": "Можете да я намерите по всяко време в библиотеката със заявки.", + "notification.success.queryLibrarySaved.title": "Заявката е запазена във вашата библиотека.", "notification.success.removedAllCapiKeys.message": "Всички API ключове бяха премахнати от Redis Insight.", "notification.success.removedAllCapiKeys.title": "API ключовете бяха премахнати", "notification.success.removedArrayRange.message": "{{total}} елемент(а) премахнати от {{name}}", @@ -319,6 +329,12 @@ "notification.success.uploadDataBulk.success": "Успешни", "notification.success.uploadDataBulk.timeTaken": "Изразходвано време", "notification.success.uploadDataBulk.title": "Действието завърши", + "notification.success.vectorSearchIndexCreated.message": "Данните ви вече са достъпни за търсене. Можете да започнете да изпълнявате заявки.", + "notification.success.vectorSearchIndexCreated.title": "Индексът е създаден успешно.", + "notification.success.vectorSearchSampleDataCreated.message": "Започнете да пишете заявки или разгледайте примерни в Библиотеката.", + "notification.success.vectorSearchSampleDataCreated.title": "Примерните данни вече са достъпни за търсене.", + "notification.success.vectorSearchSampleDataExists.message": "Можете да започнете да пишете нови заявки или да разгледате съществуващи в Библиотеката.", + "notification.success.vectorSearchSampleDataExists.title": "Примерните данни вече са достъпни за търсене чрез съществуващ индекс.", "query.actions.groupMode.label": "Групиране на резултатите", "query.actions.groupMode.tooltip": "Групира резултатите от командите в един прозорец.Когато са групирани, резултатите могат да се визуализират само в текстов формат.", "query.actions.rawMode.label": "Необработен режим", @@ -409,10 +425,273 @@ "settings.workbench.pipeline.label": "Команди в pipeline:", "settings.workbench.pipeline.summary": "Задава размера на пакета от команди за pipeline режима в Работна среда. 0 или 1 изпраща всяка команда поотделно.", "settings.workbench.pipeline.title": "Pipeline режим", + "vectorSearch.commandView.copied": "Копирано", + "vectorSearch.commandView.copyAria": "Копирай командата", + "vectorSearch.createIndex.confirmKeyChange.body": "Вече сте направили промени по типовете на индекса. Избирането на друг ключ ще отхвърли промените ви и ще зареди полета от новия ключ.", + "vectorSearch.createIndex.confirmKeyChange.discardAndLoad": "Отхвърли и зареди", + "vectorSearch.createIndex.confirmKeyChange.keepEditing": "Продължи редактирането", + "vectorSearch.createIndex.confirmKeyChange.title": "Незапазени промени", + "vectorSearch.createIndex.content.emptyState": "Схемата на индексиране ще се появи тук, след като\nизберете ключ от браузъра вляво.", + "vectorSearch.createIndex.createDisabledReason": "Изберете ключ и поне едно поле за индексиране.", + "vectorSearch.createIndex.displayNameFallback": "съществуващи данни", + "vectorSearch.createIndex.footer.cancel": "Отказ", + "vectorSearch.createIndex.footer.createIndex": "Създай индекс", + "vectorSearch.createIndex.footer.skippedFields_one": "Полето \"{{name}}\" беше премахнато — вложени обекти и масиви не могат да бъдат индексирани директно.", + "vectorSearch.createIndex.footer.skippedFields_other": "{{count}} полета бяха премахнати ({{list}}) — вложени обекти и масиви не могат да бъдат индексирани директно.", + "vectorSearch.createIndex.header.defineTitle": "Дефиниране на индекс за търсене:", + "vectorSearch.createIndex.header.infoTooltip": "Изберете ключ от левия панел, за да ви предложим автоматично схема на индексиране.", + "vectorSearch.createIndex.header.sampleTitle": "Преглед на индекс за примерни данни: {{name}}", + "vectorSearch.createIndex.indexName.cancelEditing": "Отказ от редактиране", + "vectorSearch.createIndex.indexName.confirmName": "Потвърди името на индекса", + "vectorSearch.createIndex.indexName.editName": "Редактирай името на индекса", + "vectorSearch.createIndex.toolbar.addField": "+ Добави поле", + "vectorSearch.createIndex.toolbar.commandView": "Изглед за напреднали", + "vectorSearch.createIndex.toolbar.indexPrefix": "Префикс на индекса:", + "vectorSearch.createIndex.toolbar.tableView": "Табличен изглед", + "vectorSearch.fallback.getStarted": "Започнете безплатно", + "vectorSearch.fallback.learnMore": "Научете повече", + "vectorSearch.fieldType.desc.geo": "Използвайте GEO за географски координати (ширина и дължина).", + "vectorSearch.fieldType.desc.numeric": "Използвайте NUMERIC за съхранение и заявки към числа.", + "vectorSearch.fieldType.desc.tag": "Използвайте TAG за филтриране по точно съвпадение на стойности.", + "vectorSearch.fieldType.desc.text": "Използвайте TEXT за пълнотекстово търсене и индексиране на свободен текст.", + "vectorSearch.fieldType.desc.vector": "Използвайте VECTOR за семантично търсене чрез векторни представяния.", + "vectorSearch.fieldType.list.geo": "Заявки за географско разстояние и радиус", + "vectorSearch.fieldType.list.intro": "Определя как Redis търси в това поле и как то се държи по време на заявка. Налични типове индексиране:", + "vectorSearch.fieldType.list.numeric": "Заявки за диапазон и сортиране", + "vectorSearch.fieldType.list.optionalSettings": "Незадължителните настройки може да повлияят на производителността, съхранението или класирането.", + "vectorSearch.fieldType.list.tag": "Точно съвпадение и филтриране", + "vectorSearch.fieldType.list.text": "Пълнотекстово търсене и оценяване на релевантност", + "vectorSearch.fieldType.list.vector": "Търсене по сходство и семантика", + "vectorSearch.fieldType.modal.add": "Добави", + "vectorSearch.fieldType.modal.addTitle": "Добавяне на поле", + "vectorSearch.fieldType.modal.cancel": "Отказ", + "vectorSearch.fieldType.modal.changeTypeBody": "Можете да промените типа на това поле. Имайте предвид, че промяната на типа на полето ще повлияе на начина, по който полето се индексира и към него се правят заявки.", + "vectorSearch.fieldType.modal.editTitle": "Редактиране на поле", + "vectorSearch.fieldType.modal.fieldName": "Име на поле", + "vectorSearch.fieldType.modal.fieldNameLabel": "Име на поле:", + "vectorSearch.fieldType.modal.fieldNamePlaceholder": "Въведете име на поле", + "vectorSearch.fieldType.modal.fieldSampleValue": "Примерна стойност на поле:", + "vectorSearch.fieldType.modal.save": "Запази", + "vectorSearch.fieldType.phonetic.en": "Английски (dm:en)", + "vectorSearch.fieldType.phonetic.es": "Испански (dm:es)", + "vectorSearch.fieldType.phonetic.fr": "Френски (dm:fr)", + "vectorSearch.fieldType.phonetic.none": "Няма", + "vectorSearch.fieldType.phonetic.pt": "Португалски (dm:pt)", + "vectorSearch.fieldType.sectionOptions": "{{type}} опции", + "vectorSearch.fieldType.text.phoneticMatcher": "Фонетично съответствие", + "vectorSearch.fieldType.text.phoneticMatcherTooltip": "Извършва фонетично съответствие при търсения.", + "vectorSearch.fieldType.text.weight": "Тегло", + "vectorSearch.fieldType.text.weightTooltip": "Определя важността на този атрибут при изчисляване на точността на резултатите.", + "vectorSearch.fieldType.validation.candidateLimitRange": "Ограничението на кандидатите трябва да е между {{min}} и {{max}}.", + "vectorSearch.fieldType.validation.dimensionsRange": "Размерностите трябва да са между {{min}} и {{max}}.", + "vectorSearch.fieldType.validation.dimensionsRequired": "Стойността за размерности е задължителна.", + "vectorSearch.fieldType.validation.epsilonMin": "Epsilon трябва да е {{min}} или по-голямо.", + "vectorSearch.fieldType.validation.fieldNameDuplicate": "Поле с това име вече съществува.", + "vectorSearch.fieldType.validation.fieldNameRequired": "Името на полето е задължително.", + "vectorSearch.fieldType.validation.maxEdgesRange": "Максималният брой ребра трябва да е между {{min}} и {{max}}.", + "vectorSearch.fieldType.validation.maxNeighborsRange": "Максималният брой съседи трябва да е между {{min}} и {{max}}.", + "vectorSearch.fieldType.validation.weightMin": "Теглото трябва да е по-голямо от 0.", + "vectorSearch.fieldType.vector.algorithm": "Алгоритъм", + "vectorSearch.fieldType.vector.algorithmTooltip": "Използвайте FLAT за малки набори от данни или когато е важна точната точност. Използвайте HNSW за по-големи набори от данни или когато бързото търсене е важно.", + "vectorSearch.fieldType.vector.candidateLimit": "Ограничение на кандидатите", + "vectorSearch.fieldType.vector.candidateLimitTooltip": "Максимален брой водещи кандидати, разглеждани по време на KNN търсене. По-високите стойности подобряват точността, но увеличават латентността.", + "vectorSearch.fieldType.vector.dimensions": "Размерности", + "vectorSearch.fieldType.vector.dimensionsTooltip": "Брой размерности във всеки вектор. Векторите на заявката трябва да съвпадат с този размер.", + "vectorSearch.fieldType.vector.distanceMetric": "Метрика за разстояние", + "vectorSearch.fieldType.vector.distanceMetricTooltip": "Метрика за разстояние при сравнение на вектори.", + "vectorSearch.fieldType.vector.epsilon": "Epsilon", + "vectorSearch.fieldType.vector.epsilonTooltip": "Относителен фактор за границите на заявка за диапазон. По-високите стойности разширяват търсенето.", + "vectorSearch.fieldType.vector.maxEdges": "Максимален брой ребра", + "vectorSearch.fieldType.vector.maxEdgesTooltip": "Максимален брой изходящи ребра на възел. По-високите стойности подобряват точността, но увеличават използваната памет.", + "vectorSearch.fieldType.vector.maxNeighbors": "Максимален брой съседи", + "vectorSearch.fieldType.vector.maxNeighborsTooltip": "Максимален брой съседи, разглеждани при изграждане на графа. По-високите стойности подобряват точността, но забавят индексирането.", + "vectorSearch.fieldType.vector.vectorType": "Тип вектор", + "vectorSearch.indexDetails.editFieldAria": "Редактиране на поле", + "vectorSearch.indexDetails.editFieldType": "Редактиране на типа на полето", + "vectorSearch.indexDetails.fieldName": "Име на поле", + "vectorSearch.indexDetails.fieldNameTooltip.description": "Представлява атрибут за търсене във вашите данни. Само избраните полета ще бъдат достъпни за търсене.", + "vectorSearch.indexDetails.fieldNameTooltip.title": "Име на поле", + "vectorSearch.indexDetails.fieldSampleValue": "Примерна стойност на поле", + "vectorSearch.indexDetails.fieldTypeTooltip.title": "Тип на индексиране и опции", + "vectorSearch.indexDetails.fieldValueTooltip.description": "Примерна стойност от данните, които ще бъдат индексирани. Използвайте я, за да проверите типа на полето и избора на индексиране.", + "vectorSearch.indexDetails.fieldValueTooltip.title": "Примерна стойност на поле", + "vectorSearch.indexDetails.indexingType": "Тип на индексиране", + "vectorSearch.indexDetails.suggestedIndexingType": "Предложен тип на индексиране", + "vectorSearch.indexInfo.closePanel": "Затваряне на панела", + "vectorSearch.indexInfo.column.attribute": "Атрибут", + "vectorSearch.indexInfo.column.identifier": "Идентификатор", + "vectorSearch.indexInfo.column.type": "Тип", + "vectorSearch.indexInfo.column.weight": "Тегло", + "vectorSearch.indexInfo.documents": "документи.", + "vectorSearch.indexInfo.documentsPrefixed": "документи с префикс {{prefixes}}.", + "vectorSearch.indexInfo.indexing": "Индексиране", + "vectorSearch.indexInfo.noOptionsFound": "няма намерени опции", + "vectorSearch.indexInfo.optionFilter": "филтър: {{value}}", + "vectorSearch.indexInfo.optionLanguage": "език: {{value}}", + "vectorSearch.indexInfo.options": "Опции: {{options}}", + "vectorSearch.indexInfo.summary": "Брой документи: {{numDocs}} (макс. {{maxDocId}}) | Брой записи: {{numRecords}} | Брой термини: {{numTerms}}", + "vectorSearch.keysBrowser.results": "Резултати: {{count}} ключа", + "vectorSearch.keysBrowser.scanned": "Сканирани {{scanned}}/{{total}}", + "vectorSearch.keysBrowser.scanning": "Сканиране...", + "vectorSearch.keysBrowser.selectKey": "Изберете ключ", + "vectorSearch.keysBrowser.supportedTypesInfo": "Само типовете ключове HASH и JSON се поддържат при създаване на индекс.", + "vectorSearch.keysBrowser.total": "Общо: {{total}}", + "vectorSearch.list.action.browseDataset": "Преглед на данните", + "vectorSearch.list.action.delete": "Изтрий", + "vectorSearch.list.action.query": "Заявка", + "vectorSearch.list.action.viewIndex": "Преглед на индекс", + "vectorSearch.list.column.docs": "Документи", + "vectorSearch.list.column.fields": "Полета", + "vectorSearch.list.column.name": "Име на индекс", + "vectorSearch.list.column.prefix": "Префикс на индекс", + "vectorSearch.list.column.records": "Записи", + "vectorSearch.list.column.terms": "Термини", + "vectorSearch.list.column.types": "Типове на индекс", + "vectorSearch.list.createMenu.checkingKeys": "Проверка за съществуващи ключове…", + "vectorSearch.list.createMenu.create": "+ Създай индекс за търсене", + "vectorSearch.list.createMenu.existingData": "Използвай съществуващи данни", + "vectorSearch.list.createMenu.noKeys": "Няма намерени Hash или JSON ключове във вашата база данни", + "vectorSearch.list.createMenu.sampleData": "Използвай примерни данни", + "vectorSearch.list.delete.cancel": "Запази индекса", + "vectorSearch.list.delete.confirm": "Изтрий индекса", + "vectorSearch.list.delete.message": "Изтриването на индекса ще го премахне от страницата за Търсене, но няма да изтрие основните ви данни.", + "vectorSearch.list.delete.question": "Сигурни ли сте, че искате да изтриете този индекс?", + "vectorSearch.list.delete.title": "Изтриване на индекс", + "vectorSearch.list.empty.loading": "Зареждане...", + "vectorSearch.list.empty.noIndexes": "Няма намерени индекси", + "vectorSearch.list.empty.noResults": "Няма намерени резултати", + "vectorSearch.list.header.description": "Индексът за търсене организира данните ви, за да позволи бързо векторно, пълнотекстово, хибридно и числово търсене в Redis.", + "vectorSearch.list.header.learnMore": "Научете повече", + "vectorSearch.list.header.title": "Индекси за търсене", + "vectorSearch.list.tooltip.docs": "Брой на текущо индексираните документи.", + "vectorSearch.list.tooltip.fields": "Общ брой полета, дефинирани в схемата на индекса.", + "vectorSearch.list.tooltip.prefix": "Ключовете, съвпадащи с този префикс, се индексират автоматично.", + "vectorSearch.list.tooltip.records": "Общ брой индексирани двойки поле-стойност във всички документи. Един документ с 5 полета = 5 записа.", + "vectorSearch.list.tooltip.terms": "Уникални думи, извлечени от TEXT полета за пълнотекстово търсене.", + "vectorSearch.noResults.imageAlt": "Няма резултати от търсенето", + "vectorSearch.noResults.text": "Резултатите от вашата заявка ще се покажат тук, след като изпълните заявка.", + "vectorSearch.notAvailable.ctaText": "Използвайте безплатна база данни в Redis Cloud „всичко в едно“, за да започнете да изпозвате тези фунционалности", + "vectorSearch.notAvailable.description": "Тези функции позволяват заявки по няколко полета, агрегиране, точно съвпадение на фрази, числово филтриране, гео филтриране и семантично търсене по векторно сходство върху текстови заявки.", + "vectorSearch.notAvailable.feature.fullTextSearch": "Пълнотекстово търсене", + "vectorSearch.notAvailable.feature.query": "Заявки", + "vectorSearch.notAvailable.feature.secondaryIndex": "Вторичен индекс", + "vectorSearch.notAvailable.subtitle": "Redis Search позволява:", + "vectorSearch.notAvailable.title": "Redis Search не е наличен за тази база данни", + "vectorSearch.onboarding.back": "Назад", + "vectorSearch.onboarding.close": "Затвори", + "vectorSearch.onboarding.commandView.body": "Това е командата FT.CREATE, която Redis ще изпълни. След изпълнение данните ви стават достъпни за търсене.", + "vectorSearch.onboarding.commandView.title": "Команда за създаване на индекс", + "vectorSearch.onboarding.defineIndex.body1": "Индексът определя как Redis търси и прави заявки към данните ви. Схемата контролира кои полета се индексират, техните типове и други конфигурационни опции.", + "vectorSearch.onboarding.defineIndex.body2": "Прегледайте предложеното име на индекса. Ще го използвате при изграждане на заявки.", + "vectorSearch.onboarding.defineIndex.body3": "Съвет: Индексирайте само полета, които планирате да търсите или филтрирате.", + "vectorSearch.onboarding.defineIndex.title": "Прегледайте и коригирайте схемата на индексиране", + "vectorSearch.onboarding.fieldName.body": "Представлява атрибут за търсене във вашите данни. Само избраните полета ще бъдат достъпни за търсене.", + "vectorSearch.onboarding.fieldName.title": "Име на поле", + "vectorSearch.onboarding.gotIt": "Разбрах", + "vectorSearch.onboarding.indexPrefix.body1": "Контролира кои ключове са включени в индекса. Всички ключове, започващи с този префикс, ще бъдат индексирани.", + "vectorSearch.onboarding.indexPrefix.body2": "Пример: bike: ще индексира bike:1, bike:road:3.", + "vectorSearch.onboarding.indexPrefix.title": "Префикс на индекса", + "vectorSearch.onboarding.indexingType.title": "Тип индексиране и опции", + "vectorSearch.onboarding.next": "Напред", + "vectorSearch.onboarding.sampleValue.body": "Примерна стойност от данните за индексиране. Използвайте я, за да проверите типа на полето и избора на индексиране.", + "vectorSearch.onboarding.sampleValue.title": "Примерна стойност", + "vectorSearch.onboarding.skipTour": "Пропусни обиколката", + "vectorSearch.onboarding.stepCounter": "{{current}}/{{total}}", + "vectorSearch.query.breadcrumb.ariaLabel": "Навигационна пътека", + "vectorSearch.query.breadcrumb.indexes": "Индекси", + "vectorSearch.query.editor.action.explain": "Обясни", + "vectorSearch.query.editor.action.explainAria": "Обясни командата", + "vectorSearch.query.editor.action.profile": "Профилирай", + "vectorSearch.query.editor.action.profileAria": "Профилирай командата", + "vectorSearch.query.editor.action.save": "Запази", + "vectorSearch.query.editor.action.saveAria": "Запази заявката", + "vectorSearch.query.editor.onboarding.detail.ftAggregate": "Групиране и обобщаване на резултатите", + "vectorSearch.query.editor.onboarding.detail.ftExplain": "Преглед на плана за изпълнение", + "vectorSearch.query.editor.onboarding.detail.ftList": "Преглед на схемата и статистиките на индекса", + "vectorSearch.query.editor.onboarding.detail.ftProfile": "Анализ на производителността", + "vectorSearch.query.editor.onboarding.detail.ftSearch": "Намиране на документи по текст или филтри", + "vectorSearch.query.editor.onboarding.detail.ftSpellcheck": "Предлагане на корекции за печатни грешки", + "vectorSearch.query.editor.onboarding.detail.ftSugget": "Извличане на предложения за автоматично довършване", + "vectorSearch.query.editor.onboarding.documentation": "Документация", + "vectorSearch.query.editor.placeholder": "Започнете да въвеждате FT., за да достигнете командите за търсене, или превключете към Библиотека със заявки за достъп до запазените команди.", + "vectorSearch.query.editor.tab.editor": "Редактор на заявки", + "vectorSearch.query.editor.tab.library": "Библиотека със заявки", + "vectorSearch.query.editor.tooltip.disabledLoading": "Деактивирано: заявката се изпълнява.", + "vectorSearch.query.editor.tooltip.disabledNoQuery": "Деактивирано: не е разпозната заявка.", + "vectorSearch.query.editor.tooltip.explain": "Показва как ще се изпълни заявката (план за изпълнение), за да разберете какво се използва.", + "vectorSearch.query.editor.tooltip.profile": "Профилира заявката, за да покаже къде се изразходва време и да открие проблемните частти.", + "vectorSearch.query.error.executeCommand": "Неуспешно изпълнение на командата", + "vectorSearch.query.error.loadCommandDetails": "Неуспешно зареждане на детайлите на командата", + "vectorSearch.query.groupCommandLabel_one": "{{count}} - команда", + "vectorSearch.query.groupCommandLabel_other": "{{count}} - команди", + "vectorSearch.query.onboarding.description": "Създавайте заявки в редактора на заявки или ги запазвайте за по-късно в библиотеката със заявки.", + "vectorSearch.query.onboarding.dismiss": "Разбрах", + "vectorSearch.query.onboarding.editorDescription": "пишете заявки за търсене директно с помощта на команди на Redis.", + "vectorSearch.query.onboarding.editorTitle": "Редактор на заявки", + "vectorSearch.query.onboarding.libraryDescription": "използвайте повторно запазени заявки или готови примери за примерните данни.", + "vectorSearch.query.onboarding.libraryTitle": "Библиотека със заявки", + "vectorSearch.query.onboarding.title": "Започнете да разглеждате данните си", + "vectorSearch.query.viewIndexButton": "Преглед на индекс", + "vectorSearch.queryLibrary.badge.sample": "Примерна заявка", + "vectorSearch.queryLibrary.badge.saved": "Запазена заявка", + "vectorSearch.queryLibrary.delete.cancel": "Задръж заявката", + "vectorSearch.queryLibrary.delete.confirm": "Изтрий заявката", + "vectorSearch.queryLibrary.delete.message": "Това действие ще премахне запазената заявка, но няма да засегне вашия индекс или данни.", + "vectorSearch.queryLibrary.delete.question": "Сигурни ли сте, че искате да изтриете тази заявка?", + "vectorSearch.queryLibrary.delete.title": "Изтриване на заявка", + "vectorSearch.queryLibrary.empty.noMatch": "Няма заявки, отговарящи на търсенето ви", + "vectorSearch.queryLibrary.empty.noQueries": "Все още няма запазени заявки. Създайте заявка в редактора и щракнете върху Запази, за да я добавите тук.", + "vectorSearch.queryLibrary.error.load": "Неуспешно зареждане на библиотеката със заявки", + "vectorSearch.queryLibrary.item.copyNameAria": "Копирай името на заявката", + "vectorSearch.queryLibrary.item.deleteAria": "Изтрий заявката", + "vectorSearch.queryLibrary.item.load": "Зареди", + "vectorSearch.queryLibrary.item.loadAria": "Зареди заявката", + "vectorSearch.queryLibrary.item.run": "Изпълни", + "vectorSearch.queryLibrary.item.runAria": "Изпълни заявката", + "vectorSearch.queryLibrary.save.cancel": "Отказ", + "vectorSearch.queryLibrary.save.confirm": "Запази заявката", + "vectorSearch.queryLibrary.save.description": "Задайте име на заявката, за да я добавите към списъка със запазени заявки за бързо повторно използване.", + "vectorSearch.queryLibrary.save.placeholder": "Въведете име на командата", + "vectorSearch.queryLibrary.save.title": "Запазване на заявка", + "vectorSearch.queryLibrary.searchPlaceholder": "Търсене на заявка", + "vectorSearch.sampleData.cancel": "Отказ", + "vectorSearch.sampleData.content.description": "Откривайте съдържание по тема или сюжет.", + "vectorSearch.sampleData.content.label": "Препоръки за съдържание", + "vectorSearch.sampleData.ecommerce.description": "Откривайте продукти, които отговарят на очакванията ви, а не само на текста", + "vectorSearch.sampleData.ecommerce.label": "Откриване в електронната търговия", + "vectorSearch.sampleData.seeIndexDefinition": "Виж дефиницията на индекса", + "vectorSearch.sampleData.startQuerying": "Създай и започни да търсиш", + "vectorSearch.sampleData.subtitle1": "Изберете примерен набор от данни.", + "vectorSearch.sampleData.subtitle2": "Ще заредим данните и ще генерираме индекса, необходим за търсене.", + "vectorSearch.sampleData.title": "Подготовка на примерните ви данни за търсене", + "vectorSearch.selectKeyOnboarding.body1": "Ще използваме избрания ключ, за да генерираме предложена схема на индексиране. Redis ще индексира всички ключове със същия префикс, а не само този единствен ключ.", + "vectorSearch.selectKeyOnboarding.body2": "Индексирането е налично за структурите от данни Hash и JSON.", + "vectorSearch.selectKeyOnboarding.close": "Затвори", + "vectorSearch.selectKeyOnboarding.gotIt": "Разбрах", + "vectorSearch.selectKeyOnboarding.title": "Изберете ключ, за да започнете", + "vectorSearch.upgradeBanner.cta": "Безплатна Redis Cloud база данни", + "vectorSearch.upgradeBanner.message": "Надградете до Redis 7.2+, за да отключите бързо семантично AI търсене в реално време с векторно търсене", + "vectorSearch.versionNotSupported.ctaText": "Създайте безплатна база данни Redis Cloud, за да започнете да използвате тези функционалности.", + "vectorSearch.versionNotSupported.description": "Тази функционалност изисква Redis Search 2.0 или по-нова версия (включена в Redis 6+). По-старите версии на Redis Search не са съвместими с командите, използвани тук.", + "vectorSearch.versionNotSupported.title": "Изисква се Redis Search 2.0+", + "vectorSearch.welcome.checkingKeys": "Проверка за съществуващи ключове…", + "vectorSearch.welcome.feature.fullText.description": "Намирайте и филтрирайте данните си мигновено чрез мощни заявки по ключови думи и полета.", + "vectorSearch.welcome.feature.fullText.title": "Пълнотекстово търсене", + "vectorSearch.welcome.feature.hybrid.description": "Комбинирайте векторно търсене и търсене по ключови думи за по-висока точност и по-добри резултати.", + "vectorSearch.welcome.feature.hybrid.title": "Хибридно търсене", + "vectorSearch.welcome.feature.performance.description": "Вградената квантизация и компресия осигуряват изключителна скорост и ефективност при всякакъв мащаб.", + "vectorSearch.welcome.feature.performance.title": "Висока производителност, малко усилия", + "vectorSearch.welcome.feature.vector.description": "Извличайте резултати по смисъл, а не само по думи. Идеално за AI, семантични и припоръчващи приложения.", + "vectorSearch.welcome.feature.vector.title": "Векторно търсене", + "vectorSearch.welcome.noKeysFound": "Не са намерени Hash или JSON ключове във вашата база данни", + "vectorSearch.welcome.subtitle": "Вижте как Redis позволява пълнотекстовото и векторното търсене. Бързо, лесно и ефективно.", + "vectorSearch.welcome.title": "Търсете със скоростта на светлината", + "vectorSearch.welcome.trySampleData": "Опитайте с примерни данни", + "vectorSearch.welcome.useMyDatabase": "Използвайте данни от моята база данни", "whatsNew.button.gotIt": "Разбрах", "whatsNew.card.comingSoon": "Очаквайте скоро", - "whatsNew.card.tooltip": "Функцията се въвежда поетапно.", "whatsNew.card.locationLabel": "Къде да го намерите:", + "whatsNew.card.tooltip": "Функцията се въвежда поетапно.", "whatsNew.menuItem": "Какво ново?", "whatsNew.releaseDate": "Издадена на {{date}}", "whatsNew.releaseNotes.link": "Вижте пълните бележки по изданието за {{version}}", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 7386ace7d4..9edcc6f3a5 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -216,10 +216,16 @@ "notification.error.encryption.checkKeychain": "Check the system keychain or disable encryption to proceed.", "notification.error.encryption.disableWarning": "Disabling encryption will result in storing sensitive information locally in plain text. Re-enter database connection information to work with databases.", "notification.error.encryption.title": "Unable to decrypt", + "notification.error.queryLibraryCleanupFailed.message": "An error occurred while removing saved queries for the deleted index.", + "notification.error.queryLibraryCleanupFailed.title": "Failed to clean up query library", + "notification.error.queryLibrarySaveFailed.message": "An error occurred while saving the query. Please try again.", + "notification.error.queryLibrarySaveFailed.title": "Failed to save query", "notification.error.reportIssue": "If the issue persists, please", "notification.error.reportIssueLink": "report it.", "notification.error.title.default": "Error", "notification.error.tryAgainLater": "Try again later.", + "notification.error.vectorSearchCreateIndexFailed.message": "An error occurred while creating the index. Please try again.", + "notification.error.vectorSearchCreateIndexFailed.title": "Failed to create index", "notification.infinite.appUpdateAvailable.button.restart": "Restart", "notification.infinite.appUpdateAvailable.description": "With Redis Insight {{version}} you have access to new useful features and optimizations.", "notification.infinite.appUpdateAvailable.descriptionRestart": "Restart Redis Insight to install updates.", @@ -293,6 +299,10 @@ "notification.success.messageAction.title": "Message has been {{action}}", "notification.success.noClaimedMessages.message": "No messages exceed the minimum idle time.", "notification.success.noClaimedMessages.title": "No messages claimed", + "notification.success.queryLibraryDeleted.title": "Query has been deleted.", + "notification.success.queryLibrarySaved.action": "Go to Query Library", + "notification.success.queryLibrarySaved.message": "You can find it anytime in the Query Library.", + "notification.success.queryLibrarySaved.title": "Query saved to your library.", "notification.success.removedAllCapiKeys.message": "All API keys have been removed from Redis Insight.", "notification.success.removedAllCapiKeys.title": "API keys have been removed", "notification.success.removedArrayRange.message": "{{total}} element(s) removed from {{name}}", @@ -319,6 +329,12 @@ "notification.success.uploadDataBulk.success": "Success", "notification.success.uploadDataBulk.timeTaken": "Time Taken", "notification.success.uploadDataBulk.title": "Action completed", + "notification.success.vectorSearchIndexCreated.message": "Your data is now searchable. You can start running queries.", + "notification.success.vectorSearchIndexCreated.title": "Index created successfully.", + "notification.success.vectorSearchSampleDataCreated.message": "Start building queries or explore sample ones under Query library.", + "notification.success.vectorSearchSampleDataCreated.title": "Your sample data is now searchable.", + "notification.success.vectorSearchSampleDataExists.message": "You can start building new queries or explore existing ones in the Query Library.", + "notification.success.vectorSearchSampleDataExists.title": "Your sample data is already searchable using an existing index.", "query.actions.groupMode.label": "Group results", "query.actions.groupMode.tooltip": "Groups the command results into a single window.When grouped, the results can be visualized only in the text format.", "query.actions.rawMode.label": "Raw mode", @@ -409,10 +425,273 @@ "settings.workbench.pipeline.label": "Commands in pipeline:", "settings.workbench.pipeline.summary": "Sets the size of a command batch for the pipeline mode in Workbench. 0 or 1 pipelines every command.", "settings.workbench.pipeline.title": "Pipeline Mode", + "vectorSearch.commandView.copied": "Copied", + "vectorSearch.commandView.copyAria": "Copy command", + "vectorSearch.createIndex.confirmKeyChange.body": "You have modified the index types. Selecting a different key will discard your changes and load fields from the new key.", + "vectorSearch.createIndex.confirmKeyChange.discardAndLoad": "Discard and load", + "vectorSearch.createIndex.confirmKeyChange.keepEditing": "Keep editing", + "vectorSearch.createIndex.confirmKeyChange.title": "Unsaved changes", + "vectorSearch.createIndex.content.emptyState": "The indexing schema will appear here once you\nselect a key from the browser on the left.", + "vectorSearch.createIndex.createDisabledReason": "Select a key and at least one field to index.", + "vectorSearch.createIndex.displayNameFallback": "existing data", + "vectorSearch.createIndex.footer.cancel": "Cancel", + "vectorSearch.createIndex.footer.createIndex": "Create index", + "vectorSearch.createIndex.footer.skippedFields_one": "Field \"{{name}}\" was removed — nested objects and arrays cannot be indexed directly.", + "vectorSearch.createIndex.footer.skippedFields_other": "{{count}} fields were removed ({{list}}) — nested objects and arrays cannot be indexed directly.", + "vectorSearch.createIndex.header.defineTitle": "Define search index:", + "vectorSearch.createIndex.header.infoTooltip": "Select a key from the left panel to auto-detect the indexing schema.", + "vectorSearch.createIndex.header.sampleTitle": "View sample data index: {{name}}", + "vectorSearch.createIndex.indexName.cancelEditing": "Cancel editing", + "vectorSearch.createIndex.indexName.confirmName": "Confirm index name", + "vectorSearch.createIndex.indexName.editName": "Edit index name", + "vectorSearch.createIndex.toolbar.addField": "+ Add field", + "vectorSearch.createIndex.toolbar.commandView": "Command view", + "vectorSearch.createIndex.toolbar.indexPrefix": "Index prefix:", + "vectorSearch.createIndex.toolbar.tableView": "Table view", + "vectorSearch.fallback.getStarted": "Get started for free", + "vectorSearch.fallback.learnMore": "Learn more", + "vectorSearch.fieldType.desc.geo": "Use GEO for geographic coordinates (latitude and longitude).", + "vectorSearch.fieldType.desc.numeric": "Use NUMERIC for storing and querying numbers.", + "vectorSearch.fieldType.desc.tag": "Use TAG for filtering by exact match values.", + "vectorSearch.fieldType.desc.text": "Use TEXT for full-text search and indexing free-form text.", + "vectorSearch.fieldType.desc.vector": "Use VECTOR for semantic search using vector embeddings.", + "vectorSearch.fieldType.list.geo": "Geographic distance and radius queries", + "vectorSearch.fieldType.list.intro": "Defines how Redis searches this field and how it behaves at query time. Available indexing types:", + "vectorSearch.fieldType.list.numeric": "Range queries and sorting", + "vectorSearch.fieldType.list.optionalSettings": "Optional settings may affect performance, storage, or ranking.", + "vectorSearch.fieldType.list.tag": "Exact matching and filtering", + "vectorSearch.fieldType.list.text": "Full-text search and relevance scoring", + "vectorSearch.fieldType.list.vector": "Similarity and semantic search", + "vectorSearch.fieldType.modal.add": "Add", + "vectorSearch.fieldType.modal.addTitle": "Add field", + "vectorSearch.fieldType.modal.cancel": "Cancel", + "vectorSearch.fieldType.modal.changeTypeBody": "You can change the field type for this field. Keep in mind that changing the field type will affect how the field is indexed and queried.", + "vectorSearch.fieldType.modal.editTitle": "Edit field", + "vectorSearch.fieldType.modal.fieldName": "Field name", + "vectorSearch.fieldType.modal.fieldNameLabel": "Field name:", + "vectorSearch.fieldType.modal.fieldNamePlaceholder": "Enter field name", + "vectorSearch.fieldType.modal.fieldSampleValue": "Field sample value:", + "vectorSearch.fieldType.modal.save": "Save", + "vectorSearch.fieldType.phonetic.en": "English (dm:en)", + "vectorSearch.fieldType.phonetic.es": "Spanish (dm:es)", + "vectorSearch.fieldType.phonetic.fr": "French (dm:fr)", + "vectorSearch.fieldType.phonetic.none": "None", + "vectorSearch.fieldType.phonetic.pt": "Portuguese (dm:pt)", + "vectorSearch.fieldType.sectionOptions": "{{type}} options", + "vectorSearch.fieldType.text.phoneticMatcher": "Phonetic matcher", + "vectorSearch.fieldType.text.phoneticMatcherTooltip": "Performs phonetic matching in searches.", + "vectorSearch.fieldType.text.weight": "Weight", + "vectorSearch.fieldType.text.weightTooltip": "Declares the importance of this attribute when calculating result accuracy.", + "vectorSearch.fieldType.validation.candidateLimitRange": "Candidate limit must be between {{min}} and {{max}}.", + "vectorSearch.fieldType.validation.dimensionsRange": "Dimensions must be between {{min}} and {{max}}.", + "vectorSearch.fieldType.validation.dimensionsRequired": "Dimensions value is required.", + "vectorSearch.fieldType.validation.epsilonMin": "Epsilon must be {{min}} or greater.", + "vectorSearch.fieldType.validation.fieldNameDuplicate": "A field with this name already exists.", + "vectorSearch.fieldType.validation.fieldNameRequired": "Field name is required.", + "vectorSearch.fieldType.validation.maxEdgesRange": "Max edges must be between {{min}} and {{max}}.", + "vectorSearch.fieldType.validation.maxNeighborsRange": "Max neighbors must be between {{min}} and {{max}}.", + "vectorSearch.fieldType.validation.weightMin": "Weight must be greater than 0.", + "vectorSearch.fieldType.vector.algorithm": "Algorithm", + "vectorSearch.fieldType.vector.algorithmTooltip": "Use FLAT for small datasets or when exact accuracy matters. Use HNSW for larger datasets or when fast search is important.", + "vectorSearch.fieldType.vector.candidateLimit": "Candidate Limit", + "vectorSearch.fieldType.vector.candidateLimitTooltip": "Max top candidates considered during KNN search. Higher values improve accuracy but increase latency.", + "vectorSearch.fieldType.vector.dimensions": "Dimensions", + "vectorSearch.fieldType.vector.dimensionsTooltip": "Number of dimensions in each vector. Query vectors must match this size.", + "vectorSearch.fieldType.vector.distanceMetric": "Distance metric", + "vectorSearch.fieldType.vector.distanceMetricTooltip": "Distance metric for vector comparison.", + "vectorSearch.fieldType.vector.epsilon": "Epsilon", + "vectorSearch.fieldType.vector.epsilonTooltip": "Relative factor for range query boundaries. Higher values widen the search.", + "vectorSearch.fieldType.vector.maxEdges": "Max Edges", + "vectorSearch.fieldType.vector.maxEdgesTooltip": "Maximum outgoing edges per node. Higher values improve accuracy but increase memory.", + "vectorSearch.fieldType.vector.maxNeighbors": "Max Neighbors", + "vectorSearch.fieldType.vector.maxNeighborsTooltip": "Maximum neighbors considered during graph build. Higher values improve accuracy but slow indexing.", + "vectorSearch.fieldType.vector.vectorType": "Vector type", + "vectorSearch.indexDetails.editFieldAria": "Edit field", + "vectorSearch.indexDetails.editFieldType": "Edit field type", + "vectorSearch.indexDetails.fieldName": "Field name", + "vectorSearch.indexDetails.fieldNameTooltip.description": "Represents a searchable attribute in your data. Only selected fields will be searchable.", + "vectorSearch.indexDetails.fieldNameTooltip.title": "Field name", + "vectorSearch.indexDetails.fieldSampleValue": "Field sample value", + "vectorSearch.indexDetails.fieldTypeTooltip.title": "Indexing type & options", + "vectorSearch.indexDetails.fieldValueTooltip.description": "A sample value from the data to be indexed. Use it to verify the field type and indexing choice.", + "vectorSearch.indexDetails.fieldValueTooltip.title": "Field sample value", + "vectorSearch.indexDetails.indexingType": "Indexing type", + "vectorSearch.indexDetails.suggestedIndexingType": "Suggested indexing type", + "vectorSearch.indexInfo.closePanel": "Close panel", + "vectorSearch.indexInfo.column.attribute": "Attribute", + "vectorSearch.indexInfo.column.identifier": "Identifier", + "vectorSearch.indexInfo.column.type": "Type", + "vectorSearch.indexInfo.column.weight": "Weight", + "vectorSearch.indexInfo.documents": "documents.", + "vectorSearch.indexInfo.documentsPrefixed": "documents prefixed by {{prefixes}}.", + "vectorSearch.indexInfo.indexing": "Indexing", + "vectorSearch.indexInfo.noOptionsFound": "no options found", + "vectorSearch.indexInfo.optionFilter": "filter: {{value}}", + "vectorSearch.indexInfo.optionLanguage": "language: {{value}}", + "vectorSearch.indexInfo.options": "Options: {{options}}", + "vectorSearch.indexInfo.summary": "Number of docs: {{numDocs}} (max {{maxDocId}}) | Number of records: {{numRecords}} | Number of terms: {{numTerms}}", + "vectorSearch.keysBrowser.results": "Results: {{count}} keys", + "vectorSearch.keysBrowser.scanned": "Scanned {{scanned}}/{{total}}", + "vectorSearch.keysBrowser.scanning": "Scanning...", + "vectorSearch.keysBrowser.selectKey": "Select key", + "vectorSearch.keysBrowser.supportedTypesInfo": "Only HASH and JSON key types are supported for index creation.", + "vectorSearch.keysBrowser.total": "Total: {{total}}", + "vectorSearch.list.action.browseDataset": "Browse dataset", + "vectorSearch.list.action.delete": "Delete", + "vectorSearch.list.action.query": "Query", + "vectorSearch.list.action.viewIndex": "View index", + "vectorSearch.list.column.docs": "Docs", + "vectorSearch.list.column.fields": "Fields", + "vectorSearch.list.column.name": "Index name", + "vectorSearch.list.column.prefix": "Index prefix", + "vectorSearch.list.column.records": "Records", + "vectorSearch.list.column.terms": "Terms", + "vectorSearch.list.column.types": "Index types", + "vectorSearch.list.createMenu.checkingKeys": "Checking for existing keys…", + "vectorSearch.list.createMenu.create": "+ Create search index", + "vectorSearch.list.createMenu.existingData": "Use existing data", + "vectorSearch.list.createMenu.noKeys": "No Hash or JSON keys found in your database", + "vectorSearch.list.createMenu.sampleData": "Use sample data", + "vectorSearch.list.delete.cancel": "Keep index", + "vectorSearch.list.delete.confirm": "Delete index", + "vectorSearch.list.delete.message": "Deleting the index will remove it from Search and Vector Search, but will not delete your underlying data.", + "vectorSearch.list.delete.question": "Are you sure you want to delete this index?", + "vectorSearch.list.delete.title": "Delete Index", + "vectorSearch.list.empty.loading": "Loading...", + "vectorSearch.list.empty.noIndexes": "No indexes found", + "vectorSearch.list.empty.noResults": "No results found", + "vectorSearch.list.header.description": "A search index organizes your data to enable fast Vector, full-text, hybrid, and numeric searches in Redis.", + "vectorSearch.list.header.learnMore": "Learn more", + "vectorSearch.list.header.title": "Search indexes", + "vectorSearch.list.tooltip.docs": "Number of documents currently indexed.", + "vectorSearch.list.tooltip.fields": "Total number of fields defined in the index schema.", + "vectorSearch.list.tooltip.prefix": "Keys matching this prefix are automatically indexed.", + "vectorSearch.list.tooltip.records": "Total indexed field-value pairs across all documents. One document with 5 fields = 5 records.", + "vectorSearch.list.tooltip.terms": "Unique words extracted from TEXT fields for full-text search.", + "vectorSearch.noResults.imageAlt": "No search results", + "vectorSearch.noResults.text": "Your query results will appear here once you run a query.", + "vectorSearch.notAvailable.ctaText": "Use your free trial all-in-one Redis Cloud database to start exploring these capabilities", + "vectorSearch.notAvailable.description": "These features enable multi-field queries, aggregation, exact phrase matching, numeric filtering, geo filtering and vector similarity semantic search on top of text queries.", + "vectorSearch.notAvailable.feature.fullTextSearch": "Full-text search", + "vectorSearch.notAvailable.feature.query": "Query", + "vectorSearch.notAvailable.feature.secondaryIndex": "Secondary index", + "vectorSearch.notAvailable.subtitle": "Redis Search allows to:", + "vectorSearch.notAvailable.title": "Redis Search is not available for this database", + "vectorSearch.onboarding.back": "Back", + "vectorSearch.onboarding.close": "Close", + "vectorSearch.onboarding.commandView.body": "This is the FT.CREATE command Redis will run. Once executed, your data becomes searchable.", + "vectorSearch.onboarding.commandView.title": "Create index command", + "vectorSearch.onboarding.defineIndex.body1": "An index defines how Redis searches and queries your data. The schema controls which fields are indexed, their types, and other configuration options.", + "vectorSearch.onboarding.defineIndex.body2": "Review the suggested index name. You’ll use it when building queries.", + "vectorSearch.onboarding.defineIndex.body3": "Tip: Index only fields you plan to search or filter on.", + "vectorSearch.onboarding.defineIndex.title": "Review and adjust the indexing schema", + "vectorSearch.onboarding.fieldName.body": "Represents a searchable attribute in your data. Only selected fields will be searchable.", + "vectorSearch.onboarding.fieldName.title": "Field name", + "vectorSearch.onboarding.gotIt": "Got it", + "vectorSearch.onboarding.indexPrefix.body1": "Controls which keys are included in the index. All keys starting with this prefix will be indexed.", + "vectorSearch.onboarding.indexPrefix.body2": "Example: bike: will index bike:1, bike:road:3.", + "vectorSearch.onboarding.indexPrefix.title": "Index prefix", + "vectorSearch.onboarding.indexingType.title": "Indexing type & options", + "vectorSearch.onboarding.next": "Next", + "vectorSearch.onboarding.sampleValue.body": "A sample value from the data to be indexed. Use it to verify the field type and indexing choice.", + "vectorSearch.onboarding.sampleValue.title": "Sample value", + "vectorSearch.onboarding.skipTour": "Skip tour", + "vectorSearch.onboarding.stepCounter": "{{current}}/{{total}}", + "vectorSearch.query.breadcrumb.ariaLabel": "Breadcrumb", + "vectorSearch.query.breadcrumb.indexes": "Indexes", + "vectorSearch.query.editor.action.explain": "Explain", + "vectorSearch.query.editor.action.explainAria": "Explain command", + "vectorSearch.query.editor.action.profile": "Profile", + "vectorSearch.query.editor.action.profileAria": "Profile command", + "vectorSearch.query.editor.action.save": "Save", + "vectorSearch.query.editor.action.saveAria": "Save query", + "vectorSearch.query.editor.onboarding.detail.ftAggregate": "Group and summarize results", + "vectorSearch.query.editor.onboarding.detail.ftExplain": "See execution plan", + "vectorSearch.query.editor.onboarding.detail.ftList": "View index schema and stats", + "vectorSearch.query.editor.onboarding.detail.ftProfile": "Analyze performance", + "vectorSearch.query.editor.onboarding.detail.ftSearch": "Find documents by text or filters", + "vectorSearch.query.editor.onboarding.detail.ftSpellcheck": "Suggest corrections for typos", + "vectorSearch.query.editor.onboarding.detail.ftSugget": "Retrieve autocomplete suggestions", + "vectorSearch.query.editor.onboarding.documentation": "Documentation", + "vectorSearch.query.editor.placeholder": "Start typing FT. to access search commands or switch to Query Library to access saved commands.", + "vectorSearch.query.editor.tab.editor": "Query editor", + "vectorSearch.query.editor.tab.library": "Query library", + "vectorSearch.query.editor.tooltip.disabledLoading": "Disabled: query is running.", + "vectorSearch.query.editor.tooltip.disabledNoQuery": "Disabled: no query identified.", + "vectorSearch.query.editor.tooltip.explain": "Shows how your query will run (execution plan) to understand what's used.", + "vectorSearch.query.editor.tooltip.profile": "Profiles your query to show where time is spent and spot bottlenecks.", + "vectorSearch.query.error.executeCommand": "Failed to execute command", + "vectorSearch.query.error.loadCommandDetails": "Failed to load command details", + "vectorSearch.query.groupCommandLabel_one": "{{count}} - Command", + "vectorSearch.query.groupCommandLabel_other": "{{count}} - Commands", + "vectorSearch.query.onboarding.description": "Build queries in the Query Editor or save them for later in the Query Library.", + "vectorSearch.query.onboarding.dismiss": "Got it", + "vectorSearch.query.onboarding.editorDescription": "write search queries directly using Redis commands.", + "vectorSearch.query.onboarding.editorTitle": "Query editor", + "vectorSearch.query.onboarding.libraryDescription": "reuse saved queries or use prebuilt examples for the sample data.", + "vectorSearch.query.onboarding.libraryTitle": "Query library", + "vectorSearch.query.onboarding.title": "Start exploring your data", + "vectorSearch.query.viewIndexButton": "View index", + "vectorSearch.queryLibrary.badge.sample": "Sample query", + "vectorSearch.queryLibrary.badge.saved": "Saved query", + "vectorSearch.queryLibrary.delete.cancel": "Keep query", + "vectorSearch.queryLibrary.delete.confirm": "Delete query", + "vectorSearch.queryLibrary.delete.message": "This action will remove the saved query, but won't affect your index or data.", + "vectorSearch.queryLibrary.delete.question": "Are you sure you want to delete this query?", + "vectorSearch.queryLibrary.delete.title": "Delete query", + "vectorSearch.queryLibrary.empty.noMatch": "No queries match your search", + "vectorSearch.queryLibrary.empty.noQueries": "No saved queries yet. Create your query in editor and click Save to add it here.", + "vectorSearch.queryLibrary.error.load": "Failed to load query library", + "vectorSearch.queryLibrary.item.copyNameAria": "Copy query name", + "vectorSearch.queryLibrary.item.deleteAria": "Delete query", + "vectorSearch.queryLibrary.item.load": "Load", + "vectorSearch.queryLibrary.item.loadAria": "Load query", + "vectorSearch.queryLibrary.item.run": "Run", + "vectorSearch.queryLibrary.item.runAria": "Run query", + "vectorSearch.queryLibrary.save.cancel": "Cancel", + "vectorSearch.queryLibrary.save.confirm": "Save query", + "vectorSearch.queryLibrary.save.description": "Name your query to add it to your saved queries list for quick reuse.", + "vectorSearch.queryLibrary.save.placeholder": "Enter command name", + "vectorSearch.queryLibrary.save.title": "Save query", + "vectorSearch.queryLibrary.searchPlaceholder": "Search query", + "vectorSearch.sampleData.cancel": "Cancel", + "vectorSearch.sampleData.content.description": "Discover content by theme or plot.", + "vectorSearch.sampleData.content.label": "Content recommendations", + "vectorSearch.sampleData.ecommerce.description": "Discover products that match intent, not just text", + "vectorSearch.sampleData.ecommerce.label": "E-commerce Discovery", + "vectorSearch.sampleData.seeIndexDefinition": "See index definition", + "vectorSearch.sampleData.startQuerying": "Start querying", + "vectorSearch.sampleData.subtitle1": "Select a sample dataset.", + "vectorSearch.sampleData.subtitle2": "We'll load the data and generate the index needed for search.", + "vectorSearch.sampleData.title": "Getting your sample data ready for Search", + "vectorSearch.selectKeyOnboarding.body1": "We'll use the selected key to generate a suggested indexing schema. Redis will index all keys with the same prefix, not just this single key.", + "vectorSearch.selectKeyOnboarding.body2": "Indexing available for Hash and JSON data structures.", + "vectorSearch.selectKeyOnboarding.close": "Close", + "vectorSearch.selectKeyOnboarding.gotIt": "Got it", + "vectorSearch.selectKeyOnboarding.title": "Select a key to get started", + "vectorSearch.upgradeBanner.cta": "Free Redis Cloud DB", + "vectorSearch.upgradeBanner.message": "Upgrade to Redis 7.2+ to unlock fast, real-time semantic AI search with vector search", + "vectorSearch.versionNotSupported.ctaText": "Create a free Redis Cloud database to start exploring these capabilities.", + "vectorSearch.versionNotSupported.description": "This page requires Redis Search 2.0 or later (included with Redis 6+). Older versions of Redis Search are not compatible with the commands used here.", + "vectorSearch.versionNotSupported.title": "Redis Search 2.0+ required", + "vectorSearch.welcome.checkingKeys": "Checking for existing keys…", + "vectorSearch.welcome.feature.fullText.description": "Find and filter your data instantly using powerful keyword and field-based queries.", + "vectorSearch.welcome.feature.fullText.title": "Full-text search", + "vectorSearch.welcome.feature.hybrid.description": "Combine vector and keyword search for higher accuracy and more relevant results.", + "vectorSearch.welcome.feature.hybrid.title": "Hybrid search", + "vectorSearch.welcome.feature.performance.description": "Built-in quantization and compression deliver blazing speed and efficiency at any scale.", + "vectorSearch.welcome.feature.performance.title": "High performance, low effort", + "vectorSearch.welcome.feature.vector.description": "Retrieve results by meaning, not just words. Ideal for AI, semantic, and recommendation apps.", + "vectorSearch.welcome.feature.vector.title": "Vector search", + "vectorSearch.welcome.noKeysFound": "No Hash or JSON keys found in your database", + "vectorSearch.welcome.subtitle": "Discover how Redis enables full-text and vector search. Fast, simple, and production-ready.", + "vectorSearch.welcome.title": "Search your data at in-memory speed", + "vectorSearch.welcome.trySampleData": "Try with sample data", + "vectorSearch.welcome.useMyDatabase": "Use data from my database", "whatsNew.button.gotIt": "Got it", "whatsNew.card.comingSoon": "Coming soon", - "whatsNew.card.tooltip": "The feature is rolled out gradually.", "whatsNew.card.locationLabel": "Where to find it:", + "whatsNew.card.tooltip": "The feature is rolled out gradually.", "whatsNew.menuItem": "What's new?", "whatsNew.releaseDate": "Released {{date}}", "whatsNew.releaseNotes.link": "See full release notes for {{version}}", diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts index 74ed056ae9..0c5444527e 100644 --- a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts @@ -30,26 +30,26 @@ export const FIELD_TYPE_OPTIONS = [ { text: 'TEXT', value: FieldTypes.TEXT, - description: 'Use TEXT for full-text search and indexing free-form text.', + descriptionKey: 'vectorSearch.fieldType.desc.text', }, { text: 'TAG', value: FieldTypes.TAG, - description: 'Use TAG for filtering by exact match values.', + descriptionKey: 'vectorSearch.fieldType.desc.tag', }, { text: 'NUMERIC', value: FieldTypes.NUMERIC, - description: 'Use NUMERIC for storing and querying numbers.', + descriptionKey: 'vectorSearch.fieldType.desc.numeric', }, { text: 'GEO', value: FieldTypes.GEO, - description: 'Use GEO for geographic coordinates (latitude and longitude).', + descriptionKey: 'vectorSearch.fieldType.desc.geo', }, { text: 'VECTOR', value: FieldTypes.VECTOR, - description: 'Use VECTOR for semantic search using vector embeddings.', + descriptionKey: 'vectorSearch.fieldType.desc.vector', }, ] diff --git a/redisinsight/ui/src/pages/vector-search/components/command-view/CommandView.tsx b/redisinsight/ui/src/pages/vector-search/components/command-view/CommandView.tsx index f446697698..1f236c1df5 100644 --- a/redisinsight/ui/src/pages/vector-search/components/command-view/CommandView.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/command-view/CommandView.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react' import { merge } from 'lodash' +import { useTranslation } from 'uiSrc/i18n' import { MonacoLanguage } from 'uiSrc/constants' import { defaultMonacoOptions } from 'uiSrc/constants/monaco/monaco' import { CopyButton } from 'uiSrc/components/copy-button' @@ -18,6 +19,7 @@ export const CommandView = ({ onCopy, showLineNumbers = false, }: CommandViewProps) => { + const { t } = useTranslation() const editorOptions = useMemo( () => merge({}, defaultMonacoOptions, COMMAND_VIEW_EDITOR_OPTIONS, { @@ -37,10 +39,10 @@ export const CommandView = ({ diff --git a/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboarding.constants.tsx b/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboarding.constants.tsx index 9e77e02547..4c6b91a0cf 100644 --- a/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboarding.constants.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboarding.constants.tsx @@ -1,5 +1,6 @@ import React from 'react' +import i18n, { Trans } from 'uiSrc/i18n' import { Text } from 'uiSrc/components/base/text' import { IndexingTypeContent } from '../field-type-list' @@ -28,70 +29,70 @@ export interface StepContent { body: React.ReactNode } -export const STEP_CONTENT: Record = { +// Built at call time (not module scope) so titles/bodies resolve in the active +// language when the popover renders. +export const getStepContent = (): Record< + CreateIndexOnboardingStep, + StepContent +> => ({ [CreateIndexOnboardingStep.DefineIndex]: { - title: 'Review and adjust the indexing schema', + title: i18n.t('vectorSearch.onboarding.defineIndex.title'), body: ( <> - An index defines how Redis searches and queries your data. The schema - controls which fields are indexed, their types, and other - configuration options. + {i18n.t('vectorSearch.onboarding.defineIndex.body1')} - Review the suggested index name. You{'\u2019'}ll use it when building - queries. + {i18n.t('vectorSearch.onboarding.defineIndex.body2')} - Tip: Index only fields you plan to search or filter on. + {i18n.t('vectorSearch.onboarding.defineIndex.body3')} ), }, [CreateIndexOnboardingStep.IndexPrefix]: { - title: 'Index prefix', + title: i18n.t('vectorSearch.onboarding.indexPrefix.title'), body: ( <> - Controls which keys are included in the index. All keys starting with - this prefix will be indexed. + {i18n.t('vectorSearch.onboarding.indexPrefix.body1')} - Example: bike: will index bike:1,{' '} - bike:road:3. + }} + /> ), }, [CreateIndexOnboardingStep.FieldName]: { - title: 'Field name', + title: i18n.t('vectorSearch.onboarding.fieldName.title'), body: ( - Represents a searchable attribute in your data. Only selected fields - will be searchable. + {i18n.t('vectorSearch.onboarding.fieldName.body')} ), }, [CreateIndexOnboardingStep.SampleValue]: { - title: 'Sample value', + title: i18n.t('vectorSearch.onboarding.sampleValue.title'), body: ( - A sample value from the data to be indexed. Use it to verify the field - type and indexing choice. + {i18n.t('vectorSearch.onboarding.sampleValue.body')} ), }, [CreateIndexOnboardingStep.IndexingType]: { - title: 'Indexing type & options', + title: i18n.t('vectorSearch.onboarding.indexingType.title'), body: , }, [CreateIndexOnboardingStep.CommandView]: { - title: 'Create index command', + title: i18n.t('vectorSearch.onboarding.commandView.title'), body: ( - This is the FT.CREATE command Redis will run. Once executed, your data - becomes searchable. + {i18n.t('vectorSearch.onboarding.commandView.body')} ), }, -} +}) diff --git a/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboardingPopover.tsx b/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboardingPopover.tsx index 3f981c49d5..b23f6b4f1e 100644 --- a/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboardingPopover.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/create-index-onboarding/CreateIndexOnboardingPopover.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { AnchorPosition, RiPopover } from 'uiSrc/components/base' import { Button, @@ -15,7 +16,7 @@ import { useCreateIndexOnboarding } from '../../context/create-index-onboarding' import { CreateIndexOnboardingStep, ONBOARDING_STEPS, - STEP_CONTENT, + getStepContent, TOTAL_STEPS, } from './CreateIndexOnboarding.constants' import * as S from './CreateIndexOnboardingPopover.styles' @@ -31,6 +32,7 @@ export const CreateIndexOnboardingPopover = ({ children, anchorPosition = 'rightCenter', }: CreateIndexOnboardingPopoverProps) => { + const { t } = useTranslation() const { currentStep, isActive, nextStep, prevStep, skipOnboarding } = useCreateIndexOnboarding() @@ -40,7 +42,7 @@ export const CreateIndexOnboardingPopover = ({ return <>{children} } - const content = STEP_CONTENT[step] + const content = getStepContent()[step] if (!content) { return <>{children} @@ -54,7 +56,9 @@ export const CreateIndexOnboardingPopover = ({ const stepNumber = stepIndex + 1 const handleAction = isLastStep ? skipOnboarding : nextStep - const actionLabel = isLastStep ? 'Got it' : 'Next' + const actionLabel = isLastStep + ? t('vectorSearch.onboarding.gotIt') + : t('vectorSearch.onboarding.next') return (
e.stopPropagation()} role="presentation"> @@ -75,7 +79,7 @@ export const CreateIndexOnboardingPopover = ({ icon={CancelSlimIcon} onClick={skipOnboarding} size="S" - aria-label="close-onboarding" + aria-label={t('vectorSearch.onboarding.close')} data-testid="create-index-onboarding-close" /> ) : ( @@ -84,7 +88,7 @@ export const CreateIndexOnboardingPopover = ({ data-testid="create-index-onboarding-skip" > - Skip tour + {t('vectorSearch.onboarding.skipTour')} )} @@ -99,7 +103,10 @@ export const CreateIndexOnboardingPopover = ({ - {stepNumber}/{TOTAL_STEPS} + {t('vectorSearch.onboarding.stepCounter', { + current: stepNumber, + total: TOTAL_STEPS, + })} @@ -110,7 +117,7 @@ export const CreateIndexOnboardingPopover = ({ onClick={prevStep} data-testid="create-index-onboarding-back" > - Back + {t('vectorSearch.onboarding.back')} )}
- - Defines how Redis searches this field and how it behaves at query time. - Available indexing types: - +export const IndexingTypeContent = () => { + const { t } = useTranslation() - {FIELD_TYPE_DESCRIPTIONS.map(({ type, description }) => ( - - - {description} - - ))} + return ( + + + {t('vectorSearch.fieldType.list.intro')} + - - Optional settings may affect performance, storage, or ranking. - - -) + {FIELD_TYPE_DESCRIPTION_KEYS.map(({ type, descriptionKey }) => ( + + + {t(descriptionKey as never)} + + ))} + + + {t('vectorSearch.fieldType.list.optionalSettings')} + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.constants.ts b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.constants.ts index 4cb6391178..213f1eeab1 100644 --- a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.constants.ts +++ b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.constants.ts @@ -1,3 +1,5 @@ +import i18n from 'uiSrc/i18n' + import { VectorAlgorithm, VectorDataType, @@ -46,12 +48,16 @@ export const VECTOR_DATA_TYPE_FLOAT16_OPTIONS = [ export const PHONETIC_NONE = 'none' -export const PHONETIC_OPTIONS = [ - { value: PHONETIC_NONE, label: 'None' }, - { value: 'dm:en', label: 'English (dm:en)' }, - { value: 'dm:fr', label: 'French (dm:fr)' }, - { value: 'dm:pt', label: 'Portuguese (dm:pt)' }, - { value: 'dm:es', label: 'Spanish (dm:es)' }, +// Built at call time (not module scope) so labels resolve in the active language. +export const getPhoneticOptions = () => [ + { + value: PHONETIC_NONE, + label: i18n.t('vectorSearch.fieldType.phonetic.none'), + }, + { value: 'dm:en', label: i18n.t('vectorSearch.fieldType.phonetic.en') }, + { value: 'dm:fr', label: i18n.t('vectorSearch.fieldType.phonetic.fr') }, + { value: 'dm:pt', label: i18n.t('vectorSearch.fieldType.phonetic.pt') }, + { value: 'dm:es', label: i18n.t('vectorSearch.fieldType.phonetic.es') }, ] export const VECTOR_ALGORITHM_OPTIONS = [ @@ -65,26 +71,45 @@ export const VECTOR_DISTANCE_METRIC_OPTIONS = [ { value: VectorDistanceMetric.COSINE, label: 'COSINE' }, ] -export const VALIDATION_MESSAGES = { - FIELD_NAME_REQUIRED: 'Field name is required.', - FIELD_NAME_DUPLICATE: 'A field with this name already exists.', - DIMENSIONS_REQUIRED: 'Dimensions value is required.', - DIMENSIONS_RANGE: - `Dimensions must be between` + - ` ${VECTOR_CONSTRAINTS.DIMENSIONS_MIN}` + - ` and ${VECTOR_CONSTRAINTS.DIMENSIONS_MAX}.`, - MAX_EDGES_RANGE: - `Max edges must be between` + - ` ${VECTOR_CONSTRAINTS.MAX_EDGES_MIN}` + - ` and ${VECTOR_CONSTRAINTS.MAX_EDGES_MAX}.`, - MAX_NEIGHBORS_RANGE: - `Max neighbors must be between` + - ` ${VECTOR_CONSTRAINTS.MAX_NEIGHBORS_MIN}` + - ` and ${VECTOR_CONSTRAINTS.MAX_NEIGHBORS_MAX}.`, - CANDIDATE_LIMIT_RANGE: - `Candidate limit must be between` + - ` ${VECTOR_CONSTRAINTS.CANDIDATE_LIMIT_MIN}` + - ` and ${VECTOR_CONSTRAINTS.CANDIDATE_LIMIT_MAX}.`, - EPSILON_MIN: `Epsilon must be ${VECTOR_CONSTRAINTS.EPSILON_MIN} or greater.`, - WEIGHT_MIN: 'Weight must be greater than 0.', -} +// Built at call time (not module scope) so messages resolve in the active +// language; numeric bounds are interpolated from VECTOR_CONSTRAINTS. +export const getValidationMessages = () => ({ + FIELD_NAME_REQUIRED: i18n.t( + 'vectorSearch.fieldType.validation.fieldNameRequired', + ), + FIELD_NAME_DUPLICATE: i18n.t( + 'vectorSearch.fieldType.validation.fieldNameDuplicate', + ), + DIMENSIONS_REQUIRED: i18n.t( + 'vectorSearch.fieldType.validation.dimensionsRequired', + ), + DIMENSIONS_RANGE: i18n.t( + 'vectorSearch.fieldType.validation.dimensionsRange', + { + min: VECTOR_CONSTRAINTS.DIMENSIONS_MIN, + max: VECTOR_CONSTRAINTS.DIMENSIONS_MAX, + }, + ), + MAX_EDGES_RANGE: i18n.t('vectorSearch.fieldType.validation.maxEdgesRange', { + min: VECTOR_CONSTRAINTS.MAX_EDGES_MIN, + max: VECTOR_CONSTRAINTS.MAX_EDGES_MAX, + }), + MAX_NEIGHBORS_RANGE: i18n.t( + 'vectorSearch.fieldType.validation.maxNeighborsRange', + { + min: VECTOR_CONSTRAINTS.MAX_NEIGHBORS_MIN, + max: VECTOR_CONSTRAINTS.MAX_NEIGHBORS_MAX, + }, + ), + CANDIDATE_LIMIT_RANGE: i18n.t( + 'vectorSearch.fieldType.validation.candidateLimitRange', + { + min: VECTOR_CONSTRAINTS.CANDIDATE_LIMIT_MIN, + max: VECTOR_CONSTRAINTS.CANDIDATE_LIMIT_MAX, + }, + ), + EPSILON_MIN: i18n.t('vectorSearch.fieldType.validation.epsilonMin', { + min: VECTOR_CONSTRAINTS.EPSILON_MIN, + }), + WEIGHT_MIN: i18n.t('vectorSearch.fieldType.validation.weightMin'), +}) diff --git a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.tsx b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.tsx index 7b680b0b8e..44c81ea72a 100644 --- a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/FieldTypeModal.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo } from 'react' import { useFormik } from 'formik' +import { useTranslation } from 'uiSrc/i18n' import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' import { CancelIcon } from 'uiSrc/components/base/icons' import { Modal } from 'uiSrc/components/base/display' @@ -31,6 +32,7 @@ export const FieldTypeModal = ({ onSubmit, onClose, }: FieldTypeModalProps) => { + const { t } = useTranslation() const validate = useFieldTypeValidation(mode, fields, field) const initialValues = useMemo( @@ -62,7 +64,9 @@ export const FieldTypeModal = ({ }, [formik, onClose]) const isCreateMode = mode === FieldTypeModalMode.Create - const title = isCreateMode ? 'Add field' : 'Edit field' + const title = isCreateMode + ? t('vectorSearch.fieldType.modal.addTitle') + : t('vectorSearch.fieldType.modal.editTitle') if (!isOpen) return null @@ -78,7 +82,10 @@ export const FieldTypeModal = ({ content={ {isCreateMode ? ( - + @@ -86,7 +93,9 @@ export const FieldTypeModal = ({ } onBlur={formik.handleBlur} name="fieldName" - placeholder="Enter field name" + placeholder={t( + 'vectorSearch.fieldType.modal.fieldNamePlaceholder', + )} error={ formik.touched.fieldName ? formik.errors.fieldName @@ -102,7 +111,7 @@ export const FieldTypeModal = ({ data-testid="field-type-modal-field-name-readonly" > - Field name: + {t('vectorSearch.fieldType.modal.fieldNameLabel')} {field?.name} @@ -110,7 +119,7 @@ export const FieldTypeModal = ({ - Field sample value: + {t('vectorSearch.fieldType.modal.fieldSampleValue')} {truncateText( @@ -131,9 +140,7 @@ export const FieldTypeModal = ({ )} - You can change the field type for this field. Keep in mind that - changing the field type will affect how the field is indexed and - queried. + {t('vectorSearch.fieldType.modal.changeTypeBody')} - Cancel + {t('vectorSearch.fieldType.modal.cancel')} - {isCreateMode ? 'Add' : 'Save'} + {isCreateMode + ? t('vectorSearch.fieldType.modal.add') + : t('vectorSearch.fieldType.modal.save')} diff --git a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeForm/FieldTypeForm.tsx b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeForm/FieldTypeForm.tsx index 6bdb933fcf..771cee6a2d 100644 --- a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeForm/FieldTypeForm.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeForm/FieldTypeForm.tsx @@ -1,5 +1,6 @@ import React from 'react' import { FormikProps } from 'formik' +import { useTranslation } from 'uiSrc/i18n' import { Col } from 'uiSrc/components/base/layout/flex' import { Text } from 'uiSrc/components/base/text' import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' @@ -12,16 +13,21 @@ export interface FieldTypeFormProps { formik: FormikProps } -const SECTION_LABELS: Partial> = { - [FieldTypes.VECTOR]: 'VECTOR options', - [FieldTypes.TEXT]: 'TEXT options', +const SECTION_LABEL_TOKENS: Partial> = { + [FieldTypes.VECTOR]: 'VECTOR', + [FieldTypes.TEXT]: 'TEXT', } export const FieldTypeForm = ({ formik }: FieldTypeFormProps) => { + const { t } = useTranslation() const { fieldType } = formik.values - const sectionLabel = SECTION_LABELS[fieldType] + const sectionToken = SECTION_LABEL_TOKENS[fieldType] - if (!sectionLabel) return null + if (!sectionToken) return null + + const sectionLabel = t('vectorSearch.fieldType.sectionOptions', { + type: sectionToken, + }) return ( diff --git a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeSelect/FieldTypeSelect.tsx b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeSelect/FieldTypeSelect.tsx index d9239dc49c..c42d66fac5 100644 --- a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeSelect/FieldTypeSelect.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/FieldTypeSelect/FieldTypeSelect.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { FieldTypes, FIELD_TYPE_OPTIONS, @@ -18,8 +19,8 @@ export interface FieldTypeSelectProps { dataTestId?: string } -const fieldTypeDescriptions: Record = Object.fromEntries( - FIELD_TYPE_OPTIONS.map((option) => [option.value, option.description]), +const fieldTypeDescriptionKeys: Record = Object.fromEntries( + FIELD_TYPE_OPTIONS.map((option) => [option.value, option.descriptionKey]), ) as Record const fieldTypeSelectOptions = FIELD_TYPE_OPTIONS.map((option) => ({ @@ -32,6 +33,8 @@ export const FieldTypeSelect = ({ onChange, dataTestId = 'field-type-select', }: FieldTypeSelectProps) => { + const { t } = useTranslation() + const valueRender = useCallback( ({ option, isOptionValue }: SelectValueRenderParams) => { const fieldType = option.value as FieldTypes @@ -40,14 +43,16 @@ export const FieldTypeSelect = ({ return ( - {fieldTypeDescriptions[fieldType]} + + {t(fieldTypeDescriptionKeys[fieldType] as never)} + ) } return }, - [], + [t], ) const handleChange = useCallback( diff --git a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/TextFieldOptions/TextFieldOptions.tsx b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/TextFieldOptions/TextFieldOptions.tsx index 580ef78ba1..34990d50b1 100644 --- a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/TextFieldOptions/TextFieldOptions.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/TextFieldOptions/TextFieldOptions.tsx @@ -1,11 +1,15 @@ import React from 'react' import { FormikProps } from 'formik' +import { useTranslation } from 'uiSrc/i18n' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { FormField } from 'uiSrc/components/base/forms/FormField' import NumericInput from 'uiSrc/components/base/inputs/NumericInput' import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' -import { PHONETIC_NONE, PHONETIC_OPTIONS } from '../../FieldTypeModal.constants' +import { + PHONETIC_NONE, + getPhoneticOptions, +} from '../../FieldTypeModal.constants' import { FieldTypeFormValues } from '../FieldTypeForm/FieldTypeForm.types' export interface TextFieldOptionsProps { @@ -13,16 +17,17 @@ export interface TextFieldOptionsProps { } export const TextFieldOptions = ({ formik }: TextFieldOptionsProps) => { + const { t } = useTranslation() const { values, errors, setFieldValue } = formik + const phoneticOptions = getPhoneticOptions() return ( { setFieldValue('phonetic', val === PHONETIC_NONE ? undefined : val) diff --git a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/VectorFieldOptions/VectorFieldOptions.tsx b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/VectorFieldOptions/VectorFieldOptions.tsx index 4b8be6f7f1..37c6e8b60b 100644 --- a/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/VectorFieldOptions/VectorFieldOptions.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/field-type-modal/components/VectorFieldOptions/VectorFieldOptions.tsx @@ -1,5 +1,6 @@ import React from 'react' import { FormikErrors, FormikProps } from 'formik' +import { useTranslation } from 'uiSrc/i18n' import { Row, Col, FlexItem } from 'uiSrc/components/base/layout/flex' import { FormField } from 'uiSrc/components/base/forms/FormField' import NumericInput from 'uiSrc/components/base/inputs/NumericInput' @@ -26,6 +27,7 @@ export interface VectorFieldOptionsProps { } export const VectorFieldOptions = ({ formik }: VectorFieldOptionsProps) => { + const { t } = useTranslation() const { values, setFieldValue } = formik const errors = formik.errors as FormikErrors const isHnsw = values.algorithm === VectorAlgorithm.HNSW @@ -36,10 +38,9 @@ export const VectorFieldOptions = ({ formik }: VectorFieldOptionsProps) => { { - + { { { { { { - useCallback( +) => { + const { t } = useTranslation() + + return useCallback( (values: FieldTypeFormValues): FormikErrors => { const errors: FormikErrors = {} + const VALIDATION_MESSAGES = getValidationMessages() if (mode === FieldTypeModalMode.Create) { if (!values.fieldName?.trim()) { @@ -107,5 +111,6 @@ export const useFieldTypeValidation = ( return errors }, - [mode, fields, _editingField], + [mode, fields, _editingField, t], ) +} diff --git a/redisinsight/ui/src/pages/vector-search/components/index-details/IndexDetails.columns.tsx b/redisinsight/ui/src/pages/vector-search/components/index-details/IndexDetails.columns.tsx index d79a4511f2..16c49c64b6 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-details/IndexDetails.columns.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-details/IndexDetails.columns.tsx @@ -1,4 +1,5 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { ColumnDef, Row, Table } from 'uiSrc/components/base/layout/table' import { IndexDetailsColumn, IndexField } from './IndexDetails.types' @@ -34,7 +35,10 @@ export const NAME_COLUMN: ColumnDef = { step={CreateIndexOnboardingStep.FieldName} anchorPosition="upCenter" > - } /> + } + /> ), cell: ({ row }: { row: Row }) => ( @@ -53,7 +57,7 @@ export const VALUE_COLUMN: ColumnDef = { anchorPosition="upCenter" > } /> @@ -74,7 +78,10 @@ export const TYPE_COLUMN_READONLY: ColumnDef = { step={CreateIndexOnboardingStep.IndexingType} anchorPosition="downCenter" > - } /> + } + /> ), cell: ({ row }: { row: Row }) => ( @@ -94,7 +101,7 @@ export const TYPE_COLUMN_EDITABLE: ColumnDef = { anchorPosition="downCenter" > } /> diff --git a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldActionsCell/FieldActionsCell.tsx b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldActionsCell/FieldActionsCell.tsx index 3a31d95eef..84c869e895 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldActionsCell/FieldActionsCell.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldActionsCell/FieldActionsCell.tsx @@ -2,9 +2,11 @@ import React from 'react' import { EditIcon } from 'uiSrc/components/base/icons' import { IconButton } from 'uiSrc/components/base/forms/buttons/IconButton' import { RiTooltip } from 'uiSrc/components/base/tooltip' +import { useTranslation } from 'uiSrc/i18n' import { FieldActionsCellProps } from './FieldActionsCell.types' export const FieldActionsCell = ({ field, onEdit }: FieldActionsCellProps) => { + const { t } = useTranslation() const handleClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -12,10 +14,10 @@ export const FieldActionsCell = ({ field, onEdit }: FieldActionsCellProps) => { } return ( - + diff --git a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldNameCell/FieldNameTooltip.tsx b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldNameCell/FieldNameTooltip.tsx index b78a4b5b42..61cf4b865a 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldNameCell/FieldNameTooltip.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldNameCell/FieldNameTooltip.tsx @@ -1,15 +1,19 @@ import React from 'react' import { Text } from 'uiSrc/components/base/text' import { Col } from 'uiSrc/components/base/layout/flex' +import { useTranslation } from 'uiSrc/i18n' -export const FieldNameTooltip = () => ( - - - Field name - - - Represents a searchable attribute in your data. Only selected fields will - be searchable. - - -) +export const FieldNameTooltip = () => { + const { t } = useTranslation() + + return ( + + + {t('vectorSearch.indexDetails.fieldNameTooltip.title')} + + + {t('vectorSearch.indexDetails.fieldNameTooltip.description')} + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldTypeCell/FieldTypeTooltip.tsx b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldTypeCell/FieldTypeTooltip.tsx index b69ae60a10..e44e335062 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldTypeCell/FieldTypeTooltip.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldTypeCell/FieldTypeTooltip.tsx @@ -1,13 +1,18 @@ import React from 'react' import { Text } from 'uiSrc/components/base/text' import { Col } from 'uiSrc/components/base/layout/flex' +import { useTranslation } from 'uiSrc/i18n' import { IndexingTypeContent } from '../../../../components/field-type-list' -export const FieldTypeTooltip = () => ( - - - Indexing type & options - - - -) +export const FieldTypeTooltip = () => { + const { t } = useTranslation() + + return ( + + + {t('vectorSearch.indexDetails.fieldTypeTooltip.title')} + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldValueCell/FieldValueTooltip.tsx b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldValueCell/FieldValueTooltip.tsx index 9cb6699db0..0baabadeaa 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldValueCell/FieldValueTooltip.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-details/components/FieldValueCell/FieldValueTooltip.tsx @@ -1,15 +1,19 @@ import React from 'react' import { Text } from 'uiSrc/components/base/text' import { Col } from 'uiSrc/components/base/layout/flex' +import { useTranslation } from 'uiSrc/i18n' -export const FieldValueTooltip = () => ( - - - Field sample value - - - A sample value from the data to be indexed. Use it to verify the field - type and indexing choice. - - -) +export const FieldValueTooltip = () => { + const { t } = useTranslation() + + return ( + + + {t('vectorSearch.indexDetails.fieldValueTooltip.title')} + + + {t('vectorSearch.indexDetails.fieldValueTooltip.description')} + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/components/index-info-side-panel/IndexInfoSidePanel.tsx b/redisinsight/ui/src/pages/vector-search/components/index-info-side-panel/IndexInfoSidePanel.tsx index 08552b36ae..6fb82a5a3f 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-info-side-panel/IndexInfoSidePanel.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-info-side-panel/IndexInfoSidePanel.tsx @@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom' import { Text } from 'uiSrc/components/base/text' import { IconButton } from 'uiSrc/components/base/forms/buttons' import { CancelIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import { useIndexInfo } from '../../hooks' import { decodeIndexNameFromUrl } from '../../utils' @@ -16,6 +17,7 @@ export const IndexInfoSidePanel = ({ onClose, indexName: indexNameProp, }: IndexInfoSidePanelProps) => { + const { t } = useTranslation() const { indexName: indexNameParam } = useParams<{ indexName?: string }>() const resolvedName = indexNameProp ?? decodeIndexNameFromUrl(indexNameParam ?? '') @@ -32,7 +34,7 @@ export const IndexInfoSidePanel = ({ diff --git a/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.constants.tsx b/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.constants.tsx index 6fca62e12d..00b70b5f40 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.constants.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.constants.tsx @@ -1,5 +1,6 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { ColumnDef } from 'uiSrc/components/base/layout/table' import { FieldTag } from 'uiSrc/pages/vector-search/components/field-tag/FieldTag' @@ -14,29 +15,31 @@ export enum IndexInfoTableColumn { /** * Table columns for displaying index attributes. + * Built at call time (not module scope) so headers resolve in the active + * language when the table renders. */ -export const TABLE_COLUMNS: ColumnDef[] = [ +export const getTableColumns = (): ColumnDef[] => [ { id: IndexInfoTableColumn.Identifier, accessorKey: IndexInfoTableColumn.Identifier, - header: 'Identifier', + header: i18n.t('vectorSearch.indexInfo.column.identifier'), }, { id: IndexInfoTableColumn.Attribute, accessorKey: IndexInfoTableColumn.Attribute, - header: 'Attribute', + header: i18n.t('vectorSearch.indexInfo.column.attribute'), }, { id: IndexInfoTableColumn.Type, accessorKey: IndexInfoTableColumn.Type, - header: 'Type', + header: i18n.t('vectorSearch.indexInfo.column.type'), enableSorting: false, cell: ({ row }) => , }, { id: IndexInfoTableColumn.Weight, accessorKey: IndexInfoTableColumn.Weight, - header: 'Weight', + header: i18n.t('vectorSearch.indexInfo.column.weight'), enableSorting: false, }, ] diff --git a/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.tsx b/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.tsx index bb7c250d83..3894322c08 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.tsx @@ -5,9 +5,10 @@ import { Loader } from 'uiSrc/components/base/display' import { Row } from 'uiSrc/components/base/layout/flex' import { Text } from 'uiSrc/components/base/text' import { GroupBadge } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' import { IndexInfoProps } from './IndexInfo.types' -import { TABLE_COLUMNS } from './IndexInfo.constants' +import { getTableColumns } from './IndexInfo.constants' import { parseIndexAttributes, formatOptions, @@ -17,6 +18,8 @@ import { IndexInfoContainer } from './IndexInfo.styles' import { formatPrefixes } from 'uiSrc/pages/vector-search/utils' export const IndexInfo = ({ indexInfo, dataTestId }: IndexInfoProps) => { + const { t } = useTranslation() + if (!indexInfo) { return ( @@ -37,11 +40,13 @@ export const IndexInfo = ({ indexInfo, dataTestId }: IndexInfoProps) => { data-testid={`${dataTestId ?? 'index-info'}--definition`} > - Indexing + {t('vectorSearch.indexInfo.indexing')} - documents{prefixes && ` prefixed by ${prefixes}`}. + {prefixes + ? t('vectorSearch.indexInfo.documentsPrefixed', { prefixes }) + : t('vectorSearch.indexInfo.documents')} @@ -51,13 +56,16 @@ export const IndexInfo = ({ indexInfo, dataTestId }: IndexInfoProps) => { color="secondary" data-testid={`${dataTestId ?? 'index-info'}--options`} > - Options:{' '} - {showOptions ? formatOptions(indexOptions!) : 'no options found'} + {t('vectorSearch.indexInfo.options', { + options: showOptions + ? formatOptions(indexOptions!) + : t('vectorSearch.indexInfo.noOptionsFound'), + })} {/* Attributes Table */}
` in some browsers. +const BLACKLISTED_ATTRS: Array = [ + /^on.+/i, + /^style$/i, + /^background$/i, +] -// Case-sensitive strip of raw HTML elements to prevent external -// resource loading while preserving the PascalCase component emitted by -// the markdown formatter. JsxParser's blacklistedTags is case-insensitive, so -// blacklisting `link` would also drop legitimate — handle it here. -const LOWERCASE_LINK_TAG = /]*\/?>|<\/link\s*>/g +// Note: raw HTML `` elements never reach the parser — `remarkSanitize` +// (DOMPurify) strips them during formatting. We must NOT blacklist the `link` +// tag here: JsxParser's blacklistedTags is case-insensitive, so it would also +// drop the legitimate PascalCase component emitted by the formatter. export interface Props { onRunCommand?: (query: string) => void @@ -103,7 +107,7 @@ const MarkdownMessage = (props: Props) => { blacklistedTags={BLACKLISTED_TAGS} blacklistedAttrs={BLACKLISTED_ATTRS} autoCloseVoidElements - jsx={content.replace(LOWERCASE_LINK_TAG, '')} + jsx={content} onError={() => setParseAsIs(true)} /> ) From 7ccf88f96f76875bfd8b9d6c4b0ea82404da5b7e Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Thu, 9 Jul 2026 16:55:43 +0300 Subject: [PATCH 013/166] RI-8312,RI-8314: Align array results table columns and expanded rows (#6186) --- .../ArrayDetailsTable.config.tsx | 32 +++++++++++++-- .../ArrayDetailsTable.styles.ts | 14 +++++++ .../array-details-table/ArrayDetailsTable.tsx | 14 ++++++- .../array-details-table/constants.ts | 9 +++++ .../NeighbourBand/NeighbourBand.styles.ts | 40 +++++++++++++++---- .../NeighbourBand/NeighbourBand.tsx | 20 ++++++---- 6 files changed, 109 insertions(+), 20 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx index 4d51d4c114..3e68fdf05f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.config.tsx @@ -8,17 +8,23 @@ import { ArrayValueCell } from './components/ArrayValueCell' import { RowActionsCell } from './components/RowActionsCell' import { BulkDeleteHeaderCell } from './components/BulkDeleteHeaderCell' import { ArrayTableConfig } from './ArrayDetailsTable.types' +import { + ACTIONS_COLUMN_SIZE, + INDEX_COLUMN_SIZE, + SELECTION_COLUMN_WIDTH_REM, + VALUE_COLUMN_SIZE, +} from './constants' export const TEST_ID = 'array-details-table' -const ACTIONS_COLUMN_SIZE = 48 - const indexColumn: ColumnDef = { id: 'index', accessorKey: 'index', header: 'Index', enableSorting: false, enableResizing: true, + size: INDEX_COLUMN_SIZE, + sizeUnit: 'px', cell: ({ row }: CellContext) => ( = { header: 'Value', enableSorting: false, enableResizing: true, + size: VALUE_COLUMN_SIZE, + sizeUnit: 'px', cell: ({ row, table }: CellContext) => { const { compressor, @@ -97,5 +105,21 @@ export const arrayColumns: ColumnDef[] = [ valueColumn, ] -const MIN_COLUMN_WIDTH = 160 -export const TABLE_MIN_WIDTH = `${arrayColumns.length * MIN_COLUMN_WIDTH}px` +// Width below which the table scrolls horizontally instead of squeezing the +// index/value columns. Sums every column present — including the optional +// selection (rem) and actions (px) columns, hence the calc. +export const getTableMinWidth = ({ + hasSelectionColumn, + hasActionsColumn, +}: { + hasSelectionColumn: boolean + hasActionsColumn: boolean +}): string => { + const pxColumns = + INDEX_COLUMN_SIZE + + VALUE_COLUMN_SIZE + + (hasActionsColumn ? ACTIONS_COLUMN_SIZE : 0) + return hasSelectionColumn + ? `calc(${pxColumns}px + ${SELECTION_COLUMN_WIDTH_REM}rem)` + : `${pxColumns}px` +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts index db7c5ece52..2594795c62 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.styles.ts @@ -3,6 +3,8 @@ import { FlexItem } from 'uiSrc/components/base/layout/flex' import { Table, TableProps } from 'uiSrc/components/base/layout/table' import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' +import { SELECTION_COLUMN_CELL_CLASS } from './constants' + export const Container = styled(FlexItem)` display: flex; flex: 1; @@ -31,4 +33,16 @@ export const StyledTable = styled(Table)` [data-role='table-body'] .array-row-action--open { opacity: 1; } + + /* Trim the selection column's wide side padding and center the checkbox in it. + The element+class selector outranks the base cell padding (no !important). */ + th.${SELECTION_COLUMN_CELL_CLASS}, td.${SELECTION_COLUMN_CELL_CLASS} { + padding-left: ${({ theme }) => theme.core.space.space050}; + padding-right: ${({ theme }) => theme.core.space.space050}; + } + th.${SELECTION_COLUMN_CELL_CLASS} > *, + td.${SELECTION_COLUMN_CELL_CLASS} > * { + width: 100%; + justify-content: center; + } ` as unknown as (props: TableProps) => JSX.Element diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx index 69f5e88582..c7be239839 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/ArrayDetailsTable.tsx @@ -27,11 +27,13 @@ import { ArrayDataElement } from 'uiSrc/slices/interfaces/array' import { ARRAY_TABLE_EMPTY_MESSAGE, ARRAY_TABLE_LOADING_MESSAGE, + SELECTION_COLUMN_CELL_CLASS, + SELECTION_COLUMN_WIDTH_REM, } from './constants' import { actionsColumn, arrayColumns, - TABLE_MIN_WIDTH, + getTableMinWidth, TEST_ID, } from './ArrayDetailsTable.config' import { @@ -215,6 +217,14 @@ const ArrayDetailsTable = memo( () => buildSelectionColumn({ disableSelectAll: !hasSelectableRows, + // Override redis-ui's default 4.2rem so the column hugs the checkbox; + // the class trims the cell's side padding (see ArrayDetailsTable.styles). + size: SELECTION_COLUMN_WIDTH_REM, + sizeUnit: 'rem', + getCellProps: () => ({ className: SELECTION_COLUMN_CELL_CLASS }), + getHeaderCellProps: () => ({ + className: SELECTION_COLUMN_CELL_CLASS, + }), }), [buildSelectionColumn, hasSelectableRows], ) @@ -262,7 +272,7 @@ const ArrayDetailsTable = memo( data={elements} meta={meta} stripedRows - minWidth={TABLE_MIN_WIDTH} + minWidth={getTableMinWidth({ hasSelectionColumn, hasActionsColumn })} emptyState={emptyState} renderExpandedRow={renderExpandedRow} getIsRowExpandable={getIsRowExpandable} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts index ea3abe7537..11fb3dc2f7 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-details-table/constants.ts @@ -1,2 +1,11 @@ export const ARRAY_TABLE_EMPTY_MESSAGE = 'No elements in range' export const ARRAY_TABLE_LOADING_MESSAGE = 'Loading…' + +// Array results table column widths, shared with NeighbourBand so the expanded +// row lines up with the same columns. +export const INDEX_COLUMN_SIZE = 140 +export const VALUE_COLUMN_SIZE = 420 +export const ACTIONS_COLUMN_SIZE = 48 +// Snug around the 1.8rem checkbox, not redis-ui's default 4.2rem. +export const SELECTION_COLUMN_WIDTH_REM = 2.6 +export const SELECTION_COLUMN_CELL_CLASS = 'array-selection-cell' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.styles.ts index c7e7551bde..a3262a311a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.styles.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.styles.ts @@ -2,11 +2,21 @@ import React from 'react' import styled from 'styled-components' import { Col } from 'uiSrc/components/base/layout/flex' -const INDEX_COLUMN_MIN_WIDTH = '120px' -const VALUE_COLUMN_MIN_WIDTH = '160px' +import { + ACTIONS_COLUMN_SIZE, + INDEX_COLUMN_SIZE, + SELECTION_COLUMN_WIDTH_REM, + VALUE_COLUMN_SIZE, +} from '../../array-details-table/constants' + +// Mirror the parent table's columns (selection + index + value + actions +// spacers) so expanded rows line up under them at any width. `* 10` scales the +// rem selection width to the px columns' scale (app's 62.5% root). +const SELECTION_COLUMN_FR = SELECTION_COLUMN_WIDTH_REM * 10 export const Band = styled(Col)` - padding: ${({ theme }) => theme.core.space.space050}; + width: 100%; + padding: ${({ theme }) => theme.core.space.space050} 0; ` export const BandRow = styled.div< @@ -14,15 +24,31 @@ export const BandRow = styled.div< >` display: grid; grid-template-columns: - minmax(${INDEX_COLUMN_MIN_WIDTH}, 1fr) - minmax(${VALUE_COLUMN_MIN_WIDTH}, 2fr); - gap: ${({ theme }) => theme.core.space.space100}; - padding: ${({ theme }) => theme.core.space.space050}; + minmax(0, ${SELECTION_COLUMN_FR}fr) + minmax(0, ${INDEX_COLUMN_SIZE}fr) + minmax(0, ${VALUE_COLUMN_SIZE}fr) + minmax(0, ${ACTIONS_COLUMN_SIZE}fr); + /* Center cells vertically so a short index stays aligned with a value that + wraps to multiple lines. */ + align-items: center; background: ${({ theme, $match }) => $match ? theme.semantic.color.background.neutral200 : 'transparent'}; ` +// Match the parent body cell's padding and overflow so content lines up under — +// and clips like — the parent columns. +export const BandCell = styled.div` + min-width: 0; + overflow: hidden; + padding: ${({ theme }) => theme.core.space.space050} + ${({ theme }) => theme.core.space.space150}; +` + export const Message = styled(Col)` padding: ${({ theme }) => theme.core.space.space100}; + padding-left: calc( + ${SELECTION_COLUMN_WIDTH_REM}rem + + ${({ theme }) => theme.core.space.space150} + ); color: ${({ theme }) => theme.semantic.color.text.neutral600}; ` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.tsx index 70a521daaa..65d5223cd3 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/NeighbourBand/NeighbourBand.tsx @@ -106,13 +106,19 @@ export const NeighbourBand = ({ : `${TEST_ID_PREFIX}-row-${el.index}` } > - - + + + + + + + + ) })} From 533dddee0d79c7d3f17e8b603b4c14b9a48eb9e8 Mon Sep 17 00:00:00 2001 From: dantovska Date: Thu, 9 Jul 2026 16:58:25 +0300 Subject: [PATCH 014/166] RI-8219 Reuse the Array command preview in Vector Set (#6178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ui): reuse the Array command preview in Vector Set Promote CommandPreview from array-details to key-details/shared and adopt it in the Vector Set similarity-search form, replacing the duplicated component. Existing test ids are preserved via a new data-testid prop; the loading placeholder is unified on the Array copy ("Building command…"). References: #RI-8219 Co-Authored-By: Claude Fable 5 * refactor(ui): reuse the Array preview toggle in Vector Set Promote PreviewToggle and useResponsivePreviewLabel from array-details to key-details/shared and adopt them in the similarity-search form, so the toggle label expands to "Preview command" on wide layouts exactly like the Array forms. The shared toggle gains disabled/disabledTooltip props for the vector set's query-not-ready state; its show/hide tooltips unify on the Array copy. References: #RI-8219 Co-Authored-By: Claude Fable 5 * test(e2e): seed vector sets with NOQUANT to stabilize self-match score The default int8 quantization makes the VSIM ELE self-match score land slightly below 1 (0.9993-0.9998 observed) for most random vectors, so the "100 %" self-match assertion in similarity-search.spec was a coin flip on the seeded data - it failed all three CI retries with 99.93 %, 99.94 % and 99.98 %. fp32 storage keeps the self-match at 1 within float epsilon, which always renders as "100.00 %". Co-Authored-By: Claude Fable 5 * chore: trim comments in the command-preview refactor Co-Authored-By: Claude Fable 5 * test(e2e): scope NOQUANT seeding to the similarity self-match spec Seeding every vector set as fp32 broke the add-elements specs: the app's VADD sends no quantization token, and Redis rejects writes whose implied int8 default mismatches an fp32 set. Make NOQUANT an opt-in seed option used only by the "100 %" self-match assertion, which needs fp32 to avoid int8 self-similarity drift. Co-Authored-By: Claude Fable 5 * test(e2e): assert similarity self-match score with tolerance Replace the exact "100" text match with |100 - shown| <= 0.1: the default int8 quantization lands the self-match at 99.93-99.98 % for most random vectors, so exactness was testing the quantizer rather than the ranking. Seeding returns to plain VADD (the NOQUANT opt-in is no longer needed), keeping test sets identical to what the app itself writes. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../ArrayAggregateForm.tsx | 16 ++++-- .../array-range-form/ArrayRangeForm.tsx | 16 ++++-- .../array-search-form/ArraySearchForm.tsx | 16 ++++-- .../command-preview/CommandPreview.types.ts | 4 -- .../components/array-details/constants.ts | 3 ++ .../components/array-details/hooks/index.ts | 1 - .../command-preview/CommandPreview.styles.ts | 23 -------- .../command-preview/CommandPreview.tsx | 37 ------------- .../command-preview/index.ts | 1 - .../SimilaritySearchForm.spec.tsx | 4 +- .../SimilaritySearchForm.styles.ts | 20 +++---- .../SimilaritySearchForm.tsx | 52 ++++++++----------- .../similarity-search-form/constants.ts | 8 +-- .../command-preview/CommandPreview.styles.ts | 0 .../command-preview/CommandPreview.tsx | 14 +++-- .../command-preview/CommandPreview.types.ts | 1 + .../command-preview/index.ts | 0 .../modules/key-details/shared/index.ts | 4 ++ .../preview-toggle/PreviewToggle.constants.ts | 0 .../preview-toggle/PreviewToggle.spec.tsx | 16 ++++++ .../preview-toggle/PreviewToggle.styles.ts | 0 .../preview-toggle/PreviewToggle.tsx | 15 ++++-- .../preview-toggle/PreviewToggle.types.ts | 3 ++ .../preview-toggle/index.ts | 1 + .../useResponsivePreviewLabel.spec.tsx | 2 +- .../useResponsivePreviewLabel.ts | 2 +- .../vector-set/similarity-search.spec.ts | 14 +++-- 27 files changed, 130 insertions(+), 143 deletions(-) delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.types.ts delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.styles.ts delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.tsx delete mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/index.ts rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/command-preview/CommandPreview.styles.ts (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/command-preview/CommandPreview.tsx (61%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/vector-set-details/similarity-search-form => shared}/command-preview/CommandPreview.types.ts (75%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/command-preview/index.ts (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/preview-toggle/PreviewToggle.constants.ts (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/preview-toggle/PreviewToggle.spec.tsx (75%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/preview-toggle/PreviewToggle.styles.ts (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/preview-toggle/PreviewToggle.tsx (70%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/preview-toggle/PreviewToggle.types.ts (70%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details => shared}/preview-toggle/index.ts (60%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details/hooks => shared/preview-toggle}/useResponsivePreviewLabel.spec.tsx (94%) rename redisinsight/ui/src/pages/browser/modules/key-details/{components/array-details/hooks => shared/preview-toggle}/useResponsivePreviewLabel.ts (93%) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-aggregate-form/ArrayAggregateForm.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-aggregate-form/ArrayAggregateForm.tsx index cec8f4a4e2..d5c48efcde 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-aggregate-form/ArrayAggregateForm.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-aggregate-form/ArrayAggregateForm.tsx @@ -9,10 +9,13 @@ import { TextInput } from 'uiSrc/components/base/inputs' import { defaultValueRender } from 'uiSrc/components/base/forms/select/RiSelect' import { parseArrayIndex } from 'uiSrc/utils/arrayIndex' import { ArrayAggregateOperation } from 'uiSrc/slices/interfaces/array' +import { + CommandPreview, + PreviewToggle, + useResponsivePreviewLabel, +} from 'uiSrc/pages/browser/modules/key-details/shared' -import { CommandPreview } from '../command-preview' -import { PreviewToggle } from '../preview-toggle' -import { useResponsivePreviewLabel } from '../hooks' +import { ARRAY_COMMAND_PREVIEW_TEST_ID } from '../constants' import * as RangeStyles from '../array-range-form/ArrayRangeForm.styles' import * as S from './ArrayAggregateForm.styles' import { @@ -159,7 +162,12 @@ export const ArrayAggregateForm = ({ /> - {previewVisible && } + {previewVisible && ( + + )} {onReset && ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx index 5c2521afc4..72dfe54807 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-range-form/ArrayRangeForm.tsx @@ -9,11 +9,14 @@ import { TextInput } from 'uiSrc/components/base/inputs' import { Checkbox } from 'uiSrc/components/base/forms/checkbox/Checkbox' import { parseArrayIndex } from 'uiSrc/utils/arrayIndex' import { DEFAULT_SCAN_LIMIT } from 'uiSrc/slices/browser/array' +import { + CommandPreview, + PreviewToggle, + useResponsivePreviewLabel, +} from 'uiSrc/pages/browser/modules/key-details/shared' -import { CommandPreview } from '../command-preview' -import { PreviewToggle } from '../preview-toggle' -import { useResponsivePreviewLabel } from '../hooks' import { quoteRedisArgument } from '../utils' +import { ARRAY_COMMAND_PREVIEW_TEST_ID } from '../constants' import { ARRAY_RANGE_FORM_TEST_ID as TEST_ID, ARRAY_RANGE_MAX_SPAN, @@ -153,7 +156,12 @@ export const ArrayRangeForm = ({ /> - {previewVisible && } + {previewVisible && ( + + )} {onReset && ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx index 580c2c5740..8ac9238507 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx @@ -21,11 +21,14 @@ import { ArrayCombinator, ArrayGrepCriteria, } from 'uiSrc/slices/interfaces/array' +import { + CommandPreview, + PreviewToggle, + useResponsivePreviewLabel, +} from 'uiSrc/pages/browser/modules/key-details/shared' -import { CommandPreview } from '../command-preview' -import { PreviewToggle } from '../preview-toggle' -import { useResponsivePreviewLabel } from '../hooks' import { + ARRAY_COMMAND_PREVIEW_TEST_ID, CONTEXT_COUNT_MAX, CONTEXT_COUNT_MIN, DEFAULT_LIMIT, @@ -450,7 +453,12 @@ export const ArraySearchForm = ({ /> - {previewVisible && } + {previewVisible && ( + + )} {onReset && ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.types.ts deleted file mode 100644 index 91a40a6b19..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface CommandPreviewProps { - command: string - loading?: boolean -} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/constants.ts index bdb63712c0..877df27d22 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/constants.ts @@ -85,3 +85,6 @@ export const DEFAULT_CONTEXT = { * of a raw validation error. */ export const ARRAY_BULK_DELETE_MAX = 1_000_000 + +/** Shared by all three array forms; the "range-form" prefix is historical. */ +export const ARRAY_COMMAND_PREVIEW_TEST_ID = 'array-range-form-command-preview' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/index.ts index 56a51e7fa1..d80c31007d 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/index.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/index.ts @@ -2,4 +2,3 @@ export { useArrayRangeQuery } from './useArrayRangeQuery' export { useArrayAggregateQuery } from './useArrayAggregateQuery' export { useArraySearchQuery } from './useArraySearchQuery' export { useArrayElementActions } from './useArrayElementActions' -export { useResponsivePreviewLabel } from './useResponsivePreviewLabel' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.styles.ts deleted file mode 100644 index 003f4cbfd7..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.styles.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { HTMLAttributes } from 'react' -import styled from 'styled-components' -import { Row } from 'uiSrc/components/base/layout/flex' - -export const PreviewBar = styled(Row)` - width: 100%; - padding: ${({ theme }) => - `${theme.core.space.space100} ${theme.core.space.space200}`}; - border: 1px solid ${({ theme }) => theme.semantic.color.border.neutral600}; - border-radius: 4px; - background: ${({ theme }) => theme.semantic.color.background.neutral100}; -` - -export const PreviewText = styled.code>` - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: 'Source Code Pro', Menlo, Consolas, monospace; - font-size: 12px; - color: ${({ theme }) => theme.semantic.color.text.neutral800}; -` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.tsx deleted file mode 100644 index 2a9108ec4c..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' - -import { CopyButton } from 'uiSrc/components/copy-button' - -import { COMMAND_PREVIEW_LOADING_PLACEHOLDER } from '../similarity-search-form/constants' -import { PreviewBar, PreviewText } from './CommandPreview.styles' -import { CommandPreviewProps } from './CommandPreview.types' - -const TEST_ID = 'similarity-search-command-preview' - -export const CommandPreview = ({ - command, - loading = false, -}: CommandPreviewProps) => { - const isEmpty = command.length === 0 - let displayText = command - if (loading) { - displayText = COMMAND_PREVIEW_LOADING_PLACEHOLDER - } else if (isEmpty) { - displayText = '' - } - - return ( - - - {displayText} - - - - - ) -} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/index.ts deleted file mode 100644 index c83e7e7038..0000000000 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CommandPreview } from './CommandPreview' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.spec.tsx index 31b7edbcd9..bcccf2fe52 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.spec.tsx @@ -129,7 +129,7 @@ describe('SimilaritySearchForm', () => { expect( screen.getByTestId('similarity-search-command-preview-text'), - ).toHaveTextContent('command is loading...') + ).toHaveTextContent('Building command…') }) it('shows the loading placeholder even when a previous command exists', () => { @@ -145,7 +145,7 @@ describe('SimilaritySearchForm', () => { expect( screen.getByTestId('similarity-search-command-preview-text'), - ).toHaveTextContent('command is loading...') + ).toHaveTextContent('Building command…') }) it('renders the hook-supplied preview verbatim once toggled on', () => { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.styles.ts index d4e16a9d2f..49622337ca 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.styles.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.styles.ts @@ -1,7 +1,6 @@ -import { HTMLAttributes } from 'react' +import React, { HTMLAttributes } from 'react' import styled from 'styled-components' -import { ToggleButton } from 'uiSrc/components/base/forms/buttons' -import { Col, Row } from 'uiSrc/components/base/layout/flex' +import { Col } from 'uiSrc/components/base/layout/flex' import { MIDDLE_SCREEN_RESOLUTION } from 'uiSrc/constants' /** @@ -60,11 +59,14 @@ export const FilterLabel = styled.span>` gap: ${({ theme }) => theme.core.space.space050}; ` -export const ActionRow = styled(Row)` +// A plain div (not `Row`) so it can hold the ResizeObserver ref that drives +// the responsive preview label — layout components don't forward refs. +export const ActionRow = styled.div<{ + children?: React.ReactNode + ref?: React.Ref +}>` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space100}; min-height: ${ACTION_ROW_HEIGHT}; ` - -export const PreviewToggleButton = styled(ToggleButton)` - ${({ theme, pressed }) => - !pressed && `border-color: ${theme.semantic.color.border.neutral600};`} -` diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.tsx index 04f17464dc..e0cea37cdc 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/SimilaritySearchForm.tsx @@ -5,30 +5,30 @@ import { RiTooltip } from 'uiSrc/components' import { ButtonGroup } from 'uiSrc/components/base/forms/button-group/ButtonGroup' import { IconButton, PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { FormField } from 'uiSrc/components/base/forms/FormField' -import { InfoIcon, ResetIcon, RiIcon } from 'uiSrc/components/base/icons' +import { InfoIcon, ResetIcon } from 'uiSrc/components/base/icons' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { TextInput, QuantityCounter } from 'uiSrc/components/base/inputs' -import { Text } from 'uiSrc/components/base/text' import { vectorSetAttributeKeysSelector } from 'uiSrc/slices/browser/vectorSet' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { + CommandPreview, + PreviewToggle, + useResponsivePreviewLabel, +} from 'uiSrc/pages/browser/modules/key-details/shared' import { VectorSetSimilarityInputMode } from '../../telemetry.constants' import { getVectorFieldInfo } from '../../vector-set-element-form/utils' import { useSimilaritySearch } from '../../hooks/useSimilaritySearch' -import { CommandPreview } from '../command-preview' import { FilterInputWithSuggestions } from '../filter-input-with-suggestions' import { FilterSyntaxHelpPopover } from '../filter-syntax-help-popover' import * as S from './SimilaritySearchForm.styles' import { + COMMAND_PREVIEW_TEST_ID, ELEMENT_MODE_TOOLTIP, ELEMENT_PLACEHOLDER, FILTER_PLACEHOLDER, - PREVIEW_TOGGLE_ARIA_LABEL, - PREVIEW_TOGGLE_HIDE_TOOLTIP, - PREVIEW_TOGGLE_LABEL, - PREVIEW_TOGGLE_SHOW_TOOLTIP, QUERY_NOT_READY_TOOLTIP, SIMILARITY_SEARCH_COUNT_DEFAULT, SIMILARITY_SEARCH_COUNT_MAX, @@ -62,6 +62,7 @@ export const SimilaritySearchForm = ({ useState(initialFormState) const [previewVisible, setPreviewVisible] = useState(false) + const { containerRef, isWide } = useResponsivePreviewLabel() const { id: databaseId } = useAppSelector(connectedInstanceSelector) const attributeKeys = useAppSelector(vectorSetAttributeKeysSelector) @@ -253,33 +254,24 @@ export const SimilaritySearchForm = ({ - + - - - - {PREVIEW_TOGGLE_LABEL} - - + {previewVisible && ( - + )} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/constants.ts index 4f6dade490..86a58a7bfe 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/similarity-search-form/constants.ts @@ -1,4 +1,5 @@ export const SIMILARITY_SEARCH_FORM_TEST_ID = 'similarity-search-form' +export const COMMAND_PREVIEW_TEST_ID = 'similarity-search-command-preview' export const SIMILARITY_SEARCH_COUNT_DEFAULT = 10 export const SIMILARITY_SEARCH_COUNT_MIN = 1 @@ -13,10 +14,3 @@ export const VECTOR_MODE_TOOLTIP = 'Search by raw vector values' export const ELEMENT_MODE_TOOLTIP = 'Search by an existing element.' export const QUERY_NOT_READY_TOOLTIP = 'Enter a vector or element to search' - -export const PREVIEW_TOGGLE_LABEL = 'Preview' -export const PREVIEW_TOGGLE_ARIA_LABEL = 'Toggle command preview' -export const PREVIEW_TOGGLE_HIDE_TOOLTIP = 'Hide command preview' -export const PREVIEW_TOGGLE_SHOW_TOOLTIP = 'Show command preview' - -export const COMMAND_PREVIEW_LOADING_PLACEHOLDER = 'command is loading...' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.styles.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.styles.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.styles.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.tsx similarity index 61% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.tsx index 09c1127726..810590391f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/CommandPreview.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.tsx @@ -5,19 +5,17 @@ import { CopyButton } from 'uiSrc/components/copy-button' import { PreviewBar, PreviewText } from './CommandPreview.styles' import { CommandPreviewProps } from './CommandPreview.types' -const TEST_ID = 'array-range-form-command-preview' +const DEFAULT_TEST_ID = 'command-preview' const LOADING_PLACEHOLDER = 'Building command…' /** * Inline single-line preview of the Redis command that the current form - * state will dispatch. Mirrors the pattern established by Vector Set's - * similarity-search form so the look-and-feel is consistent across - * verticals. Will be promoted to `key-details/shared/` once the Aggregate - * and Search verticals (Tasks 4 / 5) start needing it. + * state will dispatch. */ export const CommandPreview = ({ command, loading = false, + 'data-testid': dataTestId = DEFAULT_TEST_ID, }: CommandPreviewProps) => { const isEmpty = command.length === 0 let displayText = command @@ -28,14 +26,14 @@ export const CommandPreview = ({ } return ( - - + + {displayText} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.types.ts similarity index 75% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.types.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.types.ts index 91a40a6b19..85b0da8853 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/vector-set-details/similarity-search-form/command-preview/CommandPreview.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/CommandPreview.types.ts @@ -1,4 +1,5 @@ export interface CommandPreviewProps { command: string loading?: boolean + 'data-testid'?: string } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/index.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/command-preview/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/command-preview/index.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/shared/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/index.ts index dfab31b03e..0588b94207 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/shared/index.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/index.ts @@ -3,3 +3,7 @@ import EditableInput from './editable-input' import FormattedValue from './formatted-value' export { EditableTextArea, EditableInput, FormattedValue } +export { CommandPreview } from './command-preview' +export type { CommandPreviewProps } from './command-preview' +export { PreviewToggle, useResponsivePreviewLabel } from './preview-toggle' +export type { PreviewToggleProps } from './preview-toggle' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.constants.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.constants.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.constants.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.spec.tsx similarity index 75% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.spec.tsx index 0005c93ef5..705892b143 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.spec.tsx @@ -43,4 +43,20 @@ describe('PreviewToggle', () => { expect(onPressedChange).toHaveBeenCalledWith(true) }) + + it('blocks presses while disabled', () => { + const onPressedChange = jest.fn() + render( + , + ) + + expect(screen.getByTestId(TEST_ID)).toBeDisabled() + fireEvent.click(screen.getByTestId(TEST_ID)) + expect(onPressedChange).not.toHaveBeenCalled() + }) }) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.styles.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.styles.ts similarity index 100% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.styles.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.styles.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.tsx similarity index 70% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.tsx index fb0351b3d1..6293c9c79f 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.tsx @@ -15,25 +15,32 @@ import { PreviewToggleProps } from './PreviewToggle.types' import * as S from './PreviewToggle.styles' /** - * Toggle that shows/hides the inline command preview across the array forms. - * The label reads "Preview command" when there's room and collapses to - * "Preview" on narrow layouts — the caller decides via `wide`. + * Toggle that shows/hides the inline command preview. The label reads + * "Preview command" when there's room and collapses to "Preview" on narrow + * layouts — the caller decides via `wide`. */ export const PreviewToggle = ({ pressed, onPressedChange, wide = false, + disabled = false, + disabledTooltip, 'data-testid': dataTestId, }: PreviewToggleProps) => ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.types.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.types.ts similarity index 70% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.types.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.types.ts index 1d1391923a..a772c5790a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/PreviewToggle.types.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/PreviewToggle.types.ts @@ -4,5 +4,8 @@ export interface PreviewToggleProps { onPressedChange: (pressed: boolean) => void /** Wide layout → full "Preview command" label; narrow → "Preview". */ wide?: boolean + disabled?: boolean + /** Tooltip shown instead of the "show" one while `disabled`. */ + disabledTooltip?: string 'data-testid'?: string } diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/index.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/index.ts similarity index 60% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/index.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/index.ts index d05054396b..297036e8ed 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/preview-toggle/index.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/index.ts @@ -1,2 +1,3 @@ export { PreviewToggle } from './PreviewToggle' export type { PreviewToggleProps } from './PreviewToggle.types' +export { useResponsivePreviewLabel } from './useResponsivePreviewLabel' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/useResponsivePreviewLabel.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/useResponsivePreviewLabel.spec.tsx similarity index 94% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/useResponsivePreviewLabel.spec.tsx rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/useResponsivePreviewLabel.spec.tsx index f8a27377af..4d7eafa7c2 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/useResponsivePreviewLabel.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/useResponsivePreviewLabel.spec.tsx @@ -1,7 +1,7 @@ import React from 'react' import { act, render, screen } from 'uiSrc/utils/test-utils' -import { PREVIEW_LABEL_WIDE_MIN_WIDTH } from '../preview-toggle/PreviewToggle.constants' +import { PREVIEW_LABEL_WIDE_MIN_WIDTH } from './PreviewToggle.constants' import { useResponsivePreviewLabel } from './useResponsivePreviewLabel' let resizeCallback: (entries: Array<{ contentRect: { width: number } }>) => void diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/useResponsivePreviewLabel.ts b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/useResponsivePreviewLabel.ts similarity index 93% rename from redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/useResponsivePreviewLabel.ts rename to redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/useResponsivePreviewLabel.ts index cca24ccf5d..c334e67889 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/hooks/useResponsivePreviewLabel.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/shared/preview-toggle/useResponsivePreviewLabel.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { PREVIEW_LABEL_WIDE_MIN_WIDTH } from '../preview-toggle/PreviewToggle.constants' +import { PREVIEW_LABEL_WIDE_MIN_WIDTH } from './PreviewToggle.constants' interface UseResponsivePreviewLabel { /** Attach to the element whose width decides the preview label. */ diff --git a/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts b/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts index 0d2b3db3a6..2a47d48fab 100644 --- a/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/vector-set/similarity-search.spec.ts @@ -54,9 +54,17 @@ test.describe('Browser > Vector Set > Similarity search', () => { const queried = keyData.elements[0].name; await browserPage.vectorSetKeyDetails.runSimilaritySearchByElement(queried); - // Self-match: an element vs. itself has cosine similarity 1 (= 100%), - // so VSIM by element name must rank the queried element first. - await expect(browserPage.vectorSetKeyDetails.similarityResultCell(0)).toContainText('100'); + // Self-match: an element vs. itself must rank first with a ~100 % score. + // The default int8 quantization can land the displayed score slightly + // under 100 % (99.93–99.98 % observed), so assert closeness instead of + // an exact match. + const scoreCell = browserPage.vectorSetKeyDetails.similarityResultCell(0); + await expect(scoreCell).toBeVisible(); + await expect + .poll(async () => Math.abs(100 - parseFloat((await scoreCell.textContent()) ?? '')), { + timeout: 10000, + }) + .toBeLessThanOrEqual(0.1); await expect(browserPage.vectorSetKeyDetails.similarityResultElementValue(queried)).toBeVisible(); // Reset returns the form to Vector mode (see SimilaritySearchForm.utils From 2cee692eab4b34bde8ed2e33497b169ccae82fd5 Mon Sep 17 00:00:00 2001 From: dantovska Date: Thu, 9 Jul 2026 17:29:51 +0300 Subject: [PATCH 015/166] RI-8172 Replace RQE with Redis Search (#6177) * feat(ui): rename Redis Query Engine to Redis Search in user-facing text * feat(ui): rename RQE to Redis Search on the vector search page * feat(ui): rename RQE to Redis Search in workbench plugins * test(e2e): rename RQE references to Redis Search * fix(e2e): align RedisSearchNotAvailable locators with derived test ids * refactor: limit RQE rename to user-visible text only --- .../messages/feature-not-available/constants.ts | 6 +++--- .../ModuleNotLoadedMinimalized.spec.tsx | 2 +- .../module-not-loaded-minimalized/constants.ts | 6 +++--- .../module-not-loaded/ModuleNotLoaded.spec.tsx | 8 ++------ .../OnboardingFeatures.spec.tsx | 2 +- .../onboarding-features/OnboardingFeatures.tsx | 4 ++-- .../components/expert-chat/ExpertChat.tsx | 6 +++--- redisinsight/ui/src/constants/workbenchResults.ts | 8 ++++---- redisinsight/ui/src/packages/geodata/package.json | 8 ++++---- .../ui/src/packages/geodata/src/App.spec.tsx | 10 +++++----- .../RqeGeoVisualization.spec.tsx | 14 +++++++------- .../RqeGeoVisualization/RqeGeoVisualization.tsx | 12 ++++++------ .../geodata/src/utils/rqeGeoParser.spec.ts | 12 ++++++------ .../src/packages/geodata/src/utils/rqeGeoParser.ts | 8 ++++---- redisinsight/ui/src/packages/redisearch/index.html | 2 +- .../src/pages/home/components/db-status/texts.tsx | 4 ++-- .../components/search-page-fallback/constants.ts | 10 +++++----- redisinsight/ui/src/slices/interfaces/instances.ts | 8 ++++---- redisinsight/ui/src/utils/capability.ts | 2 +- redisinsight/ui/src/utils/tests/capability.spec.ts | 2 +- redisinsight/ui/src/utils/tests/modules.spec.ts | 8 ++++---- .../vector-search/components/RqeNotAvailable.ts | 8 ++++---- .../browser/key-list/key-list-view.spec.ts | 2 +- 23 files changed, 74 insertions(+), 78 deletions(-) diff --git a/redisinsight/ui/src/components/messages/feature-not-available/constants.ts b/redisinsight/ui/src/components/messages/feature-not-available/constants.ts index aa95b23449..0918b00002 100644 --- a/redisinsight/ui/src/components/messages/feature-not-available/constants.ts +++ b/redisinsight/ui/src/components/messages/feature-not-available/constants.ts @@ -14,10 +14,10 @@ export const FILTER_NOT_AVAILABLE_CONTENT: FeatureNotAvailableContent = { export const REDISEARCH_VERSION_REQUIRED_CONTENT: FeatureNotAvailableContent = { testId: 'redisearch-version-required', - title: 'Redis Query Engine 2.0+ required', + title: 'Redis Search 2.0+ required', description: - 'This feature requires Redis Query Engine 2.0 or later (included with Redis 6+). ' + - 'Older versions of the query engine are not compatible with the commands used here.', + 'This feature requires Redis Search 2.0 or later (included with Redis 6+). ' + + 'Older versions of Redis Search are not compatible with the commands used here.', freeInstanceText: 'Use your free all-in-one Redis Cloud database to start exploring these capabilities.', noInstanceText: diff --git a/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/ModuleNotLoadedMinimalized.spec.tsx b/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/ModuleNotLoadedMinimalized.spec.tsx index 8ba0a2c525..e567e7476b 100644 --- a/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/ModuleNotLoadedMinimalized.spec.tsx +++ b/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/ModuleNotLoadedMinimalized.spec.tsx @@ -70,7 +70,7 @@ describe('ModuleNotLoadedMinimalized', () => { expect(screen.queryByTestId('connect-free-db-btn')).not.toBeInTheDocument() expect(screen.getByText(/Redis Databases page/)).toBeInTheDocument() expect( - screen.getByText(/Open a database with Redis Query Engine/), + screen.getByText(/Open a database with Redis Search/), ).toBeInTheDocument() }) diff --git a/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/constants.ts b/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/constants.ts index 3ae94d92b2..e595894abb 100644 --- a/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/constants.ts +++ b/redisinsight/ui/src/components/messages/module-not-loaded-minimalized/constants.ts @@ -15,7 +15,7 @@ export const MODULE_CAPABILITY_TEXT_NOT_AVAILABLE: { text: 'Create a free Redis Cloud database with JSON capability that extends the core capabilities of your Redis.', }, [RedisDefaultModules.Search]: { - title: 'Redis Query Engine capability is not available', + title: 'Redis Search capability is not available', text: 'Create a free Redis Cloud database with search and query features that extend the core capabilities of your Redis.', }, [RedisDefaultModules.TimeSeries]: { @@ -39,8 +39,8 @@ export const MODULE_CAPABILITY_TEXT_NOT_AVAILABLE_ENTERPRISE: { text: 'Open a database with JSON.', }, [RedisDefaultModules.Search]: { - title: 'Redis Query Engine capability is not available', - text: 'Open a database with Redis Query Engine.', + title: 'Redis Search capability is not available', + text: 'Open a database with Redis Search.', }, [RedisDefaultModules.TimeSeries]: { title: 'Time series data structure is not available', diff --git a/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.spec.tsx b/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.spec.tsx index ae9610df2e..aaf85424a2 100644 --- a/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.spec.tsx +++ b/redisinsight/ui/src/components/messages/module-not-loaded/ModuleNotLoaded.spec.tsx @@ -80,9 +80,7 @@ describe('ModuleNotLoaded', () => { }) mockGetDbWithModuleLoaded(true) // should not affect output const { queryByText } = render() - expect( - queryByText(/Open a database with Redis Query Engine/), - ).toBeInTheDocument() + expect(queryByText(/Open a database with Redis Search/)).toBeInTheDocument() }) it('should not show CTA button when envDependant feature is disabled', () => { @@ -133,9 +131,7 @@ describe('ModuleNotLoaded', () => { }, }) const { getByText } = render() - expect( - getByText(/Open a database with Redis Query Engine/), - ).toBeInTheDocument() + expect(getByText(/Open a database with Redis Search/)).toBeInTheDocument() }) it('should show expected text when free db exists', () => { diff --git a/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.spec.tsx b/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.spec.tsx index 551a5f2028..ba264b891c 100644 --- a/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.spec.tsx +++ b/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.spec.tsx @@ -673,7 +673,7 @@ describe('ONBOARDING_FEATURES', () => { , ) expect(screen.getByTestId('step-content')).toHaveTextContent( - 'This is Search, where you can index your data and query it using Redis Query Engine.', + 'This is Search, where you can index your data and query it using Redis Search.', ) expect(screen.getByTestId('step-content')).toHaveTextContent( 'Load sample data to create your first index and run sample queries to see results instantly.', diff --git a/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.tsx b/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.tsx index b4a1ae5fb0..e09e781f40 100644 --- a/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.tsx +++ b/redisinsight/ui/src/components/onboarding-features/OnboardingFeatures.tsx @@ -332,8 +332,8 @@ const ONBOARDING_FEATURES = { content: ( <> This is Search, where you can index your data and query it using - Redis Query Engine. Run full-text search, vector similarity, and - filtered queries right from the UI. + Redis Search. Run full-text search, vector similarity, and filtered + queries right from the UI. Load sample data to create your first index and run sample queries to see results instantly. diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx index 5dbe9ced91..64a55b9e4b 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx @@ -198,16 +198,16 @@ const ExpertChat = () => { return { title: 'Open a database', content: - 'Open your Redis database with Redis Query Engine, or create a new database to get started.', + 'Open your Redis database with Redis Search, or create a new database to get started.', } } if (!isRedisearchAvailable(modules)) { return { - title: 'Redis Query Engine capability is not available', + title: 'Redis Search capability is not available', content: freeInstances?.length ? 'Use your free all-in-one Redis Cloud database to start exploring these capabilities.' - : 'Create a free Redis Cloud database with Redis Query Engine capability that extends the core capabilities of open-source Redis.', + : 'Create a free Redis Cloud database with Redis Search capability that extends the core capabilities of open-source Redis.', icon: , } } diff --git a/redisinsight/ui/src/constants/workbenchResults.ts b/redisinsight/ui/src/constants/workbenchResults.ts index 8c022f51cd..403cbe7148 100644 --- a/redisinsight/ui/src/constants/workbenchResults.ts +++ b/redisinsight/ui/src/constants/workbenchResults.ts @@ -25,8 +25,8 @@ export const MODULE_NOT_LOADED_CONTENT: { [key in RedisDefaultModules]?: any } = link: 'https://redis.io/docs/latest/develop/data-types/timeseries/', }, [RedisDefaultModules.Search]: { - title: ['Redis Query Engine is not available for this database'], - text: ['Redis Query Engine allows to:'], + title: ['Redis Search is not available for this database'], + text: ['Redis Search allows to:'], improvements: ['Query', 'Secondary index', 'Full-text search'], additionalText: [ 'These features enable multi-field queries, aggregation, exact phrase matching, numeric filtering, ', @@ -45,7 +45,7 @@ export const MODULE_NOT_LOADED_CONTENT: { [key in RedisDefaultModules]?: any } = 'Retrieve JSON documents', ], additionalText: [ - 'JSON data structure also works seamlessly with Redis Query Engine to let you index and query JSON documents.', + 'JSON data structure also works seamlessly with Redis Search to let you index and query JSON documents.', ], link: 'https://redis.io/docs/latest/develop/data-types/json/', }, @@ -68,6 +68,6 @@ export const MODULE_NOT_LOADED_CONTENT: { [key in RedisDefaultModules]?: any } = export const MODULE_TEXT_VIEW: { [key in RedisDefaultModules]?: string } = { [RedisDefaultModules.Bloom]: 'probabilistic data structures', [RedisDefaultModules.ReJSON]: 'JSON data structure', - [RedisDefaultModules.Search]: 'Redis Query Engine', + [RedisDefaultModules.Search]: 'Redis Search', [RedisDefaultModules.TimeSeries]: 'time series data structure', } diff --git a/redisinsight/ui/src/packages/geodata/package.json b/redisinsight/ui/src/packages/geodata/package.json index 7f3153bf39..0c8331eaff 100644 --- a/redisinsight/ui/src/packages/geodata/package.json +++ b/redisinsight/ui/src/packages/geodata/package.json @@ -136,7 +136,7 @@ }, "iconDark": "./dist/geodata_icon_dark.svg", "iconLight": "./dist/geodata_icon_light.svg", - "description": "Show Redis Query Engine GEO results as plotted locations", + "description": "Show Redis Search GEO results as plotted locations", "default": false }, { @@ -156,7 +156,7 @@ }, "iconDark": "./dist/geodata_heatmap_icon_dark.svg", "iconLight": "./dist/geodata_heatmap_icon_light.svg", - "description": "Show Redis Query Engine GEO result density", + "description": "Show Redis Search GEO result density", "default": false }, { @@ -177,7 +177,7 @@ }, "iconDark": "./dist/geodata_inspector_icon_dark.svg", "iconLight": "./dist/geodata_inspector_icon_light.svg", - "description": "Inspect Redis Query Engine geospatial command inputs and results", + "description": "Inspect Redis Search geospatial command inputs and results", "default": false }, { @@ -196,7 +196,7 @@ }, "iconDark": "./dist/geodata_inspector_icon_dark.svg", "iconLight": "./dist/geodata_inspector_icon_light.svg", - "description": "Show Redis Query Engine GEOSHAPE WKT results", + "description": "Show Redis Search GEOSHAPE WKT results", "default": false } ], diff --git a/redisinsight/ui/src/packages/geodata/src/App.spec.tsx b/redisinsight/ui/src/packages/geodata/src/App.spec.tsx index d9dde13108..613366e078 100644 --- a/redisinsight/ui/src/packages/geodata/src/App.spec.tsx +++ b/redisinsight/ui/src/packages/geodata/src/App.spec.tsx @@ -194,9 +194,9 @@ describe('Geodata App', () => { GeodataMode.RqeInspector, ) - expect(screen.getByText('Cannot inspect RQE geo command')).toBeInTheDocument() + expect(screen.getByText('Cannot inspect Redis Search geo command')).toBeInTheDocument() expect( - screen.getByText('No Redis Query Engine geospatial predicate found.'), + screen.getByText('No Redis Search geospatial predicate found.'), ).toBeInTheDocument() }) @@ -207,7 +207,7 @@ describe('Geodata App', () => { GeodataMode.RqeMarkers, ) - expect(screen.getByText('Cannot render RQE geo map')).toBeInTheDocument() + expect(screen.getByText('Cannot render Redis Search geo map')).toBeInTheDocument() expect( screen.getByText( 'No returned geospatial fields found. Add RETURN 1 coords to the FT.SEARCH command.', @@ -242,7 +242,7 @@ describe('Geodata App', () => { const plot = screen.getByRole('img', { name: 'Leaflet geospatial shape plot', }) - const summary = screen.getByLabelText('RQE command summary') + const summary = screen.getByLabelText('Redis Search command summary') expect( Boolean( plot.compareDocumentPosition(summary) & @@ -258,7 +258,7 @@ describe('Geodata App', () => { GeodataMode.RqeShape, ) - expect(screen.getByText('Cannot render RQE geo shape')).toBeInTheDocument() + expect(screen.getByText('Cannot render Redis Search geo shape')).toBeInTheDocument() expect( screen.getByText( 'No returned geospatial fields found. Add RETURN 1 geom to the FT.SEARCH command.', diff --git a/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.spec.tsx b/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.spec.tsx index 32d6dba0ef..327e92ec7d 100644 --- a/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.spec.tsx +++ b/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.spec.tsx @@ -124,17 +124,17 @@ describe('RqeGeoVisualization', () => { ) expect( - screen.getByText('Cannot inspect RQE geo results'), + screen.getByText('Cannot inspect Redis Search geo results'), ).toBeInTheDocument() expect( - screen.queryByText('Cannot render RQE geo map'), + screen.queryByText('Cannot render Redis Search geo map'), ).not.toBeInTheDocument() }) it('shows a heatmap-specific error title when heatmap command parsing fails', () => { jest.spyOn(rqeGeoParser, 'parseRqeGeoCommand').mockReturnValue({ ok: false, - error: 'No Redis Query Engine geospatial predicate found.', + error: 'No Redis Search geospatial predicate found.', }) render( @@ -146,9 +146,9 @@ describe('RqeGeoVisualization', () => { />, ) - expect(screen.getByText('Cannot render RQE geo heatmap')).toBeInTheDocument() + expect(screen.getByText('Cannot render Redis Search geo heatmap')).toBeInTheDocument() expect( - screen.queryByText('Cannot inspect RQE geo command'), + screen.queryByText('Cannot inspect Redis Search geo command'), ).not.toBeInTheDocument() }) @@ -170,9 +170,9 @@ describe('RqeGeoVisualization', () => { />, ) - expect(screen.getByText('Cannot render RQE geo heatmap')).toBeInTheDocument() + expect(screen.getByText('Cannot render Redis Search geo heatmap')).toBeInTheDocument() expect( - screen.queryByText('Cannot render RQE geo map'), + screen.queryByText('Cannot render Redis Search geo map'), ).not.toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.tsx b/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.tsx index d71d82b31f..561ad1b656 100644 --- a/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.tsx +++ b/redisinsight/ui/src/packages/geodata/src/components/RqeGeoVisualization/RqeGeoVisualization.tsx @@ -42,22 +42,22 @@ const getResultsErrorTitle = ( mode: RqeGeoVisualizationProps['mode'], ): string => { if (mode === 'shape') { - return 'Cannot render RQE geo shape' + return 'Cannot render Redis Search geo shape' } if (mode === 'heatmap') { - return 'Cannot render RQE geo heatmap' + return 'Cannot render Redis Search geo heatmap' } if (mode === 'inspector') { - return 'Cannot inspect RQE geo results' + return 'Cannot inspect Redis Search geo results' } - return 'Cannot render RQE geo map' + return 'Cannot render Redis Search geo map' } const getCommandErrorTitle = ( mode: RqeGeoVisualizationProps['mode'], ): string => { if (mode === 'inspector') { - return 'Cannot inspect RQE geo command' + return 'Cannot inspect Redis Search geo command' } return getResultsErrorTitle(mode) } @@ -107,7 +107,7 @@ const renderSummary = (command: ParsedRqeGeoCommand, rowCount: number) => { return ( { ), ).toEqual({ ok: false, - error: 'No Redis Query Engine geospatial predicate found.', + error: 'No Redis Search geospatial predicate found.', }) }) @@ -177,7 +177,7 @@ describe('rqeGeoParser', () => { ), ).toEqual({ ok: false, - error: 'No Redis Query Engine geospatial predicate found.', + error: 'No Redis Search geospatial predicate found.', }) expect( @@ -186,7 +186,7 @@ describe('rqeGeoParser', () => { ), ).toEqual({ ok: false, - error: 'No Redis Query Engine geospatial predicate found.', + error: 'No Redis Search geospatial predicate found.', }) }) @@ -282,11 +282,11 @@ describe('rqeGeoParser', () => { it('rejects malformed RQE geo predicates', () => { expect(parseRqeGeoCommand('')).toEqual({ ok: false, - error: 'Missing Redis Query Engine command.', + error: 'Missing Redis Search command.', }) expect(parseRqeGeoCommand('FT.INFO idx')).toEqual({ ok: false, - error: 'Unsupported Redis Query Engine command: FT.INFO.', + error: 'Unsupported Redis Search command: FT.INFO.', }) expect(parseRqeGeoCommand('FT.SEARCH')).toEqual({ ok: false, @@ -791,7 +791,7 @@ describe('rqeGeoParser', () => { it('rejects unsupported RQE geo commands and malformed shapes', () => { expect(parseRqeGeoCommand('FT.SEARCH idx "*"')).toEqual({ ok: false, - error: 'No Redis Query Engine geospatial predicate found.', + error: 'No Redis Search geospatial predicate found.', }) expect( parseRqeGeoCommand( diff --git a/redisinsight/ui/src/packages/geodata/src/utils/rqeGeoParser.ts b/redisinsight/ui/src/packages/geodata/src/utils/rqeGeoParser.ts index 4822c76e20..20060b2293 100644 --- a/redisinsight/ui/src/packages/geodata/src/utils/rqeGeoParser.ts +++ b/redisinsight/ui/src/packages/geodata/src/utils/rqeGeoParser.ts @@ -307,10 +307,10 @@ export const parseRqeGeoCommand = ( const tokens = tokenizeRedisCommand(command) const commandToken = tokens[0]?.toUpperCase() as RqeGeoCommand | undefined if (!commandToken) { - return { ok: false, error: 'Missing Redis Query Engine command.' } + return { ok: false, error: 'Missing Redis Search command.' } } if (!RQE_GEO_COMMANDS.has(commandToken)) { - return { ok: false, error: `Unsupported Redis Query Engine command: ${tokens[0]}.` } + return { ok: false, error: `Unsupported Redis Search command: ${tokens[0]}.` } } if (!tokens[1]) { return { ok: false, error: `${commandToken} requires an index.` } @@ -333,7 +333,7 @@ export const parseRqeGeoCommand = ( queryOverlay if (!parsedOverlay) { - return { ok: false, error: 'No Redis Query Engine geospatial predicate found.' } + return { ok: false, error: 'No Redis Search geospatial predicate found.' } } if (!parsedOverlay.ok) { return parsedOverlay @@ -534,7 +534,7 @@ const parseRqeRows = ( return { ok: false, - error: `Unsupported Redis Query Engine command: ${command.command}.`, + error: `Unsupported Redis Search command: ${command.command}.`, } } diff --git a/redisinsight/ui/src/packages/redisearch/index.html b/redisinsight/ui/src/packages/redisearch/index.html index 1d9e3ae24c..a2d4740ade 100644 --- a/redisinsight/ui/src/packages/redisearch/index.html +++ b/redisinsight/ui/src/packages/redisearch/index.html @@ -4,7 +4,7 @@ - Redis Query Engine plugin + Redis Search plugin diff --git a/redisinsight/ui/src/pages/home/components/db-status/texts.tsx b/redisinsight/ui/src/pages/home/components/db-status/texts.tsx index 595535edc1..5f1977bd3e 100644 --- a/redisinsight/ui/src/pages/home/components/db-status/texts.tsx +++ b/redisinsight/ui/src/pages/home/components/db-status/texts.tsx @@ -11,7 +11,7 @@ export const CHECK_CLOUD_DATABASE = ( But not to worry, you can always re-create it to test your ideas.
- Includes native support for JSON, Query Engine and more. + Includes native support for JSON, Redis Search and more. ) @@ -38,7 +38,7 @@ export const WARNING_WITHOUT_CAPABILITY = (
Test ideas and build prototypes.
- Includes native support for JSON, Query Engine and more. + Includes native support for JSON, Redis Search and more.
diff --git a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts index 5ba8903876..3838788138 100644 --- a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts +++ b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts @@ -3,8 +3,8 @@ import { SearchPageFallbackContent } from './SearchPageFallback.types' export const RQE_NOT_AVAILABLE_CONTENT: SearchPageFallbackContent = { testId: 'rqe-not-available', - title: 'Redis Query Engine is not available for this database', - subtitle: 'Redis Query Engine allows to:', + title: 'Redis Search is not available for this database', + subtitle: 'Redis Search allows to:', features: ['Query', 'Secondary index', 'Full-text search'], description: 'These features enable multi-field queries, aggregation, exact phrase matching, numeric filtering, ' + @@ -16,10 +16,10 @@ export const RQE_NOT_AVAILABLE_CONTENT: SearchPageFallbackContent = { export const VERSION_NOT_SUPPORTED_CONTENT: SearchPageFallbackContent = { testId: 'version-not-supported', - title: 'Redis Query Engine 2.0+ required', + title: 'Redis Search 2.0+ required', description: - 'This page requires Redis Query Engine 2.0 or later (included with Redis 6+). ' + - 'Older versions of the query engine are not compatible with the commands used here.', + 'This page requires Redis Search 2.0 or later (included with Redis 6+). ' + + 'Older versions of Redis Search are not compatible with the commands used here.', ctaText: 'Create a free Redis Cloud database to start exploring these capabilities.', oauthSource: OAuthSocialSource.BrowserFiltering, diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 2ab9125160..7ee5de627a 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -214,10 +214,10 @@ export const DATABASE_LIST_MODULES_TEXT = Object.freeze({ [RedisDefaultModules.TimeSeries]: 'Time Series', [RedisCustomModulesName.Proto]: 'redis-protobuf', [RedisCustomModulesName.IpTables]: 'RedisPushIpTables', - [RedisDefaultModules.Search]: 'Redis Query Engine', - [RedisDefaultModules.SearchLight]: 'Redis Query Engine', - [RedisDefaultModules.FT]: 'Redis Query Engine', - [RedisDefaultModules.FTL]: 'Redis Query Engine', + [RedisDefaultModules.Search]: 'Redis Search', + [RedisDefaultModules.SearchLight]: 'Redis Search', + [RedisDefaultModules.FT]: 'Redis Search', + [RedisDefaultModules.FTL]: 'Redis Search', [RedisDefaultModules.VectorSet]: 'Vector Set', }) diff --git a/redisinsight/ui/src/utils/capability.ts b/redisinsight/ui/src/utils/capability.ts index 4caac52af1..86f342f4da 100644 --- a/redisinsight/ui/src/utils/capability.ts +++ b/redisinsight/ui/src/utils/capability.ts @@ -27,7 +27,7 @@ export const getTutorialCapability = (source: any = '') => { case getSourceTutorialByCapability(RedisDefaultModules.FTL): return getCapability( 'searchAndQuery', - 'Redis Query Engine', + 'Redis Search', findMarkdownPath(store.getState()?.workbench?.tutorials?.items, { id: 'sq-intro', }), diff --git a/redisinsight/ui/src/utils/tests/capability.spec.ts b/redisinsight/ui/src/utils/tests/capability.spec.ts index 4f226adef0..b430958655 100644 --- a/redisinsight/ui/src/utils/tests/capability.spec.ts +++ b/redisinsight/ui/src/utils/tests/capability.spec.ts @@ -22,7 +22,7 @@ describe('getSourceTutorialByCapability', () => { const emptyCapability = { name: '', telemetryName: '', path: null } const searchCapability = { - name: 'Redis Query Engine', + name: 'Redis Search', telemetryName: 'searchAndQuery', path: null, } diff --git a/redisinsight/ui/src/utils/tests/modules.spec.ts b/redisinsight/ui/src/utils/tests/modules.spec.ts index bdde0d0d62..9cac1a9ffc 100644 --- a/redisinsight/ui/src/utils/tests/modules.spec.ts +++ b/redisinsight/ui/src/utils/tests/modules.spec.ts @@ -11,7 +11,7 @@ import { const modules1: IDatabaseModule[] = [ { moduleName: 'JSON', abbreviation: 'RS' }, { moduleName: 'My1Module', abbreviation: 'MD' }, - { moduleName: 'Redis Query Engine', abbreviation: 'RS' }, + { moduleName: 'Redis Search', abbreviation: 'RS' }, ] const modules2: IDatabaseModule[] = [ { moduleName: '', abbreviation: '' }, @@ -23,17 +23,17 @@ const modules2: IDatabaseModule[] = [ { moduleName: 'My1Module', abbreviation: 'MD' }, { moduleName: 'JSON', abbreviation: 'RS' }, { moduleName: 'My2Modul2e', abbreviation: 'MX' }, - { moduleName: 'Redis Query Engine', abbreviation: 'RS' }, + { moduleName: 'Redis Search', abbreviation: 'RS' }, ] const result1: IDatabaseModule[] = [ - { moduleName: 'Redis Query Engine', abbreviation: 'RS' }, + { moduleName: 'Redis Search', abbreviation: 'RS' }, { moduleName: 'JSON', abbreviation: 'RS' }, { moduleName: 'My1Module', abbreviation: 'MD' }, ] const result2: IDatabaseModule[] = [ - { moduleName: 'Redis Query Engine', abbreviation: 'RS' }, + { moduleName: 'Redis Search', abbreviation: 'RS' }, { moduleName: 'JSON', abbreviation: 'RS' }, { moduleName: 'Probabilistic', abbreviation: 'RS' }, { moduleName: 'MycvModule', abbreviation: 'MC' }, diff --git a/tests/e2e-playwright/pages/vector-search/components/RqeNotAvailable.ts b/tests/e2e-playwright/pages/vector-search/components/RqeNotAvailable.ts index 17d4005386..f758784ac1 100644 --- a/tests/e2e-playwright/pages/vector-search/components/RqeNotAvailable.ts +++ b/tests/e2e-playwright/pages/vector-search/components/RqeNotAvailable.ts @@ -19,11 +19,11 @@ export class RqeNotAvailable { this.page = page; this.container = page.getByTestId('rqe-not-available'); - this.title = page.getByText('Redis Query Engine is not available for this database'); - this.description = page.getByTestId('rqe-description'); - this.featureList = page.getByTestId('rqe-feature-list'); + this.title = page.getByText('Redis Search is not available for this database'); + this.description = page.getByTestId('rqe-not-available-description'); + this.featureList = page.getByTestId('rqe-not-available-feature-list'); this.getStartedButton = page.getByRole('button', { name: 'Get started for free' }); this.learnMoreLink = page.getByRole('link', { name: 'Learn more' }); - this.illustration = page.getByTestId('rqe-illustration'); + this.illustration = page.getByTestId('rqe-not-available-illustration'); } } diff --git a/tests/e2e-playwright/tests/parallel/browser/key-list/key-list-view.spec.ts b/tests/e2e-playwright/tests/parallel/browser/key-list/key-list-view.spec.ts index 05fb0e1ce2..e29663b785 100644 --- a/tests/e2e-playwright/tests/parallel/browser/key-list/key-list-view.spec.ts +++ b/tests/e2e-playwright/tests/parallel/browser/key-list/key-list-view.spec.ts @@ -201,7 +201,7 @@ test.describe('Browser > Key List View', () => { // Verify switching between Pattern search and Search by values UI await browserPage.keyList.searchByValuesButton.click(); const indexOrHint = browserPage.keyList.indexSelector.or( - browserPage.page.getByText(/Redis Query Engine|Select an index|Query Engine/i), + browserPage.page.getByText(/Redis Search|Select an index/i), ); await expect(indexOrHint.first()).toBeVisible(); await browserPage.keyList.filterByNameButton.click(); From 4d8ec8e5d0922218984015c9f9db26b9cf8061a8 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 10 Jul 2026 10:17:51 +0300 Subject: [PATCH 016/166] RI-8311: Move Search-flow Context control above the results table (#6183) --- .../ArraySearchForm.constants.ts | 4 - .../ArraySearchForm.spec.tsx | 71 +--------------- .../array-search-form/ArraySearchForm.tsx | 59 +------------ .../ArraySearchForm.types.ts | 12 --- .../InfoHint}/InfoHint.tsx | 0 .../InfoHint}/InfoHint.types.ts | 0 .../components/InfoHint/index.ts | 2 + .../ContextControl.constants.ts | 6 ++ .../ContextControl/ContextControl.spec.tsx | 82 +++++++++++++++++++ .../ContextControl/ContextControl.styles.ts | 16 ++++ .../ContextControl/ContextControl.tsx | 68 +++++++++++++++ .../ContextControl/ContextControl.types.ts | 20 +++++ .../search-tab/ContextControl/index.ts | 2 + .../search-tab/SearchTab.spec.tsx | 31 ++++--- .../array-details/search-tab/SearchTab.tsx | 59 ++++++++----- .../KeyDetailsSubheader.spec.tsx | 12 +++ .../KeyDetailsSubheader.tsx | 37 +++++++-- 17 files changed, 299 insertions(+), 182 deletions(-) rename redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/{array-search-form/components => components/InfoHint}/InfoHint.tsx (100%) rename redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/{array-search-form/components => components/InfoHint}/InfoHint.types.ts (100%) create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/components/InfoHint/index.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.constants.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.styles.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.tsx create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/ContextControl.types.ts create mode 100644 redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/search-tab/ContextControl/index.ts diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts index b2329eb780..5b2effb541 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.constants.ts @@ -26,8 +26,6 @@ export const END_PLACEHOLDER = '+' export const NOCASE_LABEL = 'NOCASE' export const WITHVALUES_LABEL = 'WITHVALUES' export const LIMIT_LABEL = 'LIMIT' -export const CONTEXT_LABEL = 'Context' -export const CONTEXT_PREFIX = '±' /** Per-option (i) hints rendered next to each control. */ export const RANGE_HINT = @@ -35,8 +33,6 @@ export const RANGE_HINT = export const NOCASE_HINT = 'Match case-insensitively.' export const WITHVALUES_HINT = "Return each match's value, not just its index." export const LIMIT_HINT = 'Cap the number of matches returned.' -export const CONTEXT_HINT = - 'When expanding a match, also show ±N neighbouring elements.' export const INVALID_INDEX_MESSAGE = 'Index must be a valid 64-bit unsigned integer' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx index d4cfea07b9..ac6c6460f2 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.spec.tsx @@ -1,11 +1,5 @@ import React from 'react' -import { - fireEvent, - render, - screen, - userEvent, - waitFor, -} from 'uiSrc/utils/test-utils' +import { fireEvent, render, screen, userEvent } from 'uiSrc/utils/test-utils' import { ArrayCombinator, ArrayGrepCriteria, @@ -30,8 +24,6 @@ const defaultProps: ArraySearchFormProps = { onChangePredicate: jest.fn(), onChangeCombinator: jest.fn(), onChangeOptions: jest.fn(), - context: { enabled: false, count: 5 }, - onChangeContext: jest.fn(), onRun: jest.fn(), onReset: jest.fn(), } @@ -192,67 +184,6 @@ describe('ArraySearchForm', () => { }) }) - describe('context', () => { - // Context is always visible, so no need to expand Options first. - it('keeps the context input disabled until the toggle is ticked', () => { - const { rerender } = renderComponent() - // Off by default → input present (so layout is stable) but disabled. - expect(screen.getByTestId(`${TEST_ID}-context`)).toBeDisabled() - - rerender( - , - ) - expect(screen.getByTestId(`${TEST_ID}-context`)).toBeEnabled() - }) - - it('enables context when the toggle is ticked', () => { - const onChangeContext = jest.fn() - renderComponent({ onChangeContext }) - - fireEvent.click(screen.getByTestId(`${TEST_ID}-context-toggle`)) - - expect(onChangeContext).toHaveBeenCalledWith({ enabled: true }) - }) - - it('shows the passed count and clamps a typed value above the max to 50', async () => { - const user = userEvent.setup() - renderComponent({ context: { enabled: true, count: 5 } }) - - const input = screen.getByTestId(`${TEST_ID}-context`) - // redis-ui NumericInput renders a text input, so the DOM value is a - // string. - expect(input).toHaveValue('5') - - // redis-ui's `autoValidate` clamps onChange, but the field text only - // settles to the clamped value on blur — so '99' stays verbatim while - // typing and resolves to '50' once the input blurs. - await user.clear(input) - await user.type(input, '99') - await user.tab() - - await waitFor(() => { - expect(input).toHaveValue('50') - }) - }) - - it('reports a new count via onChangeContext', () => { - const onChangeContext = jest.fn() - renderComponent({ - context: { enabled: true, count: 5 }, - onChangeContext, - }) - - fireEvent.change(screen.getByTestId(`${TEST_ID}-context`), { - target: { value: '8' }, - }) - - expect(onChangeContext).toHaveBeenCalledWith({ count: 8 }) - }) - }) - describe('run', () => { it('calls onRun on click and on Enter in a value input', () => { const onRun = jest.fn() diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx index 8ac9238507..f5787b4a80 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/array-details/array-search-form/ArraySearchForm.tsx @@ -15,7 +15,7 @@ import { RiIcon, } from 'uiSrc/components/base/icons' import { Col, FlexItem, Row } from 'uiSrc/components/base/layout/flex' -import { NumericInput, TextInput } from 'uiSrc/components/base/inputs' +import { TextInput } from 'uiSrc/components/base/inputs' import { Text } from 'uiSrc/components/base/text' import { ArrayCombinator, @@ -27,12 +27,7 @@ import { useResponsivePreviewLabel, } from 'uiSrc/pages/browser/modules/key-details/shared' -import { - ARRAY_COMMAND_PREVIEW_TEST_ID, - CONTEXT_COUNT_MAX, - CONTEXT_COUNT_MIN, - DEFAULT_LIMIT, -} from '../constants' +import { ARRAY_COMMAND_PREVIEW_TEST_ID, DEFAULT_LIMIT } from '../constants' import { quoteRedisArgument } from '../utils' import { ADD_PREDICATE_ARIA, @@ -40,9 +35,6 @@ import { ARRAY_GREP_CRITERIA_OPTIONS, ARRAY_SEARCH_FORM_TEST_ID as TEST_ID, COMBINATOR_ARIA, - CONTEXT_HINT, - CONTEXT_LABEL, - CONTEXT_PREFIX, REMOVE_PREDICATE_ARIA, END_PLACEHOLDER, INVALID_INDEX_MESSAGE, @@ -68,7 +60,7 @@ import { } from './ArraySearchForm.constants' import { ArraySearchFormProps } from './ArraySearchForm.types' import { isBoundInvalid, isLimitInvalid } from './ArraySearchForm.utils' -import { InfoHint } from './components/InfoHint' +import { InfoHint } from '../components/InfoHint' import * as S from './ArraySearchForm.styles' /** @@ -91,8 +83,6 @@ export const ArraySearchForm = ({ onChangePredicate, onChangeCombinator, onChangeOptions, - context, - onChangeContext, onRun, onReset, disabled = false, @@ -230,49 +220,6 @@ export const ArraySearchForm = ({ ))} - - - - - onChangeContext({ enabled: e.target.checked })} - disabled={disabled} - data-testid={`${TEST_ID}-context-toggle`} - /> - - - - - - - - {CONTEXT_PREFIX} - - {/* Always rendered so ticking Context doesn't shift the row; it just - becomes editable once the toggle is on. */} - - - - onChangeContext({ - count: Math.round(Number(next ?? CONTEXT_COUNT_MIN)), - }) - } - disabled={disabled || !context.enabled} - data-testid={`${TEST_ID}-context`} - /> - - - -
@@ -68,9 +76,12 @@ export const IndexInfo = ({ indexInfo, dataTestId }: IndexInfoProps) => { color="secondary" data-testid={`${dataTestId ?? 'index-info'}--summary`} > - Number of docs: {indexInfo.numDocs} (max {indexInfo.maxDocId}) | Number - of records: {indexInfo.numRecords} | Number of terms:{' '} - {indexInfo.numTerms} + {t('vectorSearch.indexInfo.summary', { + numDocs: indexInfo.numDocs, + maxDocId: indexInfo.maxDocId, + numRecords: indexInfo.numRecords, + numTerms: indexInfo.numTerms, + })} ) diff --git a/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.utils.ts b/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.utils.ts index 66a2c259fc..5f25a11d88 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.utils.ts +++ b/redisinsight/ui/src/pages/vector-search/components/index-info/IndexInfo.utils.ts @@ -1,3 +1,4 @@ +import i18n from 'uiSrc/i18n' import { IndexInfo, IndexOptions, @@ -27,11 +28,17 @@ export const formatOptions = (options: IndexOptions): string => { const optionParts: string[] = [] if (options.filter) { - optionParts.push(`filter: ${options.filter}`) + optionParts.push( + i18n.t('vectorSearch.indexInfo.optionFilter', { value: options.filter }), + ) } if (options.defaultLang) { - optionParts.push(`language: ${options.defaultLang}`) + optionParts.push( + i18n.t('vectorSearch.indexInfo.optionLanguage', { + value: options.defaultLang, + }), + ) } return optionParts.join(', ') diff --git a/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.config.tsx b/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.config.tsx index 0d57820ac6..8d4cfb66fb 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.config.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.config.tsx @@ -1,5 +1,6 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { ColumnDef, Row } from 'uiSrc/components/base/layout/table' import { @@ -7,10 +8,6 @@ import { IndexListColumn, IndexListAction, } from './IndexList.types' -import { - INDEX_LIST_COLUMN_HEADERS, - INDEX_LIST_COLUMN_TOOLTIPS, -} from './constants' import { NameCell } from './components/NameCell/NameCell' import { PrefixCell } from './components/PrefixCell/PrefixCell' import { FieldTypesCell } from './components/FieldTypesCell/FieldTypesCell' @@ -23,7 +20,7 @@ const createActionsColumn = ( actions?: IndexListAction[], ): ColumnDef => ({ id: IndexListColumn.Actions, - header: INDEX_LIST_COLUMN_HEADERS[IndexListColumn.Actions], + header: '', enableSorting: false, enableResizing: false, size: 110, @@ -37,133 +34,132 @@ const createActionsColumn = ( ), }) -const INDEX_LIST_COLUMNS_BASE: ColumnDef[] = [ - { - id: IndexListColumn.Name, - accessorKey: IndexListColumn.Name, - header: INDEX_LIST_COLUMN_HEADERS[IndexListColumn.Name], - enableSorting: true, - size: 240, - cell: ({ row }: { row: Row }) => ( - - ), - sortingFn: (rowA, rowB) => - rowA.original.name - .toLowerCase() - .localeCompare(rowB.original.name.toLowerCase()), - }, - { - id: IndexListColumn.Prefix, - accessorKey: IndexListColumn.Prefix, - header: () => ( - - ), - enableSorting: false, - cell: ({ row }: { row: Row }) => ( - - ), - size: 200, - }, - { - id: IndexListColumn.FieldTypes, - accessorKey: IndexListColumn.FieldTypes, - header: INDEX_LIST_COLUMN_HEADERS[IndexListColumn.FieldTypes], - enableSorting: false, - size: 220, - cell: ({ row }: { row: Row }) => ( - - ), - }, - { - id: IndexListColumn.Docs, - accessorKey: IndexListColumn.Docs, - header: () => ( - - ), - enableSorting: true, - size: 110, - cell: ({ row }) => ( - - ), - sortingFn: (rowA, rowB) => rowA.original.numDocs - rowB.original.numDocs, - }, - { - id: IndexListColumn.Records, - accessorKey: IndexListColumn.Records, - header: () => ( - - ), - enableSorting: true, - size: 130, - cell: ({ row }) => ( - - ), - sortingFn: (rowA, rowB) => - rowA.original.numRecords - rowB.original.numRecords, - }, - { - id: IndexListColumn.Terms, - accessorKey: IndexListColumn.Terms, - header: () => ( - - ), - enableSorting: true, - size: 120, - cell: ({ row }) => ( - - ), - sortingFn: (rowA, rowB) => rowA.original.numTerms - rowB.original.numTerms, - }, - { - id: IndexListColumn.Fields, - accessorKey: IndexListColumn.Fields, - header: () => ( - - ), - enableSorting: true, - size: 120, - cell: ({ row }) => ( - - ), - sortingFn: (rowA, rowB) => - rowA.original.numFields - rowB.original.numFields, - }, -] - +// Columns are built per call (at render) so i18n.t() reflects the active +// language — a module-level column list would capture it at import time. export const getIndexListColumns = (options?: { onQueryClick?: (indexName: string) => void actions?: IndexListAction[] }): ColumnDef[] => { const actions = options?.actions ?? [] return [ - ...INDEX_LIST_COLUMNS_BASE, + { + id: IndexListColumn.Name, + accessorKey: IndexListColumn.Name, + header: i18n.t('vectorSearch.list.column.name'), + enableSorting: true, + size: 240, + cell: ({ row }: { row: Row }) => ( + + ), + sortingFn: (rowA, rowB) => + rowA.original.name + .toLowerCase() + .localeCompare(rowB.original.name.toLowerCase()), + }, + { + id: IndexListColumn.Prefix, + accessorKey: IndexListColumn.Prefix, + header: () => ( + + ), + enableSorting: false, + cell: ({ row }: { row: Row }) => ( + + ), + size: 200, + }, + { + id: IndexListColumn.FieldTypes, + accessorKey: IndexListColumn.FieldTypes, + header: i18n.t('vectorSearch.list.column.types'), + enableSorting: false, + size: 220, + cell: ({ row }: { row: Row }) => ( + + ), + }, + { + id: IndexListColumn.Docs, + accessorKey: IndexListColumn.Docs, + header: () => ( + + ), + enableSorting: true, + size: 110, + cell: ({ row }) => ( + + ), + sortingFn: (rowA, rowB) => rowA.original.numDocs - rowB.original.numDocs, + }, + { + id: IndexListColumn.Records, + accessorKey: IndexListColumn.Records, + header: () => ( + + ), + enableSorting: true, + size: 130, + cell: ({ row }) => ( + + ), + sortingFn: (rowA, rowB) => + rowA.original.numRecords - rowB.original.numRecords, + }, + { + id: IndexListColumn.Terms, + accessorKey: IndexListColumn.Terms, + header: () => ( + + ), + enableSorting: true, + size: 120, + cell: ({ row }) => ( + + ), + sortingFn: (rowA, rowB) => + rowA.original.numTerms - rowB.original.numTerms, + }, + { + id: IndexListColumn.Fields, + accessorKey: IndexListColumn.Fields, + header: () => ( + + ), + enableSorting: true, + size: 120, + cell: ({ row }) => ( + + ), + sortingFn: (rowA, rowB) => + rowA.original.numFields - rowB.original.numFields, + }, createActionsColumn(options?.onQueryClick, actions), ] } diff --git a/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.tsx b/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.tsx index 071ab8b163..f58ff9358d 100644 --- a/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/index-list/IndexList.tsx @@ -1,5 +1,6 @@ import React, { memo, useMemo } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Table } from 'uiSrc/components/base/layout/table' import { IndexListProps } from './IndexList.types' @@ -13,6 +14,7 @@ export const IndexList = memo( onQueryClick, actions, }: IndexListProps) => { + const { t } = useTranslation() const columns = useMemo( () => getIndexListColumns({ onQueryClick, actions }), [onQueryClick, actions], @@ -22,13 +24,13 @@ export const IndexList = memo( const emptyMessage = useMemo(() => { if (loading) { - return 'Loading...' + return t('vectorSearch.list.empty.loading') } if (!hasIndexes) { - return 'No indexes found' + return t('vectorSearch.list.empty.noIndexes') } - return 'No results found' - }, [loading, hasIndexes]) + return t('vectorSearch.list.empty.noResults') + }, [loading, hasIndexes, t]) return (
{ + const { t } = useTranslation() const { id, name } = row const handleQueryClick = useCallback( @@ -45,7 +47,7 @@ export const ActionsCell = ({ onClick={handleQueryClick} data-testid={`index-query-btn-${id}`} > - Query + {t('vectorSearch.list.action.query')} )} {actions.length > 0 && ( @@ -68,7 +70,7 @@ export const ActionsCell = ({ key={action.name} icon={action.icon} variant={action.variant} - text={action.name} + text={action.label ?? action.name} onClick={handleActionClick} data-testid={`index-actions-${action.name.toLowerCase()}-btn-${id}`} /> diff --git a/redisinsight/ui/src/pages/vector-search/components/index-list/constants.ts b/redisinsight/ui/src/pages/vector-search/components/index-list/constants.ts deleted file mode 100644 index 37e5da0cce..0000000000 --- a/redisinsight/ui/src/pages/vector-search/components/index-list/constants.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IndexListColumn } from './IndexList.types' - -/** - * Column header labels for the IndexList component. - */ -export const INDEX_LIST_COLUMN_HEADERS: Record = { - [IndexListColumn.Name]: 'Index name', - [IndexListColumn.Prefix]: 'Index prefix', - [IndexListColumn.FieldTypes]: 'Index types', - [IndexListColumn.Docs]: 'Docs', - [IndexListColumn.Records]: 'Records', - [IndexListColumn.Terms]: 'Terms', - [IndexListColumn.Fields]: 'Fields', - [IndexListColumn.Actions]: '', -} - -/** - * Column header tooltips for the IndexList component. - */ -export const INDEX_LIST_COLUMN_TOOLTIPS: Partial< - Record -> = { - [IndexListColumn.Prefix]: - 'Keys matching this prefix are automatically indexed.', - [IndexListColumn.Docs]: 'Number of documents currently indexed.', - [IndexListColumn.Records]: - 'Total indexed field-value pairs across all documents. One document with 5 fields = 5 records.', - [IndexListColumn.Terms]: - 'Unique words extracted from TEXT fields for full-text search.', - [IndexListColumn.Fields]: - 'Total number of fields defined in the index schema.', -} diff --git a/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Footer.tsx b/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Footer.tsx index fb55f8ad26..0751f3ff97 100644 --- a/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Footer.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Footer.tsx @@ -5,11 +5,13 @@ import ScanMore from 'uiSrc/components/scan-more' import { numberWithSpaces, nullableNumberWithSpaces } from 'uiSrc/utils/numbers' import { ColorText } from 'uiSrc/components/base/text' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' +import { Trans, useTranslation } from 'uiSrc/i18n' import * as S from '../KeysBrowser.styles' import { useKeysBrowser } from '../hooks/useKeysBrowser' const Footer = () => { + const { t } = useTranslation() const { keysState, headerLoading, isSearched, isFiltered, handleScanMore } = useKeysBrowser() @@ -38,36 +40,45 @@ const Footer = () => { color="secondary" data-testid="vs-scanning-text" > - Scanning... + {t('vectorSearch.keysBrowser.scanning')} )} {!!footerScanned && ( <> - {'Results: '} - - {numberWithSpaces(keysState.keys.length)} - - {' keys'} + + ), + }} + /> - {'Scanned '} - - {footerNotAccurateScanned} - {numberWithSpaces(footerScannedDisplay)} - - {'/'} - - {nullableNumberWithSpaces(keysState.total)} - + + ), + totalCount: , + }} + /> )} {!footerScanned && (!!keysState.total || isNull(keysState.total)) && ( - {'Total: '} - {nullableNumberWithSpaces(keysState.total)} + {t('vectorSearch.keysBrowser.total', { + total: nullableNumberWithSpaces(keysState.total), + })} )} diff --git a/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Header.tsx b/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Header.tsx index 9f24935c1e..7a39bfaf16 100644 --- a/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Header.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/Header.tsx @@ -4,12 +4,14 @@ import { AutoRefresh } from 'uiSrc/components' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Title } from 'uiSrc/components/base/text' import { KeyTreeSettings } from 'uiSrc/pages/browser/components/key-tree' +import { useTranslation } from 'uiSrc/i18n' import { SelectKeyOnboardingPopover } from '../../select-key-onboarding-popover' import { useKeysBrowser } from '../hooks/useKeysBrowser' import * as S from '../KeysBrowser.styles' const Header = () => { + const { t } = useTranslation() const { loading, keysState, @@ -24,7 +26,7 @@ const Header = () => { - Select key + {t('vectorSearch.keysBrowser.selectKey')} diff --git a/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/TypeTabs.tsx b/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/TypeTabs.tsx index 20cba06366..cf238f8a66 100644 --- a/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/TypeTabs.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/keys-browser/components/TypeTabs.tsx @@ -4,6 +4,7 @@ import { KeyTypes } from 'uiSrc/constants' import Tabs from 'uiSrc/components/base/layout/tabs' import { RiTooltip } from 'uiSrc/components/base' import { RiIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import { useKeysBrowser } from '../hooks/useKeysBrowser' import * as S from '../KeysBrowser.styles' @@ -14,6 +15,7 @@ const TABS = [ ] const TypeTabs = () => { + const { t } = useTranslation() const { activeTab, handleTabChange } = useKeysBrowser() return ( @@ -30,7 +32,7 @@ const TypeTabs = () => { ))} diff --git a/redisinsight/ui/src/pages/vector-search/components/no-search-results/NoSearchResults.tsx b/redisinsight/ui/src/pages/vector-search/components/no-search-results/NoSearchResults.tsx index 6d1731bcb6..e606f7a258 100644 --- a/redisinsight/ui/src/pages/vector-search/components/no-search-results/NoSearchResults.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/no-search-results/NoSearchResults.tsx @@ -2,12 +2,14 @@ import React, { useContext } from 'react' import { Text } from 'uiSrc/components/base/text' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { Theme } from 'uiSrc/constants' +import { useTranslation } from 'uiSrc/i18n' import NoQueryResultsIcon from 'uiSrc/assets/img/vector-search/no-query-results.svg' import NoQueryResultsIconDark from 'uiSrc/assets/img/vector-search/no-query-results-dark.svg' import * as S from './NoSearchResults.styles' export const NoSearchResults = () => { + const { t } = useTranslation() const { theme } = useContext(ThemeContext) const icon = theme === Theme.Dark ? NoQueryResultsIconDark : NoQueryResultsIcon @@ -19,10 +21,8 @@ export const NoSearchResults = () => { align="center" justify="center" > - - - Your query results will appear here once you run a query. - + + {t('vectorSearch.noResults.text')} ) } diff --git a/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.constants.ts b/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.constants.ts index d11c763385..cbe0a7a492 100644 --- a/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.constants.ts +++ b/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.constants.ts @@ -1,25 +1,21 @@ +import i18n from 'uiSrc/i18n' + import { SampleDataContent, SampleDataOption, } from './PickSampleDataModal.types' -export const MODAL_TITLE = 'Getting your sample data ready for Search' -export const MODAL_SUBTITLE_LINE_1 = 'Select a sample dataset.' -export const MODAL_SUBTITLE_LINE_2 = - "We'll load the data and generate the index needed for search." -export const CANCEL_BUTTON_TEXT = 'Cancel' -export const SEE_INDEX_DEFINITION_BUTTON_TEXT = 'See index definition' -export const START_QUERYING_BUTTON_TEXT = 'Start querying' - -export const SAMPLE_DATA_OPTIONS: SampleDataOption[] = [ +// Built at call time (not module scope) so label/description resolve in the +// active language; the enum `value` stays stable as an identifier. +export const getSampleDataOptions = (): SampleDataOption[] => [ { value: SampleDataContent.E_COMMERCE_DISCOVERY, - label: 'E-commerce Discovery', - description: 'Discover products that match intent, not just text', + label: i18n.t('vectorSearch.sampleData.ecommerce.label'), + description: i18n.t('vectorSearch.sampleData.ecommerce.description'), }, { value: SampleDataContent.CONTENT_RECOMMENDATIONS, - label: 'Content recommendations', - description: 'Discover content by theme or plot.', + label: i18n.t('vectorSearch.sampleData.content.label'), + description: i18n.t('vectorSearch.sampleData.content.description'), }, ] diff --git a/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.spec.tsx b/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.spec.tsx index 601ac5fc05..147453637e 100644 --- a/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.spec.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.spec.tsx @@ -6,7 +6,7 @@ import { PickSampleDataModalProps, SampleDataContent, } from './PickSampleDataModal.types' -import { SAMPLE_DATA_OPTIONS } from './PickSampleDataModal.constants' +import { getSampleDataOptions } from './PickSampleDataModal.constants' const mockedOnSelectDataset = jest.fn() const mockedOnCancel = jest.fn() @@ -56,7 +56,7 @@ describe('PickSampleDataModal', () => { it('should render all sample data option cards', () => { renderComponent() - SAMPLE_DATA_OPTIONS.forEach((option) => { + getSampleDataOptions().forEach((option) => { expect( screen.getByTestId(`pick-sample-data-modal--option-${option.value}`), ).toBeInTheDocument() diff --git a/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.tsx b/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.tsx index d47292c89b..6ce9c641a4 100644 --- a/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/pick-sample-data-modal/PickSampleDataModal.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Modal } from 'uiSrc/components/base/display' import { CancelIcon } from 'uiSrc/components/base/icons' import { @@ -19,15 +20,7 @@ import { PickSampleDataModalProps, SampleDataContent, } from './PickSampleDataModal.types' -import { - SAMPLE_DATA_OPTIONS, - MODAL_TITLE, - MODAL_SUBTITLE_LINE_1, - MODAL_SUBTITLE_LINE_2, - CANCEL_BUTTON_TEXT, - SEE_INDEX_DEFINITION_BUTTON_TEXT, - START_QUERYING_BUTTON_TEXT, -} from './PickSampleDataModal.constants' +import { getSampleDataOptions } from './PickSampleDataModal.constants' import * as S from './PickSampleDataModal.styles' const PickSampleDataModal = ({ @@ -39,14 +32,19 @@ const PickSampleDataModal = ({ onSeeIndexDefinition, onStartQuerying, }: PickSampleDataModalProps) => { + const { t } = useTranslation() + if (!isOpen) return null const hasSelection = selectedDataset !== null + const sampleDataOptions = getSampleDataOptions() return ( - {MODAL_TITLE} + + {t('vectorSearch.sampleData.title')} + - {MODAL_TITLE} + {t('vectorSearch.sampleData.title')} @@ -72,9 +70,9 @@ const PickSampleDataModal = ({ color="primary" data-testid="pick-sample-data-modal--subtitle" > - {MODAL_SUBTITLE_LINE_1} + {t('vectorSearch.sampleData.subtitle1')}
- {MODAL_SUBTITLE_LINE_2} + {t('vectorSearch.sampleData.subtitle2')} - {SAMPLE_DATA_OPTIONS.map((option) => ( + {sampleDataOptions.map((option) => ( - {CANCEL_BUTTON_TEXT} + {t('vectorSearch.sampleData.cancel')} @@ -127,7 +125,7 @@ const PickSampleDataModal = ({ } data-testid="pick-sample-data-modal--see-index-definition" > - {SEE_INDEX_DEFINITION_BUTTON_TEXT} + {t('vectorSearch.sampleData.seeIndexDefinition')} hasSelection && onStartQuerying(selectedDataset)} data-testid="pick-sample-data-modal--start-querying" > - {START_QUERYING_BUTTON_TEXT} + {t('vectorSearch.sampleData.startQuerying')} diff --git a/redisinsight/ui/src/pages/vector-search/components/query-editor/EditorLibraryToggle.tsx b/redisinsight/ui/src/pages/vector-search/components/query-editor/EditorLibraryToggle.tsx index 756a3168bf..8e2e7696de 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-editor/EditorLibraryToggle.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/query-editor/EditorLibraryToggle.tsx @@ -1,35 +1,49 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { ButtonGroup } from 'uiSrc/components/base/forms/button-group/ButtonGroup' import { Icon, KnowledgeBaseIcon } from 'uiSrc/components/base/icons' import { EditorTab, EditorLibraryToggleProps } from './QueryEditor.types' import { QueryOnboardingPopover } from './components/query-onboarding-popover' import * as S from './QueryEditor.styles' -const tabs = [ - { value: EditorTab.Editor, label: 'Query editor' }, - { value: EditorTab.Library, label: 'Query library', icon: KnowledgeBaseIcon }, -] - export const EditorLibraryToggle = ({ activeTab, onChangeTab, -}: EditorLibraryToggleProps) => ( - - - - {tabs.map((tab) => ( - onChangeTab(tab.value)} - data-testid={`editor-library-tab-${tab.value}`} - > - {tab.icon && }{' '} - {tab.label} - - ))} - - - -) +}: EditorLibraryToggleProps) => { + const { t } = useTranslation() + + const tabs = [ + { + value: EditorTab.Editor, + label: t('vectorSearch.query.editor.tab.editor'), + }, + { + value: EditorTab.Library, + label: t('vectorSearch.query.editor.tab.library'), + icon: KnowledgeBaseIcon, + }, + ] + + return ( + + + + {tabs.map((tab) => ( + onChangeTab(tab.value)} + data-testid={`editor-library-tab-${tab.value}`} + > + {tab.icon && ( + + )}{' '} + {tab.label} + + ))} + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.constants.ts b/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.constants.ts index 12f816a2cf..11dd628cb3 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.constants.ts +++ b/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.constants.ts @@ -11,15 +11,5 @@ export const EDITOR_OPTIONS = merge({}, defaultMonacoOptions, { }, }) -export const EDITOR_PLACEHOLDER = - 'Start typing FT. to access search commands or switch to Query Library to access saved commands.' - /** Commands that support FT.EXPLAIN and FT.PROFILE. */ export const EXPLAINABLE_COMMANDS = ['FT.SEARCH', 'FT.AGGREGATE'] as const - -export const TOOLTIP_EXPLAIN = - "Shows how your query will run (execution plan) to understand what's used." -export const TOOLTIP_PROFILE = - 'Profiles your query to show where time is spent and spot bottlenecks.' -export const TOOLTIP_DISABLED_NO_QUERY = 'Disabled: no query identified.' -export const TOOLTIP_DISABLED_LOADING = 'Disabled: query is running.' diff --git a/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.types.ts b/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.types.ts index 932d02e676..d64968990c 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.types.ts +++ b/redisinsight/ui/src/pages/vector-search/components/query-editor/QueryEditor.types.ts @@ -1,3 +1,5 @@ +import { ParseKeys } from 'i18next' + import { EXPLAINABLE_COMMANDS } from './QueryEditor.constants' export type ExplainableCommand = (typeof EXPLAINABLE_COMMANDS)[number] @@ -5,8 +7,8 @@ export type ExplainableCommand = (typeof EXPLAINABLE_COMMANDS)[number] export interface OnboardingTemplate { /** The Redis command name (used as the suggestion label). */ command: string - /** Short description shown as the suggestion detail. */ - detail: string + /** i18n key for the short description shown as the suggestion detail. */ + detailKey: ParseKeys /** Whether the template includes an index argument placeholder. */ usesIndex: boolean } diff --git a/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.spec.tsx b/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.spec.tsx index f4e4508db0..f590db5fd7 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.spec.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.spec.tsx @@ -7,15 +7,19 @@ import { waitForRiTooltipVisible, } from 'uiSrc/utils/test-utils' +import i18n from 'uiSrc/i18n' import { QueryEditorContextProvider } from 'uiSrc/components/query' -import { - TOOLTIP_EXPLAIN, - TOOLTIP_PROFILE, - TOOLTIP_DISABLED_NO_QUERY, - TOOLTIP_DISABLED_LOADING, -} from './QueryEditor.constants' import { VectorSearchActions } from './VectorSearchActions' +const TOOLTIP_EXPLAIN = i18n.t('vectorSearch.query.editor.tooltip.explain') +const TOOLTIP_PROFILE = i18n.t('vectorSearch.query.editor.tooltip.profile') +const TOOLTIP_DISABLED_NO_QUERY = i18n.t( + 'vectorSearch.query.editor.tooltip.disabledNoQuery', +) +const TOOLTIP_DISABLED_LOADING = i18n.t( + 'vectorSearch.query.editor.tooltip.disabledLoading', +) + const mockOnSubmit = jest.fn() const mockOnSaveClick = jest.fn() diff --git a/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.tsx b/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.tsx index 9328169ce3..120623891f 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchActions.tsx @@ -1,16 +1,11 @@ import React, { useMemo } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { RiTooltip } from 'uiSrc/components' import { EmptyButton } from 'uiSrc/components/base/forms/buttons' import RunButton from 'uiSrc/components/query/components/RunButton' import { useQueryEditorContext } from 'uiSrc/components/query' -import { - TOOLTIP_EXPLAIN, - TOOLTIP_PROFILE, - TOOLTIP_DISABLED_NO_QUERY, - TOOLTIP_DISABLED_LOADING, -} from './QueryEditor.constants' import { parseExplainableCommand, buildExplainQuery, @@ -38,6 +33,7 @@ interface VectorSearchActionsProps { export const VectorSearchActions = ({ onSaveClick, }: VectorSearchActionsProps) => { + const { t } = useTranslation() const { query, isLoading, onSubmit } = useQueryEditorContext() const parsed = useMemo(() => parseExplainableCommand(query), [query]) @@ -48,10 +44,11 @@ export const VectorSearchActions = ({ const isSaveEnabled = hasQuery && !isLoading const disabledReason = useMemo(() => { - if (!hasValidCommand) return TOOLTIP_DISABLED_NO_QUERY - if (isLoading) return TOOLTIP_DISABLED_LOADING + if (!hasValidCommand) + return t('vectorSearch.query.editor.tooltip.disabledNoQuery') + if (isLoading) return t('vectorSearch.query.editor.tooltip.disabledLoading') return undefined - }, [hasValidCommand, isLoading]) + }, [hasValidCommand, isLoading, t]) const handleExplain = () => { if (!parsed) return @@ -67,42 +64,42 @@ export const VectorSearchActions = ({ - Explain + {t('vectorSearch.query.editor.action.explain')} - Profile + {t('vectorSearch.query.editor.action.profile')} - Save + {t('vectorSearch.query.editor.action.save')} onSubmit()} /> diff --git a/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchEditor.tsx b/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchEditor.tsx index b25e536787..16f5bf5988 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchEditor.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/query-editor/VectorSearchEditor.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useRef, useState } from 'react' import { monaco as monacoEditor } from 'react-monaco-editor' +import { useTranslation } from 'uiSrc/i18n' import { MonacoLanguage } from 'uiSrc/constants' import { CodeEditor } from 'uiSrc/components/base/code-editor' import { useQueryEditorContext, useQueryEditor } from 'uiSrc/components/query' import { UseRedisCompletionsReturn } from 'uiSrc/components/query/hooks/useRedisCompletions.types' -import { EDITOR_OPTIONS, EDITOR_PLACEHOLDER } from './QueryEditor.constants' +import { EDITOR_OPTIONS } from './QueryEditor.constants' import { getOnboardingSuggestions } from './onboardingSuggestions' import * as S from './QueryEditor.styles' @@ -52,6 +53,7 @@ const triggerEmptySuggestions = ( * autocomplete behaviour takes over with all Redis commands available. */ export const VectorSearchEditor = () => { + const { t } = useTranslation() const { query, onSubmit, indexes, activeIndexName } = useQueryEditorContext() // Start as true because useMonacoRedisEditor auto-focuses the editor on mount const [focused, setFocused] = useState(true) @@ -141,7 +143,7 @@ export const VectorSearchEditor = () => { $contentLeft={contentLeft} data-testid="editor-placeholder" > - {EDITOR_PLACEHOLDER} + {t('vectorSearch.query.editor.placeholder')} )} { + const { t } = useTranslation() const [isOpen, setIsOpen] = useState( () => localStorageService.get( @@ -40,28 +42,27 @@ export const QueryOnboardingPopover = ({ > - Start exploring your data + {t('vectorSearch.query.onboarding.title')} - Build queries in the Query Editor or save them for later in the Query - Library. + {t('vectorSearch.query.onboarding.description')} - Query editor + {t('vectorSearch.query.onboarding.editorTitle')} - write search queries directly using Redis commands. + {t('vectorSearch.query.onboarding.editorDescription')} - Query library + {t('vectorSearch.query.onboarding.libraryTitle')} - reuse saved queries or use prebuilt examples for the sample data. + {t('vectorSearch.query.onboarding.libraryDescription')} @@ -71,7 +72,7 @@ export const QueryOnboardingPopover = ({ onClick={handleDismiss} data-testid="query-library-onboarding-dismiss" > - Got it + {t('vectorSearch.query.onboarding.dismiss')} diff --git a/redisinsight/ui/src/pages/vector-search/components/query-editor/onboardingSuggestions.ts b/redisinsight/ui/src/pages/vector-search/components/query-editor/onboardingSuggestions.ts index 7d4d00a9e4..e673d843fa 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-editor/onboardingSuggestions.ts +++ b/redisinsight/ui/src/pages/vector-search/components/query-editor/onboardingSuggestions.ts @@ -1,5 +1,6 @@ import * as monacoEditor from 'monaco-editor' +import i18n from 'uiSrc/i18n' import { bufferToString, formatLongName } from 'uiSrc/utils' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { getUtmExternalLink } from 'uiSrc/utils/links' @@ -14,37 +15,37 @@ import { OnboardingTemplate } from './QueryEditor.types' export const ONBOARDING_TEMPLATES: OnboardingTemplate[] = [ { command: 'FT.SEARCH', - detail: 'Find documents by text or filters', + detailKey: 'vectorSearch.query.editor.onboarding.detail.ftSearch', usesIndex: true, }, { command: 'FT.AGGREGATE', - detail: 'Group and summarize results', + detailKey: 'vectorSearch.query.editor.onboarding.detail.ftAggregate', usesIndex: true, }, { command: 'FT.SUGGET', - detail: 'Retrieve autocomplete suggestions', + detailKey: 'vectorSearch.query.editor.onboarding.detail.ftSugget', usesIndex: false, }, { command: 'FT.SPELLCHECK', - detail: 'Suggest corrections for typos', + detailKey: 'vectorSearch.query.editor.onboarding.detail.ftSpellcheck', usesIndex: true, }, { command: 'FT.EXPLAIN', - detail: 'See execution plan', + detailKey: 'vectorSearch.query.editor.onboarding.detail.ftExplain', usesIndex: true, }, { command: 'FT.PROFILE', - detail: 'Analyze performance', + detailKey: 'vectorSearch.query.editor.onboarding.detail.ftProfile', usesIndex: true, }, { command: 'FT._LIST', - detail: 'View index schema and stats', + detailKey: 'vectorSearch.query.editor.onboarding.detail.ftList', usesIndex: false, }, ] @@ -137,22 +138,29 @@ export const getOnboardingSuggestions = ( activeIndexName?: string, ): monacoEditor.languages.CompletionItem[] => { const { snippet, isFixed } = getIndexSnippet(indexes, activeIndexName) + const documentationLabel = i18n.t( + 'vectorSearch.query.editor.onboarding.documentation', + ) - return ONBOARDING_TEMPLATES.map((t, i) => ({ - label: t.command, - kind: monacoEditor.languages.CompletionItemKind.Snippet, - detail: t.detail, - documentation: { - value: `**${t.command}** — ${t.detail}\n\n[Documentation](${getDocUrl(t.command)})`, - }, - insertText: getInsertText( - t.command, - t.usesIndex ? snippet : '', - t.usesIndex && isFixed, - ), - insertTextRules: - monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, - range: EMPTY_EDITOR_RANGE, - sortText: `!${String(i).padStart(2, '0')}`, - })) as monacoEditor.languages.CompletionItem[] + return ONBOARDING_TEMPLATES.map((template, i) => { + const detail = i18n.t(template.detailKey) + + return { + label: template.command, + kind: monacoEditor.languages.CompletionItemKind.Snippet, + detail, + documentation: { + value: `**${template.command}** — ${detail}\n\n[${documentationLabel}](${getDocUrl(template.command)})`, + }, + insertText: getInsertText( + template.command, + template.usesIndex ? snippet : '', + template.usesIndex && isFixed, + ), + insertTextRules: + monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: EMPTY_EDITOR_RANGE, + sortText: `!${String(i).padStart(2, '0')}`, + } + }) as monacoEditor.languages.CompletionItem[] } diff --git a/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.constants.ts b/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.constants.ts index 29e4f8fe36..f9d759c649 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.constants.ts +++ b/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.constants.ts @@ -8,11 +8,11 @@ export const QUERY_TYPE_BADGE_MAP: Record< QueryTypeBadgeConfig > = { [QueryLibraryItemType.Sample]: { - label: 'Sample query', + labelKey: 'vectorSearch.queryLibrary.badge.sample', variant: 'default', }, [QueryLibraryItemType.Saved]: { - label: 'Saved query', + labelKey: 'vectorSearch.queryLibrary.badge.saved', variant: 'white', }, } diff --git a/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.tsx b/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.tsx index 3812d00aef..918ef39bc6 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.tsx @@ -1,5 +1,6 @@ import React, { useCallback } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { RiBadge } from 'uiSrc/components/base/display/badge/RiBadge' import { RiTooltip } from 'uiSrc/components/base/tooltip' import { CopyButton } from 'uiSrc/components/copy-button' @@ -31,6 +32,7 @@ export const QueryLibraryItem = ({ onDelete, dataTestId = 'query-library-item', }: QueryLibraryItemProps) => { + const { t } = useTranslation() const badgeConfig = QUERY_TYPE_BADGE_MAP[type] const handleToggle = useCallback(() => { @@ -80,7 +82,7 @@ export const QueryLibraryItem = ({ @@ -102,7 +104,7 @@ export const QueryLibraryItem = ({ )} @@ -114,27 +116,27 @@ export const QueryLibraryItem = ({ )} {onLoad && ( - Load + {t('vectorSearch.queryLibrary.item.load')} )} {onRun && ( - Run + {t('vectorSearch.queryLibrary.item.run')} )} diff --git a/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.types.ts b/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.types.ts index 30d98e31d9..2a58262cca 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.types.ts +++ b/redisinsight/ui/src/pages/vector-search/components/query-library-item/QueryLibraryItem.types.ts @@ -1,3 +1,5 @@ +import { ParseKeys } from 'i18next' + import { BadgeVariants } from 'uiSrc/components/base/display/badge/RiBadge' export enum QueryLibraryItemType { @@ -23,6 +25,6 @@ export interface QueryLibraryItemProps { } export interface QueryTypeBadgeConfig { - label: string + labelKey: ParseKeys variant: BadgeVariants } diff --git a/redisinsight/ui/src/pages/vector-search/components/query-library-view/QueryLibraryView.tsx b/redisinsight/ui/src/pages/vector-search/components/query-library-view/QueryLibraryView.tsx index 8c9e0978a7..ce639b14f5 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-library-view/QueryLibraryView.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/query-library-view/QueryLibraryView.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from 'react' import { useParams } from 'react-router-dom' +import { useTranslation } from 'uiSrc/i18n' import { SearchInput } from 'uiSrc/components/base/inputs' import { LoadingContent } from 'uiSrc/components/base/layout' import { Text } from 'uiSrc/components/base/text' @@ -21,6 +22,7 @@ const SERVICE_TYPE_TO_UI_TYPE: Record = } export const QueryLibraryView = ({ onRun, onLoad }: QueryLibraryViewProps) => { + const { t } = useTranslation() const { instanceId } = useParams<{ instanceId: string }>() const { items, @@ -100,7 +102,7 @@ export const QueryLibraryView = ({ onRun, onLoad }: QueryLibraryViewProps) => { {showSearchBar && ( { {search - ? 'No queries match your search' - : 'No saved queries yet. Create your query in editor and click Save to add it here.'} + ? t('vectorSearch.queryLibrary.empty.noMatch') + : t('vectorSearch.queryLibrary.empty.noQueries')} )} diff --git a/redisinsight/ui/src/pages/vector-search/components/query-library-view/components/delete-query-modal/DeleteQueryModal.tsx b/redisinsight/ui/src/pages/vector-search/components/query-library-view/components/delete-query-modal/DeleteQueryModal.tsx index 3f33027caf..be9e282e7a 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-library-view/components/delete-query-modal/DeleteQueryModal.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/query-library-view/components/delete-query-modal/DeleteQueryModal.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { DeleteConfirmationModal } from 'uiSrc/pages/vector-search/components/delete-confirmation-modal' import { DeleteQueryModalProps } from './DeleteQueryModal.types' @@ -7,16 +8,20 @@ import { DeleteQueryModalProps } from './DeleteQueryModal.types' export const DeleteQueryModal = ({ onConfirm, onCancel, -}: DeleteQueryModalProps) => ( - -) +}: DeleteQueryModalProps) => { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts b/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts index e77c5c553e..086878755b 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts +++ b/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts @@ -3,6 +3,7 @@ import { useAppDispatch } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' import { debounce } from 'lodash' +import { useTranslation } from 'uiSrc/i18n' import { addMessageNotification } from 'uiSrc/slices/app/notifications' import { QueryLibraryService } from 'uiSrc/services/query-library/QueryLibraryService' import { QueryLibraryItem } from 'uiSrc/services/query-library/types' @@ -11,6 +12,7 @@ import { queryLibraryNotifications } from 'uiSrc/pages/vector-search/constants' const SEARCH_DEBOUNCE_MS = 300 export const useQueryLibrary = () => { + const { t } = useTranslation() const dispatch = useAppDispatch() const { instanceId: databaseId, indexName: rawIndexName } = useParams<{ instanceId: string @@ -49,12 +51,12 @@ export const useQueryLibrary = () => { } } catch { setItems([]) - setError('Failed to load query library') + setError(t('vectorSearch.queryLibrary.error.load')) } finally { setLoading(false) } }, - [databaseId, indexName], + [databaseId, indexName, t], ) const debouncedFetch = useMemo( diff --git a/redisinsight/ui/src/pages/vector-search/components/rqe-not-available/RqeNotAvailable.tsx b/redisinsight/ui/src/pages/vector-search/components/rqe-not-available/RqeNotAvailable.tsx index f51c85b960..b7d31c1a39 100644 --- a/redisinsight/ui/src/pages/vector-search/components/rqe-not-available/RqeNotAvailable.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/rqe-not-available/RqeNotAvailable.tsx @@ -2,9 +2,9 @@ import React from 'react' import { SearchPageFallback, - RQE_NOT_AVAILABLE_CONTENT, + getRqeNotAvailableContent, } from '../search-page-fallback' export const RqeNotAvailable = () => ( - + ) diff --git a/redisinsight/ui/src/pages/vector-search/components/save-query-modal/SaveQueryModal.tsx b/redisinsight/ui/src/pages/vector-search/components/save-query-modal/SaveQueryModal.tsx index ec317526ec..cc31e65c02 100644 --- a/redisinsight/ui/src/pages/vector-search/components/save-query-modal/SaveQueryModal.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/save-query-modal/SaveQueryModal.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Modal } from 'uiSrc/components/base/display' import { CancelIcon } from 'uiSrc/components/base/icons' import { Col, Row } from 'uiSrc/components/base/layout/flex' @@ -22,6 +23,7 @@ export const SaveQueryModal = ({ onSave, onClose, }: SaveQueryModalProps) => { + const { t } = useTranslation() const [name, setName] = useState('') useEffect(() => { @@ -50,19 +52,20 @@ export const SaveQueryModal = ({ /> - Save query + + {t('vectorSearch.queryLibrary.save.title')} +
- Name your query to add it to your saved queries list for quick - reuse. + {t('vectorSearch.queryLibrary.save.description')} - Cancel + {t('vectorSearch.queryLibrary.save.cancel')} - Save query + {t('vectorSearch.queryLibrary.save.confirm')} diff --git a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/SearchPageFallback.tsx b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/SearchPageFallback.tsx index 5163806d75..613819837c 100644 --- a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/SearchPageFallback.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/SearchPageFallback.tsx @@ -12,6 +12,7 @@ import { Title } from 'uiSrc/components/base/text/Title' import { ColorText } from 'uiSrc/components/base/text' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { Link } from 'uiSrc/components/base/link/Link' +import { useTranslation } from 'uiSrc/i18n' import { SearchPageFallbackContent } from './SearchPageFallback.types' import * as S from './SearchPageFallback.styles' @@ -21,6 +22,7 @@ interface SearchPageFallbackProps { } export const SearchPageFallback = ({ content }: SearchPageFallbackProps) => { + const { t } = useTranslation() const { [FeatureFlags.envDependent]: envDependentFeature } = useAppSelector( appFeatureFlagsFeaturesSelector, ) @@ -90,7 +92,7 @@ export const SearchPageFallback = ({ content }: SearchPageFallbackProps) => { data-testid={`${content.testId}-get-started-button`} > - Get started for free + {t('vectorSearch.fallback.getStarted')} )} @@ -105,7 +107,7 @@ export const SearchPageFallback = ({ content }: SearchPageFallbackProps) => { })} data-testid={`${content.testId}-learn-more-link`} > - Learn more + {t('vectorSearch.fallback.learnMore')} )} diff --git a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts index 3838788138..c28267374f 100644 --- a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts +++ b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/constants.ts @@ -1,26 +1,27 @@ +import i18n from 'uiSrc/i18n' import { OAuthSocialSource } from 'uiSrc/slices/interfaces' import { SearchPageFallbackContent } from './SearchPageFallback.types' -export const RQE_NOT_AVAILABLE_CONTENT: SearchPageFallbackContent = { +// Built at call time (not module scope) so copy resolves in the active +// language when the fallback renders. +export const getRqeNotAvailableContent = (): SearchPageFallbackContent => ({ testId: 'rqe-not-available', - title: 'Redis Search is not available for this database', - subtitle: 'Redis Search allows to:', - features: ['Query', 'Secondary index', 'Full-text search'], - description: - 'These features enable multi-field queries, aggregation, exact phrase matching, numeric filtering, ' + - 'geo filtering and vector similarity semantic search on top of text queries.', - ctaText: - 'Use your free trial all-in-one Redis Cloud database to start exploring these capabilities', + title: i18n.t('vectorSearch.notAvailable.title'), + subtitle: i18n.t('vectorSearch.notAvailable.subtitle'), + features: [ + i18n.t('vectorSearch.notAvailable.feature.query'), + i18n.t('vectorSearch.notAvailable.feature.secondaryIndex'), + i18n.t('vectorSearch.notAvailable.feature.fullTextSearch'), + ], + description: i18n.t('vectorSearch.notAvailable.description'), + ctaText: i18n.t('vectorSearch.notAvailable.ctaText'), oauthSource: OAuthSocialSource.BrowserSearch, -} +}) -export const VERSION_NOT_SUPPORTED_CONTENT: SearchPageFallbackContent = { +export const getVersionNotSupportedContent = (): SearchPageFallbackContent => ({ testId: 'version-not-supported', - title: 'Redis Search 2.0+ required', - description: - 'This page requires Redis Search 2.0 or later (included with Redis 6+). ' + - 'Older versions of Redis Search are not compatible with the commands used here.', - ctaText: - 'Create a free Redis Cloud database to start exploring these capabilities.', + title: i18n.t('vectorSearch.versionNotSupported.title'), + description: i18n.t('vectorSearch.versionNotSupported.description'), + ctaText: i18n.t('vectorSearch.versionNotSupported.ctaText'), oauthSource: OAuthSocialSource.BrowserFiltering, -} +}) diff --git a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/index.ts b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/index.ts index eba7b23ca6..e9d6bd5651 100644 --- a/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/index.ts +++ b/redisinsight/ui/src/pages/vector-search/components/search-page-fallback/index.ts @@ -1,6 +1,6 @@ export { SearchPageFallback } from './SearchPageFallback' export type { SearchPageFallbackContent } from './SearchPageFallback.types' export { - RQE_NOT_AVAILABLE_CONTENT, - VERSION_NOT_SUPPORTED_CONTENT, + getRqeNotAvailableContent, + getVersionNotSupportedContent, } from './constants' diff --git a/redisinsight/ui/src/pages/vector-search/components/select-key-onboarding-popover/SelectKeyOnboardingPopover.tsx b/redisinsight/ui/src/pages/vector-search/components/select-key-onboarding-popover/SelectKeyOnboardingPopover.tsx index f22af3572c..a63e6e0fce 100644 --- a/redisinsight/ui/src/pages/vector-search/components/select-key-onboarding-popover/SelectKeyOnboardingPopover.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/select-key-onboarding-popover/SelectKeyOnboardingPopover.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' +import { useTranslation } from 'uiSrc/i18n' import { RiPopover } from 'uiSrc/components/base' import { Button, IconButton } from 'uiSrc/components/base/forms/buttons' import { CancelSlimIcon } from 'uiSrc/components/base/icons' @@ -22,6 +23,7 @@ interface PopoverContentProps { } const PopoverContent = ({ children, onDismiss }: PopoverContentProps) => { + const { t } = useTranslation() const selectedKey = useAppSelector(selectedKeyDataSelector) useEffect(() => { @@ -44,22 +46,20 @@ const PopoverContent = ({ children, onDismiss }: PopoverContentProps) => { icon={CancelSlimIcon} onClick={onDismiss} size="S" - aria-label="close-onboarding" + aria-label={t('vectorSearch.selectKeyOnboarding.close')} data-testid="select-key-onboarding-close" /> - Select a key to get started + {t('vectorSearch.selectKeyOnboarding.title')} - We'll use the selected key to generate a suggested indexing - schema. Redis will index all keys with the same prefix, not just - this single key. + {t('vectorSearch.selectKeyOnboarding.body1')} - Indexing available for Hash and JSON data structures. + {t('vectorSearch.selectKeyOnboarding.body2')} @@ -68,7 +68,7 @@ const PopoverContent = ({ children, onDismiss }: PopoverContentProps) => { onClick={onDismiss} data-testid="select-key-onboarding-dismiss" > - Got it + {t('vectorSearch.selectKeyOnboarding.gotIt')} diff --git a/redisinsight/ui/src/pages/vector-search/components/upgrade-redis-banner/UpgradeRedisBanner.tsx b/redisinsight/ui/src/pages/vector-search/components/upgrade-redis-banner/UpgradeRedisBanner.tsx index 29a0fd1e9a..40f7a6aef0 100644 --- a/redisinsight/ui/src/pages/vector-search/components/upgrade-redis-banner/UpgradeRedisBanner.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/upgrade-redis-banner/UpgradeRedisBanner.tsx @@ -9,8 +9,10 @@ import { OAuthSocialAction, OAuthSocialSource, } from 'uiSrc/slices/interfaces/cloud' +import { useTranslation } from 'uiSrc/i18n' export const UpgradeRedisBanner = () => { + const { t } = useTranslation() const { [FeatureFlags.cloudSso]: featureFlagCloudSsl, [FeatureFlags.cloudAds]: featureFlagCloudAds, @@ -27,7 +29,7 @@ export const UpgradeRedisBanner = () => { {...(isCloudSsoEnabled && { actions: { primary: { - label: 'Free Redis Cloud DB', + label: t('vectorSearch.upgradeBanner.cta'), onClick: () => // @ts-ignore: We don't have the event arg here ssoCloudHandlerClick(null, { @@ -39,8 +41,7 @@ export const UpgradeRedisBanner = () => { })} data-testid="upgrade-redis-banner" > - Upgrade to Redis 7.2+ to unlock fast, real-time semantic AI search - with vector search + {t('vectorSearch.upgradeBanner.message')} )} diff --git a/redisinsight/ui/src/pages/vector-search/components/version-not-supported/VersionNotSupported.tsx b/redisinsight/ui/src/pages/vector-search/components/version-not-supported/VersionNotSupported.tsx index 4be7f09dc9..181caf0fb5 100644 --- a/redisinsight/ui/src/pages/vector-search/components/version-not-supported/VersionNotSupported.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/version-not-supported/VersionNotSupported.tsx @@ -2,9 +2,9 @@ import React from 'react' import { SearchPageFallback, - VERSION_NOT_SUPPORTED_CONTENT, + getVersionNotSupportedContent, } from '../search-page-fallback' export const VersionNotSupported = () => ( - + ) diff --git a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.constants.ts b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.constants.ts index 5f3cb1d36b..3225ebbb2f 100644 --- a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.constants.ts +++ b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.constants.ts @@ -1,35 +1,28 @@ +import i18n from 'uiSrc/i18n' + import type { Feature } from './WelcomeScreen.types' -export const FEATURES: Feature[] = [ +// Built at call time (not module scope) so titles/descriptions resolve in the +// active language when the welcome screen renders. +export const getFeatures = (): Feature[] => [ { icon: 'VectorSearchIcon', - title: 'Full-text search', - description: - 'Find and filter your data instantly using powerful keyword and field-based queries.', + title: i18n.t('vectorSearch.welcome.feature.fullText.title'), + description: i18n.t('vectorSearch.welcome.feature.fullText.description'), }, { icon: 'WorkbenchIcon', - title: 'Vector search', - description: - 'Retrieve results by meaning, not just words. Ideal for AI, semantic, and recommendation apps.', + title: i18n.t('vectorSearch.welcome.feature.vector.title'), + description: i18n.t('vectorSearch.welcome.feature.vector.description'), }, { icon: 'MindmapIcon', - title: 'Hybrid search', - description: - 'Combine vector and keyword search for higher accuracy and more relevant results.', + title: i18n.t('vectorSearch.welcome.feature.hybrid.title'), + description: i18n.t('vectorSearch.welcome.feature.hybrid.description'), }, { icon: 'RocketIcon', - title: 'High performance, low effort', - description: - 'Built-in quantization and compression deliver blazing speed and efficiency at any scale.', + title: i18n.t('vectorSearch.welcome.feature.performance.title'), + description: i18n.t('vectorSearch.welcome.feature.performance.description'), }, ] - -export const TITLE = 'Search your data at in-memory speed' -export const SUBTITLE = - 'Discover how Redis enables full-text and vector search. Fast, simple, and production-ready.' - -export const TRY_SAMPLE_DATA_LABEL = 'Try with sample data' -export const USE_MY_DATABASE_LABEL = 'Use data from my database' diff --git a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx index f626f06225..113eff6603 100644 --- a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx @@ -7,9 +7,10 @@ import { waitForRiTooltipVisible, } from 'uiSrc/utils/test-utils' +import i18n from 'uiSrc/i18n' import { WelcomeScreen } from './WelcomeScreen' import type { WelcomeScreenProps } from './WelcomeScreen.types' -import { TITLE, SUBTITLE, FEATURES } from './WelcomeScreen.constants' +import { getFeatures } from './WelcomeScreen.constants' const defaultProps: WelcomeScreenProps = { onTrySampleDataClick: jest.fn(), @@ -31,15 +32,15 @@ describe('WelcomeScreen', () => { expect(welcomeScreen).toBeInTheDocument() const title = screen.getByTestId('welcome-screen--title') - expect(title).toHaveTextContent(TITLE) + expect(title).toHaveTextContent(i18n.t('vectorSearch.welcome.title')) const subtitle = screen.getByTestId('welcome-screen--subtitle') - expect(subtitle).toHaveTextContent(SUBTITLE) + expect(subtitle).toHaveTextContent(i18n.t('vectorSearch.welcome.subtitle')) const features = screen.getByTestId('welcome-screen--features') expect(features).toBeInTheDocument() - FEATURES.forEach((feature) => { + getFeatures().forEach((feature) => { const featureTitle = screen.getByText(feature.title) expect(featureTitle).toBeInTheDocument() }) diff --git a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx index 113d85fe33..63ba34e06e 100644 --- a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx @@ -9,14 +9,9 @@ import { SecondaryButton, } from 'uiSrc/components/base/forms/buttons' import { RiTooltip } from 'uiSrc/components/base/tooltip' +import { useTranslation } from 'uiSrc/i18n' -import { - FEATURES, - TITLE, - SUBTITLE, - TRY_SAMPLE_DATA_LABEL, - USE_MY_DATABASE_LABEL, -} from './WelcomeScreen.constants' +import { getFeatures } from './WelcomeScreen.constants' import type { WelcomeScreenProps } from './WelcomeScreen.types' import * as S from './WelcomeScreen.styles' @@ -25,8 +20,10 @@ export const WelcomeScreen = ({ onUseMyDatabaseClick, useMyDatabaseDisabled, }: WelcomeScreenProps) => { + const { t } = useTranslation() const isUseMyDatabaseDisabled = !!useMyDatabaseDisabled const useMyDatabaseTooltip = useMyDatabaseDisabled?.tooltip + const features = getFeatures() return ( @@ -38,14 +35,14 @@ export const WelcomeScreen = ({ color="primary" data-testid="welcome-screen--title" > - {TITLE} + {t('vectorSearch.welcome.title')} - {SUBTITLE} + {t('vectorSearch.welcome.subtitle')} @@ -56,7 +53,7 @@ export const WelcomeScreen = ({ gap="xl" data-testid="welcome-screen--features" > - {FEATURES.map((feature) => ( + {features.map((feature) => ( - {TRY_SAMPLE_DATA_LABEL} + {t('vectorSearch.welcome.trySampleData')} - {USE_MY_DATABASE_LABEL} + {t('vectorSearch.welcome.useMyDatabase')} diff --git a/redisinsight/ui/src/pages/vector-search/constants/notifications.ts b/redisinsight/ui/src/pages/vector-search/constants/notifications.ts index 814af32344..b257037ae2 100644 --- a/redisinsight/ui/src/pages/vector-search/constants/notifications.ts +++ b/redisinsight/ui/src/pages/vector-search/constants/notifications.ts @@ -1,3 +1,4 @@ +import i18n from 'uiSrc/i18n' import { RiToastType, ToastVariant, @@ -17,9 +18,10 @@ interface NotificationMessage { export const createIndexNotifications = { /** Shown after a new index is successfully created from sample data. */ sampleDataCreated: (): NotificationMessage => ({ - title: 'Your sample data is now searchable.', - message: - 'Start building queries or explore sample ones under Query library.', + title: i18n.t('notification.success.vectorSearchSampleDataCreated.title'), + message: i18n.t( + 'notification.success.vectorSearchSampleDataCreated.message', + ), showCloseButton: false, actions: {}, }), @@ -29,9 +31,10 @@ export const createIndexNotifications = { * Variant: notice – the data is usable but nothing new was created. */ sampleDataAlreadyExists: (): NotificationMessage => ({ - title: 'Your sample data is already searchable using an existing index.', - message: - 'You can start building new queries or explore existing ones in the Query Library.', + title: i18n.t('notification.success.vectorSearchSampleDataExists.title'), + message: i18n.t( + 'notification.success.vectorSearchSampleDataExists.message', + ), variant: 'notice' as ToastVariant, showCloseButton: false, actions: {}, @@ -39,18 +42,18 @@ export const createIndexNotifications = { /** Shown when the index creation request fails. */ createFailed: (details?: string): NotificationMessage => ({ - title: 'Failed to create index', + title: i18n.t('notification.error.vectorSearchCreateIndexFailed.title'), message: details || - 'An error occurred while creating the index. Please try again.', + i18n.t('notification.error.vectorSearchCreateIndexFailed.message'), variant: 'danger' as ToastVariant, }), // TODO: Use when creating an index from existing database keys (not sample data). /** Shown after a new index is successfully created from database data. */ indexCreated: (): NotificationMessage => ({ - title: 'Index created successfully.', - message: 'Your data is now searchable. You can start running queries.', + title: i18n.t('notification.success.vectorSearchIndexCreated.title'), + message: i18n.t('notification.success.vectorSearchIndexCreated.message'), showCloseButton: false, actions: {}, }), @@ -58,12 +61,12 @@ export const createIndexNotifications = { export const queryLibraryNotifications = { querySaved: (onGoToLibrary?: VoidFunction): NotificationMessage => ({ - title: 'Query saved to your library.', - message: 'You can find it anytime in the Query Library.', + title: i18n.t('notification.success.queryLibrarySaved.title'), + message: i18n.t('notification.success.queryLibrarySaved.message'), showCloseButton: false, actions: { primary: { - label: 'Go to Query Library', + label: i18n.t('notification.success.queryLibrarySaved.action'), onClick: onGoToLibrary ?? (() => {}), closes: true, }, @@ -71,20 +74,19 @@ export const queryLibraryNotifications = { }), saveFailed: (): NotificationMessage => ({ - title: 'Failed to save query', - message: 'An error occurred while saving the query. Please try again.', + title: i18n.t('notification.error.queryLibrarySaveFailed.title'), + message: i18n.t('notification.error.queryLibrarySaveFailed.message'), variant: 'error' as ToastVariant, }), queryDeleted: (): NotificationMessage => ({ - title: 'Query has been deleted.', + title: i18n.t('notification.success.queryLibraryDeleted.title'), message: '', }), cleanupFailed: (): NotificationMessage => ({ - title: 'Failed to clean up query library', - message: - 'An error occurred while removing saved queries for the deleted index.', + title: i18n.t('notification.error.queryLibraryCleanupFailed.title'), + message: i18n.t('notification.error.queryLibraryCleanupFailed.message'), variant: 'error' as ToastVariant, }), } diff --git a/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx b/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx index 3f7130832f..258f83a91e 100644 --- a/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx +++ b/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react' import { useHistory } from 'react-router-dom' import { useAppDispatch } from 'uiSrc/slices/hooks' +import { useTranslation } from 'uiSrc/i18n' import { Pages } from 'uiSrc/constants' import { RowSelectionState } from 'uiSrc/components/base/layout/table' import { RedisearchIndexKeyType } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' @@ -66,6 +67,7 @@ export const CreateIndexPageProvider = ({ initialPrefix: initialPrefixProp, children, }: CreateIndexPageProviderProps) => { + const { t } = useTranslation() const mode = modeProp ?? CreateIndexMode.SampleData const isSampleData = mode === CreateIndexMode.SampleData @@ -172,8 +174,8 @@ export const CreateIndexPageProvider = ({ const displayName = useMemo(() => { if (isSampleData && sampleData) return getDisplayNameBySampleData(sampleData) - return 'existing data' - }, [isSampleData, sampleData]) + return t('vectorSearch.createIndex.displayNameFallback') + }, [isSampleData, sampleData, t]) const showBrowser = !isSampleData && showBrowserProp @@ -203,10 +205,10 @@ export const CreateIndexPageProvider = ({ const createDisabledReason = useMemo((): string | null => { if (isSampleData) return null if (selectedFields.length === 0) - return 'Select a key and at least one field to index.' + return t('vectorSearch.createIndex.createDisabledReason') if (indexNameError !== null) return indexNameError return null - }, [isSampleData, indexNameError, selectedFields]) + }, [isSampleData, indexNameError, selectedFields, t]) const isCreateDisabled = createDisabledReason !== null diff --git a/redisinsight/ui/src/pages/vector-search/hooks/useListContent/useListContent.ts b/redisinsight/ui/src/pages/vector-search/hooks/useListContent/useListContent.ts index 0602cdeac7..4ff8d35753 100644 --- a/redisinsight/ui/src/pages/vector-search/hooks/useListContent/useListContent.ts +++ b/redisinsight/ui/src/pages/vector-search/hooks/useListContent/useListContent.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo, useState } from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { useHistory, useParams } from 'react-router-dom' +import { useTranslation } from 'uiSrc/i18n' + import { BrowserStorageItem, Pages } from 'uiSrc/constants' import { bufferToString, stringToBuffer } from 'uiSrc/utils' import { encodeIndexNameForUrl } from 'uiSrc/pages/vector-search/utils' @@ -28,6 +30,7 @@ import { IndexListAction } from '../../components/index-list/IndexList.types' import { useIndexListData } from '../useIndexListData' export const useListContent = () => { + const { t } = useTranslation() const dispatch = useAppDispatch() const history = useHistory() const { instanceId } = useParams<{ instanceId: string }>() @@ -144,20 +147,27 @@ export const useListContent = () => { const actions: IndexListAction[] = useMemo( () => [ - { name: 'View index', icon: ShowIcon, callback: handleViewIndex }, + { + name: 'View index', + label: t('vectorSearch.list.action.viewIndex'), + icon: ShowIcon, + callback: handleViewIndex, + }, { name: 'Browse dataset', + label: t('vectorSearch.list.action.browseDataset'), icon: VectorSearchKeyIcon, callback: handleBrowseDataset, }, { name: 'Delete', + label: t('vectorSearch.list.action.delete'), icon: DeleteIcon, variant: 'destructive', callback: handleDelete, }, ], - [handleViewIndex, handleBrowseDataset, handleDelete], + [handleViewIndex, handleBrowseDataset, handleDelete, t], ) return { diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/ConfirmKeyChangeModal/ConfirmKeyChangeModal.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/ConfirmKeyChangeModal/ConfirmKeyChangeModal.tsx index fb633e7f67..e9a33f7f09 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/ConfirmKeyChangeModal/ConfirmKeyChangeModal.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/ConfirmKeyChangeModal/ConfirmKeyChangeModal.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Modal } from 'uiSrc/components/base/display' import { Button, PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { CancelIcon } from 'uiSrc/components/base/icons' @@ -13,44 +14,49 @@ import * as S from './ConfirmKeyChangeModal.styles' export const ConfirmKeyChangeModal = ({ onConfirm, onCancel, -}: ConfirmKeyChangeModalProps) => ( - - - +}: ConfirmKeyChangeModalProps) => { + const { t } = useTranslation() - - Unsaved changes - + return ( + + + - - - You have modified the index types. Selecting a different key will - discard your changes and load fields from the new key. - - - + + + {t('vectorSearch.createIndex.confirmKeyChange.title')} + + - - - - Discard and load - - - - -) + + + {t('vectorSearch.createIndex.confirmKeyChange.body')} + + + + + + + + {t('vectorSearch.createIndex.confirmKeyChange.discardAndLoad')} + + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexContent.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexContent.tsx index 2bd8ecca75..b3c260d960 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexContent.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexContent.tsx @@ -1,5 +1,6 @@ import React, { useContext } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Text } from 'uiSrc/components/base/text' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { Theme } from 'uiSrc/constants' @@ -21,6 +22,7 @@ import { CreateIndexFooter } from './CreateIndexFooter' import * as S from '../VectorSearchCreateIndexPage.styles' export const CreateIndexContent = () => { + const { t } = useTranslation() const { theme } = useContext(ThemeContext) const { mode, @@ -50,8 +52,7 @@ export const CreateIndexContent = () => { > - The indexing schema will appear here once you{'\n'} - select a key from the browser on the left. + {t('vectorSearch.createIndex.content.emptyState')} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexFooter.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexFooter.tsx index 150c5b881b..ef31fd472a 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexFooter.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexFooter.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { PrimaryButton, SecondaryButton, @@ -13,6 +14,7 @@ import { useCreateIndexPage } from '../../../context/create-index-page' import * as S from '../VectorSearchCreateIndexPage.styles' export const CreateIndexFooter = () => { + const { t } = useTranslation() const { loading, isCreateDisabled, @@ -36,9 +38,11 @@ export const CreateIndexFooter = () => { - {skippedFields.length === 1 - ? `Field "${skippedFields[0]}" was removed — nested objects and arrays cannot be indexed directly.` - : `${skippedFields.length} fields were removed (${skippedFields.join(', ')}) — nested objects and arrays cannot be indexed directly.`} + {t('vectorSearch.createIndex.footer.skippedFields', { + count: skippedFields.length, + name: skippedFields[0], + list: skippedFields.join(', '), + })} @@ -51,7 +55,7 @@ export const CreateIndexFooter = () => { onClick={handleCancel} data-testid="vector-search--create-index--cancel-btn" > - Cancel + {t('vectorSearch.createIndex.footer.cancel')} { onClick={handleCreateIndex} data-testid="vector-search--create-index--submit-btn" > - Create index + {t('vectorSearch.createIndex.footer.createIndex')} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx index 5d354f6abe..fe2f19c633 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Title } from 'uiSrc/components/base/text' import { RiTooltip } from 'uiSrc/components/base/tooltip' import { RiIcon } from 'uiSrc/components/base/icons' @@ -13,10 +14,8 @@ import { CreateIndexOnboardingStep } from '../../../components/create-index-onbo import { IndexNameEditor } from './IndexNameEditor' import * as S from '../VectorSearchCreateIndexPage.styles' -const INFO_TOOLTIP = - 'Select a key from the left panel to auto-detect the indexing schema.' - export const CreateIndexHeader = () => { + const { t } = useTranslation() const { mode, displayName, indexName, setIndexName, indexNameError, fields } = useCreateIndexPage() const { startOnboarding } = useCreateIndexOnboarding() @@ -46,14 +45,16 @@ export const CreateIndexHeader = () => { data-testid="vector-search--create-index--title" > {isSampleData - ? `View sample data index: ${displayName}` - : 'Define search index:'} + ? t('vectorSearch.createIndex.header.sampleTitle', { + name: displayName, + }) + : t('vectorSearch.createIndex.header.defineTitle')} {!isSampleData && !hasFields && ( diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexToolbar.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexToolbar.tsx index 4fb603b6dd..007904b4da 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexToolbar.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexToolbar.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { EmptyButton } from 'uiSrc/components/base/forms/buttons' import { Text } from 'uiSrc/components/base/text' import { ButtonGroup } from 'uiSrc/components/base/forms/button-group/ButtonGroup' @@ -15,6 +16,7 @@ import { CreateIndexOnboardingStep } from '../../../components/create-index-onbo import * as S from '../VectorSearchCreateIndexPage.styles' export const CreateIndexToolbar = () => { + const { t } = useTranslation() const { mode, activeTab, @@ -56,14 +58,14 @@ export const CreateIndexToolbar = () => { onClick={() => setActiveTab(CreateIndexTab.Table)} data-testid="vector-search--create-index--table-view-btn" > - Table view + {t('vectorSearch.createIndex.toolbar.tableView')} setActiveTab(CreateIndexTab.Command)} data-testid="vector-search--create-index--command-view-btn" > - Command view + {t('vectorSearch.createIndex.toolbar.commandView')} @@ -77,7 +79,7 @@ export const CreateIndexToolbar = () => { onClick={openAddFieldModal} data-testid="vector-search--create-index--add-field-btn" > - + Add field + {t('vectorSearch.createIndex.toolbar.addField')} @@ -88,7 +90,7 @@ export const CreateIndexToolbar = () => { > - Index prefix: + {t('vectorSearch.createIndex.toolbar.indexPrefix')} {isExistingData ? ( { + const { t } = useTranslation() const [isEditing, setIsEditing] = useState(false) const [draft, setDraft] = useState(indexName) const inputRef = useRef(null) @@ -85,7 +87,7 @@ export const IndexNameEditor = ({ @@ -93,7 +95,7 @@ export const IndexNameEditor = ({ icon={CheckThinIcon} size="S" color="primary" - aria-label="Confirm index name" + aria-label={t('vectorSearch.createIndex.indexName.confirmName')} onClick={confirmEditing} disabled={hasError} data-testid="index-name-confirm-btn" @@ -126,7 +128,7 @@ export const IndexNameEditor = ({ diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx index a589dddcfc..b4c941188e 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { ToggleButton } from 'uiSrc/components/base/forms/buttons' import { Menu, @@ -14,6 +15,7 @@ import { useVectorSearch } from '../../../../context/vector-search' import { SearchTelemetrySource } from '../../../../telemetry.constants' export const CreateIndexMenu = () => { + const { t } = useTranslation() const { openPickSampleDataModal, navigateToExistingDataFlow, @@ -25,15 +27,15 @@ export const CreateIndexMenu = () => { const existingDataTooltip = useMemo(() => { if (hasExistingKeysLoading) { - return 'Checking for existing keys…' + return t('vectorSearch.list.createMenu.checkingKeys') } if (!hasExistingKeys) { - return 'No Hash or JSON keys found in your database' + return t('vectorSearch.list.createMenu.noKeys') } return null - }, [hasExistingKeysLoading, hasExistingKeys]) + }, [hasExistingKeysLoading, hasExistingKeys, t]) const handleSampleData = useCallback( () => openPickSampleDataModal(SearchTelemetrySource.List), @@ -49,12 +51,12 @@ export const CreateIndexMenu = () => { - + Create search index + {t('vectorSearch.list.createMenu.create')} @@ -62,7 +64,7 @@ export const CreateIndexMenu = () => { content={isExistingDataDisabled ? existingDataTooltip : null} > ( - -) +}: DeleteIndexConfirmationProps) => { + const { t } = useTranslation() + return ( + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/header-title/HeaderTitle.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/header-title/HeaderTitle.tsx index 17dd2dba62..95007e1c63 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/header-title/HeaderTitle.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/header-title/HeaderTitle.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Title } from 'uiSrc/components/base/text/Title' import { Text } from 'uiSrc/components/base/text' import { Row } from 'uiSrc/components/base/layout/flex' @@ -13,12 +14,13 @@ import { getUtmExternalLink } from 'uiSrc/utils/links' import * as S from './HeaderTitle.styles' export const HeaderTitle = () => { + const { t } = useTranslation() const [isInfoPopoverOpen, setIsInfoPopoverOpen] = useState(false) return ( - Search indexes + {t('vectorSearch.list.header.title')} { } > - - A search index organizes your data to enable fast Vector, full-text, - hybrid, and numeric searches in Redis. - + {t('vectorSearch.list.header.description')} { external data-testid="vector-search--list--learn-more-link" > - Learn more + {t('vectorSearch.list.header.learnMore')} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/header-title/HeaderTitle.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/header-title/HeaderTitle.tsx index 8910fcf2bd..eb7c6e0b21 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/header-title/HeaderTitle.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/header-title/HeaderTitle.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Breadcrumbs } from 'uiSrc/components/base/navigation/breadcrumbs' import { useHistory, useParams } from 'react-router-dom' +import { useTranslation } from 'uiSrc/i18n' import { Title } from 'uiSrc/components/base/text' import { Pages } from 'uiSrc/constants' import { RiIcon } from 'uiSrc/components/base/icons' @@ -23,6 +24,7 @@ export const HeaderTitle = ({ indexOptions, onIndexChange, }: HeaderTitleProps) => { + const { t } = useTranslation() const { instanceId } = useParams<{ instanceId: string }>() const history = useHistory() @@ -32,7 +34,7 @@ export const HeaderTitle = ({ return ( @@ -44,7 +46,7 @@ export const HeaderTitle = ({ > - Indexes + {t('vectorSearch.query.breadcrumb.indexes')} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/view-index-button/ViewIndexButton.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/view-index-button/ViewIndexButton.tsx index 47ae69f03c..3e07546653 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/view-index-button/ViewIndexButton.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/components/view-index-button/ViewIndexButton.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { ToggleButton } from 'uiSrc/components/base/forms/buttons' export interface ViewIndexButtonProps { @@ -10,12 +11,16 @@ export interface ViewIndexButtonProps { export const ViewIndexButton = ({ isActive, onClick, -}: ViewIndexButtonProps) => ( - - View index - -) +}: ViewIndexButtonProps) => { + const { t } = useTranslation() + + return ( + + {t('vectorSearch.query.viewIndexButton')} + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.ts b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.ts index 5817fdc136..4f093a3dfd 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.ts +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useParams } from 'react-router-dom' import { chunk } from 'lodash' +import { useTranslation } from 'uiSrc/i18n' import { Nullable, getCommandsForExecution, @@ -28,6 +29,7 @@ import { } from './useQuery.utils' export const useQuery = () => { + const { t } = useTranslation() const { instanceId } = useParams<{ instanceId: string }>() const scrollDivRef = useRef(null) @@ -85,26 +87,31 @@ export const useQuery = () => { [resultsMode], ) - const handleApiError = useCallback((error: unknown) => { - const message = - error instanceof Error ? error.message : 'Failed to execute command' + const handleApiError = useCallback( + (error: unknown) => { + const message = + error instanceof Error + ? error.message + : t('vectorSearch.query.error.executeCommand') - setItems((prevItems) => - prevItems.map((item) => { - if (item.loading) { - return { - ...item, - loading: false, - error: message, - result: createErrorResult(message), - isOpen: true, + setItems((prevItems) => + prevItems.map((item) => { + if (item.loading) { + return { + ...item, + loading: false, + error: message, + result: createErrorResult(message), + isOpen: true, + } } - } - return item - }), - ) - setProcessing(false) - }, []) + return item + }), + ) + setProcessing(false) + }, + [t], + ) const executeCommandBatch = useCallback( async ( @@ -257,7 +264,7 @@ export const useQuery = () => { ? { ...i, loading: false, - error: 'Failed to load command details', + error: t('vectorSearch.query.error.loadCommandDetails'), } : i, ), @@ -269,7 +276,7 @@ export const useQuery = () => { setItems((prev) => prev.map((i) => (i.id === id ? { ...i, isOpen } : i))) }, - [items, instanceId], + [items, instanceId, t], ) return { diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.spec.ts b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.spec.ts index 38d1dfcbc1..13e3198048 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.spec.ts +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.spec.ts @@ -86,7 +86,7 @@ describe('useQuery.utils', () => { const result = createGroupItem(5, commandId) expect(result).toEqual({ - command: '5 - Command(s)', + command: '5 - Commands', id: commandId, loading: true, isOpen: true, diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.ts b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.ts index 0652334021..cd048cc378 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.ts +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchQueryPage/hooks/useQuery.utils.ts @@ -1,3 +1,4 @@ +import i18n from 'uiSrc/i18n' import { scrollIntoView } from 'uiSrc/utils' import { CommandExecutionUI } from 'uiSrc/slices/interfaces' import { WORKBENCH_HISTORY_MAX_LENGTH } from 'uiSrc/pages/workbench/constants' @@ -28,7 +29,7 @@ export const createGroupItem = ( itemCount: number, commandId: string, ): CommandExecutionUI => ({ - command: `${itemCount} - Command(s)`, + command: i18n.t('vectorSearch.query.groupCommandLabel', { count: itemCount }), id: commandId, loading: true, isOpen: true, diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx index 962739fd82..356cada462 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx @@ -1,5 +1,7 @@ import React, { useCallback } from 'react' +import { useTranslation } from 'uiSrc/i18n' + import { WelcomeScreen } from '../../components/welcome-screen' import { useVectorSearch } from '../../context/vector-search' import { SearchTelemetrySource } from '../../telemetry.constants' @@ -10,6 +12,7 @@ import { SearchTelemetrySource } from '../../telemetry.constants' * context, providing callbacks and configuration. */ export const VectorSearchWelcomePage = () => { + const { t } = useTranslation() const { openPickSampleDataModal, navigateToExistingDataFlow, @@ -18,9 +21,9 @@ export const VectorSearchWelcomePage = () => { } = useVectorSearch() const useMyDatabaseDisabled = hasExistingKeysLoading - ? { tooltip: 'Checking for existing keys…' } + ? { tooltip: t('vectorSearch.welcome.checkingKeys') } : !hasExistingKeys - ? { tooltip: 'No Hash or JSON keys found in your database' } + ? { tooltip: t('vectorSearch.welcome.noKeysFound') } : undefined const handleTrySampleData = useCallback( From ab9ad286a245c88004f3ae4afa8a70bce41ee760 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 13 Jul 2026 16:33:10 +0300 Subject: [PATCH 024/166] Fix jest watch scoping and add typeahead filtering (#6192) --- jest.config.cjs | 12 ++++++++++++ package.json | 11 ++++++----- yarn.lock | 42 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/jest.config.cjs b/jest.config.cjs index c069331638..bd462c7b32 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -2,6 +2,18 @@ require('dotenv').config({ path: './redisinsight/ui/.env.test' }); /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ module.exports = { + // Limit discovery to UI sources and the api-client they import, so watch + // only reruns on relevant changes. + roots: [ + '/redisinsight/ui', + '/redisinsight/__mocks__', + '/redisinsight/api-client', + ], + // Fuzzy filename / test-name filtering in --watch (the `p` and `t` prompts). + watchPlugins: [ + 'jest-watch-typeahead/filename', + 'jest-watch-typeahead/testname', + ], testEnvironmentOptions: { url: 'http://localhost/', customExportConditions: [''], diff --git a/package.json b/package.json index 80836276a3..b5e7594800 100644 --- a/package.json +++ b/package.json @@ -47,13 +47,13 @@ "package:mac:arm": "yarn build:prod && electron-builder build --mac --arm64 -p never", "package:linux": "yarn build:prod && electron-builder build --linux -p never", "postinstall": "patch-package && vite optimize -c ./redisinsight/ui/vite.config.mjs && skip-postinstall || yarn-deduplicate yarn.lock", - "test": "jest ./redisinsight/ui -w 1", + "test": "jest -w 1", "test:api": "yarn --cwd redisinsight/api test", "test:api:integration": "yarn --cwd redisinsight/api test:api", - "test:watch": "jest ./redisinsight/ui --watch -w 1", - "test:cov": "cross-env NODE_OPTIONS='' jest ./redisinsight/ui --testLocationInResults --json --outputFile=\"report/coverage/report.json\" --silent --coverage --no-cache --forceExit -w 3", - "test:cov:unit": "jest ./redisinsight/ui --group=-component --coverage -w 1", - "test:cov:component": "jest ./redisinsight/ui --group=component --coverage -w 1", + "test:watch": "jest --watch -w 1", + "test:cov": "cross-env NODE_OPTIONS='' jest --testLocationInResults --json --outputFile=\"report/coverage/report.json\" --silent --coverage --no-cache --forceExit -w 3", + "test:cov:unit": "jest --group=-component --coverage -w 1", + "test:cov:component": "jest --group=component --coverage -w 1", "type-check": "yarn --cwd redisinsight/ui type-check && yarn --cwd redisinsight/api type-check && yarn --cwd redisinsight/desktop type-check && tsc --project configs/tsconfig.json --noEmit", "tscheck": "yarn --cwd redisinsight/ui tscheck && yarn --cwd redisinsight/api tscheck && yarn --cwd redisinsight/desktop tscheck", "tscheck:force": "yarn --cwd redisinsight/ui tscheck:force && yarn --cwd redisinsight/api tscheck:force && yarn --cwd redisinsight/desktop tscheck:force", @@ -209,6 +209,7 @@ "jest-fixed-jsdom": "^0.0.10", "jest-html-reporters": "^3.1.7", "jest-runner-groups": "^2.2.0", + "jest-watch-typeahead": "^2.2.2", "jest-when": "^4.0.2", "json-stable-stringify": "^1.3.0", "license-checker": "^25.0.1", diff --git a/yarn.lock b/yarn.lock index d4185f95f0..7be5bf6d15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5533,6 +5533,11 @@ ansi-escapes@^4.2.1: dependencies: type-fest "^0.21.3" +ansi-escapes@^6.0.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-6.2.1.tgz#76c54ce9b081dad39acec4b5d53377913825fb0f" + integrity sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig== + ansi-escapes@^7.0.0: version "7.3.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz#5395bb74b2150a4a1d6e3c2565f4aeca78d28627" @@ -6405,7 +6410,7 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.6.2: +chalk@^5.2.0, chalk@^5.6.2: version "5.6.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== @@ -6415,6 +6420,11 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +char-regex@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.2.tgz#81385bb071af4df774bff8721d0ca15ef29ea0bb" + integrity sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg== + character-entities-html4@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.4.tgz#0e64b0a3753ddbf1fdc044c5fd01d0199a02e125" @@ -10838,7 +10848,7 @@ jest-regex-util@30.0.1: resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== -jest-regex-util@^29.6.3: +jest-regex-util@^29.0.0, jest-regex-util@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== @@ -10988,7 +10998,20 @@ jest-validate@^29.7.0: leven "^3.1.0" pretty-format "^29.7.0" -jest-watcher@^29.7.0: +jest-watch-typeahead@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/jest-watch-typeahead/-/jest-watch-typeahead-2.2.2.tgz#5516d3cd006485caa5cfc9bd1de40f1f8b136abf" + integrity sha512-+QgOFW4o5Xlgd6jGS5X37i08tuuXNW8X0CV9WNFi+3n8ExCIP+E1melYhvYLjv5fE6D0yyzk74vsSO8I6GqtvQ== + dependencies: + ansi-escapes "^6.0.0" + chalk "^5.2.0" + jest-regex-util "^29.0.0" + jest-watcher "^29.0.0" + slash "^5.0.0" + string-length "^5.0.1" + strip-ansi "^7.0.1" + +jest-watcher@^29.0.0, jest-watcher@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== @@ -15055,6 +15078,11 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slash@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== + slice-ansi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" @@ -15344,6 +15372,14 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" +string-length@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-5.0.1.tgz#3d647f497b6e8e8d41e422f7e0b23bc536c8381e" + integrity sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow== + dependencies: + char-regex "^2.0.0" + strip-ansi "^7.0.1" + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" From 4a791f1f724cda28ccb57feea0dcf5d9f6cf565e Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Tue, 14 Jul 2026 10:29:04 +0300 Subject: [PATCH 025/166] feat(i18n): migrate cluster-details analytics page (RI-8276) (#6198) --- redisinsight/ui/src/i18n/locales/bg.json | 20 +++++++++ redisinsight/ui/src/i18n/locales/en.json | 20 +++++++++ .../cluster-details/ClusterDetailsPage.tsx | 4 +- .../ClusterNodesTable.constants.ts | 18 ++++---- .../ClusterNodesEmptyState.tsx | 7 ++-- .../utils/formatters.ts | 10 +++-- .../ClusterDetailsGraphics.tsx | 16 ++++++-- .../ClusterDetailsHeader.tsx | 41 +++++++++++-------- 8 files changed, 102 insertions(+), 34 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index efc578a03b..b287084b6a 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -1,4 +1,24 @@ { + "analytics.clusterDetails.graphics.keys": "Ключове", + "analytics.clusterDetails.graphics.memory": "Памет", + "analytics.clusterDetails.header.defaultUsername": "По подразбиране", + "analytics.clusterDetails.header.type": "Тип", + "analytics.clusterDetails.header.uptime": "Време на работа", + "analytics.clusterDetails.header.user": "Потребител", + "analytics.clusterDetails.header.version": "Версия", + "analytics.clusterDetails.pageTitle": "{{dbName}} - Преглед", + "analytics.clusterDetails.table.clients": "Клиенти", + "analytics.clusterDetails.table.commandsPerSec": "Команди/сек", + "analytics.clusterDetails.table.emptyState": "Данните за първичните възли не са налични за тази клъстерна конфигурация.", + "analytics.clusterDetails.table.networkInput": "Входящ трафик", + "analytics.clusterDetails.table.networkOutput": "Изходящ трафик", + "analytics.clusterDetails.table.primaryNodes_one": "{{count}} първичен възел", + "analytics.clusterDetails.table.primaryNodes_other": "{{count}} първични възела", + "analytics.clusterDetails.table.totalKeys": "Общо ключове", + "analytics.clusterDetails.table.totalMemory": "Обща памет", + "analytics.units.bytes": "Б", + "analytics.units.kbps": "кб/с", + "analytics.units.percent": "%", "api.agreement.analytics.description": "Помогнете за подобряването на Redis Insight, като споделяте анонимни данни за употреба. Това ни помага да разберем използването на функциите и да направим приложението по-добро. Активирайки това, се съгласявате с нашата ", "api.agreement.analytics.label": "Данни за употреба", "api.agreement.notifications.description": "Изберете, за да се показват известия. В противен случай известията се показват в Центъра за известия.", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 9edcc6f3a5..a663b689e7 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -1,4 +1,24 @@ { + "analytics.clusterDetails.graphics.keys": "Keys", + "analytics.clusterDetails.graphics.memory": "Memory", + "analytics.clusterDetails.header.defaultUsername": "Default", + "analytics.clusterDetails.header.type": "Type", + "analytics.clusterDetails.header.uptime": "Uptime", + "analytics.clusterDetails.header.user": "User", + "analytics.clusterDetails.header.version": "Version", + "analytics.clusterDetails.pageTitle": "{{dbName}} - Overview", + "analytics.clusterDetails.table.clients": "Clients", + "analytics.clusterDetails.table.commandsPerSec": "Commands/s", + "analytics.clusterDetails.table.emptyState": "Primary node details are not available for this cluster configuration.", + "analytics.clusterDetails.table.networkInput": "Network Input", + "analytics.clusterDetails.table.networkOutput": "Network Output", + "analytics.clusterDetails.table.primaryNodes_one": "{{count}} Primary node", + "analytics.clusterDetails.table.primaryNodes_other": "{{count}} Primary nodes", + "analytics.clusterDetails.table.totalKeys": "Total Keys", + "analytics.clusterDetails.table.totalMemory": "Total Memory", + "analytics.units.bytes": "B", + "analytics.units.kbps": "kb/s", + "analytics.units.percent": "%", "api.agreement.analytics.description": "Help improve Redis Insight by sharing anonymous usage data. This helps us understand feature usage and make the app better. By enabling this, you agree to our ", "api.agreement.analytics.label": "Usage Data", "api.agreement.notifications.description": "Select to display notifications. Otherwise, notifications are shown in the Notification Center.", diff --git a/redisinsight/ui/src/pages/cluster-details/ClusterDetailsPage.tsx b/redisinsight/ui/src/pages/cluster-details/ClusterDetailsPage.tsx index 2ee6b93f95..634359b542 100644 --- a/redisinsight/ui/src/pages/cluster-details/ClusterDetailsPage.tsx +++ b/redisinsight/ui/src/pages/cluster-details/ClusterDetailsPage.tsx @@ -26,6 +26,7 @@ import { import { ColorScheme, getRGBColorByScheme, RGBColor } from 'uiSrc/utils/colors' import { ConnectionType } from 'uiSrc/slices/interfaces' +import { useTranslation } from 'uiSrc/i18n' import { ClusterDetailsHeader, ClusterDetailsGraphics, @@ -43,6 +44,7 @@ export interface ModifiedClusterNodes extends ClusterNodeDetails { const POLLING_INTERVAL = 5_000 const ClusterDetailsPage = () => { + const { t } = useTranslation() let interval: NodeJS.Timeout const { instanceId } = useParams<{ instanceId: string }>() const { @@ -59,7 +61,7 @@ const ClusterDetailsPage = () => { const { theme } = useContext(ThemeContext) const dbName = `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)}` - setTitle(`${dbName} - Overview`) + setTitle(t('analytics.clusterDetails.pageTitle', { dbName })) const colorScheme: ColorScheme = { cHueStart: 180, diff --git a/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/ClusterNodesTable.constants.ts b/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/ClusterNodesTable.constants.ts index 5275542344..fb10f0f0c2 100644 --- a/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/ClusterNodesTable.constants.ts +++ b/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/ClusterNodesTable.constants.ts @@ -1,3 +1,4 @@ +import i18n from 'uiSrc/i18n' import { ColumnDef, SortingState } from 'uiSrc/components/base/layout/table' import { ModifiedClusterNodes } from '../../ClusterDetailsPage' @@ -14,7 +15,10 @@ export const DEFAULT_SORTING: SortingState = [ export const DEFAULT_CLUSTER_NODES_COLUMNS: ColumnDef[] = [ { - header: ({ table }) => `${table.options.data.length} Primary nodes`, + header: ({ table }) => + i18n.t('analytics.clusterDetails.table.primaryNodes', { + count: table.options.data.length, + }), isHeaderCustom: true, id: 'host', accessorKey: 'host', @@ -22,42 +26,42 @@ export const DEFAULT_CLUSTER_NODES_COLUMNS: ColumnDef[] = cell: ClusterNodesHostCell, }, { - header: 'Commands/s', + header: () => i18n.t('analytics.clusterDetails.table.commandsPerSec'), id: 'opsPerSecond', accessorKey: 'opsPerSecond', enableSorting: true, cell: ClusterNodesNumericCell, }, { - header: 'Network Input', + header: () => i18n.t('analytics.clusterDetails.table.networkInput'), id: 'networkInKbps', accessorKey: 'networkInKbps', enableSorting: true, cell: ClusterNodesNumericCell, }, { - header: 'Network Output', + header: () => i18n.t('analytics.clusterDetails.table.networkOutput'), id: 'networkOutKbps', accessorKey: 'networkOutKbps', enableSorting: true, cell: ClusterNodesNumericCell, }, { - header: 'Total Memory', + header: () => i18n.t('analytics.clusterDetails.table.totalMemory'), id: 'usedMemory', accessorKey: 'usedMemory', enableSorting: true, cell: ClusterNodesNumericCell, }, { - header: 'Total Keys', + header: () => i18n.t('analytics.clusterDetails.table.totalKeys'), id: 'totalKeys', accessorKey: 'totalKeys', enableSorting: true, cell: ClusterNodesNumericCell, }, { - header: 'Clients', + header: () => i18n.t('analytics.clusterDetails.table.clients'), id: 'connectedClients', accessorKey: 'connectedClients', enableSorting: true, diff --git a/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesEmptyState/ClusterNodesEmptyState.tsx b/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesEmptyState/ClusterNodesEmptyState.tsx index 27627e1110..f2345580cf 100644 --- a/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesEmptyState/ClusterNodesEmptyState.tsx +++ b/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesEmptyState/ClusterNodesEmptyState.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { LoadingContent } from 'uiSrc/components' import { Text } from 'uiSrc/components/base/text' @@ -12,6 +13,8 @@ interface ClusterNodesEmptyStateProps { export const ClusterNodesEmptyState = ({ loading, }: ClusterNodesEmptyStateProps) => { + const { t } = useTranslation() + if (loading) { return ( @@ -22,9 +25,7 @@ export const ClusterNodesEmptyState = ({ return ( - - Primary node details are not available for this cluster configuration. - + {t('analytics.clusterDetails.table.emptyState')} ) } diff --git a/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesNumericCell/utils/formatters.ts b/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesNumericCell/utils/formatters.ts index 397d8d4ac9..d57dc64d9c 100644 --- a/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesNumericCell/utils/formatters.ts +++ b/redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/components/ClusterNodesNumericCell/utils/formatters.ts @@ -1,3 +1,4 @@ +import i18n from 'uiSrc/i18n' import { formatBytes } from 'uiSrc/utils' import { numberWithSpaces } from 'uiSrc/utils/numbers' import { ModifiedClusterNodes } from 'uiSrc/pages/cluster-details/ClusterDetailsPage' @@ -6,12 +7,15 @@ export const displayValueFormatter: Partial< Record string> > = { usedMemory: (v) => formatBytes(v, 3, false).toString(), - networkInKbps: (v) => `${numberWithSpaces(v)} kb/s`, - networkOutKbps: (v) => `${numberWithSpaces(v)} kb/s`, + networkInKbps: (v) => + `${numberWithSpaces(v)} ${i18n.t('analytics.units.kbps')}`, + networkOutKbps: (v) => + `${numberWithSpaces(v)} ${i18n.t('analytics.units.kbps')}`, } export const tooltipContentFormatter: Partial< Record string> > = { - usedMemory: (v) => `${numberWithSpaces(v)} B`, + usedMemory: (v) => + `${numberWithSpaces(v)} ${i18n.t('analytics.units.bytes')}`, } diff --git a/redisinsight/ui/src/pages/cluster-details/components/cluster-details-graphics/ClusterDetailsGraphics.tsx b/redisinsight/ui/src/pages/cluster-details/components/cluster-details-graphics/ClusterDetailsGraphics.tsx index c05aa4bd57..af92ed95bf 100644 --- a/redisinsight/ui/src/pages/cluster-details/components/cluster-details-graphics/ClusterDetailsGraphics.tsx +++ b/redisinsight/ui/src/pages/cluster-details/components/cluster-details-graphics/ClusterDetailsGraphics.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react' import { DonutChart } from 'uiSrc/components/charts' import { ChartData } from 'uiSrc/components/charts/donut-chart/DonutChart' import { ModifiedClusterNodes } from 'uiSrc/pages/cluster-details/ClusterDetailsPage' +import { useTranslation } from 'uiSrc/i18n' import { formatBytes, Nullable } from 'uiSrc/utils' import { getPercentage, numberWithSpaces } from 'uiSrc/utils/numbers' import { Title } from 'uiSrc/components/base/text/Title' @@ -18,6 +19,7 @@ const ClusterDetailsGraphics = ({ nodes: Nullable dataLoaded: boolean }) => { + const { t } = useTranslation() // Show loading until data is received; don't show during refresh polls const showLoading = !dataLoaded const [memoryData, setMemoryData] = useState([]) @@ -38,7 +40,8 @@ const ClusterDetailsGraphics = ({ className={styles.tooltipPercentage} data-testid="tooltip-node-percent" > - {getPercentage(data.value, memorySum)}% + {getPercentage(data.value, memorySum)} + {t('analytics.units.percent')} ( {formatBytes(data.value, 3, false)} ) @@ -60,7 +63,8 @@ const ClusterDetailsGraphics = ({ className={styles.tooltipPercentage} data-testid="tooltip-node-percent" > - {getPercentage(data.value, keysSum)}% + {getPercentage(data.value, keysSum)} + {t('analytics.units.percent')} ( {numberWithSpaces(data.value)} ) @@ -119,7 +123,9 @@ const ClusterDetailsGraphics = ({
- Memory + + {t('analytics.clusterDetails.graphics.memory')} +

@@ -137,7 +143,9 @@ const ClusterDetailsGraphics = ({
- Keys + + {t('analytics.clusterDetails.graphics.keys')} +

diff --git a/redisinsight/ui/src/pages/cluster-details/components/cluster-details-header/ClusterDetailsHeader.tsx b/redisinsight/ui/src/pages/cluster-details/components/cluster-details-header/ClusterDetailsHeader.tsx index ecc4b4a2f1..fd00f61ec5 100644 --- a/redisinsight/ui/src/pages/cluster-details/components/cluster-details-header/ClusterDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/cluster-details/components/cluster-details-header/ClusterDetailsHeader.tsx @@ -1,6 +1,7 @@ import React from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' +import { useTranslation } from 'uiSrc/i18n' import { LoadingContent } from 'uiSrc/components/base/layout' import { truncateNumberToFirstUnit, @@ -26,56 +27,64 @@ import { } from './ClusterDetailsHeader.styles' interface IMetrics { + id: string label: string value: any border?: 'left' } const MAX_NAME_LENGTH = 30 -const DEFAULT_USERNAME = 'Default' const ClusterDetailsHeader = () => { - const { - username = DEFAULT_USERNAME, - connectionType = ConnectionType.Cluster, - } = useAppSelector(connectedInstanceSelector) + const { t } = useTranslation() + const { username, connectionType = ConnectionType.Cluster } = useAppSelector( + connectedInstanceSelector, + ) const { data, loading } = useAppSelector(clusterDetailsSelector) + const defaultUsername = t('analytics.clusterDetails.header.defaultUsername') + const metrics: IMetrics[] = [ { - label: 'Type', + id: 'Type', + label: t('analytics.clusterDetails.header.type'), value: CONNECTION_TYPE_DISPLAY[connectionType], }, { - label: 'Version', + id: 'Version', + label: t('analytics.clusterDetails.header.version'), value: data?.version || '', }, { - label: 'User', + id: 'User', + label: t('analytics.clusterDetails.header.user'), value: - (username || DEFAULT_USERNAME)?.length < MAX_NAME_LENGTH ? ( - username || DEFAULT_USERNAME + (username || defaultUsername)?.length < MAX_NAME_LENGTH ? ( + username || defaultUsername ) : ( {formatLongName(username || DEFAULT_USERNAME)}} + content={<>{formatLongName(username || defaultUsername)}} >
- {formatLongName(username || DEFAULT_USERNAME, MAX_NAME_LENGTH, 5)} + {formatLongName(username || defaultUsername, MAX_NAME_LENGTH, 5)}
), }, { - label: 'Uptime', + id: 'Uptime', + label: t('analytics.clusterDetails.header.uptime'), border: 'left', value: ( + {/* seconds suffix kept literal to match the untranslated + duration from truncateNumberToDuration below */} {`${nullableNumberWithSpaces(data?.uptimeSec) || 0} s`}
{`(${truncateNumberToDuration(data?.uptimeSec || 0)})`} @@ -100,11 +109,11 @@ const ClusterDetailsHeader = () => { )} {data && ( - {metrics.map(({ value, label, border }) => ( + {metrics.map(({ id, value, label, border }) => ( {value} {label} From 6d1eb7e1e06bab3e5b66580e9320587736aaf619 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 14 Jul 2026 13:06:33 +0300 Subject: [PATCH 026/166] RI-8308: Add ability to choose IPv4/IPv6 when connecting to a database (#6197) --- ...784000000000-database-connection-family.ts | 19 +++++ redisinsight/api/migration/index.ts | 2 + .../database-import.service.spec.ts | 5 ++ .../database-import.service.ts | 1 + .../dto/import.database.dto.ts | 1 + .../modules/database/database.service.spec.ts | 4 + .../src/modules/database/database.service.ts | 1 + .../database/dto/create.database.dto.ts | 1 + .../database/entities/database.entity.ts | 11 +++ .../src/modules/database/models/database.ts | 17 +++++ .../database/models/export-database.ts | 1 + .../repositories/local.database.repository.ts | 1 + .../ioredis.redis.connection.strategy.ts | 15 +++- .../node.redis.connection.strategy.spec.ts | 39 +++++++++- .../node.redis.connection.strategy.ts | 15 +++- .../modules/redis/utils/family.util.spec.ts | 20 +++++ .../src/modules/redis/utils/family.util.ts | 13 ++++ .../api/src/modules/redis/utils/index.ts | 1 + .../test/api/database/GET-databases.test.ts | 1 + .../api/database/PATCH-databases-id.test.ts | 1 + .../database/POST-databases-export.test.ts | 1 + .../test/api/database/POST-databases.test.ts | 75 +++++++++++++++++++ .../api/test/api/database/constants.ts | 1 + redisinsight/api/test/helpers/constants.ts | 2 + .../components/form/DatabaseForm.spec.tsx | 11 +++ .../home/components/form/DatabaseForm.tsx | 37 +++++++++ .../ManualConnectionWrapper.tsx | 2 + .../ui/src/pages/home/interfaces/form.ts | 2 + redisinsight/ui/src/pages/home/utils/form.tsx | 3 +- .../ui/src/slices/interfaces/instances.ts | 2 + 30 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 redisinsight/api/migration/1784000000000-database-connection-family.ts create mode 100644 redisinsight/api/src/modules/redis/utils/family.util.spec.ts create mode 100644 redisinsight/api/src/modules/redis/utils/family.util.ts diff --git a/redisinsight/api/migration/1784000000000-database-connection-family.ts b/redisinsight/api/migration/1784000000000-database-connection-family.ts new file mode 100644 index 0000000000..9da7c8f780 --- /dev/null +++ b/redisinsight/api/migration/1784000000000-database-connection-family.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DatabaseConnectionFamily1784000000000 + implements MigrationInterface +{ + name = 'DatabaseConnectionFamily1784000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "database_instance" ADD COLUMN "connectionFamily" varchar NOT NULL DEFAULT ('auto')`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "database_instance" DROP COLUMN "connectionFamily"`, + ); + } +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index c63f7b4265..5ca4a3739b 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -59,6 +59,7 @@ import { QueryLibrary1771500000000 } from './1771500000000-query-library'; import { DatabaseIsProduction1778758000000 } from './1778758000000-database-isProduction'; import { Environment1779000000000 } from './1779000000000-database-environment'; import { DropDatabaseIsProduction1779000000001 } from './1779000000001-drop-database-isProduction'; +import { DatabaseConnectionFamily1784000000000 } from './1784000000000-database-connection-family'; export default [ initialMigration1614164490968, @@ -122,4 +123,5 @@ export default [ DatabaseIsProduction1778758000000, Environment1779000000000, DropDatabaseIsProduction1779000000001, + DatabaseConnectionFamily1784000000000, ]; diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts index 7340248626..ac2b63226d 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -204,6 +204,7 @@ describe('DatabaseImportService', () => { 'compressor', 'modules', 'environment', + 'connectionFamily', ]), provider: 'REDIS_CLOUD', new: true, @@ -232,6 +233,7 @@ describe('DatabaseImportService', () => { 'compressor', 'modules', 'environment', + 'connectionFamily', ]), name: `${mockDatabase.host}:${mockDatabase.port}`, new: true, @@ -260,6 +262,7 @@ describe('DatabaseImportService', () => { 'compressor', 'modules', 'environment', + 'connectionFamily', ]), compressor: Compressor.NONE, new: true, @@ -290,6 +293,7 @@ describe('DatabaseImportService', () => { 'modules', 'tlsServername', 'environment', + 'connectionFamily', ]), compressor: Compressor.GZIP, tlsServername: 'redis-insight', @@ -319,6 +323,7 @@ describe('DatabaseImportService', () => { 'compressor', 'modules', 'environment', + 'connectionFamily', ]), connectionType: ConnectionType.CLUSTER, new: true, diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index c9a79f4328..d5317d0721 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -115,6 +115,7 @@ export class DatabaseImportService { ['tags', ['tags']], ['providerDetails', ['providerDetails']], ['environment', ['environment']], + ['connectionFamily', ['connectionFamily']], ]; constructor( diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index 122c8cf38f..4b26dc643b 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -43,6 +43,7 @@ export class ImportDatabaseDto extends PickType(Database, [ 'forceStandalone', 'tags', 'environment', + 'connectionFamily', ] as const) { @Expose() @IsNotEmpty() diff --git a/redisinsight/api/src/modules/database/database.service.spec.ts b/redisinsight/api/src/modules/database/database.service.spec.ts index 6f6975d60a..cb212a00d3 100644 --- a/redisinsight/api/src/modules/database/database.service.spec.ts +++ b/redisinsight/api/src/modules/database/database.service.spec.ts @@ -39,6 +39,7 @@ import ERROR_MESSAGES from 'src/constants/error-messages'; import { Compressor, Environment, + RedisConnectionFamily, } from 'src/modules/database/entities/database.entity'; import { RedisClientFactory } from 'src/modules/redis/redis.client.factory'; import { RedisClientStorage } from 'src/modules/redis/redis.client.storage'; @@ -61,6 +62,7 @@ const updateDatabaseTests = [ { input: { sentinelMaster: 'master' }, expected: 1 }, { input: { caCert: mockCaCertificate }, expected: 1 }, { input: { clientCert: mockClientCertificate }, expected: 1 }, + { input: { connectionFamily: RedisConnectionFamily.IPv4 }, expected: 1 }, { input: { compressor: Compressor.NONE }, expected: 0 }, { input: { timeout: 45_000 }, expected: 0 }, { input: { port: 6379, timeout: 45_000 }, expected: 1 }, @@ -270,6 +272,7 @@ describe('DatabaseService', () => { timeout: 30000, compressor: Compressor.NONE, environment: Environment.Unspecified, + connectionFamily: RedisConnectionFamily.Auto, id: 'a77b23c1-7816-4ea4-b61f-d37795a0f805-db-id', name: 'database-name', host: '127.0.100.1', @@ -317,6 +320,7 @@ describe('DatabaseService', () => { timeout: 30000, compressor: Compressor.NONE, environment: Environment.Unspecified, + connectionFamily: RedisConnectionFamily.Auto, name: 'database-name', id: 'a77b23c1-7816-4ea4-b61f-d37795a0f805-db-id', host: '127.0.100.1', diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index f17198c030..8ac03e4757 100644 --- a/redisinsight/api/src/modules/database/database.service.ts +++ b/redisinsight/api/src/modules/database/database.service.ts @@ -48,6 +48,7 @@ export class DatabaseService { static connectionFields: string[] = [ 'host', 'port', + 'connectionFamily', 'db', 'username', 'password', diff --git a/redisinsight/api/src/modules/database/dto/create.database.dto.ts b/redisinsight/api/src/modules/database/dto/create.database.dto.ts index 29469c0e38..1179a65f0e 100644 --- a/redisinsight/api/src/modules/database/dto/create.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/create.database.dto.ts @@ -55,6 +55,7 @@ export class CreateDatabaseDto extends PickType(Database, [ 'forceStandalone', 'keyNameFormat', 'environment', + 'connectionFamily', ] as const) { @ApiPropertyOptional({ description: 'CA Certificate', diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 8c18b80be4..abb75002be 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -68,6 +68,13 @@ export enum Environment { Development = 'development', } +// IP protocol used to resolve the host: auto (dual-stack), IPv4, or IPv6. +export enum RedisConnectionFamily { + Auto = 'auto', + IPv4 = 'ipv4', + IPv6 = 'ipv6', +} + @Entity('database_instance') export class DatabaseEntity { @Expose() @@ -300,4 +307,8 @@ export class DatabaseEntity { @Expose() @Column({ nullable: false, default: Environment.Unspecified }) environment: Environment; + + @Expose() + @Column({ nullable: false, default: RedisConnectionFamily.Auto }) + connectionFamily: RedisConnectionFamily; } diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 3626c27c14..62ac0d7fc5 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -9,6 +9,7 @@ import { Encoding, Environment, HostingProvider, + RedisConnectionFamily, } from 'src/modules/database/entities/database.entity'; import { IsBoolean, @@ -384,4 +385,20 @@ export class Database { }) @IsOptional() environment?: Environment = Environment.Unspecified; + + @ApiPropertyOptional({ + description: + 'IP protocol family to use when connecting. "auto" resolves both IPv4 and IPv6 (dual-stack), "ipv4" forces IPv4, "ipv6" forces IPv6.', + default: RedisConnectionFamily.Auto, + enum: RedisConnectionFamily, + enumName: 'RedisConnectionFamily', + }) + @Expose() + @IsEnum(RedisConnectionFamily, { + message: `connectionFamily must be a valid enum value. Valid values: ${Object.values( + RedisConnectionFamily, + )}.`, + }) + @IsOptional() + connectionFamily?: RedisConnectionFamily = RedisConnectionFamily.Auto; } diff --git a/redisinsight/api/src/modules/database/models/export-database.ts b/redisinsight/api/src/modules/database/models/export-database.ts index 60e923f7c0..290e9ef9cc 100644 --- a/redisinsight/api/src/modules/database/models/export-database.ts +++ b/redisinsight/api/src/modules/database/models/export-database.ts @@ -27,4 +27,5 @@ export class ExportDatabase extends PickType(Database, [ 'tags', 'providerDetails', 'environment', + 'connectionFamily', ] as const) {} diff --git a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts index 09b5320e44..164cfc5df1 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts @@ -130,6 +130,7 @@ export class LocalDatabaseRepository extends DatabaseRepository { 'cd', 'd.createdAt', 'd.environment', + 'd.connectionFamily', 'tags', ]) .getMany(); diff --git a/redisinsight/api/src/modules/redis/connection/ioredis.redis.connection.strategy.ts b/redisinsight/api/src/modules/redis/connection/ioredis.redis.connection.strategy.ts index c507c3adee..461cfad73e 100644 --- a/redisinsight/api/src/modules/redis/connection/ioredis.redis.connection.strategy.ts +++ b/redisinsight/api/src/modules/redis/connection/ioredis.redis.connection.strategy.ts @@ -15,7 +15,7 @@ import { SentinelIoredisClient, ClusterIoredisClient, } from 'src/modules/redis/client'; -import { discoverClusterNodes } from 'src/modules/redis/utils'; +import { discoverClusterNodes, getIpFamily } from 'src/modules/redis/utils'; import { SshTunnel } from 'src/modules/ssh/models/ssh-tunnel'; import { getRedisConnectionException } from 'src/utils'; import { ReplyError } from 'src/models'; @@ -46,13 +46,22 @@ export class IoredisRedisConnectionStrategy extends RedisConnectionStrategy { database: Database, options: IRedisConnectionOptions, ): Promise { - const { host, port, password, username, tls, db, timeout } = database; + const { + host, + port, + password, + username, + tls, + db, + timeout, + connectionFamily, + } = database; const redisOptions: RedisOptions = { host, port, username, password, - family: 0, // Enable dual-stack IPv4/IPv6 (auto-detect) + family: getIpFamily(connectionFamily), connectTimeout: timeout, db: isNumber(clientMetadata.db) ? clientMetadata.db : db, connectionName: diff --git a/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.spec.ts b/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.spec.ts index 1e3bf041a5..cf60c58cc0 100644 --- a/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.spec.ts +++ b/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.spec.ts @@ -8,6 +8,7 @@ import { import { SshTunnelProvider } from 'src/modules/ssh/ssh-tunnel.provider'; import { NodeRedisConnectionStrategy } from 'src/modules/redis/connection/node.redis.connection.strategy'; import { StandaloneNodeRedisClient } from 'src/modules/redis/client/node-redis/standalone.node-redis.client'; +import { RedisConnectionFamily } from 'src/modules/database/entities/database.entity'; jest.mock('redis', () => ({ ...jest.requireActual('redis'), @@ -39,12 +40,16 @@ describe('NodeRedisConnectionStrategy', () => { }); describe('createStandaloneClient', () => { - it('should include family: 0 in socket options for dual-stack IPv4/IPv6 support', async () => { + const mockCreateClient = () => { const mockClient = { on: jest.fn().mockReturnThis(), connect: jest.fn().mockResolvedValue(undefined), }; createClientSpy.mockReturnValue(mockClient); + }; + + it('should default to family: 0 (dual-stack IPv4/IPv6) when not set', async () => { + mockCreateClient(); const result = await service.createStandaloneClient( mockClientMetadata, @@ -61,5 +66,37 @@ describe('NodeRedisConnectionStrategy', () => { }), ); }); + + it('should map connection family IPv4 to socket family: 4', async () => { + mockCreateClient(); + + await service.createStandaloneClient( + mockClientMetadata, + { ...mockDatabase, connectionFamily: RedisConnectionFamily.IPv4 }, + {}, + ); + + expect(createClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ + socket: expect.objectContaining({ family: 4 }), + }), + ); + }); + + it('should map connection family IPv6 to socket family: 6', async () => { + mockCreateClient(); + + await service.createStandaloneClient( + mockClientMetadata, + { ...mockDatabase, connectionFamily: RedisConnectionFamily.IPv6 }, + {}, + ); + + expect(createClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ + socket: expect.objectContaining({ family: 6 }), + }), + ); + }); }); }); diff --git a/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.ts b/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.ts index ce5eb2564a..d95d684d46 100644 --- a/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.ts +++ b/redisinsight/api/src/modules/redis/connection/node.redis.connection.strategy.ts @@ -14,7 +14,7 @@ import { ConnectionOptions } from 'tls'; import { ClusterNodeRedisClient, RedisClient } from 'src/modules/redis/client'; import { StandaloneNodeRedisClient } from 'src/modules/redis/client/node-redis/standalone.node-redis.client'; import { SshTunnel } from 'src/modules/ssh/models/ssh-tunnel'; -import { discoverClusterNodes } from 'src/modules/redis/utils'; +import { discoverClusterNodes, getIpFamily } from 'src/modules/redis/utils'; const REDIS_CLIENTS_CONFIG = serverConfig.get('redis_clients'); @@ -43,7 +43,16 @@ export class NodeRedisConnectionStrategy extends RedisConnectionStrategy { database: Database, options: IRedisConnectionOptions, ): Promise { - const { host, port, password, username, tls, db, timeout } = database; + const { + host, + port, + password, + username, + tls, + db, + timeout, + connectionFamily, + } = database; let tlsOptions = {}; if (tls) { @@ -57,7 +66,7 @@ export class NodeRedisConnectionStrategy extends RedisConnectionStrategy { socket: { host, port, - family: 0, // Enable dual-stack IPv4/IPv6 (auto-detect) + family: getIpFamily(connectionFamily), connectTimeout: timeout, ...tlsOptions, reconnectStrategy: options?.useRetry diff --git a/redisinsight/api/src/modules/redis/utils/family.util.spec.ts b/redisinsight/api/src/modules/redis/utils/family.util.spec.ts new file mode 100644 index 0000000000..9f4fb855c4 --- /dev/null +++ b/redisinsight/api/src/modules/redis/utils/family.util.spec.ts @@ -0,0 +1,20 @@ +import { RedisConnectionFamily } from 'src/modules/database/entities/database.entity'; +import { getIpFamily } from './family.util'; + +describe('getIpFamily', () => { + it.each([ + [RedisConnectionFamily.Auto, 0], + [RedisConnectionFamily.IPv4, 4], + [RedisConnectionFamily.IPv6, 6], + ])('should map %s to numeric family %s', (family, expected) => { + expect(getIpFamily(family)).toEqual(expected); + }); + + it('should fall back to auto (0) when family is undefined', () => { + expect(getIpFamily(undefined)).toEqual(0); + }); + + it('should fall back to auto (0) for an unknown value', () => { + expect(getIpFamily('unknown' as RedisConnectionFamily)).toEqual(0); + }); +}); diff --git a/redisinsight/api/src/modules/redis/utils/family.util.ts b/redisinsight/api/src/modules/redis/utils/family.util.ts new file mode 100644 index 0000000000..e13bf150eb --- /dev/null +++ b/redisinsight/api/src/modules/redis/utils/family.util.ts @@ -0,0 +1,13 @@ +import { RedisConnectionFamily } from 'src/modules/database/entities/database.entity'; + +// Numeric `family` option accepted by ioredis / node-redis: +// 0 = auto (dual-stack), 4 = IPv4, 6 = IPv6. +const FAMILY_MAP: Record = { + [RedisConnectionFamily.Auto]: 0, + [RedisConnectionFamily.IPv4]: 4, + [RedisConnectionFamily.IPv6]: 6, +}; + +// Falls back to auto (dual-stack) for missing or unknown values. +export const getIpFamily = (family?: RedisConnectionFamily): 0 | 4 | 6 => + (family && FAMILY_MAP[family]) ?? 0; diff --git a/redisinsight/api/src/modules/redis/utils/index.ts b/redisinsight/api/src/modules/redis/utils/index.ts index 3366abc9a3..e76eba0ebe 100644 --- a/redisinsight/api/src/modules/redis/utils/index.ts +++ b/redisinsight/api/src/modules/redis/utils/index.ts @@ -2,3 +2,4 @@ export * from './reply.util'; export * from './keys.util'; export * from './sentinel.util'; export * from './cluster.util'; +export * from './family.util'; diff --git a/redisinsight/api/test/api/database/GET-databases.test.ts b/redisinsight/api/test/api/database/GET-databases.test.ts index 60bd7a250a..1b31d79846 100644 --- a/redisinsight/api/test/api/database/GET-databases.test.ts +++ b/redisinsight/api/test/api/database/GET-databases.test.ts @@ -60,6 +60,7 @@ const responseSchema = Joi.array() 'production', 'development', ), + connectionFamily: Joi.string().valid('auto', 'ipv4', 'ipv6').allow(null), }), ) .required() diff --git a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts index 54648b04a5..0126897151 100644 --- a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts @@ -56,6 +56,7 @@ const dataSchema = Joi.object({ environment: Joi.string() .valid('unspecified', 'production', 'development') .allow(null), + connectionFamily: Joi.string().valid('auto', 'ipv4', 'ipv6').allow(null), }) .messages({ 'any.required': '{#label} should not be empty', diff --git a/redisinsight/api/test/api/database/POST-databases-export.test.ts b/redisinsight/api/test/api/database/POST-databases-export.test.ts index 8f528c30d5..ae2ea4e7ad 100644 --- a/redisinsight/api/test/api/database/POST-databases-export.test.ts +++ b/redisinsight/api/test/api/database/POST-databases-export.test.ts @@ -91,6 +91,7 @@ const responseSchema = Joi.array() environment: Joi.string() .valid('unspecified', 'production', 'development') .allow(null), + connectionFamily: Joi.string().valid('auto', 'ipv4', 'ipv6').allow(null), }), ) .required() diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index d02b4a9ac1..c537c70b56 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -53,6 +53,7 @@ const dataSchema = Joi.object({ environment: Joi.string() .valid('unspecified', 'production', 'development') .allow(null), + connectionFamily: Joi.string().valid('auto', 'ipv4', 'ipv6').allow(null), }) .messages({ 'any.required': '{#label} should not be empty', @@ -288,6 +289,80 @@ describe('POST /databases', () => { }, }); }); + it('Create standalone forcing IPv4 (connects over IPv4)', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionFamily: 'ipv4', + }, + responseSchema, + responseBody: { + name: dbName, + connectionFamily: 'ipv4', + }, + }); + }); + it('Create standalone defaults connectionFamily to auto when omitted', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + responseSchema, + responseBody: { + name: dbName, + connectionFamily: 'auto', + }, + }); + }); + it('Should throw an error with an invalid connectionFamily', async () => { + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + name: constants.getRandomString(), + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionFamily: 'ipv5', + }, + }); + }); + // Runs only when an IPv6-reachable Redis endpoint is provided via + // TEST_REDIS_IPV6_HOST; skipped otherwise so it never flakes on + // IPv4-only environments. + describe('IPv6', function () { + requirements(() => !!constants.TEST_REDIS_IPV6_HOST); + it('Create standalone forcing IPv6 (connects over IPv6)', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_IPV6_HOST, + port: constants.TEST_REDIS_PORT, + connectionFamily: 'ipv6', + }, + responseSchema, + responseBody: { + name: dbName, + connectionFamily: 'ipv6', + }, + }); + }); + }); describe('Enterprise', () => { requirements('rte.re'); it('Should throw an error if db index specified', async () => { diff --git a/redisinsight/api/test/api/database/constants.ts b/redisinsight/api/test/api/database/constants.ts index aacedd6529..67da36e601 100644 --- a/redisinsight/api/test/api/database/constants.ts +++ b/redisinsight/api/test/api/database/constants.ts @@ -72,6 +72,7 @@ export const databaseSchema = Joi.object().keys({ environment: Joi.string() .valid('unspecified', 'production', 'development') .allow(null), + connectionFamily: Joi.string().valid('auto', 'ipv4', 'ipv6').allow(null), sshOptions: Joi.object({ id: Joi.string().allow(null), host: Joi.string().required(), diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index fc2b56c43b..720e9870f6 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -128,6 +128,8 @@ export const constants = { // redis client TEST_REDIS_HOST: process.env.TEST_REDIS_HOST || 'localhost', TEST_REDIS_PORT: parseInt(process.env.TEST_REDIS_PORT) || 6379, + // Optional IPv6-reachable endpoint used to exercise connectionFamily=ipv6 connections. + TEST_REDIS_IPV6_HOST: process.env.TEST_REDIS_IPV6_HOST, TEST_REDIS_TIMEOUT: 30_000, TEST_REDIS_COMPRESSOR: Compressor.NONE, TEST_REDIS_DB_INDEX: 7, diff --git a/redisinsight/ui/src/pages/home/components/form/DatabaseForm.spec.tsx b/redisinsight/ui/src/pages/home/components/form/DatabaseForm.spec.tsx index f1a40824d4..27310cfa77 100644 --- a/redisinsight/ui/src/pages/home/components/form/DatabaseForm.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/form/DatabaseForm.spec.tsx @@ -64,6 +64,7 @@ describe('DatabaseForm', () => { expect(screen.getByTestId('username')).toBeInTheDocument() expect(screen.getByTestId('password')).toBeInTheDocument() expect(screen.getByTestId('timeout')).toBeInTheDocument() + expect(screen.getByTestId('connectionFamily')).toBeInTheDocument() }) it('should hide fields when showFields is false', () => { @@ -75,11 +76,21 @@ describe('DatabaseForm', () => { expect(screen.queryByTestId('host')).not.toBeInTheDocument() expect(screen.queryByTestId('port')).not.toBeInTheDocument() expect(screen.queryByTestId('timeout')).not.toBeInTheDocument() + // the IP protocol selector follows the host field + expect(screen.queryByTestId('connectionFamily')).not.toBeInTheDocument() // username and password always show expect(screen.getByTestId('username')).toBeInTheDocument() expect(screen.getByTestId('password')).toBeInTheDocument() }) + it('should render the IP protocol selector with its default value', () => { + renderComponent() + + expect(screen.getByText('IP protocol')).toBeInTheDocument() + expect(screen.getByTestId('connectionFamily')).toBeInTheDocument() + expect(screen.getByText('Auto (IPv4 & IPv6)')).toBeInTheDocument() + }) + it('should display initial values correctly', () => { const mockData = dbConnectionInfoFactory.build({ name: 'Test Database', diff --git a/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx b/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx index 8b781cac23..785a5a39d7 100644 --- a/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx +++ b/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx @@ -12,6 +12,7 @@ import { selectOnFocus, validateField, } from 'uiSrc/utils' +import { RedisConnectionFamily } from 'apiClient' import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import { Col, FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { @@ -23,6 +24,7 @@ import { PasswordInput, TextInput, } from 'uiSrc/components/base/inputs' +import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' import { HostInfoTooltipContent } from '../host-info-tooltip-content/HostInfoTooltipContent' interface IShowFields { @@ -38,6 +40,19 @@ const hostInfo: RiInfoIconProps = { maxWidth: '100%', } +const CONNECTION_FAMILY_OPTIONS = [ + { value: RedisConnectionFamily.Auto, label: 'Auto (IPv4 & IPv6)' }, + { value: RedisConnectionFamily.Ipv4, label: 'IPv4' }, + { value: RedisConnectionFamily.Ipv6, label: 'IPv6' }, +] + +const connectionFamilyInfo: RiInfoIconProps = { + content: + 'Choose which IP protocol to use when connecting. Use IPv4 or IPv6 if the host does not resolve correctly over the other protocol.', + placement: 'right', + maxWidth: '100%', +} + export interface Props { formik: FormikProps onHostNamePaste: (content: string) => boolean @@ -130,6 +145,28 @@ const DatabaseForm = (props: Props) => { )} + {showFields.host && ( + + + + + formik.setFieldValue('connectionFamily', value) + } + disabled={isFieldDisabled('connectionFamily')} + /> + + + + + )} + diff --git a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx index fe1db2d1a0..9aa7ded179 100644 --- a/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/manual-connection/ManualConnectionWrapper.tsx @@ -214,6 +214,7 @@ const ManualConnectionWrapper = (props: Props) => { forceStandalone, keyNameFormat, environment, + connectionFamily, } = values const database: any = { @@ -231,6 +232,7 @@ const ManualConnectionWrapper = (props: Props) => { forceStandalone, keyNameFormat, environment, + connectionFamily, } // add tls & ssh for database (modifies database object) diff --git a/redisinsight/ui/src/pages/home/interfaces/form.ts b/redisinsight/ui/src/pages/home/interfaces/form.ts index 555a9a936b..3df659a1ea 100644 --- a/redisinsight/ui/src/pages/home/interfaces/form.ts +++ b/redisinsight/ui/src/pages/home/interfaces/form.ts @@ -1,3 +1,4 @@ +import { RedisConnectionFamily } from 'apiClient' import { Instance } from 'uiSrc/slices/interfaces' import { ADD_NEW_CA_CERT, NO_CA_CERT } from 'uiSrc/pages/home/constants' import { KeyValueFormat } from 'uiSrc/constants' @@ -5,6 +6,7 @@ import { KeyValueFormat } from 'uiSrc/constants' export interface DbConnectionInfo extends Instance { id?: string port: string + connectionFamily?: RedisConnectionFamily tlsClientAuthRequired?: boolean certificates?: { id: number; name: string }[] selectedTlsClientCertId?: string | 'ADD_NEW' | undefined diff --git a/redisinsight/ui/src/pages/home/utils/form.tsx b/redisinsight/ui/src/pages/home/utils/form.tsx index 80a8393c58..e9018da06b 100644 --- a/redisinsight/ui/src/pages/home/utils/form.tsx +++ b/redisinsight/ui/src/pages/home/utils/form.tsx @@ -1,7 +1,7 @@ import { isUndefined, toString } from 'lodash' import React from 'react' import { FormikErrors } from 'formik' -import { Environment } from 'apiClient' +import { Environment, RedisConnectionFamily } from 'apiClient' import { InstanceType } from 'uiSrc/slices/interfaces' import { ADD_NEW, @@ -296,6 +296,7 @@ export const getFormValues = (instance?: Nullable>) => ({ db: instance?.db, compressor: instance?.compressor ?? NONE, environment: instance?.environment ?? Environment.Unspecified, + connectionFamily: instance?.connectionFamily ?? RedisConnectionFamily.Auto, modules: instance?.modules, showDb: !!instance?.db, forceStandalone: instance?.forceStandalone ?? false, diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 7ee5de627a..ccf0a034dd 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -13,6 +13,7 @@ import { GetListElementsResponse, Database as DatabaseInstanceResponse, Environment, + RedisConnectionFamily, SearchZSetMembersResponse, SentinelMaster, CreateSentinelDatabaseDto, @@ -58,6 +59,7 @@ export interface Instance extends Partial { loading?: boolean isFreeDb?: boolean environment: Environment + connectionFamily?: RedisConnectionFamily tags?: Tag[] } From 177a6a39553713747c0dc5b0153c1a9567bc96a8 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 14 Jul 2026 13:07:13 +0300 Subject: [PATCH 027/166] RI-8308: Support multi-tenant Azure Entra ID sign-in (#6194) --- docs/azure-setup.md | 51 ++++++ .../azure/auth/azure-auth.controller.ts | 9 + .../azure/auth/azure-auth.service.spec.ts | 162 +++++++++++++++++- .../modules/azure/auth/azure-auth.service.ts | 41 ++++- .../auth/dto/azure-auth-login.dto.spec.ts | 35 ++++ .../azure/auth/dto/azure-auth-login.dto.ts | 18 +- .../azure-autodiscovery.controller.ts | 22 ++- .../azure-autodiscovery.service.spec.ts | 65 +++++++ .../azure-autodiscovery.service.ts | 55 ++++-- .../dto/import-azure-databases.dto.ts | 17 +- .../azure/azure-token-refresh.manager.spec.ts | 150 ++++++++++++---- .../azure/azure-token-refresh.manager.ts | 97 ++++++----- .../api/src/modules/azure/constants.ts | 20 ++- .../azure-entra-id-token-expired.exception.ts | 4 + .../modules/azure/models/azure-resource.ts | 6 + .../azure-access-key.credential-strategy.ts | 1 + ...azure-entra-id.credential-strategy.spec.ts | 40 ++++- .../azure-entra-id.credential-strategy.ts | 3 +- .../database/models/provider-details.ts | 12 ++ .../AzureSignInDialog.spec.tsx | 86 ++++++++++ .../AzureSignInDialog.styles.ts | 6 + .../AzureSignInDialog.tsx | 131 ++++++++++++++ .../AzureSignInDialog.types.ts | 11 ++ .../components/azure-sign-in-dialog/index.ts | 2 + .../GlobalAzureAuth.spec.tsx | 1 + .../global-azure-auth/GlobalAzureAuth.tsx | 2 + .../ui/src/components/hooks/useAzureAuth.ts | 6 +- .../AzureTokenExpiredErrorContent.spec.tsx | 9 +- .../AzureTokenExpiredErrorContent.tsx | 10 +- .../notifications/error-messages.tsx | 8 +- .../hooks/useErrorNotifications.ts | 2 +- .../ConfigAzureAuth/ConfigAzureAuth.spec.tsx | 4 + .../ConfigAzureAuth/ConfigAzureAuth.tsx | 2 + .../factories/cloud/AzureAccount.factory.ts | 1 + .../AzureDatabasesPage.spec.tsx | 2 + .../azure-databases/AzureDatabasesPage.tsx | 25 ++- .../AzureSubscriptions.spec.tsx | 19 ++ .../AzureSubscriptions/AzureSubscriptions.tsx | 16 +- .../AzureSubscriptionsPage.spec.tsx | 53 +++++- .../AzureSubscriptionsPage.tsx | 55 ++++-- .../ConnectivityOptions.tsx | 29 +++- .../hooks/useConnectivityOptions.spec.ts | 11 +- .../hooks/useConnectivityOptions.ts | 18 +- redisinsight/ui/src/slices/instances/azure.ts | 14 +- redisinsight/ui/src/slices/oauth/azure.ts | 23 ++- .../ui/src/slices/tests/oauth/azure.spec.ts | 123 ++++++++++++- 46 files changed, 1317 insertions(+), 160 deletions(-) create mode 100644 redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.spec.ts create mode 100644 redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.spec.tsx create mode 100644 redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.styles.ts create mode 100644 redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx create mode 100644 redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.types.ts create mode 100644 redisinsight/ui/src/components/azure-sign-in-dialog/index.ts diff --git a/docs/azure-setup.md b/docs/azure-setup.md index 68723bb388..75cfc68abc 100644 --- a/docs/azure-setup.md +++ b/docs/azure-setup.md @@ -4,6 +4,8 @@ To use the Azure integration, your Azure tenant administrator may need to grant admin consent for the RedisInsight application. This is a one-time setup per Azure tenant — once done, all users in your organization can use RedisInsight with Entra ID seamlessly. +> **Which tenant?** The commands below must be run **in the home tenant of every user who signs in through RedisInsight** — this is not necessarily the tenant that owns the Azure Managed Redis resources. If your users belong to a different tenant than the one hosting the resources, see [Multi-tenant scenarios](#multi-tenant-scenarios). + > **Why is this needed?** See [Why This Setup is Required](#why-this-setup-is-required) for details on the authentication flow. > **Running in Docker?** See [Azure Docker Setup](azure-docker-setup.md) for configuration when using custom ports or reverse proxies. @@ -54,6 +56,37 @@ az ad app permission list-grants \ You should see `AzureRedisCacheAadApp` and `Windows Azure Service Management API` (or `Azure Resource Manager`) in the output. +## Multi-tenant scenarios + +By default, RedisInsight signs you in through the multi-tenant `/common` endpoint, which issues the access token against **your home tenant**. That works when your account and the Azure Managed Redis resources live in the same tenant. Two situations need extra attention: + +- **Your resources are in a different tenant than your account.** The setup commands above (`az ad sp create` / `az ad app permission grant`) must exist in **your home tenant** — the tenant your user account belongs to — because that is where the token is issued. Running them only in the resource tenant is not enough and results in `AADSTS650052`. +- **You are a guest / external user of the resource tenant.** Sign in against that tenant explicitly (see [Picking a tenant](#picking-a-tenant)) so the token is issued there and its subscriptions become visible. + +> **Personal Microsoft accounts.** Azure Resource Manager and Azure Cache for Redis are **organizational-only Azure APIs** — a personal Microsoft account (e.g. `@outlook.com`) can't be issued tokens for them, so the default sign-in fails with *"You can't sign in here with a personal account."* (This isn't about the app registration, which does allow personal accounts; it's the resources that are org-only.) To use a personal account, invite it as a **guest** into the organizational tenant that owns the resources, then sign in against that tenant with the **Tenant ID** field (see below). + +### Cross-tenant access (resources and user in different tenants) + +Say the Azure Managed Redis lives in **tenant A**, but your user account's home is **tenant B**. To reach A's resources from RedisInsight: + +1. **Get invited to tenant A.** An administrator of tenant A invites your account as a **guest** (Entra ID → External Identities), and you accept the invitation. +2. **Get a role in tenant A.** You need **Reader** on the subscription (or resource group) so autodiscovery can list it, plus a **Redis data access policy** (e.g. *Data Owner*) on the cache if you want to connect to the data, not just discover it. +3. **Make sure tenant A is set up.** The [admin-consent commands](#granting-admin-consent-azure-cli) must have been run **in tenant A** (the tenant the token will be issued for). +4. **Sign in against tenant A.** In RedisInsight, use the **Tenant ID** field (see below) to enter tenant A's ID. The token is issued by A, and A's subscriptions and databases appear. + +Without the invitation and role (steps 1–2), sign-in may succeed but you'll see **no subscriptions** — see the [troubleshooting note](#signed-in-successfully-but-no-subscriptions-appear). + +### Picking a tenant + +When you click **Azure Managed Redis**, the sign-in dialog has an optional **Tenant ID** field. Enter a tenant GUID or domain (for example `your-tenant.onmicrosoft.com`) to authenticate against that specific tenant instead of your home tenant. + +Use it when: + +- Your home tenant differs from the tenant that owns the Azure Managed Redis resources, or +- You are a guest in the resource tenant and its subscriptions don't appear by default. + +Leave the field blank to sign in against your home tenant (the default). The tenant you signed in with is shown on the subscriptions screen. To switch tenants, use the **Switch account or tenant** button there and enter a different tenant ID. + ## Troubleshooting ### Error: AADSTS650057 - Invalid resource @@ -98,6 +131,24 @@ az ad sp create --id acca5fbb-b7e4-4009-81f1-37e38fd66d78 Then grant the permissions using the CLI commands above. +> **Multi-tenant note:** This error most often means the service principals exist in the *resource* tenant but not in the tenant the token was issued for. The token is issued for your **home tenant** by default — so the commands must be run there. If your resources are in a different tenant, either run the setup in your home tenant, or sign in against the resource tenant using the **Tenant ID** field (see [Picking a tenant](#picking-a-tenant)). + +### Error: AADSTS50079 - Multi-factor authentication required + +If you see this error: + +> Due to a configuration change made by your administrator ... you must enroll in multi-factor authentication to access '797f4846-...'. + +The tenant you're signing in against enforces MFA (via Security Defaults or a Conditional Access policy), and RedisInsight refreshes the management token silently, which can't complete an interactive MFA prompt. Enroll the account in MFA once (e.g. sign in to the [Azure portal](https://portal.azure.com) as that account in the target tenant and complete the prompt), then sign in to RedisInsight again. Once enrolled, silent token refresh carries the MFA claim and autodiscovery works. + +### Signed in successfully but no subscriptions appear + +Sign-in worked and there's no error, but the subscriptions list is empty. This means your account has no role in the tenant you signed in against. Azure only returns subscriptions your identity can access, so you need at least **Reader** on the subscription (or resource group). For a guest/cross-tenant sign-in, an administrator of that tenant must assign the role — see [Cross-tenant access](#cross-tenant-access-resources-and-user-in-different-tenants). + +### "You can't sign in here with a personal account" + +**Azure Resource Manager and Azure Cache for Redis are organizational-only APIs**, so a personal Microsoft account can't be issued tokens for them — even though RedisInsight's app registration itself allows personal accounts. Signing in via the default (blank) flow routes a personal account to its consumer tenant, where those resources don't exist, so Azure blocks it. To use a personal account, it must be a **guest** in the organizational tenant that owns the resources, and you must sign in using the **Tenant ID** field (enter that tenant) rather than the blank/default sign-in — the token is then issued in that tenant's context. + ## Why This Setup is Required ### How RedisInsight Authenticates diff --git a/redisinsight/api/src/modules/azure/auth/azure-auth.controller.ts b/redisinsight/api/src/modules/azure/auth/azure-auth.controller.ts index f0dc45f093..bfe8aab46c 100644 --- a/redisinsight/api/src/modules/azure/auth/azure-auth.controller.ts +++ b/redisinsight/api/src/modules/azure/auth/azure-auth.controller.ts @@ -58,6 +58,13 @@ export class AzureAuthController { description: 'Redirect type: "deeplink" for Electron app, "web" for browser/Docker deployments', }) + @ApiQuery({ + name: 'tenantId', + required: false, + description: + 'Azure tenant (GUID or domain) to authenticate against. Omit to use the ' + + 'multi-tenant "common" endpoint (the user\'s home tenant).', + }) @ApiResponse({ status: 200, description: 'Authorization URL generated successfully', @@ -69,6 +76,7 @@ export class AzureAuthController { const { url } = await this.azureAuthService.getAuthorizationUrl( dto.prompt, dto.redirectType, + dto.tenantId, ); return { url }; } @@ -175,6 +183,7 @@ export class AzureAuthController { id: result.account.homeAccountId, username: result.account.username, name: result.account.name, + tenantId: result.account.tenantId, } : undefined, error: result.error, diff --git a/redisinsight/api/src/modules/azure/auth/azure-auth.service.spec.ts b/redisinsight/api/src/modules/azure/auth/azure-auth.service.spec.ts index c1535ca70a..cf10602ddc 100644 --- a/redisinsight/api/src/modules/azure/auth/azure-auth.service.spec.ts +++ b/redisinsight/api/src/modules/azure/auth/azure-auth.service.spec.ts @@ -130,6 +130,28 @@ describe('AzureAuthService', () => { }), ); }); + + it('should pass per-tenant authority to MSAL when tenantId provided', async () => { + const tenantId = faker.string.uuid(); + + await service.getAuthorizationUrl(undefined, undefined, tenantId); + + expect(mockPca.getAuthCodeUrl).toHaveBeenCalledWith( + expect.objectContaining({ + authority: `https://login.microsoftonline.com/${tenantId}`, + }), + ); + }); + + it('should not include authority parameter when tenantId not provided', async () => { + await service.getAuthorizationUrl(); + + expect(mockPca.getAuthCodeUrl).toHaveBeenCalledWith( + expect.not.objectContaining({ + authority: expect.anything(), + }), + ); + }); }); describe('handleCallback', () => { @@ -177,6 +199,43 @@ describe('AzureAuthService', () => { expect(result.account).toEqual(mockAccount); expect(result.error).toBeUndefined(); }); + + it('should exchange the code against the tenant authority used at sign-in', async () => { + const tenantId = faker.string.uuid(); + mockPca.acquireTokenByCode.mockResolvedValue({ + accessToken: faker.string.alphanumeric(100), + account: createMockAccount(), + } as any); + + const { state } = await service.getAuthorizationUrl( + undefined, + undefined, + tenantId, + ); + await service.handleCallback('auth-code', state); + + expect(mockPca.acquireTokenByCode).toHaveBeenCalledWith( + expect.objectContaining({ + authority: `https://login.microsoftonline.com/${tenantId}`, + }), + ); + }); + + it('should not pass authority to code exchange when no tenant was chosen', async () => { + mockPca.acquireTokenByCode.mockResolvedValue({ + accessToken: faker.string.alphanumeric(100), + account: createMockAccount(), + } as any); + + const { state } = await service.getAuthorizationUrl(); + await service.handleCallback('auth-code', state); + + expect(mockPca.acquireTokenByCode).toHaveBeenCalledWith( + expect.not.objectContaining({ + authority: expect.anything(), + }), + ); + }); }); describe('removeAuthRequest', () => { @@ -340,12 +399,17 @@ describe('AzureAuthService', () => { account: mockAccount, } as any); - await service.getRedisTokenByAccountId(mockAccount.homeAccountId); + const tenantId = faker.string.uuid(); + await service.getRedisTokenByAccountId( + mockAccount.homeAccountId, + tenantId, + ); expect(mockEventEmitter.emit).toHaveBeenCalledWith( AzureRedisTokenEvents.Acquired, { accountId: mockAccount.homeAccountId, + tenantId, tokenResult: { token: mockAccessToken, expiresOn: mockExpiresOn, @@ -362,6 +426,80 @@ describe('AzureAuthService', () => { expect(mockEventEmitter.emit).not.toHaveBeenCalled(); }); + + it('should acquire silently against the tenant authority when tenantId provided', async () => { + const mockAccount = createMockAccount(); + const tenantId = faker.string.uuid(); + mockTokenCache.getAllAccounts.mockResolvedValue([mockAccount]); + mockPca.acquireTokenSilent.mockResolvedValue({ + accessToken: faker.string.alphanumeric(100), + expiresOn: new Date(), + account: mockAccount, + } as any); + + await service.getRedisTokenByAccountId( + mockAccount.homeAccountId, + tenantId, + ); + + expect(mockPca.acquireTokenSilent).toHaveBeenCalledWith( + expect.objectContaining({ + authority: `https://login.microsoftonline.com/${tenantId}`, + }), + ); + }); + + it('should select the account matching the requested tenant when multiple realms are cached', async () => { + const homeAccountId = faker.string.uuid(); + const tenantId = faker.string.uuid(); + // Same user signed into two tenants → two records share homeAccountId + const homeRealmAccount = { + ...createMockAccount(), + homeAccountId, + tenantId: faker.string.uuid(), + }; + const targetRealmAccount = { + ...createMockAccount(), + homeAccountId, + tenantId, + }; + mockTokenCache.getAllAccounts.mockResolvedValue([ + homeRealmAccount, + targetRealmAccount, + ]); + mockPca.acquireTokenSilent.mockResolvedValue({ + accessToken: faker.string.alphanumeric(100), + expiresOn: new Date(), + account: targetRealmAccount, + } as any); + + await service.getRedisTokenByAccountId(homeAccountId, tenantId); + + expect(mockPca.acquireTokenSilent).toHaveBeenCalledWith( + expect.objectContaining({ + account: targetRealmAccount, + authority: `https://login.microsoftonline.com/${tenantId}`, + }), + ); + }); + + it('should not pass authority to silent acquisition when no tenantId', async () => { + const mockAccount = createMockAccount(); + mockTokenCache.getAllAccounts.mockResolvedValue([mockAccount]); + mockPca.acquireTokenSilent.mockResolvedValue({ + accessToken: faker.string.alphanumeric(100), + expiresOn: new Date(), + account: mockAccount, + } as any); + + await service.getRedisTokenByAccountId(mockAccount.homeAccountId); + + expect(mockPca.acquireTokenSilent).toHaveBeenCalledWith( + expect.not.objectContaining({ + authority: expect.anything(), + }), + ); + }); }); describe('getManagementTokenByAccountId', () => { @@ -406,5 +544,27 @@ describe('AzureAuthService', () => { account: mockAccount, }); }); + + it('should acquire silently against the tenant authority when tenantId provided', async () => { + const mockAccount = createMockAccount(); + const tenantId = faker.string.uuid(); + mockTokenCache.getAllAccounts.mockResolvedValue([mockAccount]); + mockPca.acquireTokenSilent.mockResolvedValue({ + accessToken: faker.string.alphanumeric(100), + expiresOn: new Date(), + account: mockAccount, + } as any); + + await service.getManagementTokenByAccountId( + mockAccount.homeAccountId, + tenantId, + ); + + expect(mockPca.acquireTokenSilent).toHaveBeenCalledWith( + expect.objectContaining({ + authority: `https://login.microsoftonline.com/${tenantId}`, + }), + ); + }); }); }); diff --git a/redisinsight/api/src/modules/azure/auth/azure-auth.service.ts b/redisinsight/api/src/modules/azure/auth/azure-auth.service.ts index 6911844af6..25e984c726 100644 --- a/redisinsight/api/src/modules/azure/auth/azure-auth.service.ts +++ b/redisinsight/api/src/modules/azure/auth/azure-auth.service.ts @@ -8,6 +8,7 @@ import { import { EventEmitter2 } from '@nestjs/event-emitter'; import { AZURE_AUTHORITY, + buildAzureAuthority, AZURE_CLIENT_ID, AZURE_REDIS_SCOPE, AZURE_MANAGEMENT_SCOPE, @@ -61,6 +62,11 @@ interface AuthRequestData { redirectUri: string; redirectType: AzureOAuthRedirectType; createdAt: number; + /** + * Per-tenant authority chosen at sign-in, if any. Reused during the code + * exchange so the token is issued against the same tenant. + */ + authority?: string; } /** @@ -152,11 +158,14 @@ export class AzureAuthService { * Returns URL to redirect user to Microsoft login. * @param prompt - Optional prompt parameter to control login behavior. * @param redirectType - Type of redirect (deeplink for Electron, web for browser/Docker) + * @param tenantId - Optional tenant id/domain to authenticate against. When set, + * the token is issued by that tenant instead of the user's home tenant. * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code */ async getAuthorizationUrl( prompt?: AzureOAuthPrompt, redirectType: AzureOAuthRedirectType = AzureOAuthRedirectType.Deeplink, + tenantId?: string, ): Promise<{ url: string; state: string }> { const pca = this.getMsalClient(); @@ -164,6 +173,7 @@ export class AzureAuthService { const challenge = generateCodeChallenge(verifier); const state = generateUuid(); const redirectUri = this.getRedirectUri(redirectType); + const authority = tenantId ? buildAzureAuthority(tenantId) : undefined; // Clean up any expired auth requests (abandoned flows) before adding new one this.cleanupExpiredAuthRequests(); @@ -174,6 +184,7 @@ export class AzureAuthService { redirectUri, redirectType, createdAt: Date.now(), + authority, }); const authUrl = await pca.getAuthCodeUrl({ @@ -183,6 +194,7 @@ export class AzureAuthService { codeChallengeMethod: 'S256', state, ...(prompt && { prompt }), + ...(authority && { authority }), }); this.logger.debug( @@ -217,7 +229,7 @@ export class AzureAuthService { }; } - const { verifier, redirectUri, redirectType } = authRequest; + const { verifier, redirectUri, redirectType, authority } = authRequest; // Clean up the auth request this.authRequests.delete(state); @@ -228,6 +240,7 @@ export class AzureAuthService { scopes: AZURE_OAUTH_SCOPES, redirectUri, codeVerifier: verifier, + ...(authority && { authority }), }); this.logger.log( @@ -302,15 +315,18 @@ export class AzureAuthService { */ async getRedisTokenByAccountId( accountId: string, + tenantId?: string, ): Promise { const tokenResult = await this.getTokenByAccountId( accountId, AZURE_REDIS_SCOPE, + tenantId, ); if (tokenResult) { this.eventEmitter.emit(AzureRedisTokenEvents.Acquired, { accountId, + tenantId, tokenResult, }); } @@ -335,8 +351,13 @@ export class AzureAuthService { */ async getManagementTokenByAccountId( accountId: string, + tenantId?: string, ): Promise { - return this.getTokenByAccountId(accountId, AZURE_MANAGEMENT_SCOPE); + return this.getTokenByAccountId( + accountId, + AZURE_MANAGEMENT_SCOPE, + tenantId, + ); } /** @@ -346,21 +367,35 @@ export class AzureAuthService { private async getTokenByAccountId( accountId: string, scope: string, + tenantId?: string, ): Promise { try { const pca = this.getMsalClient(); const cache = pca.getTokenCache(); const accounts = await cache.getAllAccounts(); - const account = accounts.find((a) => a.homeAccountId === accountId); + // A user signed into multiple tenants has one cached record per realm, + // all sharing the same homeAccountId. When a tenant is requested, prefer + // the record for that realm so silent refresh targets the right tenant + // (falling back to any record for the account otherwise). + const forAccount = (a: AccountInfo) => a.homeAccountId === accountId; + const account = + (tenantId && + accounts.find((a) => forAccount(a) && a.tenantId === tenantId)) || + accounts.find(forAccount); if (!account) { this.logger.warn(`Account not found: ${accountId}`); return null; } + // When a tenant was chosen at sign-in, refresh against that same tenant + // authority. Without it, MSAL resolves to the account's home tenant. + const authority = tenantId ? buildAzureAuthority(tenantId) : undefined; + const result = await pca.acquireTokenSilent({ account, scopes: [scope], + ...(authority && { authority }), }); if (!result?.accessToken || !result?.expiresOn || !result?.account) { diff --git a/redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.spec.ts b/redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.spec.ts new file mode 100644 index 0000000000..4d7553ddc4 --- /dev/null +++ b/redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.spec.ts @@ -0,0 +1,35 @@ +import { validate } from 'class-validator'; +import { faker } from '@faker-js/faker'; +import { AzureAuthLoginDto } from './azure-auth-login.dto'; + +const validateTenantId = async (tenantId: string | undefined) => { + const dto = new AzureAuthLoginDto(); + dto.tenantId = tenantId; + return validate(dto); +}; + +describe('AzureAuthLoginDto', () => { + describe('tenantId', () => { + it('should be optional (no error when omitted)', async () => { + expect(await validateTenantId(undefined)).toHaveLength(0); + }); + + it('should accept a GUID tenant id', async () => { + expect(await validateTenantId(faker.string.uuid())).toHaveLength(0); + }); + + it('should accept an onmicrosoft.com domain', async () => { + expect( + await validateTenantId('your-tenant.onmicrosoft.com'), + ).toHaveLength(0); + }); + + it.each(['not a tenant', 'foo bar', 'http://your-tenant.com', ' ', 'a'])( + 'should reject invalid tenant id %p', + async (input) => { + const errors = await validateTenantId(input); + expect(errors.length).toBeGreaterThan(0); + }, + ); + }); +}); diff --git a/redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.ts b/redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.ts index 7deb4c7e37..9630efea6d 100644 --- a/redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.ts +++ b/redisinsight/api/src/modules/azure/auth/dto/azure-auth-login.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; -import { AzureOAuthRedirectType } from '../../constants'; +import { IsEnum, IsOptional, Matches } from 'class-validator'; +import { AzureOAuthRedirectType, AZURE_TENANT_ID_REGEX } from '../../constants'; /** * Valid OAuth prompt parameter values for Azure Entra ID. @@ -52,4 +52,18 @@ export class AzureAuthLoginDto { message: `redirectType must be a valid value. Valid values: ${Object.values(AzureOAuthRedirectType).join(', ')}.`, }) redirectType?: AzureOAuthRedirectType; + + @ApiPropertyOptional({ + description: + 'Azure tenant to authenticate against, as a GUID or domain ' + + '(e.g. your-tenant.onmicrosoft.com). Use when the Azure resources live in a ' + + 'different tenant than the signed-in user. Defaults to the multi-tenant ' + + '"common" endpoint (the user\'s home tenant) when omitted.', + example: 'your-tenant.onmicrosoft.com', + }) + @IsOptional() + @Matches(AZURE_TENANT_ID_REGEX, { + message: 'tenantId must be a valid GUID or domain.', + }) + tenantId?: string; } diff --git a/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.controller.ts b/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.controller.ts index cdcd49f6b8..395c187a29 100644 --- a/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.controller.ts +++ b/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.controller.ts @@ -67,6 +67,12 @@ export class AzureAutodiscoveryController { name: 'accountId', description: 'Azure account ID (homeAccountId)', }) + @ApiQuery({ + name: 'tenantId', + required: false, + description: + 'Azure tenant (GUID or domain) to query. Omit to use the home tenant.', + }) @ApiResponse({ status: 200, description: 'Returns list of subscriptions', @@ -77,11 +83,14 @@ export class AzureAutodiscoveryController { async listSubscriptions( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Query('accountId') accountId: string, + @Query('tenantId') tenantId?: string, ): Promise { try { await this.ensureAuthenticated(accountId); - const subscriptions = - await this.autodiscoveryService.listSubscriptions(accountId); + const subscriptions = await this.autodiscoveryService.listSubscriptions( + accountId, + tenantId, + ); this.analytics.sendAzureSubscriptionsDiscoverySucceeded( sessionMetadata, subscriptions, @@ -102,6 +111,12 @@ export class AzureAutodiscoveryController { name: 'accountId', description: 'Azure account ID (homeAccountId)', }) + @ApiQuery({ + name: 'tenantId', + required: false, + description: + 'Azure tenant (GUID or domain) to query. Omit to use the home tenant.', + }) @ApiResponse({ status: 200, description: 'Returns list of databases in subscription', @@ -114,6 +129,7 @@ export class AzureAutodiscoveryController { @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Query('accountId') accountId: string, @Param('subscriptionId') subscriptionId: string, + @Query('tenantId') tenantId?: string, ): Promise { try { this.validateSubscriptionId(subscriptionId); @@ -122,6 +138,7 @@ export class AzureAutodiscoveryController { await this.autodiscoveryService.listDatabasesInSubscription( accountId, subscriptionId, + tenantId, ); this.analytics.sendAzureDatabasesDiscoverySucceeded( sessionMetadata, @@ -157,6 +174,7 @@ export class AzureAutodiscoveryController { sessionMetadata, dto.accountId, dto.databases, + dto.tenantId, ); const hasSuccessResult = result.some( diff --git a/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.spec.ts index 67d0888b00..57f7e5aca5 100644 --- a/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.spec.ts +++ b/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.spec.ts @@ -507,6 +507,43 @@ describe('AzureAutodiscoveryService', () => { ); }); + it('should persist the token realm GUID as tenantId even when a domain is entered', async () => { + const database = createMockDatabase(AzureRedisType.Standard); + const mockAccount = createMockAccount(); + const apiResponse = createStandardRedisApiResponse(database); + + mockAuthService.getManagementTokenByAccountId.mockResolvedValue({ + token: 'mock-token', + expiresOn: new Date(), + account: mockAccount, + }); + mockAxiosInstance.get + .mockResolvedValueOnce({ data: { value: [apiResponse] } }) + .mockResolvedValueOnce({ data: { value: [] } }); + mockAuthService.getRedisTokenByAccountId.mockResolvedValue({ + token: 'redis-token', + expiresOn: new Date(), + account: mockAccount, + }); + mockDatabaseService.create.mockResolvedValue({ id: 'new-db-id' }); + + await service.addDatabases( + sessionMetadata, + accountId, + [{ id: database.id }], + 'contoso.onmicrosoft.com', + ); + + expect(mockDatabaseService.create).toHaveBeenCalledWith( + sessionMetadata, + expect.objectContaining({ + providerDetails: expect.objectContaining({ + tenantId: mockAccount.tenantId, + }), + }), + ); + }); + it('should successfully add an enterprise Redis database', async () => { const subscriptionId = faker.string.uuid(); const mockCluster = createMockEnterpriseCluster(subscriptionId); @@ -667,4 +704,32 @@ describe('AzureAutodiscoveryService', () => { expect(result[1].message).toBe(ERROR_MESSAGES.AZURE_DATABASE_NOT_FOUND); }); }); + + describe('getAccessKey', () => { + it('should acquire the ARM token against the resource tenant', async () => { + const accountId = 'test-account-id'; + const tenantId = 'resource-realm-guid'; + mockAuthService.getManagementTokenByAccountId.mockResolvedValue({ + token: 'mock-token', + } as any); + mockAxiosInstance.post.mockResolvedValue({ + data: { primaryKey: 'primary-key' }, + }); + + const result = await service.getAccessKey( + accountId, + 'sub', + 'rg', + 'cache', + AzureRedisType.Standard, + undefined, + tenantId, + ); + + expect(result).toBe('primary-key'); + expect( + mockAuthService.getManagementTokenByAccountId, + ).toHaveBeenCalledWith(accountId, tenantId); + }); + }); }); diff --git a/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.ts b/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.ts index ca001a8acd..4df25743af 100644 --- a/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.ts +++ b/redisinsight/api/src/modules/azure/autodiscovery/azure-autodiscovery.service.ts @@ -66,9 +66,12 @@ export class AzureAutodiscoveryService { private async getAuthenticatedClient( accountId: string, + tenantId?: string, ): Promise { - const tokenResult = - await this.authService.getManagementTokenByAccountId(accountId); + const tokenResult = await this.authService.getManagementTokenByAccountId( + accountId, + tenantId, + ); if (!tokenResult) { this.logger.warn('No valid management token available'); @@ -106,8 +109,11 @@ export class AzureAutodiscoveryService { return allItems; } - async listSubscriptions(accountId: string): Promise { - const client = await this.getAuthenticatedClient(accountId); + async listSubscriptions( + accountId: string, + tenantId?: string, + ): Promise { + const client = await this.getAuthenticatedClient(accountId, tenantId); if (!client) { throw new BadRequestException('Failed to get authenticated client'); @@ -128,6 +134,7 @@ export class AzureAutodiscoveryService { async listDatabasesInSubscription( accountId: string, subscriptionId: string, + tenantId?: string, ): Promise { if (!this.isValidSubscriptionId(subscriptionId)) { throw new BadRequestException( @@ -135,7 +142,7 @@ export class AzureAutodiscoveryService { ); } - const client = await this.getAuthenticatedClient(accountId); + const client = await this.getAuthenticatedClient(accountId, tenantId); if (!client) { throw new BadRequestException('Failed to get authenticated client'); @@ -171,8 +178,13 @@ export class AzureAutodiscoveryService { async getConnectionDetails( accountId: string, databaseId: string, + tenantId?: string, ): Promise { - const database = await this.findDatabaseById(accountId, databaseId); + const database = await this.findDatabaseById( + accountId, + databaseId, + tenantId, + ); if (!database) { this.logger.warn(`Database not found: ${databaseId}`); @@ -181,12 +193,13 @@ export class AzureAutodiscoveryService { // Use Entra ID authentication (Microsoft's recommended approach) // Access Keys support will be added in a future update with proper UX - return this.getEntraIdConnectionDetails(accountId, database); + return this.getEntraIdConnectionDetails(accountId, database, tenantId); } private async findDatabaseById( accountId: string, resourceId: string, + tenantId?: string, ): Promise { if (!resourceId) { return null; @@ -205,6 +218,7 @@ export class AzureAutodiscoveryService { const databases = await this.listDatabasesInSubscription( accountId, subscriptionId, + tenantId, ); // Azure resource IDs are case-insensitive @@ -345,6 +359,7 @@ export class AzureAutodiscoveryService { * @param resourceName - Redis cache name * @param resourceType - Standard or Enterprise Redis * @param clusterName - Required for Enterprise Redis databases + * @param tenantId - Realm the resource lives in, for cross-tenant ARM access * @returns The primary access key */ async getAccessKey( @@ -354,11 +369,13 @@ export class AzureAutodiscoveryService { resourceName: string, resourceType: AzureRedisType, clusterName?: string, + tenantId?: string, ): Promise { - const client = await this.getAuthenticatedClient(accountId); + const client = await this.getAuthenticatedClient(accountId, tenantId); if (!client) { throw new AzureEntraIdTokenExpiredException( + tenantId, 'Azure session expired. Please re-authenticate with Azure to access this database.', ); } @@ -410,9 +427,12 @@ export class AzureAutodiscoveryService { private async getEntraIdConnectionDetails( accountId: string, database: AzureRedisDatabase, + tenantId?: string, ): Promise { - const tokenResult = - await this.authService.getRedisTokenByAccountId(accountId); + const tokenResult = await this.authService.getRedisTokenByAccountId( + accountId, + tenantId, + ); if (!tokenResult) { this.logger.debug( @@ -435,6 +455,9 @@ export class AzureAutodiscoveryService { tls: true, authType: AzureAuthType.EntraId, azureAccountId: accountId, + // Store the realm GUID the token was issued for, not a user-entered + // domain, so silent-refresh account selection can match it. + tenantId: tokenResult.account.tenantId, subscriptionId: database.subscriptionId, resourceGroup: database.resourceGroup, resourceId: database.id, @@ -462,6 +485,7 @@ export class AzureAutodiscoveryService { private getAccessKeyConnectionDetails( accountId: string, database: AzureRedisDatabase, + tenantId?: string, ): AzureConnectionDetails { const port = this.getTlsPort(database); const { resourceName, clusterName } = this.extractResourceNames(database); @@ -476,6 +500,7 @@ export class AzureAutodiscoveryService { tls: true, authType: AzureAuthType.AccessKey, azureAccountId: accountId, + tenantId, subscriptionId: database.subscriptionId, resourceGroup: database.resourceGroup, resourceId: database.id, @@ -494,11 +519,12 @@ export class AzureAutodiscoveryService { accountId: string, database: AzureRedisDatabase, authType: AzureAuthType, + tenantId?: string, ): Promise { if (authType === AzureAuthType.AccessKey) { - return this.getAccessKeyConnectionDetails(accountId, database); + return this.getAccessKeyConnectionDetails(accountId, database, tenantId); } - return this.getEntraIdConnectionDetails(accountId, database); + return this.getEntraIdConnectionDetails(accountId, database, tenantId); } /** @@ -509,6 +535,7 @@ export class AzureAutodiscoveryService { sessionMetadata: SessionMetadata, accountId: string, databases: ImportAzureDatabaseDto[], + tenantId?: string, ): Promise { this.logger.debug( `Adding ${databases.length} Azure database(s) for account ${accountId}`, @@ -524,7 +551,7 @@ export class AzureAutodiscoveryService { try { this.logger.debug(`[${dto.id}] Fetching database details...`); - database = await this.findDatabaseById(accountId, dto.id); + database = await this.findDatabaseById(accountId, dto.id, tenantId); if (!database) { this.logger.debug(`[${dto.id}] Database not found`); @@ -545,6 +572,7 @@ export class AzureAutodiscoveryService { accountId, database, selectedAuthType, + tenantId, ); if (!connectionDetails) { @@ -574,6 +602,7 @@ export class AzureAutodiscoveryService { provider: CloudProvider.Azure, authType: selectedAuthType, azureAccountId: connectionDetails.azureAccountId, + tenantId: connectionDetails.tenantId, subscriptionId: connectionDetails.subscriptionId, resourceGroup: connectionDetails.resourceGroup, resourceName: connectionDetails.resourceName, diff --git a/redisinsight/api/src/modules/azure/autodiscovery/dto/import-azure-databases.dto.ts b/redisinsight/api/src/modules/azure/autodiscovery/dto/import-azure-databases.dto.ts index 4e82e0317d..836a31b5ab 100644 --- a/redisinsight/api/src/modules/azure/autodiscovery/dto/import-azure-databases.dto.ts +++ b/redisinsight/api/src/modules/azure/autodiscovery/dto/import-azure-databases.dto.ts @@ -1,13 +1,16 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayNotEmpty, IsArray, IsDefined, IsNotEmpty, + IsOptional, IsString, + Matches, ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; +import { AZURE_TENANT_ID_REGEX } from '../../constants'; import { ImportAzureDatabaseDto } from './import-azure-database.dto'; export class ImportAzureDatabasesDto { @@ -31,4 +34,16 @@ export class ImportAzureDatabasesDto { @ValidateNested({ each: true }) @Type(() => ImportAzureDatabaseDto) databases: ImportAzureDatabaseDto[]; + + @ApiPropertyOptional({ + description: + 'Azure tenant (GUID or domain) the databases were discovered under. ' + + 'Used so tokens are acquired against the correct tenant.', + type: String, + }) + @IsOptional() + @Matches(AZURE_TENANT_ID_REGEX, { + message: 'tenantId must be a valid GUID or domain.', + }) + tenantId?: string; } diff --git a/redisinsight/api/src/modules/azure/azure-token-refresh.manager.spec.ts b/redisinsight/api/src/modules/azure/azure-token-refresh.manager.spec.ts index f8e840373e..0168b8c69b 100644 --- a/redisinsight/api/src/modules/azure/azure-token-refresh.manager.spec.ts +++ b/redisinsight/api/src/modules/azure/azure-token-refresh.manager.spec.ts @@ -23,12 +23,13 @@ const createMockTokenResult = () => { }; }; -const createMockClient = (tokenExpiresOn?: Date) => ({ +const createMockClient = (tokenExpiresOn?: Date, tenantId?: string) => ({ id: faker.string.uuid(), call: jest.fn().mockResolvedValue('OK'), database: { providerDetails: { azureAccountId: faker.string.uuid(), + tenantId, tokenExpiresOn, }, }, @@ -77,18 +78,18 @@ describe('AzureTokenRefreshManager', () => { const azureAccountId = faker.string.uuid(); const expiresOn = new Date(Date.now() + 60 * 60 * 1000); // 1 hour - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); expect(jest.getTimerCount()).toBe(1); }); - it('should clear existing timer when scheduling for same account with different expiry', () => { + it('should clear existing timer when scheduling for same account+tenant with different expiry', () => { const azureAccountId = faker.string.uuid(); const expiresOn1 = new Date(Date.now() + 60 * 60 * 1000); const expiresOn2 = new Date(Date.now() + 2 * 60 * 60 * 1000); - manager.scheduleRefresh(azureAccountId, expiresOn1); - manager.scheduleRefresh(azureAccountId, expiresOn2); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn1); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn2); expect(jest.getTimerCount()).toBe(1); }); @@ -97,9 +98,9 @@ describe('AzureTokenRefreshManager', () => { const azureAccountId = faker.string.uuid(); const expiresOn = new Date(Date.now() + 60 * 60 * 1000); - manager.scheduleRefresh(azureAccountId, expiresOn); - manager.scheduleRefresh(azureAccountId, expiresOn); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); // Should still be just 1 timer (not cleared and rescheduled) expect(jest.getTimerCount()).toBe(1); @@ -110,8 +111,18 @@ describe('AzureTokenRefreshManager', () => { const accountId2 = faker.string.uuid(); const expiresOn = new Date(Date.now() + 60 * 60 * 1000); - manager.scheduleRefresh(accountId1, expiresOn); - manager.scheduleRefresh(accountId2, expiresOn); + manager.scheduleRefresh(accountId1, undefined, expiresOn); + manager.scheduleRefresh(accountId2, undefined, expiresOn); + + expect(jest.getTimerCount()).toBe(2); + }); + + it('should keep separate timers per tenant for the same account', () => { + const azureAccountId = faker.string.uuid(); + const expiresOn = new Date(Date.now() + 60 * 60 * 1000); + + manager.scheduleRefresh(azureAccountId, 'tenant-a', expiresOn); + manager.scheduleRefresh(azureAccountId, 'tenant-b', expiresOn); expect(jest.getTimerCount()).toBe(2); }); @@ -122,11 +133,11 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date(Date.now() + 60 * 60 * 1000); // Simulate multiple token events arriving rapidly (e.g., from concurrent requests) - manager.scheduleRefresh(azureAccountId, expiresOn); - manager.scheduleRefresh(azureAccountId, expiresOn); - manager.scheduleRefresh(azureAccountId, expiresOn); - manager.scheduleRefresh(azureAccountId, expiresOn); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); // Should only have 1 timer, not 5 expect(jest.getTimerCount()).toBe(1); @@ -147,7 +158,7 @@ describe('AzureTokenRefreshManager', () => { ]); // Initial timer scheduled - manager.scheduleRefresh(azureAccountId, initialExpiry); + manager.scheduleRefresh(azureAccountId, undefined, initialExpiry); expect(jest.getTimerCount()).toBe(1); // Client reconnects 10 minutes later, gets new token with different expiry @@ -177,19 +188,19 @@ describe('AzureTokenRefreshManager', () => { const expiresOn1 = new Date(Date.now() + 60 * 60 * 1000); const expiresOn2 = new Date(Date.now() + 2 * 60 * 60 * 1000); - manager.scheduleRefresh(azureAccountId, expiresOn1); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn1); // Access internal timers map to verify behavior const timersMap = ( manager as unknown as { timers: Map } ).timers; - expect(timersMap.has(azureAccountId)).toBe(true); + expect(timersMap.has(`${azureAccountId}::`)).toBe(true); // Schedule with new expiry - should overwrite, not delete then set - manager.scheduleRefresh(azureAccountId, expiresOn2); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn2); // Entry should still exist (was overwritten atomically) - expect(timersMap.has(azureAccountId)).toBe(true); + expect(timersMap.has(`${azureAccountId}::`)).toBe(true); expect(jest.getTimerCount()).toBe(1); }); }); @@ -200,7 +211,7 @@ describe('AzureTokenRefreshManager', () => { // Token expires in 2 minutes (within 5-minute buffer) const expiresOn = new Date(Date.now() + 2 * 60 * 1000); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); expect(jest.getTimerCount()).toBe(1); // Timer should not fire immediately - minimum delay enforced @@ -213,7 +224,7 @@ describe('AzureTokenRefreshManager', () => { // Token already expired 1 minute ago const expiresOn = new Date(Date.now() - 60 * 1000); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); expect(jest.getTimerCount()).toBe(1); // Timer should not fire immediately - minimum delay enforced @@ -249,7 +260,11 @@ describe('AzureTokenRefreshManager', () => { ]); // Initial schedule - manager.scheduleRefresh(azureAccountId, nearExpiryToken.expiresOn); + manager.scheduleRefresh( + azureAccountId, + undefined, + nearExpiryToken.expiresOn, + ); // Advance past minimum delay to trigger refresh await jest.advanceTimersByTimeAsync(MIN_REFRESH_DELAY_MS); @@ -307,6 +322,33 @@ describe('AzureTokenRefreshManager', () => { expect(clientWithCurrentToken.call).not.toHaveBeenCalled(); }); + + it('should only re-authenticate clients of the token tenant', async () => { + const accountId = faker.string.uuid(); + const tokenResult = createMockTokenResult(); + // Same account, connections in two different tenants. + const clientTenantA = createMockClient(undefined, 'tenant-a'); + const clientTenantB = createMockClient(undefined, 'tenant-b'); + + mockRedisClientStorage.getClientsByDatabaseField.mockReturnValue([ + clientTenantA, + clientTenantB, + ]); + + await manager.handleTokenAcquired({ + accountId, + tenantId: 'tenant-a', + tokenResult, + }); + + // Only tenant-a's client gets tenant-a's token; tenant-b is untouched. + expect(clientTenantA.call).toHaveBeenCalledWith([ + 'AUTH', + tokenResult.account.localAccountId, + tokenResult.token, + ]); + expect(clientTenantB.call).not.toHaveBeenCalled(); + }); }); describe('clearTimer', () => { @@ -314,7 +356,7 @@ describe('AzureTokenRefreshManager', () => { const azureAccountId = faker.string.uuid(); const expiresOn = new Date(Date.now() + 60 * 60 * 1000); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); expect(jest.getTimerCount()).toBe(1); manager.clearTimer(azureAccountId); @@ -330,9 +372,9 @@ describe('AzureTokenRefreshManager', () => { it('should clear all timers', () => { const expiresOn = new Date(Date.now() + 60 * 60 * 1000); - manager.scheduleRefresh(faker.string.uuid(), expiresOn); - manager.scheduleRefresh(faker.string.uuid(), expiresOn); - manager.scheduleRefresh(faker.string.uuid(), expiresOn); + manager.scheduleRefresh(faker.string.uuid(), undefined, expiresOn); + manager.scheduleRefresh(faker.string.uuid(), undefined, expiresOn); + manager.scheduleRefresh(faker.string.uuid(), undefined, expiresOn); expect(jest.getTimerCount()).toBe(3); manager.clearAllTimers(); @@ -344,8 +386,8 @@ describe('AzureTokenRefreshManager', () => { it('should clear all timers on module destroy', () => { const expiresOn = new Date(Date.now() + 60 * 60 * 1000); - manager.scheduleRefresh(faker.string.uuid(), expiresOn); - manager.scheduleRefresh(faker.string.uuid(), expiresOn); + manager.scheduleRefresh(faker.string.uuid(), undefined, expiresOn); + manager.scheduleRefresh(faker.string.uuid(), undefined, expiresOn); expect(jest.getTimerCount()).toBe(2); manager.onModuleDestroy(); @@ -381,13 +423,13 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); expect( mockAzureAuthService.getRedisTokenByAccountId, - ).toHaveBeenCalledWith(azureAccountId); + ).toHaveBeenCalledWith(azureAccountId, undefined); expect( mockRedisClientStorage.getClientsByDatabaseField, ).toHaveBeenCalledWith('providerDetails.azureAccountId', azureAccountId); @@ -398,6 +440,38 @@ describe('AzureTokenRefreshManager', () => { ]); }); + it('should refresh against the tenant the timer was scheduled for', async () => { + const azureAccountId = faker.string.uuid(); + const tenantId = faker.string.uuid(); + const tokenResult = createMockTokenResult(); + const mockClient = createMockClient(undefined, tenantId); + + mockAzureAuthService.getRedisTokenByAccountId.mockImplementation( + async () => { + await manager.handleTokenAcquired({ + accountId: azureAccountId, + tenantId, + tokenResult, + }); + return tokenResult; + }, + ); + mockRedisClientStorage.getClientsByDatabaseField.mockReturnValue([ + mockClient, + ]); + + const expiresOn = new Date( + Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, + ); + manager.scheduleRefresh(azureAccountId, tenantId, expiresOn); + + await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); + + expect( + mockAzureAuthService.getRedisTokenByAccountId, + ).toHaveBeenCalledWith(azureAccountId, tenantId); + }); + it('should not re-authenticate when token refresh fails', async () => { const azureAccountId = faker.string.uuid(); const mockClient = createMockClient(); @@ -410,7 +484,7 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); @@ -431,7 +505,7 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); @@ -472,7 +546,7 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); @@ -505,7 +579,7 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); @@ -540,7 +614,7 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); @@ -572,7 +646,7 @@ describe('AzureTokenRefreshManager', () => { const expiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, expiresOn); + manager.scheduleRefresh(azureAccountId, undefined, expiresOn); await jest.advanceTimersByTimeAsync(TEST_DELAY_MS); @@ -610,7 +684,7 @@ describe('AzureTokenRefreshManager', () => { const scheduleExpiresOn = new Date( Date.now() + TOKEN_REFRESH_BUFFER_MS + TEST_DELAY_MS, ); - manager.scheduleRefresh(azureAccountId, scheduleExpiresOn); + manager.scheduleRefresh(azureAccountId, undefined, scheduleExpiresOn); expect(jest.getTimerCount()).toBe(1); // Fire the timer diff --git a/redisinsight/api/src/modules/azure/azure-token-refresh.manager.ts b/redisinsight/api/src/modules/azure/azure-token-refresh.manager.ts index 729d3c1007..d1a780aff5 100644 --- a/redisinsight/api/src/modules/azure/azure-token-refresh.manager.ts +++ b/redisinsight/api/src/modules/azure/azure-token-refresh.manager.ts @@ -12,18 +12,18 @@ import { AzureTokenResult } from './auth/models'; /** * Manages automatic token refresh for Azure Entra ID authenticated Redis clients. * - * When a token is acquired, the AzureRedisTokenEvents.Acquired event triggers: - * 1. Schedule a timer to refresh before expiry - * 2. Re-authenticate active Redis clients with the new token - * - * When the timer fires, it acquires a fresh token which emits the event again, - * continuing the cycle. The cycle stops when no clients are using the account. + * Refresh cycles are tracked per (account, tenant): one user can be signed into + * multiple tenants, each with its own token, and a client must only ever be + * re-authenticated with the token for its own tenant. */ interface ScheduledTimer { timeout: NodeJS.Timeout; expiresOn: Date; } +const refreshKey = (accountId: string, tenantId?: string): string => + `${accountId}::${tenantId ?? ''}`; + @Injectable() export class AzureTokenRefreshManager implements OnModuleDestroy { private readonly logger = new Logger(AzureTokenRefreshManager.name); @@ -42,23 +42,31 @@ export class AzureTokenRefreshManager implements OnModuleDestroy { @OnEvent(AzureRedisTokenEvents.Acquired) async handleTokenAcquired({ accountId, + tenantId, tokenResult, }: { accountId: string; + tenantId?: string; tokenResult: AzureTokenResult; }): Promise { try { - this.scheduleRefresh(accountId, tokenResult.expiresOn); - await this.reAuthenticateClients(accountId, tokenResult); + this.scheduleRefresh(accountId, tenantId, tokenResult.expiresOn); + await this.reAuthenticateClients(accountId, tenantId, tokenResult); } catch (error) { this.logger.error( - `Failed to handle token acquired event for account ${accountId}: ${error.message}`, + `Failed to handle token acquired event for account ${accountId} ` + + `(tenant=${tenantId || 'home'}): ${error.message}`, ); } } - scheduleRefresh(azureAccountId: string, expiresOn: Date): void { - const existing = this.timers.get(azureAccountId); + scheduleRefresh( + azureAccountId: string, + tenantId: string | undefined, + expiresOn: Date, + ): void { + const key = refreshKey(azureAccountId, tenantId); + const existing = this.timers.get(key); // Skip if already scheduled for the same expiry time (race condition protection) if (existing?.expiresOn?.getTime() === expiresOn.getTime()) { @@ -82,31 +90,30 @@ export class AzureTokenRefreshManager implements OnModuleDestroy { if (calculatedDelay < MIN_REFRESH_DELAY_MS) { this.logger.warn( - `Token for account ${azureAccountId} expires soon (${Math.round(calculatedDelay / 1000)}s), ` + + `Token for ${key} expires soon (${Math.round(calculatedDelay / 1000)}s), ` + `using minimum delay of ${MIN_REFRESH_DELAY_MS / 1000}s`, ); } this.logger.debug( - `Scheduling token refresh for account ${azureAccountId} in ${Math.round(delay / 1000)}s (expires: ${expiresOn.toISOString()})`, + `Scheduling token refresh for ${key} in ${Math.round(delay / 1000)}s (expires: ${expiresOn.toISOString()})`, ); const timeout = setTimeout(() => { - this.refreshToken(azureAccountId).catch((error) => { - this.logger.error( - `Token refresh failed for account ${azureAccountId}: ${error.message}`, - ); + this.refreshToken(azureAccountId, tenantId).catch((error) => { + this.logger.error(`Token refresh failed for ${key}: ${error.message}`); }); }, delay); - this.timers.set(azureAccountId, { timeout, expiresOn }); + this.timers.set(key, { timeout, expiresOn }); } - clearTimer(azureAccountId: string): void { - const existing = this.timers.get(azureAccountId); + clearTimer(azureAccountId: string, tenantId?: string): void { + const key = refreshKey(azureAccountId, tenantId); + const existing = this.timers.get(key); if (existing) { clearTimeout(existing.timeout); - this.timers.delete(azureAccountId); + this.timers.delete(key); } } @@ -115,38 +122,50 @@ export class AzureTokenRefreshManager implements OnModuleDestroy { this.timers.clear(); } - private async refreshToken(azureAccountId: string): Promise { - this.logger.debug(`Refreshing token for account ${azureAccountId}`); + /** Active clients for a given account and tenant. */ + private getClientsForTenant(azureAccountId: string, tenantId?: string) { + return this.redisClientStorage + .getClientsByDatabaseField( + 'providerDetails.azureAccountId', + azureAccountId, + ) + .filter( + (client) => client.database.providerDetails?.tenantId === tenantId, + ); + } + + private async refreshToken( + azureAccountId: string, + tenantId?: string, + ): Promise { + const key = refreshKey(azureAccountId, tenantId); + this.logger.debug(`Refreshing token for ${key}`); // Clear the stale timer entry - the timer has fired, so the entry is no longer valid. // This ensures that when getRedisTokenByAccountId emits the Acquired event, // scheduleRefresh won't skip due to matching expiresOn (e.g., MSAL cached token). - this.clearTimer(azureAccountId); + this.clearTimer(azureAccountId, tenantId); - // Stop the refresh cycle if no clients are using this account - const clients = this.redisClientStorage.getClientsByDatabaseField( - 'providerDetails.azureAccountId', - azureAccountId, - ); + // Stop the refresh cycle if no clients are using this account+tenant + const clients = this.getClientsForTenant(azureAccountId, tenantId); if (clients.length === 0) { - this.logger.debug( - `No active clients for account ${azureAccountId}, stopping refresh cycle`, - ); + this.logger.debug(`No active clients for ${key}, stopping refresh cycle`); return; } - await this.azureAuthService.getRedisTokenByAccountId(azureAccountId); + await this.azureAuthService.getRedisTokenByAccountId( + azureAccountId, + tenantId, + ); } private async reAuthenticateClients( azureAccountId: string, + tenantId: string | undefined, tokenResult: AzureTokenResult, ): Promise { - const clients = this.redisClientStorage.getClientsByDatabaseField( - 'providerDetails.azureAccountId', - azureAccountId, - ); + const clients = this.getClientsForTenant(azureAccountId, tenantId); if (clients.length === 0) { return; @@ -161,13 +180,13 @@ export class AzureTokenRefreshManager implements OnModuleDestroy { if (clientsToReauth.length === 0) { this.logger.debug( - `All clients for account ${azureAccountId} already have current token`, + `All clients for ${refreshKey(azureAccountId, tenantId)} already have current token`, ); return; } this.logger.debug( - `Re-authenticating ${clientsToReauth.length} of ${clients.length} client(s) for account ${azureAccountId}`, + `Re-authenticating ${clientsToReauth.length} of ${clients.length} client(s) for ${refreshKey(azureAccountId, tenantId)}`, ); await Promise.all( diff --git a/redisinsight/api/src/modules/azure/constants.ts b/redisinsight/api/src/modules/azure/constants.ts index 939beca570..a9e06cf4bb 100644 --- a/redisinsight/api/src/modules/azure/constants.ts +++ b/redisinsight/api/src/modules/azure/constants.ts @@ -5,11 +5,24 @@ */ export const AZURE_OAUTH_STORAGE_KEY = 'ri_azure_oauth_result'; +/** + * Azure AD authority host. Per-tenant authorities are built as + * `${AZURE_AUTHORITY_HOST}/${tenantId}`; because they share this host with the + * `/common` default, AAD instance discovery trusts them without extra config. + */ +export const AZURE_AUTHORITY_HOST = 'https://login.microsoftonline.com'; + /** * Azure AD authority URL for multi-tenant authentication. * Uses 'common' endpoint to allow any Azure AD tenant. */ -export const AZURE_AUTHORITY = 'https://login.microsoftonline.com/common'; +export const AZURE_AUTHORITY = `${AZURE_AUTHORITY_HOST}/common`; + +/** + * Build a per-tenant Azure AD authority URL from a tenant id or domain. + */ +export const buildAzureAuthority = (tenantId: string): string => + `${AZURE_AUTHORITY_HOST}/${tenantId}`; /** * Azure App Registration Client ID. @@ -139,6 +152,11 @@ export const AUTODISCOVERY_MAX_CONCURRENT_REQUESTS = 20; export const AZURE_SUBSCRIPTION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// A tenant id is either a GUID or a domain (e.g. your-tenant.onmicrosoft.com). +// MSAL accepts both as the authority path segment. +export const AZURE_TENANT_ID_REGEX = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,})$/i; + export const AzureApiUrls = { getSubscriptions: () => `/subscriptions?api-version=${API_VERSION_SUBSCRIPTIONS}`, diff --git a/redisinsight/api/src/modules/azure/exceptions/azure-entra-id-token-expired.exception.ts b/redisinsight/api/src/modules/azure/exceptions/azure-entra-id-token-expired.exception.ts index 98d7fb2df8..666316ec6b 100644 --- a/redisinsight/api/src/modules/azure/exceptions/azure-entra-id-token-expired.exception.ts +++ b/redisinsight/api/src/modules/azure/exceptions/azure-entra-id-token-expired.exception.ts @@ -8,6 +8,9 @@ import { CustomErrorCodes } from 'src/constants'; export class AzureEntraIdTokenExpiredException extends HttpException { constructor( + // Realm the expired connection was created for, so interactive recovery + // re-authenticates against it instead of the home tenant. + tenantId?: string, message = ERROR_MESSAGES.AZURE_ENTRA_ID_TOKEN_EXPIRED, options?: HttpExceptionOptions, ) { @@ -18,6 +21,7 @@ export class AzureEntraIdTokenExpiredException extends HttpException { errorCode: CustomErrorCodes.AzureEntraIdTokenExpired, additionalInfo: { errorCode: CustomErrorCodes.AzureEntraIdTokenExpired, + tenantId, }, }; diff --git a/redisinsight/api/src/modules/azure/models/azure-resource.ts b/redisinsight/api/src/modules/azure/models/azure-resource.ts index 166a6768b2..f8b9776da4 100644 --- a/redisinsight/api/src/modules/azure/models/azure-resource.ts +++ b/redisinsight/api/src/modules/azure/models/azure-resource.ts @@ -168,6 +168,12 @@ export class AzureConnectionDetails { }) azureAccountId?: string; + @ApiPropertyOptional({ + description: 'Azure tenant the token was issued against', + type: String, + }) + tenantId?: string; + @ApiProperty({ description: 'Azure subscription ID', type: String, diff --git a/redisinsight/api/src/modules/database/credentials/strategies/azure-access-key.credential-strategy.ts b/redisinsight/api/src/modules/database/credentials/strategies/azure-access-key.credential-strategy.ts index 948bd77d69..0612c3c34f 100644 --- a/redisinsight/api/src/modules/database/credentials/strategies/azure-access-key.credential-strategy.ts +++ b/redisinsight/api/src/modules/database/credentials/strategies/azure-access-key.credential-strategy.ts @@ -90,6 +90,7 @@ export class AzureAccessKeyCredentialStrategy implements ICredentialStrategy { providerDetails.resourceName, providerDetails.resourceType, providerDetails.clusterName, + providerDetails.tenantId, ); // Use plainToInstance to ensure the result is a proper Database class instance diff --git a/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.spec.ts b/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.spec.ts index 1d430bbdf3..a076967cac 100644 --- a/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.spec.ts +++ b/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.spec.ts @@ -173,6 +173,17 @@ describe('AzureEntraIdCredentialStrategy', () => { ); }); + it('should carry the connection tenant on the expired exception for recovery', async () => { + const database = createMockAzureDatabase(); + mockAzureAuthService.getRedisTokenByAccountId.mockResolvedValue(null); + + await expect(strategy.resolve(database)).rejects.toMatchObject({ + response: { + additionalInfo: { tenantId: database.providerDetails?.tenantId }, + }, + }); + }); + it('should return database with credentials from token result', async () => { const database = createMockAzureDatabase(); const tokenResult = createMockTokenResult(); @@ -186,7 +197,34 @@ describe('AzureEntraIdCredentialStrategy', () => { expect(result.password).toBe(tokenResult.token); expect( mockAzureAuthService.getRedisTokenByAccountId, - ).toHaveBeenCalledWith(database.providerDetails?.azureAccountId); + ).toHaveBeenCalledWith( + database.providerDetails?.azureAccountId, + database.providerDetails?.tenantId, + ); + }); + + it('should acquire the token against the stored tenant', async () => { + const tenantId = faker.string.uuid(); + const database = createMockAzureDatabase({ + providerDetails: { + provider: CloudProvider.Azure, + authType: AzureAuthType.EntraId, + azureAccountId: faker.string.uuid(), + tenantId, + }, + }); + mockAzureAuthService.getRedisTokenByAccountId.mockResolvedValue( + createMockTokenResult(), + ); + + await strategy.resolve(database); + + expect( + mockAzureAuthService.getRedisTokenByAccountId, + ).toHaveBeenCalledWith( + database.providerDetails?.azureAccountId, + tenantId, + ); }); it('should preserve other database properties', async () => { diff --git a/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.ts b/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.ts index a8952227c3..570f810956 100644 --- a/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.ts +++ b/redisinsight/api/src/modules/database/credentials/strategies/azure-entra-id.credential-strategy.ts @@ -45,13 +45,14 @@ export class AzureEntraIdCredentialStrategy implements ICredentialStrategy { const tokenResult = await this.azureAuthService.getRedisTokenByAccountId( providerDetails.azureAccountId, + providerDetails.tenantId, ); if (!tokenResult) { this.logger.warn( `Failed to acquire token for database ${database.id} - re-authentication needed`, ); - throw new AzureEntraIdTokenExpiredException(); + throw new AzureEntraIdTokenExpiredException(providerDetails.tenantId); } // Use plainToInstance to ensure the result is a proper Database class instance diff --git a/redisinsight/api/src/modules/database/models/provider-details.ts b/redisinsight/api/src/modules/database/models/provider-details.ts index 0cedbe2b8c..e6a8d011f9 100644 --- a/redisinsight/api/src/modules/database/models/provider-details.ts +++ b/redisinsight/api/src/modules/database/models/provider-details.ts @@ -57,6 +57,18 @@ export class AzureProviderDetails { @IsString() azureAccountId?: string; + @ApiPropertyOptional({ + description: + 'Azure tenant the token was issued against. Used as the authority for ' + + 'silent token refresh so multi-tenant sign-ins keep refreshing against ' + + 'the correct tenant.', + type: String, + }) + @Expose() + @IsOptional() + @IsString() + tenantId?: string; + @ApiPropertyOptional({ description: 'Token expiration time for filtering during re-authentication', type: Date, diff --git a/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.spec.tsx b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.spec.tsx new file mode 100644 index 0000000000..41cdd02025 --- /dev/null +++ b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.spec.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import { AzureSignInDialog } from './AzureSignInDialog' +import { AzureSignInDialogProps } from './AzureSignInDialog.types' + +const TEST_ID = 'azure-sign-in-dialog' + +describe('AzureSignInDialog', () => { + const defaultProps: AzureSignInDialogProps = { + isOpen: true, + loading: false, + onClose: jest.fn(), + onSignIn: jest.fn(), + } + + const renderComponent = (propsOverride?: Partial) => + render() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should not render when isOpen is false', () => { + renderComponent({ isOpen: false }) + + expect(screen.queryByTestId(`${TEST_ID}-body`)).not.toBeInTheDocument() + }) + + it('should render sign-in and cancel buttons by default', () => { + renderComponent() + + expect(screen.getByTestId(`${TEST_ID}-sign-in`)).toBeInTheDocument() + expect(screen.getByTestId(`${TEST_ID}-cancel`)).toBeInTheDocument() + }) + + it('should show the tenant field by default', () => { + renderComponent() + + expect(screen.getByTestId(`${TEST_ID}-tenant-input`)).toBeInTheDocument() + }) + + it('should sign in with no tenant when the field is left empty', () => { + const onSignIn = jest.fn() + renderComponent({ onSignIn }) + + fireEvent.click(screen.getByTestId(`${TEST_ID}-sign-in`)) + + expect(onSignIn).toHaveBeenCalledWith(undefined) + }) + + it('should sign in with the entered tenant', () => { + const onSignIn = jest.fn() + renderComponent({ onSignIn }) + + fireEvent.change(screen.getByTestId(`${TEST_ID}-tenant-input`), { + target: { value: 'your-tenant.onmicrosoft.com' }, + }) + fireEvent.click(screen.getByTestId(`${TEST_ID}-sign-in`)) + + expect(onSignIn).toHaveBeenCalledWith('your-tenant.onmicrosoft.com') + }) + + it('should disable sign-in and not submit an invalid tenant', () => { + const onSignIn = jest.fn() + renderComponent({ onSignIn }) + + fireEvent.change(screen.getByTestId(`${TEST_ID}-tenant-input`), { + target: { value: 'not a tenant' }, + }) + + expect(screen.getByTestId(`${TEST_ID}-sign-in`)).toBeDisabled() + + fireEvent.click(screen.getByTestId(`${TEST_ID}-sign-in`)) + expect(onSignIn).not.toHaveBeenCalled() + }) + + it('should call onClose when cancel is clicked', () => { + const onClose = jest.fn() + renderComponent({ onClose }) + + fireEvent.click(screen.getByTestId(`${TEST_ID}-cancel`)) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.styles.ts b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.styles.ts new file mode 100644 index 0000000000..b72aa0a890 --- /dev/null +++ b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.styles.ts @@ -0,0 +1,6 @@ +import styled from 'styled-components' +import { Modal } from 'uiSrc/components/base/display/modal' + +export const ModalContent = styled(Modal.Content.Compose)` + width: 540px; +` diff --git a/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx new file mode 100644 index 0000000000..d759e8215e --- /dev/null +++ b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useState } from 'react' + +import { Modal } from 'uiSrc/components/base/display' +import { CancelIcon } from 'uiSrc/components/base/icons' +import { Col, Row } from 'uiSrc/components/base/layout/flex' +import { Spacer } from 'uiSrc/components/base/layout' +import { Text } from 'uiSrc/components/base/text' +import TextInput from 'uiSrc/components/base/inputs/TextInput' +import { FormField } from 'uiSrc/components/base/forms/FormField' +import { + PrimaryButton, + SecondaryButton, +} from 'uiSrc/components/base/forms/buttons' + +import { AzureSignInDialogProps } from './AzureSignInDialog.types' +import * as S from './AzureSignInDialog.styles' + +const TEST_ID = 'azure-sign-in-dialog' + +// A tenant is either a GUID or a domain (e.g. your-tenant.onmicrosoft.com). +// Mirrors AZURE_TENANT_ID_REGEX on the backend. +const AZURE_TENANT_ID_REGEX = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,})$/i + +const TENANT_ID_ERROR = 'Enter a valid tenant GUID or domain.' + +const TENANT_ID_HINT = + 'Only needed if your resources and your account are in different tenants.' + +// Explains the cross-tenant case: authenticate against the tenant that OWNS the +// resources, not the user's home tenant. +const TENANT_ID_INFO = + "Leave blank to use your account's default (home) tenant. " + + 'If your Azure Managed Redis resources are in a different tenant than your ' + + 'account, enter the tenant that owns the resources (you need guest access ' + + 'to it) — not your own home tenant.' + +export const AzureSignInDialog = ({ + isOpen, + loading, + onClose, + onSignIn, +}: AzureSignInDialogProps) => { + const [tenantId, setTenantId] = useState('') + + useEffect(() => { + if (isOpen) { + setTenantId('') + } + }, [isOpen]) + + const trimmedTenant = tenantId.trim() + const isTenantInvalid = + trimmedTenant.length > 0 && !AZURE_TENANT_ID_REGEX.test(trimmedTenant) + + const handleSignIn = useCallback(() => { + if (isTenantInvalid) return + onSignIn(trimmedTenant || undefined) + }, [isTenantInvalid, trimmedTenant, onSignIn]) + + if (!isOpen) return null + + return ( + + + + + + + Connect to Azure Managed Redis + + + + + +
+ + Sign in with your Microsoft account to discover and add Azure + Managed Redis databases. + + + + + + + + + {isTenantInvalid ? TENANT_ID_ERROR : TENANT_ID_HINT} + + + + + + + + + Cancel + + + Sign in with Microsoft + + + + + ) +} + +export default AzureSignInDialog diff --git a/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.types.ts b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.types.ts new file mode 100644 index 0000000000..8dc234e88a --- /dev/null +++ b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.types.ts @@ -0,0 +1,11 @@ +export interface AzureSignInDialogProps { + isOpen: boolean + loading?: boolean + onClose: () => void + /** + * Called when the user confirms sign-in. `tenantId` is the optional tenant + * (GUID or domain) from the Tenant ID field, or undefined for the default + * home-tenant sign-in. + */ + onSignIn: (tenantId?: string) => void +} diff --git a/redisinsight/ui/src/components/azure-sign-in-dialog/index.ts b/redisinsight/ui/src/components/azure-sign-in-dialog/index.ts new file mode 100644 index 0000000000..30d3da7077 --- /dev/null +++ b/redisinsight/ui/src/components/azure-sign-in-dialog/index.ts @@ -0,0 +1,2 @@ +export { AzureSignInDialog, default } from './AzureSignInDialog' +export type { AzureSignInDialogProps } from './AzureSignInDialog.types' diff --git a/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.spec.tsx b/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.spec.tsx index 62bafe11d6..063d6471af 100644 --- a/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.spec.tsx +++ b/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.spec.tsx @@ -76,6 +76,7 @@ describe('GlobalAzureAuth', () => { id: faker.string.uuid(), username: faker.internet.email(), name: faker.person.fullName(), + tenantId: faker.string.uuid(), } const storedValue = JSON.stringify({ diff --git a/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.tsx b/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.tsx index 0294192b9e..5523ffd463 100644 --- a/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.tsx +++ b/redisinsight/ui/src/components/global-azure-auth/GlobalAzureAuth.tsx @@ -28,6 +28,7 @@ interface AzureOAuthCallbackPayload { id: string username: string name?: string + tenantId?: string } error?: string } @@ -56,6 +57,7 @@ const GlobalAzureAuth = () => { id: account.id, username: account.username, name: account.name, + tenantId: account.tenantId, } const currentSource = sourceRef.current dispatch(handleAzureOAuthSuccess(azureAccount)) diff --git a/redisinsight/ui/src/components/hooks/useAzureAuth.ts b/redisinsight/ui/src/components/hooks/useAzureAuth.ts index 96c4bccae3..427a0643a1 100644 --- a/redisinsight/ui/src/components/hooks/useAzureAuth.ts +++ b/redisinsight/ui/src/components/hooks/useAzureAuth.ts @@ -48,7 +48,10 @@ export const useAzureAuth = () => { }, []) const initiateLogin = useCallback( - (source: AzureLoginSource = AzureLoginSource.Autodiscovery) => { + ( + source: AzureLoginSource = AzureLoginSource.Autodiscovery, + tenantId?: string, + ) => { // In web mode, Azure OAuth only works when accessed via localhost // due to Azure's redirect URI restrictions for public client apps if (!isElectron && window.location.hostname !== 'localhost') { @@ -74,6 +77,7 @@ export const useAzureAuth = () => { onSuccess: openAuthUrl, prompt: AzureOAuthPrompt.SelectAccount, redirectType, + tenantId, }), ) }, diff --git a/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.spec.tsx b/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.spec.tsx index 4263e22d60..96c78722d0 100644 --- a/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.spec.tsx +++ b/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.spec.tsx @@ -1,5 +1,6 @@ import React from 'react' import { render, screen, fireEvent, cleanup } from 'uiSrc/utils/test-utils' +import { AzureLoginSource } from 'uiSrc/slices/interfaces' import AzureTokenExpiredErrorContent from './AzureTokenExpiredErrorContent' @@ -27,18 +28,22 @@ describe('AzureTokenExpiredErrorContent', () => { ) }) - it('should call initiateLogin and onClose when sign in button is clicked', () => { + it('should re-authenticate against the connection tenant and close on click', () => { const onClose = jest.fn() render( , ) fireEvent.click(screen.getByTestId('azure-sign-in-btn')) - expect(mockInitiateLogin).toHaveBeenCalled() + expect(mockInitiateLogin).toHaveBeenCalledWith( + AzureLoginSource.TokenRefresh, + 'realm-guid', + ) expect(onClose).toHaveBeenCalled() }) diff --git a/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.tsx b/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.tsx index e42613742c..f5a292f1c8 100644 --- a/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.tsx +++ b/redisinsight/ui/src/components/notifications/components/azure-token-expired/AzureTokenExpiredErrorContent.tsx @@ -9,15 +9,21 @@ import { AzureLoginSource } from 'uiSrc/slices/interfaces' export interface Props { text: string | JSX.Element | JSX.Element[] + tenantId?: string onClose?: () => void } -const AzureTokenExpiredErrorContent = ({ text, onClose = () => {} }: Props) => { +const AzureTokenExpiredErrorContent = ({ + text, + tenantId, + onClose = () => {}, +}: Props) => { const { initiateLogin, loading } = useAzureAuth() const { t } = useTranslation() const handleSignIn = () => { - initiateLogin(AzureLoginSource.TokenRefresh) + // Recover against the connection's own realm, not the home tenant. + initiateLogin(AzureLoginSource.TokenRefresh, tenantId) onClose?.() } diff --git a/redisinsight/ui/src/components/notifications/error-messages.tsx b/redisinsight/ui/src/components/notifications/error-messages.tsx index fb3924e70f..3aa54c491a 100644 --- a/redisinsight/ui/src/components/notifications/error-messages.tsx +++ b/redisinsight/ui/src/components/notifications/error-messages.tsx @@ -82,7 +82,7 @@ export default { description: , }), AZURE_TOKEN_EXPIRED: ( - { message }: { message: string | JSX.Element }, + { message, tenantId }: { message: string | JSX.Element; tenantId?: string }, onClose: () => void, ) => ({ 'data-testid': 'toast-info-azure-token-expired', @@ -90,7 +90,11 @@ export default { showCloseButton: true, onClose, description: ( - + ), }), PERSISTENT: ( diff --git a/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts b/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts index 257388fdaa..807c6d84b2 100644 --- a/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts +++ b/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts @@ -88,7 +88,7 @@ export const useErrorNotifications = () => { // Only show toast if not already visible if (!riToast.isActive(AZURE_TOKEN_EXPIRED_TOAST_ID)) { errorMessage = errorMessages.AZURE_TOKEN_EXPIRED( - { message }, + { message, tenantId: additionalInfo?.tenantId }, removeAzureToast, ) riToast(errorMessage, { diff --git a/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.spec.tsx b/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.spec.tsx index f4ec477f03..43c8823742 100644 --- a/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.spec.tsx +++ b/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.spec.tsx @@ -46,12 +46,14 @@ describe('ConfigAzureAuth', () => { homeAccountId: faker.string.uuid(), username: faker.internet.email(), name: faker.person.fullName(), + tenantId: faker.string.uuid(), } const expectedAccount = { id: mockMsalAccount.homeAccountId, username: mockMsalAccount.username, name: mockMsalAccount.name, + tenantId: mockMsalAccount.tenantId, } it('should call proper actions on success', () => { @@ -135,6 +137,7 @@ describe('ConfigAzureAuth', () => { account: null, error: '', source: AzureLoginSource.TokenRefresh, + tenant: null, }, }, } @@ -166,6 +169,7 @@ describe('ConfigAzureAuth', () => { account: null, error: '', source: AzureLoginSource.Autodiscovery, + tenant: null, }, }, } diff --git a/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.tsx b/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.tsx index 9456feb940..b21dd5dc45 100644 --- a/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.tsx +++ b/redisinsight/ui/src/electron/components/ConfigAzureAuth/ConfigAzureAuth.tsx @@ -19,6 +19,7 @@ interface MsalAccountInfo { homeAccountId: string username: string name?: string + tenantId?: string } interface AzureAuthCallbackResponse { @@ -47,6 +48,7 @@ const ConfigAzureAuth = () => { id: account.homeAccountId, username: account.username, name: account.name, + tenantId: account.tenantId, } const currentSource = sourceRef.current dispatch(handleAzureOAuthSuccess(azureAccount)) diff --git a/redisinsight/ui/src/mocks/factories/cloud/AzureAccount.factory.ts b/redisinsight/ui/src/mocks/factories/cloud/AzureAccount.factory.ts index 9b10aa8993..37a3f1d263 100644 --- a/redisinsight/ui/src/mocks/factories/cloud/AzureAccount.factory.ts +++ b/redisinsight/ui/src/mocks/factories/cloud/AzureAccount.factory.ts @@ -6,4 +6,5 @@ export const AzureAccountFactory = Factory.define(() => ({ id: faker.string.uuid(), username: faker.internet.email(), name: faker.person.fullName(), + tenantId: faker.string.uuid(), })) diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.spec.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.spec.tsx index 0815c1e045..f4afd61d8b 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.spec.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.spec.tsx @@ -110,6 +110,7 @@ describe('AzureDatabasesPage', () => { expect(fetchDatabasesAzure).toHaveBeenCalledWith( mockAccount.id, mockSubscription.subscriptionId, + undefined, ) }) @@ -177,6 +178,7 @@ describe('AzureDatabasesPage', () => { expect(fetchDatabasesAzure).toHaveBeenCalledWith( mockAccount.id, mockSubscription.subscriptionId, + undefined, ) }) }) diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx index 3d0de01600..d061c6c7c3 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx @@ -18,7 +18,10 @@ import { AzureRedisDatabase, ImportAzureDatabaseResponse, } from 'uiSrc/slices/interfaces' -import { azureAuthAccountSelector } from 'uiSrc/slices/oauth/azure' +import { + azureAuthAccountSelector, + azureAuthTenantSelector, +} from 'uiSrc/slices/oauth/azure' import { addDatabasesAzureAction, azureSelector, @@ -81,6 +84,7 @@ const AzureDatabasesPage = () => { const history = useHistory() const dispatch = useAppDispatch() const account = useAppSelector(azureAuthAccountSelector) + const tenant = useAppSelector(azureAuthTenantSelector) const { loading, error, databases, selectedSubscription, loaded } = useAppSelector(azureSelector) @@ -110,7 +114,11 @@ const AzureDatabasesPage = () => { // Only fetch if not already loaded if (!loaded.databases) { dispatch( - fetchDatabasesAzure(account.id, selectedSubscription.subscriptionId), + fetchDatabasesAzure( + account.id, + selectedSubscription.subscriptionId, + tenant ?? undefined, + ), ) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -160,7 +168,12 @@ const AzureDatabasesPage = () => { const databaseIds = selectedDatabases.map((db) => db.id) const results = await dispatch( - addDatabasesAzureAction(account.id, databaseIds, authType), + addDatabasesAzureAction( + account.id, + databaseIds, + authType, + tenant ?? undefined, + ), ) const successResults = results.filter( @@ -189,7 +202,11 @@ const AzureDatabasesPage = () => { if (account?.id && selectedSubscription) { dispatch(clearDatabasesAzure()) dispatch( - fetchDatabasesAzure(account.id, selectedSubscription.subscriptionId), + fetchDatabasesAzure( + account.id, + selectedSubscription.subscriptionId, + tenant ?? undefined, + ), ) setSelectedDatabases([]) } diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.spec.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.spec.tsx index 57981c8ea4..a5b71ec16c 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.spec.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.spec.tsx @@ -3,6 +3,7 @@ import { faker } from '@faker-js/faker' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import { AzureSubscription } from 'uiSrc/slices/interfaces' +import { azureAuthTenantSelector } from 'uiSrc/slices/oauth/azure' import AzureSubscriptions, { Props } from './AzureSubscriptions' jest.mock('uiSrc/slices/oauth/azure', () => ({ @@ -12,8 +13,12 @@ jest.mock('uiSrc/slices/oauth/azure', () => ({ username: 'test@example.com', name: 'Test User', }), + azureAuthTenantSelector: jest.fn().mockReturnValue(null), })) +const mockedAzureAuthTenantSelector = + azureAuthTenantSelector as unknown as jest.Mock + const mockSubscription = (): AzureSubscription => ({ subscriptionId: faker.string.uuid(), displayName: faker.company.name(), @@ -58,6 +63,20 @@ describe('AzureSubscriptions', () => { expect(screen.getByText('test@example.com')).toBeInTheDocument() }) + it('should not render the active tenant when none is set', () => { + mockedAzureAuthTenantSelector.mockReturnValue(null) + renderComponent() + expect(screen.queryByTestId('azure-active-tenant')).not.toBeInTheDocument() + }) + + it('should render the active tenant when one is set', () => { + mockedAzureAuthTenantSelector.mockReturnValue('your-tenant.onmicrosoft.com') + renderComponent() + const tenant = screen.getByTestId('azure-active-tenant') + expect(tenant).toBeInTheDocument() + expect(tenant).toHaveTextContent('your-tenant.onmicrosoft.com') + }) + it('should call onSwitchAccount when switch account button is clicked', () => { const onSwitchAccount = jest.fn() renderComponent({ onSwitchAccount }) diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx index 22dcf4e5aa..f481201eb5 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx @@ -16,7 +16,10 @@ import { Header, } from 'uiSrc/components/auto-discover' import { AzureSubscription } from 'uiSrc/slices/interfaces' -import { azureAuthAccountSelector } from 'uiSrc/slices/oauth/azure' +import { + azureAuthAccountSelector, + azureAuthTenantSelector, +} from 'uiSrc/slices/oauth/azure' import { Text } from 'uiSrc/components/base/text' import { EmptyButton, @@ -53,6 +56,7 @@ const AzureSubscriptions = ({ onManualConnection, }: Props) => { const account = useAppSelector(azureAuthAccountSelector) + const tenant = useAppSelector(azureAuthTenantSelector) const [items, setItems] = useState(subscriptions) const [selectedId, setSelectedId] = useState(null) @@ -125,12 +129,20 @@ const AzureSubscriptions = ({ {account.username} + {tenant && ( + + Tenant{' '} + + {tenant} + + + )} - Switch account + Switch account or tenant ({ jest.mock('uiSrc/slices/oauth/azure', () => ({ ...jest.requireActual('uiSrc/slices/oauth/azure'), azureAuthAccountSelector: jest.fn(), + azureAuthTenantSelector: jest.fn(), })) jest.mock('uiSrc/telemetry', () => ({ @@ -74,6 +79,8 @@ let store: typeof mockedStore const mockedAzureSelector = azureSelector as jest.Mock const mockedAzureAuthAccountSelector = azureAuthAccountSelector as jest.Mock +const mockedAzureAuthTenantSelector = azureAuthTenantSelector as jest.Mock +const mockedFetchSubscriptionsAzure = fetchSubscriptionsAzure as jest.Mock const mockedUseAzureAuth = useAzureAuth as jest.Mock const mockedSendEventTelemetry = sendEventTelemetry as jest.Mock @@ -86,6 +93,8 @@ describe('AzureSubscriptionsPage', () => { store.clearActions() mockedAzureSelector.mockReturnValue(defaultAzureState) mockedAzureAuthAccountSelector.mockReturnValue(mockAccount) + mockedAzureAuthTenantSelector.mockReturnValue(undefined) + mockedFetchSubscriptionsAzure.mockClear() mockedUseAzureAuth.mockReturnValue({ initiateLogin: mockInitiateLogin, account: mockAccount, @@ -117,16 +126,47 @@ describe('AzureSubscriptionsPage', () => { render(, { store }) - expect(fetchSubscriptionsAzure).toHaveBeenCalledWith(mockAccount.id) + expect(fetchSubscriptionsAzure).toHaveBeenCalledWith( + mockAccount.id, + undefined, + ) + }) + + it('should fetch subscriptions for the active tenant', () => { + const tenant = '11111111-1111-1111-1111-111111111111' + mockedAzureSelector.mockReturnValue({ + ...defaultAzureState, + loaded: { ...defaultAzureState.loaded, subscriptions: false }, + }) + mockedAzureAuthTenantSelector.mockReturnValue(tenant) + + render(, { store }) + + expect(fetchSubscriptionsAzure).toHaveBeenCalledWith(mockAccount.id, tenant) }) describe('switch account', () => { - it('should call initiateLogin when switch account button is clicked', () => { + it('should open the sign-in dialog when switch account is clicked', () => { + render(, { store }) + + fireEvent.click(screen.getByTestId('btn-switch-account')) + + expect( + screen.getByTestId('azure-sign-in-dialog-sign-in'), + ).toBeInTheDocument() + expect(mockInitiateLogin).not.toHaveBeenCalled() + }) + + it('should call initiateLogin when signing in from the dialog', () => { render(, { store }) fireEvent.click(screen.getByTestId('btn-switch-account')) + fireEvent.click(screen.getByTestId('azure-sign-in-dialog-sign-in')) - expect(mockInitiateLogin).toHaveBeenCalledTimes(1) + expect(mockInitiateLogin).toHaveBeenCalledWith( + AzureLoginSource.Autodiscovery, + undefined, + ) }) it('should send telemetry when switch account is clicked', () => { @@ -147,7 +187,10 @@ describe('AzureSubscriptionsPage', () => { fireEvent.click(screen.getByTestId('btn-refresh-subscriptions')) expect(clearSubscriptionsAzure).toHaveBeenCalled() - expect(fetchSubscriptionsAzure).toHaveBeenCalledWith(mockAccount.id) + expect(fetchSubscriptionsAzure).toHaveBeenCalledWith( + mockAccount.id, + undefined, + ) }) }) }) diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx index 2881ad9c58..b10c749266 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx @@ -1,11 +1,13 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { useHistory } from 'react-router-dom' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { Pages } from 'uiSrc/constants' import { setTitle } from 'uiSrc/utils' import { useAzureAuth } from 'uiSrc/components/hooks/useAzureAuth' -import { AzureSubscription } from 'uiSrc/slices/interfaces' +import { AzureSignInDialog } from 'uiSrc/components/azure-sign-in-dialog' +import { azureAuthTenantSelector } from 'uiSrc/slices/oauth/azure' +import { AzureLoginSource, AzureSubscription } from 'uiSrc/slices/interfaces' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { azureSelector, @@ -18,7 +20,9 @@ import AzureSubscriptions from './AzureSubscriptions/AzureSubscriptions' const AzureSubscriptionsPage = () => { const history = useHistory() const dispatch = useAppDispatch() - const { initiateLogin, account } = useAzureAuth() + const { initiateLogin, loading: azureLoading, account } = useAzureAuth() + const tenant = useAppSelector(azureAuthTenantSelector) + const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) const { loading, error, subscriptions, loaded } = useAppSelector(azureSelector) @@ -31,12 +35,12 @@ const AzureSubscriptionsPage = () => { setTitle('Azure Subscriptions') - // Only fetch if not already loaded or if account changed if (!loaded.subscriptions) { - dispatch(fetchSubscriptionsAzure(account.id)) + dispatch(fetchSubscriptionsAzure(account.id, tenant ?? undefined)) } + // tenant is a dep so account and the fetched tenant never read out of sync. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [account]) + }, [account, tenant]) const handleBack = () => { history.push(Pages.home) @@ -60,7 +64,7 @@ const AzureSubscriptionsPage = () => { }) if (account?.id) { dispatch(clearSubscriptionsAzure()) - dispatch(fetchSubscriptionsAzure(account.id)) + dispatch(fetchSubscriptionsAzure(account.id, tenant ?? undefined)) } } @@ -68,7 +72,12 @@ const AzureSubscriptionsPage = () => { sendEventTelemetry({ event: TelemetryEvent.AZURE_SWITCH_ACCOUNT_CLICKED, }) - initiateLogin() + setIsSignInDialogOpen(true) + } + + const handleSignIn = (tenantId?: string) => { + setIsSignInDialogOpen(false) + initiateLogin(AzureLoginSource.Autodiscovery, tenantId) } const handleManualConnection = () => { @@ -76,17 +85,25 @@ const AzureSubscriptionsPage = () => { } return ( - + <> + + setIsSignInDialogOpen(false)} + onSignIn={handleSignIn} + /> + ) } diff --git a/redisinsight/ui/src/pages/home/components/add-database-screen/components/connectivity-options/ConnectivityOptions.tsx b/redisinsight/ui/src/pages/home/components/add-database-screen/components/connectivity-options/ConnectivityOptions.tsx index 5d90fcd80d..fdc76d4f3f 100644 --- a/redisinsight/ui/src/pages/home/components/add-database-screen/components/connectivity-options/ConnectivityOptions.tsx +++ b/redisinsight/ui/src/pages/home/components/add-database-screen/components/connectivity-options/ConnectivityOptions.tsx @@ -1,17 +1,23 @@ -import React from 'react' +import React, { useState } from 'react' import { AddDbType } from 'uiSrc/pages/home/constants' import { FeatureFlagComponent, OAuthSsoHandlerDialog } from 'uiSrc/components' +import { useAzureAuth } from 'uiSrc/components/hooks/useAzureAuth' import { getUtmExternalLink } from 'uiSrc/utils/links' import { EXTERNAL_LINKS, UTM_CAMPAINGS } from 'uiSrc/constants/links' import { FeatureFlags } from 'uiSrc/constants' -import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { + AzureLoginSource, + OAuthSocialAction, + OAuthSocialSource, +} from 'uiSrc/slices/interfaces' import { Col, FlexItem, Grid, Row } from 'uiSrc/components/base/layout/flex' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { Text } from 'uiSrc/components/base/text/Text' import { RiIcon } from 'uiSrc/components/base/icons' import { Loader } from 'uiSrc/components/base/display' import { SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { AzureSignInDialog } from 'uiSrc/components/azure-sign-in-dialog' import { useConnectivityOptions } from '../../hooks/useConnectivityOptions' import { @@ -27,7 +33,18 @@ export interface Props { const ConnectivityOptions = (props: Props) => { const { onClickOption, onClose } = props - const connectivityOptions = useConnectivityOptions({ onClickOption }) + const [isAzureDialogOpen, setIsAzureDialogOpen] = useState(false) + const { initiateLogin, loading: azureLoading } = useAzureAuth() + + const connectivityOptions = useConnectivityOptions({ + onClickOption, + onRequestAzureSignIn: () => setIsAzureDialogOpen(true), + }) + + const handleAzureSignIn = (tenantId?: string) => { + setIsAzureDialogOpen(false) + initiateLogin(AzureLoginSource.Autodiscovery, tenantId) + } const loadingOption = connectivityOptions.find( (option) => option.loading && option.onCancel, @@ -119,6 +136,12 @@ const ConnectivityOptions = (props: Props) => { )} + setIsAzureDialogOpen(false)} + onSignIn={handleAzureSignIn} + /> ) } diff --git a/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.spec.ts b/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.spec.ts index 2733d36a31..84d2bc142a 100644 --- a/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.spec.ts +++ b/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.spec.ts @@ -77,8 +77,9 @@ describe('useConnectivityOptions', () => { expect(azureOption?.title).toBe('Azure Managed Redis') }) - it('should use initiateLogin for Azure option onClick when not logged in', () => { + it('should request the Azure sign-in dialog on Azure onClick when not logged in', () => { const mockHistoryPush = jest.fn() + const mockOnRequestAzureSignIn = jest.fn() reactRouterDom.useHistory = jest .fn() .mockReturnValue({ push: mockHistoryPush }) @@ -92,7 +93,10 @@ describe('useConnectivityOptions', () => { }) const { result } = renderHook(() => - useConnectivityOptions({ onClickOption: mockOnClickOption }), + useConnectivityOptions({ + onClickOption: mockOnClickOption, + onRequestAzureSignIn: mockOnRequestAzureSignIn, + }), ) const azureOption = result.current.find( @@ -101,7 +105,8 @@ describe('useConnectivityOptions', () => { azureOption?.onClick() - expect(mockInitiateLogin).toHaveBeenCalled() + expect(mockOnRequestAzureSignIn).toHaveBeenCalled() + expect(mockInitiateLogin).not.toHaveBeenCalled() expect(mockHistoryPush).not.toHaveBeenCalled() expect(mockOnClickOption).not.toHaveBeenCalled() }) diff --git a/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.ts b/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.ts index cbd74acff6..717e4d163f 100644 --- a/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.ts +++ b/redisinsight/ui/src/pages/home/components/add-database-screen/hooks/useConnectivityOptions.ts @@ -15,19 +15,21 @@ import { interface UseConnectivityOptionsProps { onClickOption: (type: AddDbType) => void + /** + * Called when the user clicks Azure while not signed in. The caller opens the + * sign-in dialog (where an optional tenant can be entered) instead of starting + * the OAuth flow immediately. + */ + onRequestAzureSignIn?: () => void } export const useConnectivityOptions = ({ onClickOption, + onRequestAzureSignIn, }: UseConnectivityOptionsProps): ConnectivityOption[] => { const history = useHistory() const isAzureEntraIdEnabled = useAppSelector(isAzureEntraIdEnabledSelector) - const { - initiateLogin, - cancelLogin, - loading: azureLoading, - account, - } = useAzureAuth() + const { cancelLogin, loading: azureLoading, account } = useAzureAuth() const handleAzureClick = useCallback(() => { sendEventTelemetry({ @@ -36,9 +38,9 @@ export const useConnectivityOptions = ({ if (account) { history.push(Pages.azureSubscriptions) } else { - initiateLogin() + onRequestAzureSignIn?.() } - }, [account, history, initiateLogin]) + }, [account, history, onRequestAzureSignIn]) return useMemo(() => { const getClickHandler = (option: ConnectivityOptionConfig) => { diff --git a/redisinsight/ui/src/slices/instances/azure.ts b/redisinsight/ui/src/slices/instances/azure.ts index e40923e525..af322d12ce 100644 --- a/redisinsight/ui/src/slices/instances/azure.ts +++ b/redisinsight/ui/src/slices/instances/azure.ts @@ -190,14 +190,14 @@ export const azureSelector = (state: RootState) => state.connections.azure export default azureSlice.reducer // Thunk actions -export function fetchSubscriptionsAzure(accountId: string) { +export function fetchSubscriptionsAzure(accountId: string, tenantId?: string) { return async (dispatch: AppDispatch) => { dispatch(loadSubscriptionsAzure()) try { const { data, status } = await apiService.get( ApiEndpoints.AZURE_SUBSCRIPTIONS, - { params: { accountId } }, + { params: { accountId, ...(tenantId ? { tenantId } : {}) } }, ) if (isStatusSuccessful(status)) { @@ -213,14 +213,18 @@ export function fetchSubscriptionsAzure(accountId: string) { } } -export function fetchDatabasesAzure(accountId: string, subscriptionId: string) { +export function fetchDatabasesAzure( + accountId: string, + subscriptionId: string, + tenantId?: string, +) { return async (dispatch: AppDispatch) => { dispatch(loadDatabasesAzure()) try { const { data, status } = await apiService.get( `${ApiEndpoints.AZURE_SUBSCRIPTIONS}/${subscriptionId}/databases`, - { params: { accountId } }, + { params: { accountId, ...(tenantId ? { tenantId } : {}) } }, ) if (isStatusSuccessful(status)) { @@ -240,6 +244,7 @@ export function addDatabasesAzureAction( accountId: string, databaseIds: string[], authType?: string, + tenantId?: string, ) { return async (dispatch: AppDispatch) => { dispatch(addDatabasesAzure()) @@ -250,6 +255,7 @@ export function addDatabasesAzureAction( >(ApiEndpoints.AZURE_AUTODISCOVERY_DATABASES, { accountId, databases: databaseIds.map((id) => ({ id, authType })), + ...(tenantId ? { tenantId } : {}), }) if (isStatusSuccessful(status)) { diff --git a/redisinsight/ui/src/slices/oauth/azure.ts b/redisinsight/ui/src/slices/oauth/azure.ts index e5d1bbd387..bcec926d37 100644 --- a/redisinsight/ui/src/slices/oauth/azure.ts +++ b/redisinsight/ui/src/slices/oauth/azure.ts @@ -19,6 +19,8 @@ export interface AzureAccount { id: string username: string name?: string + /** Realm (tenant) GUID the token was issued for. */ + tenantId?: string } export interface AzureAuthLoginResponse { @@ -30,6 +32,11 @@ export interface StateAzureAuth { account: AzureAccount | null error: string source: AzureLoginSource | null + /** + * Realm (tenant) GUID of the signed-in account; autodiscovery fetches + * target it. Null until a sign-in succeeds. + */ + tenant: string | null } export enum AzureOAuthPrompt { @@ -66,6 +73,7 @@ export const initialState: StateAzureAuth = { account: null, error: '', source: null, + tenant: null, } const clearOAuthTimeout = () => { @@ -106,6 +114,9 @@ const azureAuthSlice = createSlice({ ) => { state.loading = false state.account = payload + // Set only on success so a failed sign-in can't leave a tenant the + // user never signed into. + state.tenant = payload.tenantId ?? null state.error = '' }, azureOAuthCallbackFailure: (state, { payload }: PayloadAction) => { @@ -117,6 +128,7 @@ const azureAuthSlice = createSlice({ state.account = null state.error = '' state.source = null + state.tenant = null }, }, }) @@ -140,6 +152,8 @@ export const azureAuthLoadingSelector = (state: RootState) => state.oauth.azure?.loading export const azureAuthSourceSelector = (state: RootState) => state.oauth.azure?.source +export const azureAuthTenantSelector = (state: RootState) => + state.oauth.azure?.tenant // The reducer export default azureAuthSlice.reducer @@ -148,18 +162,24 @@ export interface InitiateAzureLoginOptions { source: AzureLoginSource prompt?: AzureOAuthPrompt redirectType?: AzureOAuthRedirectType + tenantId?: string onSuccess?: (url: string) => void onFail?: () => void } // Thunk action to initiate Azure login export function initiateAzureLoginAction(options: InitiateAzureLoginOptions) { - const { source, prompt, redirectType, onSuccess, onFail } = options + const { source, prompt, redirectType, tenantId, onSuccess, onFail } = options return async (dispatch: AppDispatch) => { dispatch(setAzureLoginSource(source)) sendEventTelemetry({ event: TelemetryEvent.AZURE_SIGN_IN_CLICKED, + eventData: { + // Whether the user signed in against a specific tenant (multi-tenant + // selector) vs. the default home tenant. Not the tenant value itself. + customTenant: Boolean(tenantId), + }, }) dispatch(azureAuthLogin()) @@ -167,6 +187,7 @@ export function initiateAzureLoginAction(options: InitiateAzureLoginOptions) { const params: Record = {} if (prompt) params.prompt = prompt if (redirectType) params.redirectType = redirectType + if (tenantId) params.tenantId = tenantId const { data, status } = await apiService.get( ApiEndpoints.AZURE_AUTH_LOGIN, diff --git a/redisinsight/ui/src/slices/tests/oauth/azure.spec.ts b/redisinsight/ui/src/slices/tests/oauth/azure.spec.ts index 5a8f9e213e..81b0d6dede 100644 --- a/redisinsight/ui/src/slices/tests/oauth/azure.spec.ts +++ b/redisinsight/ui/src/slices/tests/oauth/azure.spec.ts @@ -26,9 +26,16 @@ import { apiService } from 'uiSrc/services' import { cleanup, initialStateDefault, + mockStore, mockedStore, } from 'uiSrc/utils/test-utils' import { AzureAccountFactory } from 'uiSrc/mocks/factories/cloud/AzureAccount.factory' +import { TelemetryEvent } from 'uiSrc/telemetry' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) let store: typeof mockedStore beforeEach(() => { @@ -146,6 +153,7 @@ describe('azure auth slice', () => { ...initialState, loading: false, account: mockAccount, + tenant: mockAccount.tenantId, error: '', } @@ -160,6 +168,28 @@ describe('azure auth slice', () => { expect(azureAuthSelector(rootState)).toEqual(state) }) + it('should set the tenant to the signed-in account realm', () => { + const account = AzureAccountFactory.build({ tenantId: 'realm-guid' }) + + const nextState = reducer( + initialState, + azureOAuthCallbackSuccess(account), + ) + + expect(nextState.tenant).toEqual('realm-guid') + }) + + it('should null the tenant when the account has no realm', () => { + const account = AzureAccountFactory.build({ tenantId: undefined }) + + const nextState = reducer( + { ...initialState, tenant: 'stale-realm' }, + azureOAuthCallbackSuccess(account), + ) + + expect(nextState.tenant).toBeNull() + }) + it('should not reset source (ConfigAzureAuth needs it for redirect decision)', () => { const prevState = { ...initialState, @@ -196,19 +226,30 @@ describe('azure auth slice', () => { }) expect(azureAuthSelector(rootState)).toEqual(state) }) + + it('should keep the current tenant so a failed sign-in cannot change it', () => { + const nextState = reducer( + { ...initialState, loading: true, tenant: 'active-realm' }, + azureOAuthCallbackFailure(faker.lorem.sentence()), + ) + + expect(nextState.tenant).toEqual('active-realm') + }) }) describe('azureAuthLogout', () => { - it('should clear account and error', () => { + it('should clear account, error and tenant', () => { const prevState = { ...initialState, account: mockAccount, error: 'error', + tenant: 'active-realm', } const state = { ...initialState, account: null, error: '', + tenant: null, } const nextState = reducer(prevState, azureAuthLogout()) @@ -353,6 +394,86 @@ describe('azure auth slice', () => { params: undefined, }) }) + + it('should pass tenantId parameter as query param to API', async () => { + const authUrl = faker.internet.url() + const responsePayload = { data: { url: authUrl }, status: 200 } + const tenantId = 'your-tenant.onmicrosoft.com' + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + await store.dispatch( + initiateAzureLoginAction({ + source: AzureLoginSource.Autodiscovery, + onSuccess: jest.fn(), + tenantId, + }), + ) + + expect(apiService.get).toHaveBeenCalledWith(expect.any(String), { + params: { tenantId }, + }) + }) + }) + + // The slice binds to the real sendEventTelemetry at load time, so a + // module-level jest.mock can't intercept its thunks' calls. Reload the + // slice against the mock and read the spy from the same fresh module graph. + describe('sign-in telemetry', () => { + let telemetrySlice: typeof import('uiSrc/slices/oauth/azure') + let telemetryApi: typeof apiService + let sendEventTelemetryMock: jest.Mock + + beforeEach(async () => { + jest.resetModules() + jest.unmock('uiSrc/services') + telemetrySlice = await import('uiSrc/slices/oauth/azure') + telemetryApi = (await import('uiSrc/services')).apiService + sendEventTelemetryMock = jest.mocked( + (await import('uiSrc/telemetry')).sendEventTelemetry, + ) + }) + + it('should send customTenant=false when no tenant is provided', async () => { + telemetryApi.get = jest.fn().mockResolvedValue({ + data: { url: faker.internet.url() }, + status: 200, + }) + const local = mockStore(initialStateDefault) + + await local.dispatch( + telemetrySlice.initiateAzureLoginAction({ + source: AzureLoginSource.Autodiscovery, + onSuccess: jest.fn(), + }), + ) + + expect(sendEventTelemetryMock).toHaveBeenCalledWith({ + event: TelemetryEvent.AZURE_SIGN_IN_CLICKED, + eventData: { customTenant: false }, + }) + }) + + it('should send customTenant=true when a tenant is provided', async () => { + telemetryApi.get = jest.fn().mockResolvedValue({ + data: { url: faker.internet.url() }, + status: 200, + }) + const local = mockStore(initialStateDefault) + + await local.dispatch( + telemetrySlice.initiateAzureLoginAction({ + source: AzureLoginSource.Autodiscovery, + onSuccess: jest.fn(), + tenantId: 'your-tenant.onmicrosoft.com', + }), + ) + + expect(sendEventTelemetryMock).toHaveBeenCalledWith({ + event: TelemetryEvent.AZURE_SIGN_IN_CLICKED, + eventData: { customTenant: true }, + }) + }) }) describe('handleAzureOAuthSuccess', () => { From 249154f14eb5b75fbe1513f0801684eff8160ec0 Mon Sep 17 00:00:00 2001 From: Craig Brown Date: Tue, 14 Jul 2026 18:54:29 +0800 Subject: [PATCH 028/166] feat(ui): Add value decoder (#6143) --- redisinsight/api/config/features-config.json | 6 +- .../src/modules/feature/constants/index.ts | 1 + .../feature/constants/known-features.ts | 4 + .../feature-flag/feature-flag.provider.ts | 4 + redisinsight/ui/src/constants/featureFlags.ts | 1 + redisinsight/ui/src/constants/storage.ts | 1 + .../ConfigValueDecoderButton.styles.ts | 7 + .../ConfigValueDecoderButton.tsx | 30 + .../DecodedValueDisplay.styles.ts | 8 + .../value-decoder/DecodedValueDisplay.tsx | 79 +++ .../value-decoder/DecoderEditor.tsx | 151 +++++ .../DescriptionSelectValueRender.tsx | 31 + .../components/value-decoder/EyeIcon.tsx | 41 ++ .../value-decoder/FieldsSchemaEditor.tsx | 403 +++++++++++ .../value-decoder/KeyPatternsEditor.tsx | 102 +++ .../components/value-decoder/SortableItem.tsx | 108 +++ .../value-decoder/ValueDecoderHeaderLabel.tsx | 67 ++ .../value-decoder/ValueDecoderModal.styles.ts | 217 ++++++ .../value-decoder/ValueDecoderModal.tsx | 299 +++++++++ .../value-decoder/ValueDecoderProvider.tsx | 135 ++++ .../components/value-decoder/constants.ts | 101 +++ .../value-decoder/decoderClipboard.spec.ts | 101 +++ .../value-decoder/decoderClipboard.ts | 155 +++++ .../components/value-decoder/descriptions.ts | 34 + .../browser/components/value-decoder/index.ts | 29 + .../components/value-decoder/reorderList.ts | 20 + .../value-decoder/schemaUtils.spec.ts | 105 +++ .../components/value-decoder/schemaUtils.ts | 338 ++++++++++ .../browser/components/value-decoder/types.ts | 54 ++ .../components/value-decoder/utils.spec.ts | 631 ++++++++++++++++++ .../browser/components/value-decoder/utils.ts | 594 +++++++++++++++++ .../value-decoder/valueDecoderStorage.spec.ts | 96 +++ .../value-decoder/valueDecoderStorage.ts | 57 ++ .../key-details-header/KeyDetailsHeader.tsx | 10 + .../DynamicTypeDetails.tsx | 13 +- .../hash-details-table/HashDetailsTable.tsx | 57 +- .../BulkItemsActions/methods/handlers.ts | 2 + .../methods/handlers.ts | 2 + redisinsight/ui/src/slices/app/features.ts | 7 + 39 files changed, 4088 insertions(+), 13 deletions(-) create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.styles.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.styles.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/EyeIcon.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/SortableItem.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.styles.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderProvider.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.spec.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/descriptions.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/reorderList.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/types.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.spec.ts create mode 100644 redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 424feaf0ea..44f97f2f71 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 6, + "version": 6.1, "features": { "dev-language": { "flag": true, @@ -159,6 +159,10 @@ "whatsNew": { "flag": true, "perc": [[0, 100]] + }, + "valueDecoder": { + "flag": false, + "perc": [[0, 100]] } } } diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts index b9d0808d37..0a2fe15bca 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -41,6 +41,7 @@ export enum KnownFeatures { ProdMode = 'prodMode', DevLanguage = 'dev-language', WhatsNew = 'whatsNew', + ValueDecoder = 'valueDecoder', } export interface IFeatureFlag { diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index 4b85423bfe..50a9873b39 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -103,4 +103,8 @@ export const knownFeatures: Record = { name: KnownFeatures.WhatsNew, storage: FeatureStorage.Database, }, + [KnownFeatures.ValueDecoder]: { + name: KnownFeatures.ValueDecoder, + storage: FeatureStorage.Database, + }, }; diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts index d4d2fe7afd..87bfcf268f 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts @@ -125,6 +125,10 @@ export class FeatureFlagProvider { KnownFeatures.WhatsNew, new CommonFlagStrategy(this.featuresConfigService, this.settingsService), ); + this.strategies.set( + KnownFeatures.ValueDecoder, + new CommonFlagStrategy(this.featuresConfigService, this.settingsService), + ); } getStrategy(name: string): FeatureFlagStrategy { diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index c98875254d..0fce7db7e1 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -19,4 +19,5 @@ export enum FeatureFlags { prodMode = 'prodMode', devLanguage = 'dev-language', whatsNew = 'whatsNew', + valueDecoder = 'valueDecoder', } diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index 1012187f6a..0409cc3c32 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -48,6 +48,7 @@ enum BrowserStorageItem { wbTsResultPreferences = 'wbTsResultPreferences_', prodModeCtaActioned = 'prodModeCtaActioned', whatsNewLastVersionSeen = 'whatsNewLastVersionSeen', + valueDecoderRules = 'valueDecoderRules_', } export default BrowserStorageItem diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.styles.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.styles.ts new file mode 100644 index 0000000000..a1c841d0f2 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components' + +import { EmptyButton } from 'uiSrc/components/base/forms/buttons' + +export const ConfigButton = styled(EmptyButton)` + font-size: ${({ theme }) => theme.core.font.fontSize.s12}; +` diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.tsx new file mode 100644 index 0000000000..7f74aecab4 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.tsx @@ -0,0 +1,30 @@ +import React, { useCallback } from 'react' + +import { RiTooltip } from 'uiSrc/components' + +import { useValueDecoder } from './ValueDecoderProvider' +import { VALUE_DECODER_TEST_ID } from './constants' +import * as S from './ConfigValueDecoderButton.styles' + +export const ConfigValueDecoderButton = () => { + const { openValueDecoderModal } = useValueDecoder() + + const handleOpen = useCallback(() => { + openValueDecoderModal() + }, [openValueDecoderModal]) + + return ( + + + Value Decoders + + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.styles.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.styles.ts new file mode 100644 index 0000000000..9014a913d8 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.styles.ts @@ -0,0 +1,8 @@ +import styled from 'styled-components' + +export const DecodedValueTooltipContent = styled.span` + display: inline-block; + width: max-content; + max-width: 90vw; + white-space: pre-wrap; +` diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.tsx new file mode 100644 index 0000000000..10a5812c75 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/DecodedValueDisplay.tsx @@ -0,0 +1,79 @@ +import React, { useMemo } from 'react' + +import { Text } from 'uiSrc/components/base/text' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import FormattedValue from 'uiSrc/pages/browser/modules/key-details/shared/formatted-value/FormattedValue' + +import * as S from './DecodedValueDisplay.styles' +import { useValueDecoder } from './ValueDecoderProvider' +import { + formatParsedFields, + formatParsedFieldsInline, + parseBufferWithRule, +} from './utils' + +export interface DecodedValueDisplayProps { + buffer: RedisResponseBuffer + fallback: React.ReactNode + expanded?: boolean +} + +export const DecodedValueDisplay = ({ + buffer, + fallback, + expanded, +}: DecodedValueDisplayProps) => { + const { matchedRule, isDecodeEnabled } = useValueDecoder() + + const decodedNodes = useMemo(() => { + if (!isDecodeEnabled || !matchedRule) return null + return parseBufferWithRule(buffer, matchedRule.schema) + }, [buffer, isDecodeEnabled, matchedRule]) + + const formattedInline = useMemo( + () => (decodedNodes ? formatParsedFieldsInline(decodedNodes) : ''), + [decodedNodes], + ) + + const formattedMultiline = useMemo( + () => (decodedNodes ? formatParsedFields(decodedNodes) : ''), + [decodedNodes], + ) + + if (!decodedNodes) { + return <>{fallback} + } + + if (decodedNodes.length === 0) { + return ( + + No decoded fields + + ) + } + + if (expanded) { + return ( + + {formattedMultiline} + + ) + } + + return ( + + {formattedMultiline} + + } + maxWidth="min(90vw, max-content)" + title="Value" + /> + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx new file mode 100644 index 0000000000..6f022d85fd --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx @@ -0,0 +1,151 @@ +import React, { useCallback } from 'react' + +import { Text } from 'uiSrc/components/base/text' +import { ActionIconButton } from 'uiSrc/components/base/forms/buttons' +import { DeleteIcon } from 'uiSrc/components/base/icons' +import { Col } from 'uiSrc/components/base/layout/flex' +import { FormField } from 'uiSrc/components/base/forms/FormField' +import TextInput from 'uiSrc/components/base/inputs/TextInput' +import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' +import { CopyButton } from 'uiSrc/components/copy-button/CopyButton' + +import { + DECODER_TYPE_OPTIONS, + VALUE_DECODER_TEST_ID, +} from './constants' +import { serializeDecoderForClipboard } from './decoderClipboard' +import { + DECODER_TYPE_DESCRIPTIONS, + KEY_PATTERN_FIELD_DESCRIPTION, +} from './descriptions' +import { createDescriptionSelectValueRender } from './DescriptionSelectValueRender' +import { FieldsSchemaEditor } from './FieldsSchemaEditor' +import { KeyPatternsEditor } from './KeyPatternsEditor' +import { isDecoderValid } from './schemaUtils' +import { DecoderType, SchemaNode, ValueDecoderRule } from './types' +import * as S from './ValueDecoderModal.styles' + +const decoderTypeOptions = DECODER_TYPE_OPTIONS.map((option) => ({ + value: option.value, + label: option.content, +})) + +const decoderTypeValueRender = createDescriptionSelectValueRender( + DECODER_TYPE_DESCRIPTIONS, +) + +export interface DecoderEditorProps { + decoder: ValueDecoderRule + isExpanded: boolean + onToggle: () => void + onChange: (decoder: ValueDecoderRule) => void + onRemove: () => void + canRemove: boolean + summary: string + matchesCurrentKey?: boolean +} + +export const DecoderEditor = ({ + decoder, + isExpanded, + onToggle, + onChange, + onRemove, + canRemove, + summary, + matchesCurrentKey, +}: DecoderEditorProps) => { + const handleFieldChange = useCallback( + (key: K, value: ValueDecoderRule[K]) => { + onChange({ ...decoder, [key]: value }) + }, + [decoder, onChange], + ) + + const isValid = isDecoderValid(decoder) + + return ( + + + + + {summary} + + {matchesCurrentKey && ( + Matches current key + )} + {!isValid && ( + Incomplete + )} + + + + + + + + {isExpanded && ( + + + handleFieldChange('name', value)} + placeholder="Chunk state decoder" + data-testid={`${VALUE_DECODER_TEST_ID}-decoder-name-${decoder.id}`} + /> + + + + 0 ? decoder.keyPatterns : [''] + } + onChange={(keyPatterns) => handleFieldChange('keyPatterns', keyPatterns)} + /> + + + + + handleFieldChange('decoderType', value as DecoderType) + } + valueRender={decoderTypeValueRender} + data-testid={`${VALUE_DECODER_TEST_ID}-decoder-type-${decoder.id}`} + /> + + + + Fields + handleFieldChange('schema', schema)} + /> + + + )} + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx new file mode 100644 index 0000000000..aaa7cd34f9 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx @@ -0,0 +1,31 @@ +import { RiTooltip } from 'uiSrc/components' + +import { + SelectValueRender, + SelectValueRenderParams, +} from 'uiSrc/components/base/forms/select/RiSelect' + +import * as S from './ValueDecoderModal.styles' + +export const createDescriptionSelectValueRender = ( + descriptions: Record, +): SelectValueRender => { + const render = ({ option, isOptionValue }: SelectValueRenderParams) => { + const description = descriptions[String(option.value)] ?? '' + const label = option.label ?? option.value + + return ( + + + {label} + + + ) + } + + return render +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/EyeIcon.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/EyeIcon.tsx new file mode 100644 index 0000000000..1c691bf1ce --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/EyeIcon.tsx @@ -0,0 +1,41 @@ +import React from 'react' + +type EyeIconProps = { + size?: number + className?: string +} + +export const EyeIcon = ({ size = 16, className }: EyeIconProps) => ( + + + + +) + +export const EyeOffIcon = ({ size = 16, className }: EyeIconProps) => ( + + + +) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx new file mode 100644 index 0000000000..a5b0160042 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx @@ -0,0 +1,403 @@ +import React, { useCallback } from 'react' + +import { ActionIconButton, SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { DeleteIcon } from 'uiSrc/components/base/icons' +import { Row } from 'uiSrc/components/base/layout/flex' +import TextInput from 'uiSrc/components/base/inputs/TextInput' +import NumericInput from 'uiSrc/components/base/inputs/NumericInput' +import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' + +import { + BINARY_DATA_TYPES, + createEmptyField, + createEmptyRepeatBlock, + isRepeatNode, + SIZE_SOURCE_OPTIONS, + VALUE_DECODER_TEST_ID, +} from './constants' +import { DATA_TYPE_DESCRIPTIONS } from './descriptions' +import { createDescriptionSelectValueRender } from './DescriptionSelectValueRender' +import { + getPriorNumericFieldsInScope, + isCustomSizeType, + NumericFieldRef, + removeSchemaNode, + updateSchemaNode, +} from './schemaUtils' +import { reorderList } from './reorderList' +import { SortableItem } from './SortableItem' +import { + BinaryFieldDefinition, + FieldSizeSource, + RepeatBlockDefinition, + SchemaNode, +} from './types' +import { getFixedSize, getSizeUnit } from './utils' +import * as S from './ValueDecoderModal.styles' + +const dataTypeOptions = BINARY_DATA_TYPES.map((type) => ({ + value: type, + label: type, +})) + +const sizeSourceOptions = SIZE_SOURCE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, +})) + +const dataTypeValueRender = createDescriptionSelectValueRender( + DATA_TYPE_DESCRIPTIONS, +) + +const toNumericOptions = (fields: NumericFieldRef[]) => { + const nameCounts = fields.reduce>((counts, item) => { + counts[item.name] = (counts[item.name] ?? 0) + 1 + return counts + }, {}) + + return fields.map((item) => ({ + value: item.id, + label: + nameCounts[item.name] > 1 + ? `${item.name} (${item.dataType}) · ${item.id}` + : `${item.name} (${item.dataType})`, + })) +} + +export interface FieldsSchemaEditorProps { + sortListId: string + nodes: SchemaNode[] + onChange: (nodes: SchemaNode[]) => void + priorNumericFields?: NumericFieldRef[] + depth?: number +} + +interface FieldRowProps { + field: BinaryFieldDefinition + index: number + nodes: SchemaNode[] + priorNumericFields: NumericFieldRef[] + onFieldChange: (id: string, patch: Partial) => void + onRemove: (id: string) => void +} + +const FieldRow = ({ + field, + index, + nodes, + priorNumericFields, + onFieldChange, + onRemove, +}: FieldRowProps) => { + const fixedSize = getFixedSize(field.dataType) + const isCustomSize = fixedSize === 'custom' + const sizeSource = field.sizeSource ?? 'fixed' + const sizeRefs = toNumericOptions( + getPriorNumericFieldsInScope(priorNumericFields, nodes, index), + ) + + return ( + + onFieldChange(field.id, { name: value })} + placeholder="fieldName" + data-testid={`${VALUE_DECODER_TEST_ID}-field-name-${field.id}`} + /> + onFieldChange(field.id, { dataType: value })} + valueRender={dataTypeValueRender} + data-testid={`${VALUE_DECODER_TEST_ID}-field-type-${field.id}`} + /> +
+ {isCustomSize ? ( + + + onFieldChange(field.id, { + sizeSource: value as FieldSizeSource, + sizeFieldRef: + value === 'field' ? field.sizeFieldRef : undefined, + }) + } + data-testid={`${VALUE_DECODER_TEST_ID}-field-size-source-${field.id}`} + /> + {sizeSource === 'field' ? ( + + onFieldChange(field.id, { sizeFieldRef: value ?? '' }) + } + placeholder="Select size field" + data-testid={`${VALUE_DECODER_TEST_ID}-field-size-ref-${field.id}`} + /> + ) : ( + + + onFieldChange(field.id, { + size: + value == null || Number.isNaN(value) ? '' : value, + }) + } + min={1} + data-testid={`${VALUE_DECODER_TEST_ID}-field-size-${field.id}`} + /> + + {getSizeUnit(field.size)} + + + )} + + ) : ( + + {}} + disabled + data-testid={`${VALUE_DECODER_TEST_ID}-field-size-${field.id}`} + /> + + {getSizeUnit(field.size)} + + + )} +
+ onRemove(field.id)} + data-testid={`${VALUE_DECODER_TEST_ID}-remove-field-${field.id}`} + /> +
+ ) +} + +interface RepeatBlockEditorProps { + repeat: RepeatBlockDefinition + index: number + nodes: SchemaNode[] + priorNumericFields: NumericFieldRef[] + depth: number + onRepeatChange: ( + id: string, + patch: Partial<{ countFieldRef: string }>, + ) => void + onRepeatFieldsChange: (repeatId: string, fields: SchemaNode[]) => void + onRemove: (id: string) => void +} + +const RepeatBlockEditor = ({ + repeat, + index, + nodes, + priorNumericFields, + depth, + onRepeatChange, + onRepeatFieldsChange, + onRemove, +}: RepeatBlockEditorProps) => { + const repeatScopeNumeric = getPriorNumericFieldsInScope( + priorNumericFields, + nodes, + index, + ) + + return ( + + + Repeat + + onRepeatChange(repeat.id, { countFieldRef: value ?? '' }) + } + placeholder="Select count field" + data-testid={`${VALUE_DECODER_TEST_ID}-repeat-count-${repeat.id}`} + /> + onRemove(repeat.id)} + data-testid={`${VALUE_DECODER_TEST_ID}-remove-repeat-${repeat.id}`} + /> + + + onRepeatFieldsChange(repeat.id, fields)} + priorNumericFields={repeatScopeNumeric} + depth={depth + 1} + /> + + ) +} + +export const FieldsSchemaEditor = ({ + sortListId, + nodes, + onChange, + priorNumericFields = [], + depth = 0, +}: FieldsSchemaEditorProps) => { + const handleFieldChange = useCallback( + (id: string, patch: Partial) => { + onChange( + updateSchemaNode(nodes, id, (node) => { + if (node.kind !== 'field') { + return node + } + + const nextField = { ...node, ...patch } + if (patch.dataType) { + const fixedSize = getFixedSize(patch.dataType) + nextField.size = fixedSize === 'custom' ? '' : fixedSize + if (!isCustomSizeType(patch.dataType)) { + nextField.sizeSource = 'fixed' + nextField.sizeFieldRef = undefined + } + } + return nextField + }), + ) + }, + [nodes, onChange], + ) + + const handleRepeatChange = useCallback( + (id: string, patch: Partial<{ countFieldRef: string }>) => { + onChange( + updateSchemaNode(nodes, id, (node) => { + if (node.kind !== 'repeat') { + return node + } + return { ...node, ...patch } + }), + ) + }, + [nodes, onChange], + ) + + const handleRepeatFieldsChange = useCallback( + (repeatId: string, fields: SchemaNode[]) => { + onChange( + updateSchemaNode(nodes, repeatId, (node) => { + if (node.kind !== 'repeat') { + return node + } + return { ...node, fields } + }), + ) + }, + [nodes, onChange], + ) + + const handleRemoveNode = useCallback( + (id: string) => { + onChange(removeSchemaNode(nodes, id)) + }, + [nodes, onChange], + ) + + const handleReorder = useCallback( + (fromIndex: number, toIndex: number) => { + onChange(reorderList(nodes, fromIndex, toIndex)) + }, + [nodes, onChange], + ) + + const handleAddField = useCallback(() => { + onChange([...nodes, createEmptyField()]) + }, [nodes, onChange]) + + const handleAddRepeat = useCallback(() => { + onChange([...nodes, createEmptyRepeatBlock()]) + }, [nodes, onChange]) + + const renderNode = (node: SchemaNode, index: number) => { + const content = isRepeatNode(node) ? ( + + ) : ( + + ) + + return ( + + {content} + + ) + } + + const hasFieldNodes = nodes.some((node) => !isRepeatNode(node)) + + return ( + <> + {hasFieldNodes && ( + + Field Name + Data Type + Size + + + )} + + {nodes.map((node, index) => renderNode(node, index))} + + + + + Add Field + + + Add Repeat + + + + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx new file mode 100644 index 0000000000..cc3527b891 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx @@ -0,0 +1,102 @@ +import React, { useCallback } from 'react' + +import { ActionIconButton, SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { DeleteIcon } from 'uiSrc/components/base/icons' +import TextInput from 'uiSrc/components/base/inputs/TextInput' + +import { VALUE_DECODER_TEST_ID } from './constants' +import { reorderList } from './reorderList' +import { SortableItem } from './SortableItem' +import * as S from './ValueDecoderModal.styles' + +export interface KeyPatternsEditorProps { + listId: string + patterns: string[] + onChange: (patterns: string[]) => void +} + +export const KeyPatternsEditor = ({ + listId, + patterns, + onChange, +}: KeyPatternsEditorProps) => { + const handlePatternChange = useCallback( + (index: number, value: string) => { + onChange(patterns.map((pattern, i) => (i === index ? value : pattern))) + }, + [onChange, patterns], + ) + + const handleRemovePattern = useCallback( + (index: number) => { + const next = patterns.filter((_, i) => i !== index) + onChange(next.length > 0 ? next : ['']) + }, + [onChange, patterns], + ) + + const handleAddPattern = useCallback(() => { + onChange([...patterns, '']) + }, [onChange, patterns]) + + const handleReorder = useCallback( + (fromIndex: number, toIndex: number) => { + onChange(reorderList(patterns, fromIndex, toIndex)) + }, + [onChange, patterns], + ) + + return ( + + {patterns.map((pattern, index) => { + const isLast = index === patterns.length - 1 + const patternRow = ( + + handlePatternChange(index, value)} + placeholder="room:chunk-state:*" + data-testid={`${VALUE_DECODER_TEST_ID}-key-pattern-${index}`} + /> + handleRemovePattern(index)} + disabled={patterns.length === 1 && !pattern.trim()} + data-testid={`${VALUE_DECODER_TEST_ID}-remove-key-pattern-${index}`} + /> + + ) + + const sortableRow = ( + + {patternRow} + + ) + + if (!isLast) { + return sortableRow + } + + return ( + + {sortableRow} + + Add Pattern + + + ) + })} + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/SortableItem.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/SortableItem.tsx new file mode 100644 index 0000000000..f68e08e6f2 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/SortableItem.tsx @@ -0,0 +1,108 @@ +import React, { useCallback, useMemo } from 'react' + +import { ThreeDotsIcon } from 'uiSrc/components/base/icons' + +import * as S from './ValueDecoderModal.styles' + +const getSortMimeType = (listId: string) => + `application/x-value-decoder-sort-${listId}` + +const parseSortIndexFromDataTransfer = ( + dataTransfer: DataTransfer, + listId: string, +): number | null => { + const mimeType = getSortMimeType(listId) + if (!dataTransfer.types.includes(mimeType)) { + return null + } + + const raw = dataTransfer.getData(mimeType) + if (raw === '') { + return null + } + + const fromIndex = Number(raw) + if (!Number.isInteger(fromIndex) || fromIndex < 0) { + return null + } + + return fromIndex +} + +export interface SortableItemProps { + listId: string + index: number + onReorder: (fromIndex: number, toIndex: number) => void + children: React.ReactNode + testId?: string +} + +export const SortableItem = ({ + listId, + index, + onReorder, + children, + testId, +}: SortableItemProps) => { + const sortMimeType = useMemo(() => getSortMimeType(listId), [listId]) + + const handleDragStart = useCallback( + (event: React.DragEvent) => { + event.stopPropagation() + event.dataTransfer.setData(sortMimeType, String(index)) + event.dataTransfer.effectAllowed = 'move' + }, + [index, sortMimeType], + ) + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes(sortMimeType)) { + return + } + + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'move' + }, + [sortMimeType], + ) + + const handleDrop = useCallback( + (event: React.DragEvent) => { + const fromIndex = parseSortIndexFromDataTransfer( + event.dataTransfer, + listId, + ) + if (fromIndex === null) { + return + } + + event.preventDefault() + event.stopPropagation() + + if (fromIndex !== index) { + onReorder(fromIndex, index) + } + }, + [index, listId, onReorder], + ) + + return ( + + + + + {children} + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx new file mode 100644 index 0000000000..0507c4d774 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import styled from 'styled-components' + +import { RiTooltip } from 'uiSrc/components' +import { EmptyButton } from 'uiSrc/components/base/forms/buttons' +import { Text } from 'uiSrc/components/base/text' +import { Row } from 'uiSrc/components/base/layout/flex' + +import { EyeIcon, EyeOffIcon } from './EyeIcon' +import { useValueDecoder } from './ValueDecoderProvider' +import { VALUE_DECODER_TEST_ID } from './constants' + +const ToggleButton = styled(EmptyButton)<{ $active?: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + min-height: 24px; + padding: 0; + color: ${({ theme, $active }) => + $active + ? theme.components.typography.colors.primary + : theme.components.typography.colors.secondary}; +` + +export interface ValueDecoderHeaderLabelProps { + label?: string +} + +export const ValueDecoderHeaderLabel = ({ + label = 'Value', +}: ValueDecoderHeaderLabelProps) => { + const { hasMatchingRule, isDecodeEnabled, toggleDecodeEnabled } = + useValueDecoder() + + if (!hasMatchingRule) { + return <>{label} + } + + return ( + + + {label} + + + + {isDecodeEnabled ? : } + + + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.styles.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.styles.ts new file mode 100644 index 0000000000..4c9042fa81 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.styles.ts @@ -0,0 +1,217 @@ +import styled from 'styled-components' +import { Modal } from 'uiSrc/components/base/display/modal' + +export const ModalContent = styled(Modal.Content.Compose)` + width: ${({ theme }) => + `calc(100vw - ${theme.core.space.space800} - ${theme.core.space.space150})`}; + min-width: ${({ theme }) => `calc(${theme.core.space.space500} * 15)`}; + max-width: ${({ theme }) => + `calc(100vw - ${theme.core.space.space800} - ${theme.core.space.space150})`}; + max-height: ${({ theme }) => + `calc(100vh - ${theme.core.space.space800} - ${theme.core.space.space150})`}; +` + +export const ModalBody = styled(Modal.Content.Body)` + flex: 1; + min-height: 0; + overflow-y: auto; +` + +export const FieldTableHeader = styled.div` + display: grid; + grid-template-columns: 1.2fr 1fr 1.4fr auto; + gap: ${({ theme }) => theme.core.space.space100}; + padding: ${({ theme }) => theme.core.space.space100}; + padding-left: calc( + ${({ theme }) => theme.core.space.space100} + 1.2rem + + ${({ theme }) => theme.core.space.space050} + ); + color: ${({ theme }) => theme.components.typography.colors.secondary}; + font-weight: 600; +` + +export const FieldRowGrid = styled.div` + display: grid; + grid-template-columns: 1.2fr 1fr 1.4fr auto; + gap: ${({ theme }) => theme.core.space.space100}; + align-items: center; + flex: 1; + min-width: 0; +` + +export const DragHandle = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2rem; + flex-shrink: 0; + opacity: 0; + cursor: grab; + color: ${({ theme }) => theme.components.typography.colors.secondary}; + + &:active { + cursor: grabbing; + } +` + +export const SortableRow = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space050}; + padding: ${({ theme }) => theme.core.space.space100}; + border-top: 1px solid + ${({ theme }) => theme.semantic.color.border.neutral400}; + + &:hover ${DragHandle} { + opacity: 1; + } +` + +export const SortableContent = styled.div` + flex: 1; + min-width: 0; +` + +export const KeyPatternsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.core.space.space050}; + + ${SortableRow}:first-of-type { + border-top: none; + padding-top: 0; + } +` + +export const KeyPatternRow = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space050}; + flex: 1; + min-width: 0; +` + +export const KeyPatternLastRow = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space100}; + + ${SortableRow} { + flex: 1; + min-width: 0; + } +` + +export const SizeInputWrapper = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space050}; + min-width: 120px; +` + +export const SizeSourceWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.core.space.space050}; + min-width: 180px; +` + +export const SizeUnit = styled.span` + color: ${({ theme }) => theme.components.typography.colors.secondary}; + font-size: ${({ theme }) => theme.core.font.fontSize.s12}; + white-space: nowrap; +` + +export const RepeatBlock = styled.div<{ $depth: number }>` + margin-top: ${({ theme }) => theme.core.space.space100}; + margin-bottom: ${({ theme }) => theme.core.space.space100}; + padding: ${({ theme }) => theme.core.space.space100}; + padding-left: ${({ theme, $depth }) => + `calc(${theme.core.space.space100} + ${$depth * 16}px)`}; + border: 1px solid ${({ theme }) => theme.semantic.color.border.neutral400}; + border-radius: ${({ theme }) => theme.core.space.space100}; + background: ${({ theme }) => theme.semantic.color.background.neutral100}; + flex: 1; + min-width: 0; +` + +export const RepeatHeader = styled.div` + display: grid; + grid-template-columns: auto 1fr auto; + gap: ${({ theme }) => theme.core.space.space100}; + align-items: center; + margin-bottom: ${({ theme }) => theme.core.space.space100}; +` + +export const RowActions = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space025}; + justify-content: flex-end; +` + +export const SchemaActions = styled.div` + display: flex; + gap: ${({ theme }) => theme.core.space.space050}; +` + +export const RepeatLabel = styled.span` + color: ${({ theme }) => theme.components.typography.colors.secondary}; + font-size: ${({ theme }) => theme.core.font.fontSize.s12}; + font-weight: 600; + white-space: nowrap; +` + +export const SelectOptionAnchor = styled.span<{ $fullWidth?: boolean }>` + display: inline-flex; + align-items: center; + width: ${({ $fullWidth }) => ($fullWidth ? '100%' : 'auto')}; +` + +export const DecoderSection = styled.div<{ $expanded: boolean }>` + border: 1px solid ${({ theme }) => theme.semantic.color.border.neutral400}; + border-radius: ${({ theme }) => theme.core.space.space100}; + background: ${({ theme, $expanded }) => + $expanded + ? theme.semantic.color.background.neutral100 + : theme.semantic.color.background.neutral200}; +` + +export const DecoderHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${({ theme }) => theme.core.space.space100}; + padding: ${({ theme }) => theme.core.space.space100}; +` + +export const DecoderSummaryButton = styled.button` + display: inline-flex; + align-items: center; + gap: ${({ theme }) => theme.core.space.space100}; + flex: 1; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; + color: inherit; +` + +export const DecoderBody = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.core.space.space200}; + padding: 0 ${({ theme }) => theme.core.space.space100} + ${({ theme }) => theme.core.space.space100}; +` + +export const DecoderMatchBadge = styled.span<{ $warning?: boolean }>` + color: ${({ theme, $warning }) => + $warning + ? theme.components.typography.colors.attention + : theme.components.typography.colors.informative}; + font-size: ${({ theme }) => theme.core.font.fontSize.s12}; + white-space: nowrap; +` diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx new file mode 100644 index 0000000000..0dde105f6e --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx @@ -0,0 +1,299 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' + +import { Modal } from 'uiSrc/components/base/display' +import { Text } from 'uiSrc/components/base/text' +import { PrimaryButton, SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { CancelIcon } from 'uiSrc/components/base/icons' +import { Row, Col } from 'uiSrc/components/base/layout/flex' +import { CopyButton } from 'uiSrc/components/copy-button/CopyButton' + +import { createEmptyDecoder, VALUE_DECODER_TEST_ID } from './constants' +import { DecoderEditor } from './DecoderEditor' +import { + parseDecodersFromClipboard, + serializeDecodersForClipboard, +} from './decoderClipboard' +import { + areDecodersValid, + getDecoderLabel, + normalizeRule, +} from './schemaUtils' +import { ValueDecoderRule } from './types' +import { findMatchingDecoderRule, getDefaultKeyPattern, matchKeyPattern } from './utils' +import * as S from './ValueDecoderModal.styles' + +export interface ValueDecoderModalConfig { + keyName: string +} + +export interface ValueDecoderModalProps { + isOpen: boolean + decoders: ValueDecoderRule[] + config: ValueDecoderModalConfig | null + onSave: (decoders: ValueDecoderRule[]) => void + onCancel: () => void +} + +const buildInitialDecoders = ( + decoders: ValueDecoderRule[], + keyName: string, +): ValueDecoderRule[] => { + const normalized = decoders.map(normalizeRule) + if (normalized.length > 0) { + return normalized + } + return [createEmptyDecoder(keyName ? getDefaultKeyPattern(keyName) : '')] +} + +export const ValueDecoderModal = ({ + isOpen, + decoders, + config, + onSave, + onCancel, +}: ValueDecoderModalProps) => { + const keyName = config?.keyName ?? '' + const [localDecoders, setLocalDecoders] = useState(() => + buildInitialDecoders(decoders, keyName), + ) + const [expandedId, setExpandedId] = useState(null) + const [pasteMessage, setPasteMessage] = useState(null) + + useEffect(() => { + if (!isOpen) { + return + } + + const initial = buildInitialDecoders(decoders, keyName) + setLocalDecoders(initial) + + const matched = keyName ? findMatchingDecoderRule(initial, keyName) : null + setExpandedId(matched?.id ?? initial[0]?.id ?? null) + }, [decoders, isOpen, keyName]) + + const isValid = useMemo( + () => areDecodersValid(localDecoders), + [localDecoders], + ) + + const handleAddDecoder = useCallback(() => { + const nextDecoder = createEmptyDecoder( + keyName ? getDefaultKeyPattern(keyName) : '', + ) + setLocalDecoders((current) => [...current, nextDecoder]) + setExpandedId(nextDecoder.id) + }, [keyName]) + + const handleUpdateDecoder = useCallback( + (decoderId: string, nextDecoder: ValueDecoderRule) => { + setLocalDecoders((current) => + current.map((decoder) => + decoder.id === decoderId ? nextDecoder : decoder, + ), + ) + }, + [], + ) + + const handleRemoveDecoder = useCallback((decoderId: string) => { + setLocalDecoders((current) => + current.filter((decoder) => decoder.id !== decoderId), + ) + setExpandedId((current) => (current === decoderId ? null : current)) + }, []) + + const importDecodersFromText = useCallback((text: string): boolean => { + const imported = parseDecodersFromClipboard(text) + + if (!imported?.length) { + setPasteMessage('No decoder configuration found in clipboard') + return false + } + + setLocalDecoders((current) => [...current, ...imported]) + setExpandedId(imported[imported.length - 1].id) + setPasteMessage( + imported.length === 1 + ? 'Pasted 1 decoder' + : `Pasted ${imported.length} decoders`, + ) + return true + }, []) + + const handlePasteFromClipboard = useCallback(async () => { + try { + const text = await navigator.clipboard.readText() + importDecodersFromText(text) + } catch { + setPasteMessage('Unable to read clipboard') + } + }, [importDecodersFromText]) + + useEffect(() => { + if (!pasteMessage) { + return undefined + } + + const timeout = setTimeout(() => { + setPasteMessage(null) + }, 2500) + + return () => clearTimeout(timeout) + }, [pasteMessage]) + + useEffect(() => { + if (!isOpen) { + return undefined + } + + const handlePaste = (event: ClipboardEvent) => { + const target = event.target + + if ( + target instanceof HTMLElement && + target.closest('input, textarea, [contenteditable="true"]') + ) { + return + } + + const text = event.clipboardData?.getData('text/plain') ?? '' + + if (importDecodersFromText(text)) { + event.preventDefault() + } + } + + document.addEventListener('paste', handlePaste) + + return () => { + document.removeEventListener('paste', handlePaste) + } + }, [importDecodersFromText, isOpen]) + + const handleSave = useCallback(() => { + if (!isValid) { + return + } + + onSave(localDecoders.map(normalizeRule)) + }, [isValid, localDecoders, onSave]) + + if (!isOpen) { + return null + } + + return ( + + + + + + + Value Decoders + + + + + + Decoders are shared across all hash keys in this database. Add + multiple decoders and key patterns; matching hash values can be + decoded in the Value Preview. Copy decoders as JSON and paste + them here or into another Redis Insight connection. + + + + Decoders + + {pasteMessage && ( + + {pasteMessage} + + )} + + + Paste + + + Add Decoder + + + + +
+ {localDecoders.map((decoder) => { + const normalized = normalizeRule(decoder) + const patternCount = normalized.keyPatterns.length + const summary = `${getDecoderLabel(decoder)} · ${patternCount} pattern${patternCount === 1 ? '' : 's'}` + + return ( + + setExpandedId((current) => + current === decoder.id ? null : decoder.id, + ) + } + onChange={(nextDecoder) => + handleUpdateDecoder(decoder.id, nextDecoder) + } + onRemove={() => handleRemoveDecoder(decoder.id)} + canRemove + summary={summary} + matchesCurrentKey={Boolean( + keyName && + normalized.keyPatterns.some((pattern) => + matchKeyPattern(pattern, keyName), + ), + )} + /> + ) + })} + + + } + /> + + + + + Cancel + + + Save + + + + + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderProvider.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderProvider.tsx new file mode 100644 index 0000000000..65ffdd7d1f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderProvider.tsx @@ -0,0 +1,135 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +import { useAppSelector } from 'uiSrc/slices/hooks' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { bufferToString } from 'uiSrc/utils' + +import { ValueDecoderModal, ValueDecoderModalConfig } from './ValueDecoderModal' +import { normalizeRule } from './schemaUtils' +import { ValueDecoderRule } from './types' +import { + getValueDecoderRules, + setValueDecoderRules, +} from './valueDecoderStorage' +import { findMatchingDecoderRule } from './utils' + +export interface ValueDecoderContextValue { + decoders: ValueDecoderRule[] + matchedRule: ValueDecoderRule | null + isDecodeEnabled: boolean + hasMatchingRule: boolean + openValueDecoderModal: () => void + toggleDecodeEnabled: () => void + setDecodeEnabled: (enabled: boolean) => void +} + +const ValueDecoderContext = createContext(null) + +const NOOP_CONTEXT: ValueDecoderContextValue = { + decoders: [], + matchedRule: null, + isDecodeEnabled: false, + hasMatchingRule: false, + openValueDecoderModal: () => {}, + toggleDecodeEnabled: () => {}, + setDecodeEnabled: () => {}, +} + +export const useValueDecoder = () => { + const ctx = useContext(ValueDecoderContext) + return ctx ?? NOOP_CONTEXT +} + +export const ValueDecoderProvider = ({ + children, + keyProp, +}: { + children: React.ReactNode + keyProp: RedisResponseBuffer | null +}) => { + const { id: instanceId = '' } = useAppSelector(connectedInstanceSelector) + const [decoders, setDecoders] = useState(() => + getValueDecoderRules(instanceId), + ) + const [modalConfig, setModalConfig] = + useState(null) + const [isDecodeEnabled, setIsDecodeEnabled] = useState(false) + + const keyName = keyProp ? bufferToString(keyProp) : '' + const matchedRule = useMemo( + () => (keyName ? findMatchingDecoderRule(decoders, keyName) : null), + [decoders, keyName], + ) + + useEffect(() => { + setDecoders(getValueDecoderRules(instanceId)) + setModalConfig(null) + setIsDecodeEnabled(false) + }, [instanceId]) + + useEffect(() => { + setIsDecodeEnabled(false) + }, [keyName, matchedRule?.id ?? null]) + + const openValueDecoderModal = useCallback(() => { + setModalConfig({ keyName }) + }, [keyName]) + + const handleSaveDecoders = useCallback( + (nextDecoders: ValueDecoderRule[]) => { + const normalized = nextDecoders.map(normalizeRule) + setDecoders(normalized) + setValueDecoderRules(instanceId, normalized) + setModalConfig(null) + }, + [instanceId], + ) + + const handleCancelModal = useCallback(() => { + setModalConfig(null) + }, []) + + const toggleDecodeEnabled = useCallback(() => { + setIsDecodeEnabled((current) => !current) + }, []) + + const contextValue = useMemo( + () => ({ + decoders, + matchedRule, + isDecodeEnabled, + hasMatchingRule: matchedRule !== null, + openValueDecoderModal, + toggleDecodeEnabled, + setDecodeEnabled: setIsDecodeEnabled, + }), + [ + decoders, + isDecodeEnabled, + matchedRule, + openValueDecoderModal, + toggleDecodeEnabled, + ], + ) + + return ( + + {children} + + + ) +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts new file mode 100644 index 0000000000..5411bfd974 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts @@ -0,0 +1,101 @@ +import { + BinaryFieldDefinition, + RepeatBlockDefinition, + SchemaNode, + ValueDecoderRule, +} from './types' +import { DecoderType } from './types' + +export const VALUE_DECODER_TEST_ID = 'value-decoder' + +export const MAX_REPEAT_DECODE_ITERATIONS = 1000 + +export const BINARY_DATA_TYPES = [ + 'uint8', + 'int8', + 'boolean', + 'uint16le', + 'uint16be', + 'int16le', + 'int16be', + 'uint32le', + 'uint32be', + 'int32le', + 'int32be', + 'floatle', + 'floatbe', + 'bigint64le', + 'bigint64be', + 'biguint64le', + 'biguint64be', + 'doublele', + 'doublebe', + 'string', + 'hex', +] as const + +export type BinaryDataType = (typeof BINARY_DATA_TYPES)[number] + +export const NUMERIC_COUNT_DATA_TYPES = [ + 'uint8', + 'int8', + 'uint16le', + 'uint16be', + 'int16le', + 'int16be', + 'uint32le', + 'uint32be', + 'int32le', + 'int32be', + 'bigint64le', + 'bigint64be', + 'biguint64le', + 'biguint64be', +] as const + +export const SIZE_SOURCE_OPTIONS = [ + { value: 'fixed', label: 'Fixed bytes' }, + { value: 'field', label: 'From field' }, +] + +export const DECODER_TYPE_OPTIONS = [ + { value: DecoderType.Binary, content: 'Binary Decoder' }, +] + +let fieldIdCounter = 0 + +const nextId = (prefix: string) => { + fieldIdCounter += 1 + return `${prefix}-${fieldIdCounter}-${Date.now()}` +} + +export const createEmptyField = (): BinaryFieldDefinition => ({ + id: nextId('field'), + kind: 'field', + name: '', + dataType: 'uint8', + size: 1, + sizeSource: 'fixed', +}) + +export const createEmptyRepeatBlock = (): RepeatBlockDefinition => ({ + id: nextId('repeat'), + kind: 'repeat', + name: '', + countFieldRef: '', + fields: [createEmptyField()], +}) + +export const createEmptyDecoder = (keyName = ''): ValueDecoderRule => ({ + id: nextId('decoder'), + name: '', + keyPatterns: keyName ? [keyName] : [''], + decoderType: DecoderType.Binary, + schema: [createEmptyField()], +}) + +export const isRepeatNode = (node: SchemaNode): node is RepeatBlockDefinition => + node.kind === 'repeat' + +export const isFieldNode = (node: SchemaNode): node is BinaryFieldDefinition => + node.kind === 'field' || !('kind' in node) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.spec.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.spec.ts new file mode 100644 index 0000000000..e2ea37a72a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.spec.ts @@ -0,0 +1,101 @@ +import { createEmptyDecoder } from './constants' +import { DecoderType } from './types' +import { + cloneDecoderRule, + parseDecodersFromClipboard, + serializeDecoderForClipboard, + serializeDecodersForClipboard, + VALUE_DECODER_CLIPBOARD_TYPE, +} from './decoderClipboard' + +describe('decoderClipboard', () => { + const sampleDecoder = () => { + const countField = { + id: 'field-count', + kind: 'field' as const, + name: 'count', + dataType: 'uint8', + size: 1, + sizeSource: 'fixed' as const, + } + + return { + id: 'decoder-1', + name: 'Chunk decoder', + keyPatterns: ['room:*'], + decoderType: DecoderType.Binary, + schema: [ + countField, + { + id: 'repeat-1', + kind: 'repeat' as const, + name: 'items', + countFieldRef: 'field-count', + fields: [ + { + id: 'field-value', + kind: 'field' as const, + name: 'value', + dataType: 'uint16le', + size: 2, + sizeSource: 'fixed' as const, + }, + ], + }, + ], + } + } + + it('serializes decoders with a clipboard envelope', () => { + const decoder = sampleDecoder() + const serialized = serializeDecoderForClipboard(decoder) + const parsed = JSON.parse(serialized) + + expect(parsed.type).toBe(VALUE_DECODER_CLIPBOARD_TYPE) + expect(parsed.decoders).toHaveLength(1) + expect(parsed.decoders[0].name).toBe('Chunk decoder') + }) + + it('clones a decoder with fresh ids and remapped schema refs', () => { + const cloned = cloneDecoderRule(sampleDecoder()) + + expect(cloned.id).not.toBe('decoder-1') + expect(cloned.schema[1].kind).toBe('repeat') + if (cloned.schema[1].kind === 'repeat') { + expect(cloned.schema[1].countFieldRef).toBe(cloned.schema[0].id) + } + }) + + it('parses a clipboard payload and returns cloned decoders', () => { + const decoder = sampleDecoder() + const text = serializeDecodersForClipboard([decoder]) + const parsed = parseDecodersFromClipboard(text) + + expect(parsed).toHaveLength(1) + expect(parsed?.[0].name).toBe('Chunk decoder') + expect(parsed?.[0].id).not.toBe(decoder.id) + }) + + it('parses a single decoder object without an envelope', () => { + const decoder = sampleDecoder() + const parsed = parseDecodersFromClipboard(JSON.stringify(decoder)) + + expect(parsed).toHaveLength(1) + expect(parsed?.[0].keyPatterns).toEqual(['room:*']) + }) + + it('returns null for invalid clipboard content', () => { + expect(parseDecodersFromClipboard('not json')).toBeNull() + expect(parseDecodersFromClipboard('{"foo":"bar"}')).toBeNull() + expect(parseDecodersFromClipboard('')).toBeNull() + }) + + it('normalizes legacy decoders on import', () => { + const parsed = parseDecodersFromClipboard( + JSON.stringify(createEmptyDecoder('user:*')), + ) + + expect(parsed).toHaveLength(1) + expect(parsed?.[0].keyPatterns).toEqual(['user:*']) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts new file mode 100644 index 0000000000..95af886ae8 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts @@ -0,0 +1,155 @@ +import { isObjectLike } from 'lodash' + +import { isFieldNode, isRepeatNode } from './constants' +import { normalizeRule } from './schemaUtils' +import { SchemaNode, ValueDecoderRule } from './types' + +export const VALUE_DECODER_CLIPBOARD_TYPE = 'redisinsight/value-decoder' +export const VALUE_DECODER_CLIPBOARD_VERSION = 1 + +export interface ValueDecoderClipboardPayload { + type: typeof VALUE_DECODER_CLIPBOARD_TYPE + version: typeof VALUE_DECODER_CLIPBOARD_VERSION + decoders: ValueDecoderRule[] +} + +let idCounter = 0 + +const createId = (prefix: string) => { + idCounter += 1 + return `${prefix}-${idCounter}-${Date.now()}` +} + +const collectSchemaIds = ( + nodes: SchemaNode[], + idMap: Map, +): void => { + nodes.forEach((node) => { + if (!idMap.has(node.id)) { + idMap.set(node.id, createId(isRepeatNode(node) ? 'repeat' : 'field')) + } + + if (isRepeatNode(node)) { + collectSchemaIds(node.fields, idMap) + } + }) +} + +const remapSchemaIds = ( + nodes: SchemaNode[], + idMap: Map, +): SchemaNode[] => + nodes.map((node) => { + if (isFieldNode(node)) { + return { + ...node, + id: idMap.get(node.id) ?? node.id, + kind: 'field', + sizeFieldRef: node.sizeFieldRef + ? idMap.get(node.sizeFieldRef) ?? node.sizeFieldRef + : undefined, + } + } + + return { + ...node, + id: idMap.get(node.id) ?? node.id, + kind: 'repeat', + countFieldRef: idMap.get(node.countFieldRef) ?? node.countFieldRef, + fields: remapSchemaIds(node.fields, idMap), + } + }) + +export const cloneDecoderRule = (rule: ValueDecoderRule): ValueDecoderRule => { + const normalized = normalizeRule(rule) + const idMap = new Map() + + collectSchemaIds(normalized.schema, idMap) + + return { + ...normalized, + id: createId('decoder'), + schema: remapSchemaIds(normalized.schema, idMap), + } +} + +const toClipboardDecoder = (decoder: ValueDecoderRule): ValueDecoderRule => + normalizeRule(decoder) + +export const serializeDecodersForClipboard = ( + decoders: ValueDecoderRule[], +): string => { + const payload: ValueDecoderClipboardPayload = { + type: VALUE_DECODER_CLIPBOARD_TYPE, + version: VALUE_DECODER_CLIPBOARD_VERSION, + decoders: decoders.map(toClipboardDecoder), + } + + return JSON.stringify(payload, null, 2) +} + +export const serializeDecoderForClipboard = (decoder: ValueDecoderRule): string => + serializeDecodersForClipboard([decoder]) + +const isDecoderLike = (value: unknown): value is Record => { + if (!isObjectLike(value)) { + return false + } + + return ( + Array.isArray(value.keyPatterns) || + typeof value.keyPattern === 'string' || + Array.isArray(value.schema) || + Array.isArray(value.fields) + ) +} + +const parseDecoderCandidates = (parsed: unknown): unknown[] => { + if (Array.isArray(parsed)) { + return parsed + } + + if (!isObjectLike(parsed)) { + return [] + } + + if ( + parsed.type === VALUE_DECODER_CLIPBOARD_TYPE && + Array.isArray(parsed.decoders) + ) { + return parsed.decoders + } + + if (isDecoderLike(parsed)) { + return [parsed] + } + + return [] +} + +export const parseDecodersFromClipboard = ( + text: string, +): ValueDecoderRule[] | null => { + const trimmed = text.trim() + + if (!trimmed) { + return null + } + + try { + const parsed = JSON.parse(trimmed) as unknown + const candidates = parseDecoderCandidates(parsed) + + if (!candidates.length) { + return null + } + + const decoders = candidates + .filter(isDecoderLike) + .map((candidate) => cloneDecoderRule(candidate as ValueDecoderRule)) + + return decoders.length > 0 ? decoders : null + } catch { + return null + } +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/descriptions.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/descriptions.ts new file mode 100644 index 0000000000..4a42ad3f94 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/descriptions.ts @@ -0,0 +1,34 @@ +import { DecoderType } from './types' +import { BinaryDataType } from './constants' + +export const KEY_PATTERN_FIELD_DESCRIPTION = + 'Add one or more glob patterns to match Redis keys (e.g. user:items:*, room:chunk-state:*). A decoder applies when any pattern matches.' + +export const DECODER_TYPE_DESCRIPTIONS: Record = { + [DecoderType.Binary]: + 'Parses the value as a sequential binary structure using the field layout defined below.', +} + +export const DATA_TYPE_DESCRIPTIONS: Record = { + uint8: 'Unsigned 8-bit integer (1 byte).', + int8: 'Signed 8-bit integer (1 byte).', + boolean: 'Boolean stored as 1 byte (0 = false, non-zero = true).', + uint16le: 'Unsigned 16-bit integer, little-endian (2 bytes).', + uint16be: 'Unsigned 16-bit integer, big-endian (2 bytes).', + int16le: 'Signed 16-bit integer, little-endian (2 bytes).', + int16be: 'Signed 16-bit integer, big-endian (2 bytes).', + uint32le: 'Unsigned 32-bit integer, little-endian (4 bytes).', + uint32be: 'Unsigned 32-bit integer, big-endian (4 bytes).', + int32le: 'Signed 32-bit integer, little-endian (4 bytes).', + int32be: 'Signed 32-bit integer, big-endian (4 bytes).', + floatle: '32-bit IEEE 754 float, little-endian (4 bytes).', + floatbe: '32-bit IEEE 754 float, big-endian (4 bytes).', + bigint64le: 'Signed 64-bit integer, little-endian (8 bytes).', + bigint64be: 'Signed 64-bit integer, big-endian (8 bytes).', + biguint64le: 'Unsigned 64-bit integer, little-endian (8 bytes).', + biguint64be: 'Unsigned 64-bit integer, big-endian (8 bytes).', + doublele: '64-bit IEEE 754 double, little-endian (8 bytes).', + doublebe: '64-bit IEEE 754 double, big-endian (8 bytes).', + string: 'UTF-8 string with a custom byte length.', + hex: 'Raw bytes rendered as uppercase hexadecimal bytes separated by spaces.', +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/index.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/index.ts new file mode 100644 index 0000000000..a025140ece --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/index.ts @@ -0,0 +1,29 @@ +export { ConfigValueDecoderButton } from './ConfigValueDecoderButton' +export { DecodedValueDisplay } from './DecodedValueDisplay' +export { ValueDecoderHeaderLabel } from './ValueDecoderHeaderLabel' +export { + ValueDecoderProvider, + useValueDecoder, +} from './ValueDecoderProvider' +export { ValueDecoderModal } from './ValueDecoderModal' +export type { + ValueDecoderRule, + BinaryFieldDefinition, + SchemaNode, + RepeatBlockDefinition, + ParsedBinaryNode, + ParsedBinaryField, + ParsedBinaryGroup, +} from './types' +export { + findMatchingDecoderRule, + formatParsedFieldLine, + formatParsedFields, + formatParsedFieldsInline, + getDefaultKeyPattern, + getFixedSize, + getSizeUnit, + matchKeyPattern, + parseBinaryBuffer, + parseBufferWithRule, +} from './utils' diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/reorderList.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/reorderList.ts new file mode 100644 index 0000000000..68f9ed9986 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/reorderList.ts @@ -0,0 +1,20 @@ +export const reorderList = ( + items: T[], + fromIndex: number, + toIndex: number, +): T[] => { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= items.length || + toIndex >= items.length + ) { + return items + } + + const next = [...items] + const [item] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, item) + return next +} diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts new file mode 100644 index 0000000000..56197d9fde --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts @@ -0,0 +1,105 @@ +import { + createEmptyDecoder, + createEmptyField, + createEmptyRepeatBlock, +} from './constants' +import { isDecoderValid, isSchemaValid, areDecodersValid, normalizeRule } from './schemaUtils' +import { BinaryFieldDefinition } from './types' + +describe('schemaUtils validation', () => { + const validField = (): BinaryFieldDefinition => ({ + ...createEmptyField(), + name: 'flag', + dataType: 'uint8', + size: 1, + }) + + describe('normalizeRule', () => { + it('preserves spaces in key patterns while removing blank rows', () => { + const decoder = createEmptyDecoder(' user ') + decoder.keyPatterns = [' user ', 'room:*', ''] + + expect(normalizeRule(decoder).keyPatterns).toEqual([' user ', 'room:*']) + }) + + it('migrates legacy numeric field name references to ids', () => { + const lenField = { + ...createEmptyField(), + name: 'len', + dataType: 'uint16le', + size: 2, + } + const textField = { + ...createEmptyField(), + name: 'text', + dataType: 'string', + size: '', + sizeSource: 'field' as const, + sizeFieldRef: 'len', + } + + const normalized = normalizeRule({ + ...createEmptyDecoder(), + schema: [lenField, textField], + }) + + expect(normalized.schema[1]).toMatchObject({ + sizeFieldRef: lenField.id, + }) + }) + }) + + describe('isSchemaValid', () => { + it('requires every top-level node to be complete', () => { + expect(isSchemaValid([validField()])).toBe(true) + expect(isSchemaValid([validField(), createEmptyField()])).toBe(false) + expect(isSchemaValid([])).toBe(false) + }) + + it('requires every repeat child to be complete', () => { + const countField = { + ...createEmptyField(), + name: 'count', + dataType: 'uint16le', + size: 2, + } + const repeat = createEmptyRepeatBlock() + repeat.countFieldRef = countField.id + repeat.fields = [validField(), createEmptyField()] + + expect(isSchemaValid([countField, repeat])).toBe(false) + }) + }) + + describe('isDecoderValid', () => { + it('rejects decoders with incomplete schema rows', () => { + const decoder = createEmptyDecoder('room:state:*') + decoder.keyPatterns = ['room:state:*'] + decoder.schema = [validField(), createEmptyField()] + + expect(isDecoderValid(decoder)).toBe(false) + }) + + it('rejects fractional fixed byte sizes', () => { + const decoder = createEmptyDecoder('room:state:*') + decoder.keyPatterns = ['room:state:*'] + decoder.schema = [ + { + ...createEmptyField(), + name: 'payload', + dataType: 'string', + size: 1.5, + sizeSource: 'fixed', + }, + ] + + expect(isDecoderValid(decoder)).toBe(false) + }) + }) + + describe('areDecodersValid', () => { + it('allows saving an empty decoder list', () => { + expect(areDecodersValid([])).toBe(true) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts new file mode 100644 index 0000000000..2efeee7077 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts @@ -0,0 +1,338 @@ +import { + NUMERIC_COUNT_DATA_TYPES, + createEmptyField, + isFieldNode, + isRepeatNode, +} from './constants' +import { + BinaryFieldDefinition, + FieldSizeSource, + RepeatBlockDefinition, + SchemaNode, + ValueDecoderRule, +} from './types' + +export interface NumericFieldRef { + id: string + name: string + dataType: string +} + +export const isNumericCountType = (dataType: string): boolean => + NUMERIC_COUNT_DATA_TYPES.includes(dataType as (typeof NUMERIC_COUNT_DATA_TYPES)[number]) + +export const normalizeFieldNode = ( + field: Partial & { id: string }, +): BinaryFieldDefinition => ({ + id: field.id, + kind: 'field', + name: field.name ?? '', + dataType: field.dataType ?? 'uint8', + size: field.size ?? '', + sizeSource: field.sizeSource ?? 'fixed', + sizeFieldRef: field.sizeFieldRef, +}) + +export const normalizeSchemaNode = (node: SchemaNode): SchemaNode => { + if (isRepeatNode(node)) { + return { + ...node, + kind: 'repeat', + fields: (node.fields ?? []).map(normalizeSchemaNode), + } + } + + return normalizeFieldNode(node as BinaryFieldDefinition) +} + +const normalizeKeyPatterns = (patterns: string[]): string[] => + patterns.filter((pattern) => pattern !== '') + +export const getDecoderLabel = (decoder: ValueDecoderRule): string => { + const normalized = normalizeRule(decoder) + if (normalized.name.trim()) { + return normalized.name.trim() + } + return normalized.keyPatterns[0] ?? 'Untitled decoder' +} + +export const getPriorNumericFields = ( + schema: SchemaNode[], + beforeNodeId: string, +): NumericFieldRef[] => { + const result: NumericFieldRef[] = [] + + const walk = (nodes: SchemaNode[]): boolean => { + for (const node of nodes) { + if (node.id === beforeNodeId) { + return true + } + + if (isFieldNode(node)) { + if (node.name.trim() && isNumericCountType(node.dataType)) { + result.push({ + id: node.id, + name: node.name.trim(), + dataType: node.dataType, + }) + } + } else if (isRepeatNode(node)) { + if (walk(node.fields)) { + return true + } + } + } + return false + } + + walk(schema) + return result +} + +export const getPriorNumericFieldsForRepeat = ( + schema: SchemaNode[], + repeatId: string, +): NumericFieldRef[] => getPriorNumericFields(schema, repeatId) + +export const getPriorNumericFieldsInScope = ( + priorFields: NumericFieldRef[], + siblingNodes: SchemaNode[], + beforeIndex: number, +): NumericFieldRef[] => { + const siblings = siblingNodes.slice(0, beforeIndex).flatMap((node) => { + if (!isFieldNode(node) || !node.name.trim()) { + return [] + } + if (!isNumericCountType(node.dataType)) { + return [] + } + return [ + { + id: node.id, + name: node.name.trim(), + dataType: node.dataType, + }, + ] + }) + + return [...priorFields, ...siblings] +} + +const resolveNumericFieldRef = ( + ref: string | undefined, + priorFields: NumericFieldRef[], +): string | undefined => { + if (!ref) { + return ref + } + + if (priorFields.some((item) => item.id === ref)) { + return ref + } + + const trimmedRef = ref.trim() + const matchesByName = priorFields.filter((item) => item.name === trimmedRef) + if (matchesByName.length === 1) { + return matchesByName[0].id + } + + return ref +} + +const normalizeSchemaRefs = ( + nodes: SchemaNode[], + priorFields: NumericFieldRef[] = [], +): SchemaNode[] => + nodes.map((node, index) => { + const scopeNumeric = getPriorNumericFieldsInScope( + priorFields, + nodes, + index, + ) + + if (isFieldNode(node)) { + if (node.sizeSource !== 'field') { + return node + } + + return { + ...node, + sizeFieldRef: resolveNumericFieldRef(node.sizeFieldRef, scopeNumeric), + } + } + + if (isRepeatNode(node)) { + return { + ...node, + countFieldRef: + resolveNumericFieldRef(node.countFieldRef, scopeNumeric) ?? '', + fields: normalizeSchemaRefs(node.fields, scopeNumeric), + } + } + + return node + }) + +export const normalizeRule = (rule: ValueDecoderRule): ValueDecoderRule => { + const schemaNodes = + (rule.schema?.length ?? 0) > 0 + ? rule.schema!.map(normalizeSchemaNode) + : (rule.fields ?? []).map((field) => + normalizeFieldNode({ ...field, id: field.id ?? `field-legacy-${field.name}` }), + ) + const schema = normalizeSchemaRefs(schemaNodes) + + const keyPatterns = + (rule.keyPatterns?.length ?? 0) > 0 + ? normalizeKeyPatterns(rule.keyPatterns) + : rule.keyPattern != null && rule.keyPattern !== '' + ? [rule.keyPattern] + : [] + + return { + ...rule, + name: rule.name ?? '', + keyPatterns, + schema, + keyPattern: undefined, + fields: undefined, + } +} + +const isPositiveIntegerSize = (size: number | ''): boolean => { + const numericSize = Number(size) + return Number.isInteger(numericSize) && numericSize > 0 +} + +const isFieldValid = ( + field: BinaryFieldDefinition, + priorNumeric: NumericFieldRef[], +): boolean => { + if (!field.name.trim()) { + return false + } + + if (field.dataType === 'string' || field.dataType === 'hex') { + if (field.sizeSource === 'field') { + return Boolean( + field.sizeFieldRef && + priorNumeric.some((item) => item.id === field.sizeFieldRef), + ) + } + return isPositiveIntegerSize(field.size) + } + + return isPositiveIntegerSize(field.size) +} + +const isRepeatValid = ( + repeat: RepeatBlockDefinition, + priorNumeric: NumericFieldRef[], +): boolean => { + if (!repeat.countFieldRef) { + return false + } + + if (!priorNumeric.some((item) => item.id === repeat.countFieldRef)) { + return false + } + + return ( + repeat.fields.length > 0 && + repeat.fields.every((child, childIndex) => + isSchemaNodeValid(child, priorNumeric, repeat.fields, childIndex), + ) + ) +} + +export const isSchemaNodeValid = ( + node: SchemaNode, + priorNumeric: NumericFieldRef[], + siblingNodes: SchemaNode[], + index: number, +): boolean => { + const scopeNumeric = getPriorNumericFieldsInScope( + priorNumeric, + siblingNodes, + index, + ) + + if (isFieldNode(node)) { + return isFieldValid(node, scopeNumeric) + } + + if (isRepeatNode(node)) { + return isRepeatValid(node, scopeNumeric) + } + + return false +} + +export const isSchemaValid = (schema: SchemaNode[]): boolean => + schema.length > 0 && + schema.every((node, index) => isSchemaNodeValid(node, [], schema, index)) + +export const isDecoderValid = (decoder: ValueDecoderRule): boolean => { + const normalized = normalizeRule(decoder) + return ( + normalized.keyPatterns.length > 0 && isSchemaValid(normalized.schema) + ) +} + +export const areDecodersValid = (decoders: ValueDecoderRule[]): boolean => + decoders.length === 0 || decoders.every(isDecoderValid) + +export const updateSchemaNode = ( + nodes: SchemaNode[], + nodeId: string, + updater: (node: SchemaNode) => SchemaNode, +): SchemaNode[] => + nodes.map((node) => { + if (node.id === nodeId) { + return updater(node) + } + if (isRepeatNode(node)) { + return { + ...node, + fields: updateSchemaNode(node.fields, nodeId, updater), + } + } + return node + }) + +export const removeSchemaNode = ( + nodes: SchemaNode[], + nodeId: string, +): SchemaNode[] => { + const filtered = nodes.filter((node) => node.id !== nodeId) + + if (filtered.length !== nodes.length) { + return filtered.length > 0 ? filtered : [createEmptyField()] + } + + return nodes.map((node) => { + if (isRepeatNode(node)) { + const nextFields = removeSchemaNode(node.fields, nodeId) + return { ...node, fields: nextFields } + } + return node + }) +} + +export const insertSchemaNodeAt = ( + nodes: SchemaNode[], + index: number, + newNode: SchemaNode, +): SchemaNode[] => { + const next = [...nodes] + next.splice(index, 0, newNode) + return next +} + +export const isCustomSizeType = (dataType: string): boolean => + dataType === 'string' || dataType === 'hex' + +export const resolveSizeSource = ( + field: BinaryFieldDefinition, +): FieldSizeSource => + isCustomSizeType(field.dataType) ? field.sizeSource ?? 'fixed' : 'fixed' diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/types.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/types.ts new file mode 100644 index 0000000000..76285d3110 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/types.ts @@ -0,0 +1,54 @@ +export enum DecoderType { + Binary = 'binary', +} + +export type FieldSizeSource = 'fixed' | 'field' + +export interface BinaryFieldDefinition { + id: string + kind: 'field' + name: string + dataType: string + size: number | '' + sizeSource?: FieldSizeSource + /** Field id of a prior numeric field used as dynamic byte length */ + sizeFieldRef?: string +} + +export interface RepeatBlockDefinition { + id: string + kind: 'repeat' + name: string + /** Field id of a prior numeric field used as repeat count */ + countFieldRef: string + fields: SchemaNode[] +} + +export type SchemaNode = BinaryFieldDefinition | RepeatBlockDefinition + +export interface ValueDecoderRule { + id: string + name: string + keyPatterns: string[] + decoderType: DecoderType + schema: SchemaNode[] + /** @deprecated Legacy single pattern — migrated to keyPatterns on load */ + keyPattern?: string + /** @deprecated Legacy flat fields — migrated to schema on load */ + fields?: Omit[] +} + +export interface ParsedBinaryField { + kind: 'field' + name: string + size: number + value: string +} + +export interface ParsedBinaryGroup { + kind: 'group' + label: string + children: ParsedBinaryNode[] +} + +export type ParsedBinaryNode = ParsedBinaryField | ParsedBinaryGroup diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts new file mode 100644 index 0000000000..b6820db361 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts @@ -0,0 +1,631 @@ +import { + findMatchingDecoderRule, + formatHexBytes, + formatParsedFields, + formatParsedFieldsInline, + getDefaultKeyPattern, + getFixedSize, + getKeyPatternSpecificity, + getSizeUnit, + matchKeyPattern, + parseBinaryBuffer, + resolveRepeatCount, +} from './utils' +import { createEmptyField, createEmptyRepeatBlock, MAX_REPEAT_DECODE_ITERATIONS } from './constants' +import { DecoderType, ParsedBinaryNode, ValueDecoderRule } from './types' + +const countGroupNodes = (nodes: ParsedBinaryNode[]): number => + nodes.reduce((count, node) => { + if (node.kind === 'group') { + return count + 1 + countGroupNodes(node.children) + } + + return count + }, 0) + +describe('value-decoder utils', () => { + describe('getFixedSize', () => { + it('returns fixed sizes for known types', () => { + expect(getFixedSize('uint8')).toBe(1) + expect(getFixedSize('uint16le')).toBe(2) + expect(getFixedSize('uint32be')).toBe(4) + expect(getFixedSize('doublele')).toBe(8) + expect(getFixedSize('string')).toBe('custom') + }) + }) + + describe('getDefaultKeyPattern', () => { + it('returns the actual key name', () => { + expect(getDefaultKeyPattern('room:chunk-state:678729695330336:1:36')).toBe( + 'room:chunk-state:678729695330336:1:36', + ) + }) + + it('escapes glob metacharacters for exact-key matching', () => { + expect(getDefaultKeyPattern('user:*')).toBe('user:\\*') + expect(getDefaultKeyPattern('user:?')).toBe('user:\\?') + expect(getDefaultKeyPattern('user\\*')).toBe('user\\\\\\*') + expect(getDefaultKeyPattern('user:[0-9]')).toBe('user:\\[0-9\\]') + }) + }) + + describe('getSizeUnit', () => { + it('returns singular or plural byte labels', () => { + expect(getSizeUnit(1)).toBe('byte') + expect(getSizeUnit(2)).toBe('bytes') + expect(getSizeUnit('')).toBe('bytes') + }) + }) + + describe('matchKeyPattern', () => { + it('matches glob patterns', () => { + expect(matchKeyPattern('user:items:*', 'user:items:42')).toBe(true) + expect(matchKeyPattern('user:items:*', 'user:other:42')).toBe(false) + expect(matchKeyPattern('user:?', 'user:a')).toBe(true) + expect(matchKeyPattern('user:?', 'user:ab')).toBe(false) + }) + + it('treats regex-like strings as literal glob patterns', () => { + expect(matchKeyPattern('^user:items:.*$', 'user:items:42')).toBe(false) + expect(matchKeyPattern('^user:items:.*$', '^user:items:.*$')).toBe(true) + }) + + it('matches exact key names', () => { + expect( + matchKeyPattern( + 'room:chunk-state:678729695330336:1:36', + 'room:chunk-state:678729695330336:1:36', + ), + ).toBe(true) + }) + + it('matches escaped glob metacharacters literally', () => { + expect(matchKeyPattern('user:\\*', 'user:*')).toBe(true) + expect(matchKeyPattern('user:\\*', 'user:123')).toBe(false) + expect(matchKeyPattern('user:\\?', 'user:?')).toBe(true) + expect(matchKeyPattern('user:\\?', 'user:a')).toBe(false) + }) + + it('matches glob character classes', () => { + expect(matchKeyPattern('user:[0-9]*', 'user:3')).toBe(true) + expect(matchKeyPattern('user:[0-9]*', 'user:42')).toBe(true) + expect(matchKeyPattern('user:[0-9]*', 'user:a')).toBe(false) + expect(matchKeyPattern('h[ae]llo', 'hello')).toBe(true) + expect(matchKeyPattern('h[ae]llo', 'hallo')).toBe(true) + expect(matchKeyPattern('h[ae]llo', 'hillo')).toBe(false) + expect(matchKeyPattern('h[^e]llo', 'hallo')).toBe(true) + expect(matchKeyPattern('h[^e]llo', 'hello')).toBe(false) + expect(matchKeyPattern('h[a-b]llo', 'hallo')).toBe(true) + expect(matchKeyPattern('h[a-b]llo', 'hbllo')).toBe(true) + expect(matchKeyPattern('h[a-b]llo', 'hcllo')).toBe(false) + expect(matchKeyPattern('user:\\[0-9\\]', 'user:[0-9]')).toBe(true) + expect(matchKeyPattern('user:\\[0-9\\]', 'user:3')).toBe(false) + expect(matchKeyPattern('key:[A-Z\\-_]*', 'key:ABC')).toBe(true) + expect(matchKeyPattern('key:[A-Z\\-_]*', 'key:A-B_')).toBe(true) + expect(matchKeyPattern('key:[A-Z\\-_]*', 'key:A0B')).toBe(false) + }) + }) + + describe('findMatchingDecoderRule', () => { + const rules: ValueDecoderRule[] = [ + { + id: '1', + name: '', + keyPatterns: ['user:*'], + decoderType: DecoderType.Binary, + schema: [], + }, + ] + + it('returns the matching rule when only one rule applies', () => { + expect(findMatchingDecoderRule(rules, 'user:123')?.id).toBe('1') + expect(findMatchingDecoderRule(rules, 'other:123')).toBeNull() + }) + + it('prefers the most specific matching rule over broader patterns', () => { + const overlappingRules: ValueDecoderRule[] = [ + { + id: 'broad', + name: 'Broad', + keyPatterns: ['*'], + decoderType: DecoderType.Binary, + schema: [], + }, + { + id: 'specific', + name: 'Specific', + keyPatterns: ['user:123'], + decoderType: DecoderType.Binary, + schema: [], + }, + ] + + expect(findMatchingDecoderRule(overlappingRules, 'user:123')?.id).toBe( + 'specific', + ) + expect(findMatchingDecoderRule(overlappingRules, 'other:key')?.id).toBe( + 'broad', + ) + }) + + it('prefers a longer literal prefix over a shorter wildcard pattern', () => { + const overlappingRules: ValueDecoderRule[] = [ + { + id: 'user-wide', + name: 'User wide', + keyPatterns: ['user:*'], + decoderType: DecoderType.Binary, + schema: [], + }, + { + id: 'user-items', + name: 'User items', + keyPatterns: ['user:items:*'], + decoderType: DecoderType.Binary, + schema: [], + }, + ] + + expect(findMatchingDecoderRule(overlappingRules, 'user:items:42')?.id).toBe( + 'user-items', + ) + expect(findMatchingDecoderRule(overlappingRules, 'user:profile:42')?.id).toBe( + 'user-wide', + ) + }) + + it('scores exact key patterns higher than wildcard patterns', () => { + expect(getKeyPatternSpecificity('*')).toBeLessThan( + getKeyPatternSpecificity('user:123'), + ) + expect(getKeyPatternSpecificity('user:*')).toBeLessThan( + getKeyPatternSpecificity('user:123'), + ) + }) + }) + + describe('resolveRepeatCount', () => { + it('caps repeat count to prevent unbounded decode loops', () => { + expect(resolveRepeatCount(2)).toBe(2) + expect(resolveRepeatCount(Number.MAX_SAFE_INTEGER)).toBe( + MAX_REPEAT_DECODE_ITERATIONS, + ) + expect(resolveRepeatCount(Infinity)).toBe(0) + expect(resolveRepeatCount(undefined)).toBe(0) + expect(resolveRepeatCount(-1)).toBe(0) + }) + }) + + describe('formatHexBytes', () => { + it('formats bytes as uppercase hex pairs separated by spaces', () => { + expect( + formatHexBytes([ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, + ]), + ).toBe('DE AD BE EF CA FE BA BE') + }) + }) + + describe('parseBinaryBuffer', () => { + it('parses hex fields as spaced uppercase byte pairs', () => { + const buffer = new Uint8Array([ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, + ]) + + const parsed = parseBinaryBuffer(buffer, [ + { + id: '1', + kind: 'field', + name: 'payload', + dataType: 'hex', + size: 8, + }, + ]) + + expect(parsed).toEqual([ + { + kind: 'field', + name: 'payload', + size: 8, + value: 'DE AD BE EF CA FE BA BE', + }, + ]) + }) + + it('parses sequential binary fields', () => { + const buffer = new Uint8Array([1, 0, 2, 0, 3, 4]) + const parsed = parseBinaryBuffer(buffer, [ + { id: '1', kind: 'field', name: 'flag', dataType: 'uint8', size: 1 }, + { id: '2', kind: 'field', name: 'count', dataType: 'uint16le', size: 2 }, + { id: '3', kind: 'field', name: 'value', dataType: 'uint16be', size: 2 }, + ]) + + expect(parsed).toEqual([ + { kind: 'field', name: 'flag', size: 1, value: '1' }, + { kind: 'field', name: 'count', size: 2, value: '2' }, + { kind: 'field', name: 'value', size: 2, value: '772' }, + ]) + }) + + it('uses a prior numeric field as string size', () => { + const buffer = new Uint8Array([ + 3, 0, 97, 98, 99, + ]) + const parsed = parseBinaryBuffer(buffer, [ + { + id: '1', + kind: 'field', + name: 'len', + dataType: 'uint16le', + size: 2, + }, + { + id: '2', + kind: 'field', + name: 'text', + dataType: 'string', + size: '', + sizeSource: 'field', + sizeFieldRef: '1', + }, + ]) + + expect(parsed).toEqual([ + { kind: 'field', name: 'len', size: 2, value: '3' }, + { kind: 'field', name: 'text', size: 3, value: 'abc' }, + ]) + }) + + it('resolves dynamic size by field id when numeric names duplicate', () => { + const buffer = new Uint8Array([3, 0, 5, 97, 98, 99, 104, 101, 108, 108, 111]) + const parsed = parseBinaryBuffer(buffer, [ + { + id: 'len-a', + kind: 'field', + name: 'len', + dataType: 'uint16le', + size: 2, + }, + { + id: 'len-b', + kind: 'field', + name: 'len', + dataType: 'uint8', + size: 1, + }, + { + id: 'text-a', + kind: 'field', + name: 'textA', + dataType: 'string', + size: '', + sizeSource: 'field', + sizeFieldRef: 'len-a', + }, + { + id: 'text-b', + kind: 'field', + name: 'textB', + dataType: 'string', + size: '', + sizeSource: 'field', + sizeFieldRef: 'len-b', + }, + ]) + + expect(parsed).toEqual([ + { kind: 'field', name: 'len', size: 2, value: '3' }, + { kind: 'field', name: 'len', size: 1, value: '5' }, + { kind: 'field', name: 'textA', size: 3, value: 'abc' }, + { kind: 'field', name: 'textB', size: 5, value: 'hello' }, + ]) + }) + + it('preserves zero-length dynamic string fields', () => { + const buffer = new Uint8Array([0, 0, 42]) + const parsed = parseBinaryBuffer(buffer, [ + { + id: '1', + kind: 'field', + name: 'len', + dataType: 'uint16le', + size: 2, + }, + { + id: '2', + kind: 'field', + name: 'text', + dataType: 'string', + size: '', + sizeSource: 'field', + sizeFieldRef: '1', + }, + { + id: '3', + kind: 'field', + name: 'flag', + dataType: 'uint8', + size: 1, + }, + ]) + + expect(parsed).toEqual([ + { kind: 'field', name: 'len', size: 2, value: '0' }, + { kind: 'field', name: 'text', size: 0, value: '' }, + { kind: 'field', name: 'flag', size: 1, value: '42' }, + ]) + }) + + it('parses repeat blocks using a count field', () => { + const buffer = new Uint8Array([ + 2, 0, 1, 0, 2, 0, 3, 0, 4, 0, + ]) + const repeatBlock = createEmptyRepeatBlock() + repeatBlock.countFieldRef = '1' + repeatBlock.fields = [ + { ...createEmptyField(), name: 'anchor', dataType: 'uint16le', size: 2 }, + { ...createEmptyField(), name: 'focus', dataType: 'uint16le', size: 2 }, + ] + + const parsed = parseBinaryBuffer(buffer, [ + { + id: '1', + kind: 'field', + name: 'range_count', + dataType: 'uint16le', + size: 2, + }, + repeatBlock, + ]) + + expect(parsed).toEqual([ + { kind: 'field', name: 'range_count', size: 2, value: '2' }, + { + kind: 'group', + label: '0', + children: [ + { kind: 'field', name: 'anchor', size: 2, value: '1' }, + { kind: 'field', name: 'focus', size: 2, value: '2' }, + ], + }, + { + kind: 'group', + label: '1', + children: [ + { kind: 'field', name: 'anchor', size: 2, value: '3' }, + { kind: 'field', name: 'focus', size: 2, value: '4' }, + ], + }, + ]) + }) + + it('uses safe integer limits for bigint dynamic field sizes', () => { + const buffer = new Uint8Array(10) + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + view.setBigUint64(0, 9223372036854775807n, true) + + const parsed = parseBinaryBuffer(buffer, [ + { + id: '1', + kind: 'field', + name: 'len', + dataType: 'biguint64le', + size: 8, + }, + { + id: '2', + kind: 'field', + name: 'text', + dataType: 'string', + size: '', + sizeSource: 'field', + sizeFieldRef: '1', + }, + ]) + + expect(parsed).toEqual([ + { kind: 'field', name: 'len', size: 8, value: '9223372036854775807' }, + { + kind: 'field', + name: 'text', + size: Number.MAX_SAFE_INTEGER, + value: '', + }, + ]) + }) + + it('stops parsing sibling fields when repeat decoding is capped', () => { + const repeatCount = MAX_REPEAT_DECODE_ITERATIONS + 1 + const bufferParts = [ + repeatCount & 0xff, + (repeatCount >> 8) & 0xff, + ...Array.from({ length: repeatCount }, () => 0xaa), + 0xbb, + ] + + const repeatBlock = createEmptyRepeatBlock() + repeatBlock.countFieldRef = 'count' + repeatBlock.fields = [ + { + ...createEmptyField(), + id: 'item', + name: 'item', + dataType: 'uint8', + size: 1, + }, + ] + + const parsed = parseBinaryBuffer(new Uint8Array(bufferParts), [ + { + id: 'count', + kind: 'field', + name: 'count', + dataType: 'uint16le', + size: 2, + }, + repeatBlock, + { + id: 'tail', + kind: 'field', + name: 'tail', + dataType: 'uint8', + size: 1, + }, + ]) + + expect(countGroupNodes(parsed)).toBe(MAX_REPEAT_DECODE_ITERATIONS) + expect(parsed.some((node) => node.kind === 'field' && node.name === 'tail')).toBe( + false, + ) + }) + + it('caps nested repeat decoding with a shared global budget', () => { + const outerCount = 100 + const innerCount = 100 + const bufferParts = [outerCount & 0xff, (outerCount >> 8) & 0xff] + + for (let outer = 0; outer < outerCount; outer += 1) { + bufferParts.push(innerCount) + for (let inner = 0; inner < innerCount; inner += 1) { + bufferParts.push(1) + } + } + + const innerRepeat = createEmptyRepeatBlock() + innerRepeat.id = 'inner-repeat' + innerRepeat.countFieldRef = 'inner-count' + innerRepeat.fields = [ + { + ...createEmptyField(), + id: 'inner-value', + name: 'value', + dataType: 'uint8', + size: 1, + }, + ] + + const outerRepeat = createEmptyRepeatBlock() + outerRepeat.id = 'outer-repeat' + outerRepeat.countFieldRef = 'outer-count' + outerRepeat.fields = [ + { + id: 'inner-count', + kind: 'field', + name: 'inner_count', + dataType: 'uint8', + size: 1, + }, + innerRepeat, + ] + + const parsed = parseBinaryBuffer(new Uint8Array(bufferParts), [ + { + id: 'outer-count', + kind: 'field', + name: 'outer_count', + dataType: 'uint16le', + size: 2, + }, + outerRepeat, + ]) + + expect(countGroupNodes(parsed)).toBe(MAX_REPEAT_DECODE_ITERATIONS) + expect(countGroupNodes(parsed)).toBeLessThan(outerCount * innerCount) + }) + + it('reports insufficient data when repeat count exceeds available bytes', () => { + const buffer = new Uint8Array([2, 0, 1, 0, 2, 0]) + const repeatBlock = createEmptyRepeatBlock() + repeatBlock.countFieldRef = '1' + repeatBlock.fields = [ + { ...createEmptyField(), name: 'anchor', dataType: 'uint16le', size: 2 }, + { ...createEmptyField(), name: 'focus', dataType: 'uint16le', size: 2 }, + ] + + const parsed = parseBinaryBuffer(buffer, [ + { + id: '1', + kind: 'field', + name: 'range_count', + dataType: 'uint16le', + size: 2, + }, + repeatBlock, + ]) + + expect(parsed).toEqual([ + { kind: 'field', name: 'range_count', size: 2, value: '2' }, + { + kind: 'group', + label: '0', + children: [ + { kind: 'field', name: 'anchor', size: 2, value: '1' }, + { kind: 'field', name: 'focus', size: 2, value: '2' }, + ], + }, + { + kind: 'group', + label: '1', + children: [ + { + kind: 'field', + name: 'anchor', + size: 2, + value: '', + }, + ], + }, + ]) + }) + + it('formats parsed rows with grouped repeat indentation', () => { + expect( + formatParsedFields([ + { kind: 'field', name: 'range_count', size: 2, value: '1' }, + { + kind: 'group', + label: '0', + children: [ + { + kind: 'field', + name: 'anchor_chunk_id', + size: 8, + value: '769274194308128', + }, + ], + }, + ]), + ).toBe( + '[range_count] [2] [1]\n [0]\n [anchor_chunk_id] [8] [769274194308128]', + ) + }) + + it('formats flat parsed rows', () => { + expect( + formatParsedFields([{ kind: 'field', name: 'id', size: 1, value: '7' }]), + ).toBe('[id] [1] [7]') + }) + + it('formats parsed rows as a single inline line', () => { + expect( + formatParsedFieldsInline([ + { kind: 'field', name: 'range_count', size: 2, value: '1' }, + { + kind: 'group', + label: '0', + children: [ + { + kind: 'field', + name: 'anchor_chunk_id', + size: 8, + value: '769274194308128', + }, + ], + }, + ]), + ).toBe( + '[range_count] [2] [1] [0] [anchor_chunk_id] [8] [769274194308128]', + ) + }) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts new file mode 100644 index 0000000000..d41c306bce --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts @@ -0,0 +1,594 @@ +import { bufferToUint8Array } from 'uiSrc/utils/formatters/bufferFormatters' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' + +import { BinaryDataType, isRepeatNode, MAX_REPEAT_DECODE_ITERATIONS } from './constants' +import { isNumericCountType } from './schemaUtils' +import { + BinaryFieldDefinition, + ParsedBinaryField, + ParsedBinaryNode, + SchemaNode, + ValueDecoderRule, +} from './types' + +export const getFixedSize = (type: string): number | 'custom' => { + if (['uint8', 'int8', 'boolean'].includes(type)) return 1 + if (['uint16le', 'uint16be', 'int16le', 'int16be'].includes(type)) return 2 + if ( + ['uint32le', 'uint32be', 'int32le', 'int32be', 'floatle', 'floatbe'].includes( + type, + ) + ) { + return 4 + } + if ( + [ + 'bigint64le', + 'bigint64be', + 'biguint64le', + 'biguint64be', + 'doublele', + 'doublebe', + ].includes(type) + ) { + return 8 + } + return 'custom' +} + +export const formatHexBytes = (bytes: Uint8Array | Iterable): string => + Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()) + .join(' ') + +const REGEX_SPECIAL_CHARS = /[.+^${}()|[\]\\]/g + +const GLOB_ESCAPABLE_CHARS = new Set(['*', '?', '\\', '[', ']']) + +const escapeRegexLiteral = (char: string): string => + char.replace(REGEX_SPECIAL_CHARS, '\\$&') + +const escapeRegexCharacterClassMember = (char: string): string => { + if (char === '-' || char === ']' || char === '\\' || char === '^') { + return `\\${char}` + } + + return escapeRegexLiteral(char) +} + +export const escapeGlobPattern = (literal: string): string => + literal.replace(/[\\*?\[\]]/g, '\\$&') + +const parseGlobCharacterClass = ( + pattern: string, + startIndex: number, +): { regex: string; nextIndex: number } | null => { + let index = startIndex + 1 + if (index >= pattern.length) { + return null + } + + let negated = false + if (pattern[index] === '^') { + negated = true + index += 1 + } + + if (index >= pattern.length) { + return null + } + + const classParts: string[] = [] + let closed = false + + if (pattern[index] === ']') { + classParts.push(escapeRegexCharacterClassMember(']')) + index += 1 + } + + while (index < pattern.length) { + const char = pattern[index] + + if (char === '\\' && index + 1 < pattern.length) { + classParts.push(escapeRegexCharacterClassMember(pattern[index + 1])) + index += 2 + continue + } + + if (char === ']') { + index += 1 + closed = true + break + } + + if ( + index + 2 < pattern.length && + pattern[index + 1] === '-' && + pattern[index + 2] !== ']' + ) { + const rangeStart = pattern[index] + const rangeEnd = pattern[index + 2] + classParts.push( + `${escapeRegexLiteral(rangeStart)}-${escapeRegexLiteral(rangeEnd)}`, + ) + index += 3 + continue + } + + classParts.push(escapeRegexCharacterClassMember(char)) + index += 1 + } + + if (!closed || classParts.length === 0) { + return null + } + + const body = classParts.join('') + return { + regex: negated ? `[^${body}]` : `[${body}]`, + nextIndex: index, + } +} + +export const getDefaultKeyPattern = (keyName: string): string => + escapeGlobPattern(keyName) + +export const matchKeyPattern = (pattern: string, keyName: string): boolean => { + if (!pattern) return false + + let regex = '' + for (let i = 0; i < pattern.length; i += 1) { + const char = pattern[i] + + if (char === '\\' && i + 1 < pattern.length) { + const next = pattern[i + 1] + if (GLOB_ESCAPABLE_CHARS.has(next)) { + regex += escapeRegexLiteral(next) + i += 1 + continue + } + } + + if (char === '*') { + regex += '.*' + continue + } + + if (char === '?') { + regex += '.' + continue + } + + if (char === '[') { + const characterClass = parseGlobCharacterClass(pattern, i) + if (characterClass) { + regex += characterClass.regex + i = characterClass.nextIndex - 1 + continue + } + } + + regex += escapeRegexLiteral(char) + } + + try { + return new RegExp(`^${regex}$`).test(keyName) + } catch { + return false + } +} + +export const getSizeUnit = (size: number | ''): string => { + const numericSize = Number(size) + if (!numericSize || numericSize <= 0) { + return 'bytes' + } + return numericSize === 1 ? 'byte' : 'bytes' +} + +export const getKeyPatternSpecificity = (pattern: string): number => { + if (!pattern) { + return -1 + } + + let score = 0 + let index = 0 + + while (index < pattern.length) { + const char = pattern[index] + + if (char === '\\' && index + 1 < pattern.length) { + score += 4 + index += 2 + continue + } + + if (char === '*') { + score += 1 + index += 1 + continue + } + + if (char === '?') { + score += 2 + index += 1 + continue + } + + if (char === '[') { + const characterClass = parseGlobCharacterClass(pattern, index) + if (characterClass) { + score += 3 + index = characterClass.nextIndex + continue + } + } + + score += 4 + index += 1 + } + + return score +} + +export const findMatchingDecoderRule = ( + rules: ValueDecoderRule[], + keyName: string, +): ValueDecoderRule | null => { + let bestRule: ValueDecoderRule | null = null + let bestScore = -1 + + rules.forEach((rule) => { + const ruleScore = rule.keyPatterns.reduce((maxScore, pattern) => { + if (!matchKeyPattern(pattern, keyName)) { + return maxScore + } + + return Math.max(maxScore, getKeyPatternSpecificity(pattern)) + }, -1) + + if (ruleScore > bestScore) { + bestScore = ruleScore + bestRule = rule + } + }) + + return bestRule +} + +const readNumericValue = ( + view: DataView, + offset: number, + type: BinaryDataType, +): string => { + switch (type) { + case 'uint8': + return String(view.getUint8(offset)) + case 'int8': + return String(view.getInt8(offset)) + case 'boolean': + return view.getUint8(offset) ? 'true' : 'false' + case 'uint16le': + return String(view.getUint16(offset, true)) + case 'uint16be': + return String(view.getUint16(offset, false)) + case 'int16le': + return String(view.getInt16(offset, true)) + case 'int16be': + return String(view.getInt16(offset, false)) + case 'uint32le': + return String(view.getUint32(offset, true)) + case 'uint32be': + return String(view.getUint32(offset, false)) + case 'int32le': + return String(view.getInt32(offset, true)) + case 'int32be': + return String(view.getInt32(offset, false)) + case 'floatle': + return String(view.getFloat32(offset, true)) + case 'floatbe': + return String(view.getFloat32(offset, false)) + case 'bigint64le': + return view.getBigInt64(offset, true).toString() + case 'bigint64be': + return view.getBigInt64(offset, false).toString() + case 'biguint64le': + return view.getBigUint64(offset, true).toString() + case 'biguint64be': + return view.getBigUint64(offset, false).toString() + case 'doublele': + return String(view.getFloat64(offset, true)) + case 'doublebe': + return String(view.getFloat64(offset, false)) + default: + return '' + } +} + +const parseNumericForRef = (value: string, dataType: string): number => { + if (!isNumericCountType(dataType)) { + return 0 + } + + if (dataType.includes('bigint') || dataType.includes('biguint')) { + try { + const bigintValue = BigInt(value) + + if (bigintValue < 0n) { + return 0 + } + + const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER) + if (bigintValue > maxSafeInteger) { + return Number.MAX_SAFE_INTEGER + } + + return Number(bigintValue) + } catch { + return 0 + } + } + + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return 0 + } + + return Math.max(0, Math.floor(parsed)) +} + +const resolveFieldSize = ( + field: BinaryFieldDefinition, + parsedNumeric: Map, +): number => { + if (field.dataType === 'string' || field.dataType === 'hex') { + if (field.sizeSource === 'field' && field.sizeFieldRef) { + return parsedNumeric.get(field.sizeFieldRef) ?? 0 + } + return Number(field.size) || 0 + } + + const fixedSize = getFixedSize(field.dataType) + if (fixedSize === 'custom') { + return Number(field.size) || 0 + } + return fixedSize +} + +export const resolveRepeatCount = (rawCount: number | undefined): number => { + const normalized = rawCount ?? 0 + if (!Number.isFinite(normalized)) { + return 0 + } + + return Math.min( + MAX_REPEAT_DECODE_ITERATIONS, + Math.max(0, Math.floor(normalized)), + ) +} + +const parseFieldNode = ( + buffer: Uint8Array, + view: DataView, + field: BinaryFieldDefinition, + offset: number, + parsedNumeric: Map, +): { result: ParsedBinaryField | null; offset: number } => { + const size = resolveFieldSize(field, parsedNumeric) + + if (!field.name || size < 0) { + return { result: null, offset } + } + + if (size === 0) { + return { + result: { + kind: 'field', + name: field.name, + size: 0, + value: '', + }, + offset, + } + } + + if (offset + size > buffer.length) { + return { + result: { + kind: 'field', + name: field.name, + size, + value: '', + }, + offset, + } + } + + let value = '' + if (field.dataType === 'string') { + value = new TextDecoder('utf-8').decode(buffer.slice(offset, offset + size)) + } else if (field.dataType === 'hex') { + value = formatHexBytes(buffer.slice(offset, offset + size)) + } else { + value = readNumericValue(view, offset, field.dataType as BinaryDataType) + } + + const numericValue = parseNumericForRef(value, field.dataType) + if (isNumericCountType(field.dataType)) { + parsedNumeric.set(field.id, numericValue) + } + + return { + result: { kind: 'field', name: field.name, size, value }, + offset: offset + size, + } +} + +const hasInsufficientData = (nodes: ParsedBinaryNode[]): boolean => + nodes.some((node) => { + if (node.kind === 'field') { + return node.value === '' + } + return hasInsufficientData(node.children) + }) + +type DecodeBudget = { + remainingGroupSlots: number +} + +type ParseSchemaResult = { + results: ParsedBinaryNode[] + offset: number + truncated: boolean +} + +const getFullRepeatCount = (rawCount: number | undefined): number => { + const normalized = rawCount ?? 0 + if (!Number.isFinite(normalized)) { + return 0 + } + + return Math.max(0, Math.floor(normalized)) +} + +const parseSchemaNodes = ( + buffer: Uint8Array, + view: DataView, + nodes: SchemaNode[], + offset: number, + parsedNumeric: Map, + decodeBudget: DecodeBudget, +): ParseSchemaResult => { + let currentOffset = offset + const results: ParsedBinaryNode[] = [] + + for (const node of nodes) { + if (node.kind === 'field' || !isRepeatNode(node)) { + const field = node as BinaryFieldDefinition + const { result, offset: nextOffset } = parseFieldNode( + buffer, + view, + field, + currentOffset, + parsedNumeric, + ) + if (result) { + results.push(result) + if (result.value === '') { + return { results, offset: currentOffset, truncated: false } + } + } + currentOffset = nextOffset + continue + } + + const fullRepeatCount = getFullRepeatCount( + parsedNumeric.get(node.countFieldRef), + ) + const repeatCount = Math.min( + resolveRepeatCount(parsedNumeric.get(node.countFieldRef)), + decodeBudget.remainingGroupSlots, + ) + + let decodedIterations = 0 + for (let index = 0; index < repeatCount; index += 1) { + if (decodeBudget.remainingGroupSlots <= 0) { + break + } + + decodeBudget.remainingGroupSlots -= 1 + + const iterationScope = new Map(parsedNumeric) + const childParse = parseSchemaNodes( + buffer, + view, + node.fields, + currentOffset, + iterationScope, + decodeBudget, + ) + + results.push({ + kind: 'group', + label: String(index), + children: childParse.results, + }) + currentOffset = childParse.offset + decodedIterations += 1 + + if (hasInsufficientData(childParse.results)) { + return { results, offset: currentOffset, truncated: false } + } + + if (childParse.truncated) { + return { results, offset: currentOffset, truncated: true } + } + } + + if (decodedIterations < fullRepeatCount) { + return { results, offset: currentOffset, truncated: true } + } + } + + return { results, offset: currentOffset, truncated: false } +} + +export const formatParsedFieldLine = (field: ParsedBinaryField): string => + `[${field.name}] [${field.size}] [${field.value}]` + +export const formatParsedFields = (nodes: ParsedBinaryNode[]): string => { + const lines: string[] = [] + + const walk = (items: ParsedBinaryNode[], depth: number) => { + items.forEach((node) => { + if (node.kind === 'group') { + lines.push(`${' '.repeat(depth + 1)}[${node.label}]`) + walk(node.children, depth + 2) + return + } + + lines.push(`${' '.repeat(depth)}${formatParsedFieldLine(node)}`) + }) + } + + walk(nodes, 0) + return lines.join('\n') +} + +export const formatParsedFieldsInline = (nodes: ParsedBinaryNode[]): string => { + const parts: string[] = [] + + const walk = (items: ParsedBinaryNode[]) => { + items.forEach((node) => { + if (node.kind === 'group') { + parts.push(`[${node.label}]`) + walk(node.children) + return + } + + parts.push(formatParsedFieldLine(node)) + }) + } + + walk(nodes) + return parts.join(' ') +} + +export const parseBinaryBuffer = ( + buffer: Uint8Array, + schema: SchemaNode[], +): ParsedBinaryNode[] => + parseSchemaNodes( + buffer, + new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength), + schema, + 0, + new Map(), + { remainingGroupSlots: MAX_REPEAT_DECODE_ITERATIONS }, + ).results + +export const parseBufferWithRule = ( + buffer: RedisResponseBuffer, + schema: SchemaNode[], +): ParsedBinaryNode[] => + parseBinaryBuffer(bufferToUint8Array(buffer), schema) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.spec.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.spec.ts new file mode 100644 index 0000000000..4af60ba6be --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.spec.ts @@ -0,0 +1,96 @@ +import { faker } from '@faker-js/faker' +import { localStorageService } from 'uiSrc/services' +import BrowserStorageItem from 'uiSrc/constants/storage' + +import { createEmptyDecoder } from './constants' +import { + getValueDecoderRules, + getValueDecoderRulesStorageKey, + removeValueDecoderRules, + setValueDecoderRules, +} from './valueDecoderStorage' + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), + localStorageService: { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + }, +})) + +const mockGet = localStorageService.get as jest.Mock +const mockSet = localStorageService.set as jest.Mock +const mockRemove = localStorageService.remove as jest.Mock + +describe('valueDecoderStorage', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('reads rules from a per-instance storage key', () => { + const instanceId = faker.string.uuid() + const rules = [createEmptyDecoder('user:*')] + + mockGet.mockImplementation((key: string) => { + if (key === getValueDecoderRulesStorageKey(instanceId)) { + return rules + } + + return null + }) + + expect(getValueDecoderRules(instanceId)).toEqual(rules) + }) + + it('stores rules per instanceId', () => { + const instanceId = faker.string.uuid() + const rules = [createEmptyDecoder('session:*')] + + setValueDecoderRules(instanceId, rules) + + expect(mockSet).toHaveBeenCalledWith( + BrowserStorageItem.valueDecoderRules + instanceId, + rules, + ) + }) + + it('returns empty rules when instanceId is missing', () => { + expect(getValueDecoderRules('')).toEqual([]) + expect(mockSet).not.toHaveBeenCalled() + }) + + it('migrates legacy global rules into the current database once', () => { + const instanceId = faker.string.uuid() + const legacyRules = [createEmptyDecoder('legacy:*')] + + mockGet.mockImplementation((key: string) => { + if (key === getValueDecoderRulesStorageKey(instanceId)) { + return null + } + + if (key === 'valueDecoderRules') { + return legacyRules + } + + return null + }) + + expect(getValueDecoderRules(instanceId)).toEqual(legacyRules) + expect(mockSet).toHaveBeenCalledWith( + getValueDecoderRulesStorageKey(instanceId), + legacyRules, + ) + expect(mockRemove).toHaveBeenCalledWith('valueDecoderRules') + }) + + it('removes per-instance rules when a database is deleted', () => { + const instanceId = faker.string.uuid() + + removeValueDecoderRules(instanceId) + + expect(mockRemove).toHaveBeenCalledWith( + getValueDecoderRulesStorageKey(instanceId), + ) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts new file mode 100644 index 0000000000..316135aee8 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts @@ -0,0 +1,57 @@ +import BrowserStorageItem from 'uiSrc/constants/storage' +import { localStorageService } from 'uiSrc/services' + +import { normalizeRule } from './schemaUtils' +import { ValueDecoderRule } from './types' + +const LEGACY_GLOBAL_VALUE_DECODER_RULES_KEY = 'valueDecoderRules' + +export const getValueDecoderRulesStorageKey = (instanceId: string) => + BrowserStorageItem.valueDecoderRules + instanceId + +export const getValueDecoderRules = ( + instanceId: string, +): ValueDecoderRule[] => { + if (!instanceId) { + return [] + } + + const storageKey = getValueDecoderRulesStorageKey(instanceId) + let raw: ValueDecoderRule[] | null = localStorageService?.get(storageKey) + + if (!raw?.length) { + const legacyRules: ValueDecoderRule[] | null = localStorageService?.get( + LEGACY_GLOBAL_VALUE_DECODER_RULES_KEY, + ) + + if (legacyRules?.length) { + localStorageService.set(storageKey, legacyRules) + localStorageService.remove(LEGACY_GLOBAL_VALUE_DECODER_RULES_KEY) + raw = legacyRules + } + } + + return (raw ?? []).map(normalizeRule) +} + +export const setValueDecoderRules = ( + instanceId: string, + decoders: ValueDecoderRule[], +): void => { + if (!instanceId) { + return + } + + localStorageService?.set( + getValueDecoderRulesStorageKey(instanceId), + decoders, + ) +} + +export const removeValueDecoderRules = (instanceId: string): void => { + if (!instanceId) { + return + } + + localStorageService?.remove(getValueDecoderRulesStorageKey(instanceId)) +} diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx index 97394f7e43..8f20f77e05 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -44,6 +44,9 @@ import { } from 'uiSrc/pages/vector-search/hooks/useIsKeyIndexed' import { ViewIndexDataButton } from 'uiSrc/pages/browser/components/view-index-data-button' import { MakeSearchableButton } from 'uiSrc/pages/browser/components/make-searchable-button' +import { + ConfigValueDecoderButton, +} from 'uiSrc/pages/browser/components/value-decoder' import { KeyDetailsHeaderName } from './components/key-details-header-name' import { KeyDetailsHeaderTTL } from './components/key-details-header-ttl' import { KeyDetailsHeaderDelete } from './components/key-details-header-delete' @@ -184,6 +187,13 @@ const KeyDetailsHeader = ({ )} + {type === KeyTypes.Hash && ( + + + + + + )} {!arePanelsCollapsed && ( { const { keyType: selectedKeyType, keyProp } = props const isVectorSet = useAppSelector(isVectorSetEnabledSelector) const isArray = useAppSelector(isDevArrayEnabledSelector) + const isValueDecoderEnabled = useAppSelector(isValueDecoderEnabledSelector) + + const hashDetails = isValueDecoderEnabled ? ( + + + + ) : ( + + ) const TypeDetails: any = { [KeyTypes.ZSet]: , [KeyTypes.Set]: , [KeyTypes.String]: , - [KeyTypes.Hash]: , + [KeyTypes.Hash]: hashDetails, [KeyTypes.List]: , [KeyTypes.ReJSON]: , [KeyTypes.Stream]: , diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx index 985bfdc8c5..8ad7b28071 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx @@ -75,6 +75,12 @@ import { import { stringToBuffer } from 'uiSrc/utils/formatters/bufferFormatters' import { decompressingBuffer } from 'uiSrc/utils/decompressors' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { isValueDecoderEnabledSelector } from 'uiSrc/slices/app/features' +import { + DecodedValueDisplay, + useValueDecoder, + ValueDecoderHeaderLabel, +} from 'uiSrc/pages/browser/components/value-decoder' import { EditableInput, EditableTextArea, @@ -143,13 +149,30 @@ const HashDetailsTable = (props: Props) => { const formattedLastIndexRef = useRef(OVER_RENDER_BUFFER_COUNT) const tableRef: Ref = useRef(null) + const isInitialDecodeLayoutRef = useRef(true) const dispatch = useAppDispatch() + const isValueDecoderEnabled = useAppSelector(isValueDecoderEnabledSelector) + const { isDecodeEnabled, matchedRule } = useValueDecoder() useEffect(() => { resetState() }, [lastRefreshTime]) + useEffect(() => { + if (!isValueDecoderEnabled) { + return + } + + if (isInitialDecodeLayoutRef.current) { + isInitialDecodeLayoutRef.current = false + return + } + + cellCache.clearAll() + tableRef.current?.recomputeRowHeights() + }, [isDecodeEnabled, isValueDecoderEnabled, matchedRule]) + useEffect(() => { setFields(loadedFields) @@ -410,7 +433,7 @@ const HashDetailsTable = (props: Props) => { }, { id: 'value', - label: 'Value', + label: isValueDecoderEnabled ? : 'Value', minWidth: 120, truncateText: true, alignment: TableCellAlignment.Left, @@ -460,6 +483,19 @@ const HashDetailsTable = (props: Props) => { ? bufferToSerializedFormat(viewFormat, valueItem, 4) : '' + const formattedValueDisplay = ( + + ) + return ( { testIdPrefix="hash" >
- + {isValueDecoderEnabled && !isTruncatedFieldOrValue ? ( + + ) : ( + formattedValueDisplay + )}
) diff --git a/redisinsight/ui/src/pages/home/components/databases-list/components/BulkItemsActions/methods/handlers.ts b/redisinsight/ui/src/pages/home/components/databases-list/components/BulkItemsActions/methods/handlers.ts index a9e1ac74d0..9476cc54d9 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list/components/BulkItemsActions/methods/handlers.ts +++ b/redisinsight/ui/src/pages/home/components/databases-list/components/BulkItemsActions/methods/handlers.ts @@ -11,12 +11,14 @@ import { import { BrowserStorageItem } from 'uiSrc/constants' import { dispatch } from 'uiSrc/slices/store' import { localStorageService } from 'uiSrc/services' +import { removeValueDecoderRules } from 'uiSrc/pages/browser/components/value-decoder/valueDecoderStorage' const onDeleteInstances = (instances: Instance[]) => { dispatch(setEditedInstance(null)) instances.forEach((instance) => { localStorageService.remove(BrowserStorageItem.dbConfig + instance.id) + removeValueDecoderRules(instance.id) }) } diff --git a/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellControls/methods/handlers.ts b/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellControls/methods/handlers.ts index a018290b77..2ef544d8e1 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellControls/methods/handlers.ts +++ b/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellControls/methods/handlers.ts @@ -8,12 +8,14 @@ import { import { BrowserStorageItem } from 'uiSrc/constants' import { dispatch } from 'uiSrc/slices/store' import { localStorageService } from 'uiSrc/services' +import { removeValueDecoderRules } from 'uiSrc/pages/browser/components/value-decoder/valueDecoderStorage' const onDeleteInstances = (instances: Instance[]) => { dispatch(setEditedInstance(null)) instances.forEach((instance) => { localStorageService.remove(BrowserStorageItem.dbConfig + instance.id) + removeValueDecoderRules(instance.id) }) } diff --git a/redisinsight/ui/src/slices/app/features.ts b/redisinsight/ui/src/slices/app/features.ts index 9152d23215..73d938545a 100644 --- a/redisinsight/ui/src/slices/app/features.ts +++ b/redisinsight/ui/src/slices/app/features.ts @@ -86,6 +86,9 @@ export const initialState: StateAppFeatures = { [FeatureFlags.devLanguage]: { flag: false, }, + [FeatureFlags.valueDecoder]: { + flag: false, + }, }, }, } @@ -222,6 +225,10 @@ export const appFeatureFlagsFeaturesSelector = (state: RootState) => export const appFeatureFlagProdModeSelector = (state: RootState): boolean => state.app.features.featureFlags.features[FeatureFlags.prodMode]?.flag ?? false +export const isValueDecoderEnabledSelector = (state: RootState): boolean => + state.app.features.featureFlags.features[FeatureFlags.valueDecoder]?.flag ?? + false + export const isDevelopment = riConfig.app.env === 'development' export const isAzureEntraIdEnabledSelector = (state: RootState): boolean => { From 0ff9f875ad1aa1a56cd35c95e85e090306c14e53 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Tue, 14 Jul 2026 15:46:40 +0300 Subject: [PATCH 029/166] ci: match draft release by name so installers upload reliably (#6193) The upload job looked up the draft by ".tagName == ", but a draft release keeps a placeholder tag (untagged-) until it is published, so the final tag does not exist at build time. For 3.6.0 the draft existed ~4h before the job ran, yet the tag filter matched nothing, the job exited 0, and the release shipped with no assets. Match the in-flight draft by name instead and upload via its current tag. Fall back to creating a draft (pinned to the built commit) when none exists, and mark the job continue-on-error so a failure never blocks the release. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/github-release-upload.yml | 24 ++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/github-release-upload.yml b/.github/workflows/github-release-upload.yml index 1cdd779d4b..7a35c202d9 100644 --- a/.github/workflows/github-release-upload.yml +++ b/.github/workflows/github-release-upload.yml @@ -10,6 +10,7 @@ jobs: upload: name: Upload assets to GitHub draft release runs-on: ubuntu-latest + continue-on-error: true steps: - uses: actions/checkout@v7.0.0 @@ -27,21 +28,24 @@ jobs: APP_VERSION=$(jq -r '.version' redisinsight/package.json) echo "version=${APP_VERSION}" >> "$GITHUB_OUTPUT" - RELEASE_TAG=$(gh release list --json tagName,isDraft \ - --jq ".[] | select(.isDraft and .tagName == \"${APP_VERSION}\") | .tagName" \ - | head -n 1) + RELEASE_TAG=$(gh release list --json tagName,name,isDraft \ + --jq "[.[] | select(.isDraft and (.name | contains(\"${APP_VERSION}\")))][0].tagName") - if [ -z "$RELEASE_TAG" ]; then - echo "::warning::No draft release found for tag '${APP_VERSION}'. Skipping upload." - echo "found=false" >> "$GITHUB_OUTPUT" + if [ -z "$RELEASE_TAG" ] || [ "$RELEASE_TAG" = "null" ]; then + echo "No draft release found for '${APP_VERSION}'. Creating one." + gh release create "${APP_VERSION}" \ + --draft \ + --target "${GITHUB_SHA}" \ + --title "${APP_VERSION}" \ + --notes "Release ${APP_VERSION}" + RELEASE_TAG="${APP_VERSION}" else - echo "Draft release found: ${RELEASE_TAG}" - echo "found=true" >> "$GITHUB_OUTPUT" - echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + echo "Draft release found (tag: ${RELEASE_TAG})" fi + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + - name: Upload desktop installers to draft release - if: steps.find-release.outputs.found == 'true' env: GH_TOKEN: ${{ github.token }} run: | From b72b452164b5bf0bbe7acb627d655198b8e7d85e Mon Sep 17 00:00:00 2001 From: dantovska Date: Tue, 14 Jul 2026 15:50:17 +0300 Subject: [PATCH 030/166] RI-8275 E2E: fix Vector Search run button locator (#6201) * refactor(e2e): use accessible-name locators for run and collapse buttons --- .../pages/vector-search/components/QueryEditor.ts | 2 +- .../pages/vector-search/components/QueryResults.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e-playwright/pages/vector-search/components/QueryEditor.ts b/tests/e2e-playwright/pages/vector-search/components/QueryEditor.ts index c41266a97e..460772bdf1 100644 --- a/tests/e2e-playwright/pages/vector-search/components/QueryEditor.ts +++ b/tests/e2e-playwright/pages/vector-search/components/QueryEditor.ts @@ -27,7 +27,7 @@ export class QueryEditor { this.container = page.getByTestId('vector-search-query-editor'); this.actionsBar = page.getByTestId('vector-search-actions'); - this.runButton = this.actionsBar.getByRole('button', { name: 'submit' }); + this.runButton = this.actionsBar.getByRole('button', { name: 'Run' }); this.explainButton = this.actionsBar.getByRole('button', { name: 'explain' }); this.profileButton = this.actionsBar.getByRole('button', { name: 'profile' }); this.saveButton = this.actionsBar.getByRole('button', { name: 'save' }); diff --git a/tests/e2e-playwright/pages/vector-search/components/QueryResults.ts b/tests/e2e-playwright/pages/vector-search/components/QueryResults.ts index bd37906911..4ff6d90872 100644 --- a/tests/e2e-playwright/pages/vector-search/components/QueryResults.ts +++ b/tests/e2e-playwright/pages/vector-search/components/QueryResults.ts @@ -68,7 +68,7 @@ export class QueryResults { * exact: true prevents matching the outer div[role="button"] card header. */ get firstCardToggleCollapseButton(): Locator { - return this.container.getByRole('button', { name: 'toggle collapse', exact: true }).first(); + return this.container.getByRole('button', { name: 'Toggle result', exact: true }).first(); } /** From df1db8d20458be081049095fb1a5458483fdd18b Mon Sep 17 00:00:00 2001 From: Vasko Atanasov Date: Tue, 14 Jul 2026 17:41:48 +0300 Subject: [PATCH 031/166] RI-8174 Promote dev-array feature flag to array (#6203) * feat: promote dev-array feature flag to array The array key type is approved for release to all users, so the dev flag becomes a regular flag at full rollout. Strategy stays switchable so it can still be overridden via local config. References: #RI-8174 * fix(ui): resolve lint, type, and test failures in value decoder The value decoder feature merged to main with a red Lint workflow and type/test failures that the push-triggered Type check workflow never runs (its per-project steps are skipped on push events). This PR trips the full checks, so clean them up: - apply prettier formatting and replace bitwise byte math in tests - declare children and DOM handler props on styled components that degrade to StyledComponent (established repo workaround) - add missing React imports and drop the unsupported Row 'inline' prop - pass undefined instead of null to RiSelect values - skip binary decode for plain-string hash values - escape * and ? in glob-to-regex conversion so escaped key patterns match literally, and fix test data asserting the wrong byte layout and non-glob character-class semantics References: #RI-8174 --- redisinsight/api/config/features-config.json | 6 +- .../src/modules/feature/constants/index.ts | 2 +- .../feature/constants/known-features.ts | 4 +- .../feature-flag/feature-flag.provider.ts | 2 +- redisinsight/ui/src/constants/featureFlags.ts | 2 +- .../add-key/constants/key-type-options.ts | 4 +- .../filter-key-type/FilterKeyType.spec.tsx | 8 +- .../components/filter-key-type/constants.ts | 4 +- .../value-decoder/DecoderEditor.tsx | 23 ++- .../DescriptionSelectValueRender.tsx | 6 +- .../value-decoder/FieldsSchemaEditor.tsx | 12 +- .../value-decoder/KeyPatternsEditor.tsx | 5 +- .../value-decoder/ValueDecoderHeaderLabel.tsx | 6 +- .../value-decoder/ValueDecoderModal.styles.ts | 48 ++++-- .../value-decoder/ValueDecoderModal.tsx | 155 +++++++++--------- .../components/value-decoder/constants.ts | 2 +- .../value-decoder/decoderClipboard.ts | 18 +- .../browser/components/value-decoder/index.ts | 5 +- .../value-decoder/schemaUtils.spec.ts | 11 +- .../components/value-decoder/schemaUtils.ts | 21 ++- .../components/value-decoder/utils.spec.ts | 100 +++++++---- .../browser/components/value-decoder/utils.ts | 22 ++- .../value-decoder/valueDecoderStorage.ts | 5 +- .../key-details-header/KeyDetailsHeader.tsx | 4 +- .../DynamicTypeDetails.spec.tsx | 6 +- .../DynamicTypeDetails.tsx | 4 +- .../hash-details-table/HashDetailsTable.tsx | 10 +- redisinsight/ui/src/slices/app/features.ts | 6 +- .../ui/src/slices/tests/app/features.spec.ts | 20 +-- 29 files changed, 301 insertions(+), 220 deletions(-) diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 44f97f2f71..5443eed385 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 6.1, + "version": 7, "features": { "dev-language": { "flag": true, @@ -148,9 +148,9 @@ "flag": true, "perc": [[0, 100]] }, - "dev-array": { + "array": { "flag": true, - "perc": [[0, 0]] + "perc": [[0, 100]] }, "prodMode": { "flag": true, diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts index 0a2fe15bca..f2e6e2081e 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -37,7 +37,7 @@ export enum KnownFeatures { DevAzureEntraId = 'dev-azureEntraId', DevBrowser = 'dev-browser', VectorSet = 'vectorSet', - DevArray = 'dev-array', + Array = 'array', ProdMode = 'prodMode', DevLanguage = 'dev-language', WhatsNew = 'whatsNew', diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index 50a9873b39..00da40e0ae 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -87,8 +87,8 @@ export const knownFeatures: Record = { name: KnownFeatures.VectorSet, storage: FeatureStorage.Database, }, - [KnownFeatures.DevArray]: { - name: KnownFeatures.DevArray, + [KnownFeatures.Array]: { + name: KnownFeatures.Array, storage: FeatureStorage.Database, }, [KnownFeatures.ProdMode]: { diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts index 87bfcf268f..7ce305e227 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts @@ -104,7 +104,7 @@ export class FeatureFlagProvider { new CommonFlagStrategy(this.featuresConfigService, this.settingsService), ); this.strategies.set( - KnownFeatures.DevArray, + KnownFeatures.Array, new SwitchableFlagStrategy( this.featuresConfigService, this.settingsService, diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index 0fce7db7e1..1cc2a4baab 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -13,7 +13,7 @@ export enum FeatureFlags { customTutorials = 'customTutorials', vectorSearchV2 = 'vectorSearchV2', vectorSet = 'vectorSet', - devArray = 'dev-array', + array = 'array', azureEntraId = 'azureEntraId', devBrowser = 'dev-browser', prodMode = 'prodMode', diff --git a/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts b/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts index 0319d4669e..fc47b1592e 100644 --- a/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts +++ b/redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts @@ -1,7 +1,7 @@ import { GROUP_TYPES_COLORS, KeyTypes } from 'uiSrc/constants' import { CommandsVersions } from 'uiSrc/constants/commandsVersions' import { - isDevArrayEnabledSelector, + isArrayEnabledSelector, isVectorSetEnabledSelector, } from 'uiSrc/slices/app/features' import { AddKeyTypeOption } from '../AddKey.types' @@ -22,7 +22,7 @@ export const ADD_KEY_TYPE_OPTIONS: AddKeyTypeOption[] = [ value: KeyTypes.Array, color: GROUP_TYPES_COLORS[KeyTypes.Array], minVersion: CommandsVersions.ARRAY.since, - isEnabledSelector: isDevArrayEnabledSelector, + isEnabledSelector: isArrayEnabledSelector, }, { text: 'Set', diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx index cc49e9f2e5..c42e8f9fab 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx @@ -244,13 +244,13 @@ describe('FilterKeyType', () => { expect(queryByText('Vector Set')).not.toBeInTheDocument() }) - it('should show Array when dev-array feature flag is enabled and redis version >= 8.8', async () => { + it('should show Array when array feature flag is enabled and redis version >= 8.8', async () => { connectedInstanceOverviewSelectorMock.mockImplementationOnce(() => ({ version: '8.8.0', })) const initialStoreState = set( cloneDeep(initialStateDefault), - `app.features.featureFlags.features.${FeatureFlags.devArray}`, + `app.features.featureFlags.features.${FeatureFlags.array}`, { flag: true }, ) const { queryByText } = render(, { @@ -262,7 +262,7 @@ describe('FilterKeyType', () => { expect(queryByText('Array')).toBeInTheDocument() }) - it('should hide Array when dev-array feature flag is disabled', () => { + it('should hide Array when array feature flag is disabled', () => { // Ensure the version gate is satisfied so the assertion truly // exercises the feature-flag path and not the version path. connectedInstanceOverviewSelectorMock.mockImplementationOnce(() => ({ @@ -281,7 +281,7 @@ describe('FilterKeyType', () => { })) const initialStoreState = set( cloneDeep(initialStateDefault), - `app.features.featureFlags.features.${FeatureFlags.devArray}`, + `app.features.featureFlags.features.${FeatureFlags.array}`, { flag: true }, ) const { queryByText } = render(, { diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts b/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts index f62ea6f380..b0e28623b8 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts @@ -6,7 +6,7 @@ import { } from 'uiSrc/constants' import { CommandsVersions } from 'uiSrc/constants/commandsVersions' import { - isDevArrayEnabledSelector, + isArrayEnabledSelector, isVectorSetEnabledSelector, } from 'uiSrc/slices/app/features' import { RedisDefaultModules } from 'uiSrc/slices/interfaces' @@ -28,7 +28,7 @@ export const FILTER_KEY_TYPE_OPTIONS: FilterKeyTypeOption[] = [ value: KeyTypes.Array, color: GROUP_TYPES_COLORS[KeyTypes.Array], minVersion: CommandsVersions.ARRAY.since, - isEnabledSelector: isDevArrayEnabledSelector, + isEnabledSelector: isArrayEnabledSelector, }, { text: 'Set', diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx index 6f022d85fd..792a1ea483 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/DecoderEditor.tsx @@ -9,10 +9,7 @@ import TextInput from 'uiSrc/components/base/inputs/TextInput' import { RiSelect } from 'uiSrc/components/base/forms/select/RiSelect' import { CopyButton } from 'uiSrc/components/copy-button/CopyButton' -import { - DECODER_TYPE_OPTIONS, - VALUE_DECODER_TEST_ID, -} from './constants' +import { DECODER_TYPE_OPTIONS, VALUE_DECODER_TEST_ID } from './constants' import { serializeDecoderForClipboard } from './decoderClipboard' import { DECODER_TYPE_DESCRIPTIONS, @@ -65,7 +62,10 @@ export const DecoderEditor = ({ const isValid = isDecoderValid(decoder) return ( - + - + handleFieldChange('name', value)} @@ -120,7 +123,9 @@ export const DecoderEditor = ({ patterns={ decoder.keyPatterns.length > 0 ? decoder.keyPatterns : [''] } - onChange={(keyPatterns) => handleFieldChange('keyPatterns', keyPatterns)} + onChange={(keyPatterns) => + handleFieldChange('keyPatterns', keyPatterns) + } /> @@ -141,7 +146,9 @@ export const DecoderEditor = ({ handleFieldChange('schema', schema)} + onChange={(schema: SchemaNode[]) => + handleFieldChange('schema', schema) + } /> diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx index aaa7cd34f9..ac881f24bd 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/DescriptionSelectValueRender.tsx @@ -1,3 +1,5 @@ +import React from 'react' + import { RiTooltip } from 'uiSrc/components' import { @@ -10,7 +12,7 @@ import * as S from './ValueDecoderModal.styles' export const createDescriptionSelectValueRender = ( descriptions: Record, ): SelectValueRender => { - const render = ({ option, isOptionValue }: SelectValueRenderParams) => { + return ({ option, isOptionValue }: SelectValueRenderParams) => { const description = descriptions[String(option.value)] ?? '' const label = option.label ?? option.value @@ -26,6 +28,4 @@ export const createDescriptionSelectValueRender = ( ) } - - return render } diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx index a5b0160042..3116c42c06 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/FieldsSchemaEditor.tsx @@ -1,6 +1,9 @@ import React, { useCallback } from 'react' -import { ActionIconButton, SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { + ActionIconButton, + SecondaryButton, +} from 'uiSrc/components/base/forms/buttons' import { DeleteIcon } from 'uiSrc/components/base/icons' import { Row } from 'uiSrc/components/base/layout/flex' import TextInput from 'uiSrc/components/base/inputs/TextInput' @@ -129,7 +132,7 @@ const FieldRow = ({ {sizeSource === 'field' ? ( onFieldChange(field.id, { sizeFieldRef: value ?? '' }) } @@ -142,8 +145,7 @@ const FieldRow = ({ value={field.size === '' ? null : Number(field.size)} onChange={(value) => onFieldChange(field.id, { - size: - value == null || Number.isNaN(value) ? '' : value, + size: value == null || Number.isNaN(value) ? '' : value, }) } min={1} @@ -222,7 +224,7 @@ const RepeatBlockEditor = ({ Repeat onRepeatChange(repeat.id, { countFieldRef: value ?? '' }) } diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx index cc3527b891..b5a98a917d 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/KeyPatternsEditor.tsx @@ -1,6 +1,9 @@ import React, { useCallback } from 'react' -import { ActionIconButton, SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { + ActionIconButton, + SecondaryButton, +} from 'uiSrc/components/base/forms/buttons' import { DeleteIcon } from 'uiSrc/components/base/icons' import TextInput from 'uiSrc/components/base/inputs/TextInput' diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx index 0507c4d774..becdc7b9e0 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderHeaderLabel.tsx @@ -38,7 +38,7 @@ export const ValueDecoderHeaderLabel = ({ } return ( - + {label} @@ -51,9 +51,7 @@ export const ValueDecoderHeaderLabel = ({ position="top" > +}>` display: inline-flex; align-items: center; justify-content: center; @@ -54,13 +59,16 @@ export const DragHandle = styled.div` } ` -export const SortableRow = styled.div` +export const SortableRow = styled.div<{ + children?: React.ReactNode + onDragOver?: React.DragEventHandler + onDrop?: React.DragEventHandler +}>` display: flex; align-items: center; gap: ${({ theme }) => theme.core.space.space050}; padding: ${({ theme }) => theme.core.space.space100}; - border-top: 1px solid - ${({ theme }) => theme.semantic.color.border.neutral400}; + border-top: 1px solid ${({ theme }) => theme.semantic.color.border.neutral400}; &:hover ${DragHandle} { opacity: 1; @@ -91,7 +99,9 @@ export const KeyPatternRow = styled.div` min-width: 0; ` -export const KeyPatternLastRow = styled.div` +export const KeyPatternLastRow = styled.div<{ + children?: React.ReactNode +}>` display: flex; align-items: center; gap: ${({ theme }) => theme.core.space.space100}; @@ -116,13 +126,16 @@ export const SizeSourceWrapper = styled.div` min-width: 180px; ` -export const SizeUnit = styled.span` +export const SizeUnit = styled.span<{ children?: React.ReactNode }>` color: ${({ theme }) => theme.components.typography.colors.secondary}; font-size: ${({ theme }) => theme.core.font.fontSize.s12}; white-space: nowrap; ` -export const RepeatBlock = styled.div<{ $depth: number }>` +export const RepeatBlock = styled.div<{ + $depth: number + children?: React.ReactNode +}>` margin-top: ${({ theme }) => theme.core.space.space100}; margin-bottom: ${({ theme }) => theme.core.space.space100}; padding: ${({ theme }) => theme.core.space.space100}; @@ -162,13 +175,19 @@ export const RepeatLabel = styled.span` white-space: nowrap; ` -export const SelectOptionAnchor = styled.span<{ $fullWidth?: boolean }>` +export const SelectOptionAnchor = styled.span<{ + $fullWidth?: boolean + children?: React.ReactNode +}>` display: inline-flex; align-items: center; width: ${({ $fullWidth }) => ($fullWidth ? '100%' : 'auto')}; ` -export const DecoderSection = styled.div<{ $expanded: boolean }>` +export const DecoderSection = styled.div<{ + $expanded: boolean + children?: React.ReactNode +}>` border: 1px solid ${({ theme }) => theme.semantic.color.border.neutral400}; border-radius: ${({ theme }) => theme.core.space.space100}; background: ${({ theme, $expanded }) => @@ -185,7 +204,11 @@ export const DecoderHeader = styled.div` padding: ${({ theme }) => theme.core.space.space100}; ` -export const DecoderSummaryButton = styled.button` +export const DecoderSummaryButton = styled.button<{ + children?: React.ReactNode + type?: 'button' | 'submit' | 'reset' + onClick?: React.MouseEventHandler +}>` display: inline-flex; align-items: center; gap: ${({ theme }) => theme.core.space.space100}; @@ -207,7 +230,10 @@ export const DecoderBody = styled.div` ${({ theme }) => theme.core.space.space100}; ` -export const DecoderMatchBadge = styled.span<{ $warning?: boolean }>` +export const DecoderMatchBadge = styled.span<{ + $warning?: boolean + children?: React.ReactNode +}>` color: ${({ theme, $warning }) => $warning ? theme.components.typography.colors.attention diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx index 0dde105f6e..49a5b7093e 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ValueDecoderModal.tsx @@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Modal } from 'uiSrc/components/base/display' import { Text } from 'uiSrc/components/base/text' -import { PrimaryButton, SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { + PrimaryButton, + SecondaryButton, +} from 'uiSrc/components/base/forms/buttons' import { CancelIcon } from 'uiSrc/components/base/icons' import { Row, Col } from 'uiSrc/components/base/layout/flex' import { CopyButton } from 'uiSrc/components/copy-button/CopyButton' @@ -13,13 +16,13 @@ import { parseDecodersFromClipboard, serializeDecodersForClipboard, } from './decoderClipboard' -import { - areDecodersValid, - getDecoderLabel, - normalizeRule, -} from './schemaUtils' +import { areDecodersValid, getDecoderLabel, normalizeRule } from './schemaUtils' import { ValueDecoderRule } from './types' -import { findMatchingDecoderRule, getDefaultKeyPattern, matchKeyPattern } from './utils' +import { + findMatchingDecoderRule, + getDefaultKeyPattern, + matchKeyPattern, +} from './utils' import * as S from './ValueDecoderModal.styles' export interface ValueDecoderModalConfig { @@ -200,77 +203,77 @@ export const ValueDecoderModal = ({ - - Decoders are shared across all hash keys in this database. Add - multiple decoders and key patterns; matching hash values can be - decoded in the Value Preview. Copy decoders as JSON and paste - them here or into another Redis Insight connection. - - - - Decoders - - {pasteMessage && ( - - {pasteMessage} - - )} - - - Paste - - - Add Decoder - - + + Decoders are shared across all hash keys in this database. Add + multiple decoders and key patterns; matching hash values can be + decoded in the Value Preview. Copy decoders as JSON and paste + them here or into another Redis Insight connection. + + + + Decoders + + {pasteMessage && ( + + {pasteMessage} + + )} + + + Paste + + + Add Decoder + - -
- {localDecoders.map((decoder) => { - const normalized = normalizeRule(decoder) - const patternCount = normalized.keyPatterns.length - const summary = `${getDecoderLabel(decoder)} · ${patternCount} pattern${patternCount === 1 ? '' : 's'}` - - return ( - - setExpandedId((current) => - current === decoder.id ? null : decoder.id, - ) - } - onChange={(nextDecoder) => - handleUpdateDecoder(decoder.id, nextDecoder) - } - onRemove={() => handleRemoveDecoder(decoder.id)} - canRemove - summary={summary} - matchesCurrentKey={Boolean( - keyName && - normalized.keyPatterns.some((pattern) => - matchKeyPattern(pattern, keyName), - ), - )} - /> - ) - })} - + + + + {localDecoders.map((decoder) => { + const normalized = normalizeRule(decoder) + const patternCount = normalized.keyPatterns.length + const summary = `${getDecoderLabel(decoder)} · ${patternCount} pattern${patternCount === 1 ? '' : 's'}` + + return ( + + setExpandedId((current) => + current === decoder.id ? null : decoder.id, + ) + } + onChange={(nextDecoder) => + handleUpdateDecoder(decoder.id, nextDecoder) + } + onRemove={() => handleRemoveDecoder(decoder.id)} + canRemove + summary={summary} + matchesCurrentKey={Boolean( + keyName && + normalized.keyPatterns.some((pattern) => + matchKeyPattern(pattern, keyName), + ), + )} + /> + ) + })} + } /> diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts index 5411bfd974..a182c19b9c 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/constants.ts @@ -3,8 +3,8 @@ import { RepeatBlockDefinition, SchemaNode, ValueDecoderRule, + DecoderType, } from './types' -import { DecoderType } from './types' export const VALUE_DECODER_TEST_ID = 'value-decoder' diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts index 95af886ae8..8cc6c114b0 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/decoderClipboard.ts @@ -46,7 +46,7 @@ const remapSchemaIds = ( id: idMap.get(node.id) ?? node.id, kind: 'field', sizeFieldRef: node.sizeFieldRef - ? idMap.get(node.sizeFieldRef) ?? node.sizeFieldRef + ? (idMap.get(node.sizeFieldRef) ?? node.sizeFieldRef) : undefined, } } @@ -88,11 +88,15 @@ export const serializeDecodersForClipboard = ( return JSON.stringify(payload, null, 2) } -export const serializeDecoderForClipboard = (decoder: ValueDecoderRule): string => - serializeDecodersForClipboard([decoder]) +export const serializeDecoderForClipboard = ( + decoder: ValueDecoderRule, +): string => serializeDecodersForClipboard([decoder]) + +const isRecordLike = (value: unknown): value is Record => + isObjectLike(value) const isDecoderLike = (value: unknown): value is Record => { - if (!isObjectLike(value)) { + if (!isRecordLike(value)) { return false } @@ -109,7 +113,7 @@ const parseDecoderCandidates = (parsed: unknown): unknown[] => { return parsed } - if (!isObjectLike(parsed)) { + if (!isRecordLike(parsed)) { return [] } @@ -146,7 +150,9 @@ export const parseDecodersFromClipboard = ( const decoders = candidates .filter(isDecoderLike) - .map((candidate) => cloneDecoderRule(candidate as ValueDecoderRule)) + .map((candidate) => + cloneDecoderRule(candidate as unknown as ValueDecoderRule), + ) return decoders.length > 0 ? decoders : null } catch { diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/index.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/index.ts index a025140ece..ea24d8ad96 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/index.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/index.ts @@ -1,10 +1,7 @@ export { ConfigValueDecoderButton } from './ConfigValueDecoderButton' export { DecodedValueDisplay } from './DecodedValueDisplay' export { ValueDecoderHeaderLabel } from './ValueDecoderHeaderLabel' -export { - ValueDecoderProvider, - useValueDecoder, -} from './ValueDecoderProvider' +export { ValueDecoderProvider, useValueDecoder } from './ValueDecoderProvider' export { ValueDecoderModal } from './ValueDecoderModal' export type { ValueDecoderRule, diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts index 56197d9fde..f519aed980 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.spec.ts @@ -3,7 +3,12 @@ import { createEmptyField, createEmptyRepeatBlock, } from './constants' -import { isDecoderValid, isSchemaValid, areDecodersValid, normalizeRule } from './schemaUtils' +import { + isDecoderValid, + isSchemaValid, + areDecodersValid, + normalizeRule, +} from './schemaUtils' import { BinaryFieldDefinition } from './types' describe('schemaUtils validation', () => { @@ -34,9 +39,9 @@ describe('schemaUtils validation', () => { name: 'text', dataType: 'string', size: '', - sizeSource: 'field' as const, + sizeSource: 'field', sizeFieldRef: 'len', - } + } satisfies BinaryFieldDefinition const normalized = normalizeRule({ ...createEmptyDecoder(), diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts index 2efeee7077..89b493bff2 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/schemaUtils.ts @@ -19,7 +19,9 @@ export interface NumericFieldRef { } export const isNumericCountType = (dataType: string): boolean => - NUMERIC_COUNT_DATA_TYPES.includes(dataType as (typeof NUMERIC_COUNT_DATA_TYPES)[number]) + NUMERIC_COUNT_DATA_TYPES.includes( + dataType as (typeof NUMERIC_COUNT_DATA_TYPES)[number], + ) export const normalizeFieldNode = ( field: Partial & { id: string }, @@ -144,11 +146,7 @@ const normalizeSchemaRefs = ( priorFields: NumericFieldRef[] = [], ): SchemaNode[] => nodes.map((node, index) => { - const scopeNumeric = getPriorNumericFieldsInScope( - priorFields, - nodes, - index, - ) + const scopeNumeric = getPriorNumericFieldsInScope(priorFields, nodes, index) if (isFieldNode(node)) { if (node.sizeSource !== 'field') { @@ -178,7 +176,10 @@ export const normalizeRule = (rule: ValueDecoderRule): ValueDecoderRule => { (rule.schema?.length ?? 0) > 0 ? rule.schema!.map(normalizeSchemaNode) : (rule.fields ?? []).map((field) => - normalizeFieldNode({ ...field, id: field.id ?? `field-legacy-${field.name}` }), + normalizeFieldNode({ + ...field, + id: field.id ?? `field-legacy-${field.name}`, + }), ) const schema = normalizeSchemaRefs(schemaNodes) @@ -274,9 +275,7 @@ export const isSchemaValid = (schema: SchemaNode[]): boolean => export const isDecoderValid = (decoder: ValueDecoderRule): boolean => { const normalized = normalizeRule(decoder) - return ( - normalized.keyPatterns.length > 0 && isSchemaValid(normalized.schema) - ) + return normalized.keyPatterns.length > 0 && isSchemaValid(normalized.schema) } export const areDecodersValid = (decoders: ValueDecoderRule[]): boolean => @@ -335,4 +334,4 @@ export const isCustomSizeType = (dataType: string): boolean => export const resolveSizeSource = ( field: BinaryFieldDefinition, ): FieldSizeSource => - isCustomSizeType(field.dataType) ? field.sizeSource ?? 'fixed' : 'fixed' + isCustomSizeType(field.dataType) ? (field.sizeSource ?? 'fixed') : 'fixed' diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts index b6820db361..9e111194fb 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.spec.ts @@ -11,7 +11,11 @@ import { parseBinaryBuffer, resolveRepeatCount, } from './utils' -import { createEmptyField, createEmptyRepeatBlock, MAX_REPEAT_DECODE_ITERATIONS } from './constants' +import { + createEmptyField, + createEmptyRepeatBlock, + MAX_REPEAT_DECODE_ITERATIONS, +} from './constants' import { DecoderType, ParsedBinaryNode, ValueDecoderRule } from './types' const countGroupNodes = (nodes: ParsedBinaryNode[]): number => @@ -23,6 +27,11 @@ const countGroupNodes = (nodes: ParsedBinaryNode[]): number => return count }, 0) +const toUint16leBytes = (value: number) => [ + value % 256, + Math.floor(value / 256), +] + describe('value-decoder utils', () => { describe('getFixedSize', () => { it('returns fixed sizes for known types', () => { @@ -36,9 +45,9 @@ describe('value-decoder utils', () => { describe('getDefaultKeyPattern', () => { it('returns the actual key name', () => { - expect(getDefaultKeyPattern('room:chunk-state:678729695330336:1:36')).toBe( - 'room:chunk-state:678729695330336:1:36', - ) + expect( + getDefaultKeyPattern('room:chunk-state:678729695330336:1:36'), + ).toBe('room:chunk-state:678729695330336:1:36') }) it('escapes glob metacharacters for exact-key matching', () => { @@ -102,7 +111,7 @@ describe('value-decoder utils', () => { expect(matchKeyPattern('user:\\[0-9\\]', 'user:3')).toBe(false) expect(matchKeyPattern('key:[A-Z\\-_]*', 'key:ABC')).toBe(true) expect(matchKeyPattern('key:[A-Z\\-_]*', 'key:A-B_')).toBe(true) - expect(matchKeyPattern('key:[A-Z\\-_]*', 'key:A0B')).toBe(false) + expect(matchKeyPattern('key:[A-Z\\-_]*', 'key:0AB')).toBe(false) }) }) @@ -166,12 +175,12 @@ describe('value-decoder utils', () => { }, ] - expect(findMatchingDecoderRule(overlappingRules, 'user:items:42')?.id).toBe( - 'user-items', - ) - expect(findMatchingDecoderRule(overlappingRules, 'user:profile:42')?.id).toBe( - 'user-wide', - ) + expect( + findMatchingDecoderRule(overlappingRules, 'user:items:42')?.id, + ).toBe('user-items') + expect( + findMatchingDecoderRule(overlappingRules, 'user:profile:42')?.id, + ).toBe('user-wide') }) it('scores exact key patterns higher than wildcard patterns', () => { @@ -199,9 +208,7 @@ describe('value-decoder utils', () => { describe('formatHexBytes', () => { it('formats bytes as uppercase hex pairs separated by spaces', () => { expect( - formatHexBytes([ - 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, - ]), + formatHexBytes([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]), ).toBe('DE AD BE EF CA FE BA BE') }) }) @@ -233,11 +240,23 @@ describe('value-decoder utils', () => { }) it('parses sequential binary fields', () => { - const buffer = new Uint8Array([1, 0, 2, 0, 3, 4]) + const buffer = new Uint8Array([1, 2, 0, 3, 4]) const parsed = parseBinaryBuffer(buffer, [ { id: '1', kind: 'field', name: 'flag', dataType: 'uint8', size: 1 }, - { id: '2', kind: 'field', name: 'count', dataType: 'uint16le', size: 2 }, - { id: '3', kind: 'field', name: 'value', dataType: 'uint16be', size: 2 }, + { + id: '2', + kind: 'field', + name: 'count', + dataType: 'uint16le', + size: 2, + }, + { + id: '3', + kind: 'field', + name: 'value', + dataType: 'uint16be', + size: 2, + }, ]) expect(parsed).toEqual([ @@ -248,9 +267,7 @@ describe('value-decoder utils', () => { }) it('uses a prior numeric field as string size', () => { - const buffer = new Uint8Array([ - 3, 0, 97, 98, 99, - ]) + const buffer = new Uint8Array([3, 0, 97, 98, 99]) const parsed = parseBinaryBuffer(buffer, [ { id: '1', @@ -277,7 +294,9 @@ describe('value-decoder utils', () => { }) it('resolves dynamic size by field id when numeric names duplicate', () => { - const buffer = new Uint8Array([3, 0, 5, 97, 98, 99, 104, 101, 108, 108, 111]) + const buffer = new Uint8Array([ + 3, 0, 5, 97, 98, 99, 104, 101, 108, 108, 111, + ]) const parsed = parseBinaryBuffer(buffer, [ { id: 'len-a', @@ -357,13 +376,16 @@ describe('value-decoder utils', () => { }) it('parses repeat blocks using a count field', () => { - const buffer = new Uint8Array([ - 2, 0, 1, 0, 2, 0, 3, 0, 4, 0, - ]) + const buffer = new Uint8Array([2, 0, 1, 0, 2, 0, 3, 0, 4, 0]) const repeatBlock = createEmptyRepeatBlock() repeatBlock.countFieldRef = '1' repeatBlock.fields = [ - { ...createEmptyField(), name: 'anchor', dataType: 'uint16le', size: 2 }, + { + ...createEmptyField(), + name: 'anchor', + dataType: 'uint16le', + size: 2, + }, { ...createEmptyField(), name: 'focus', dataType: 'uint16le', size: 2 }, ] @@ -401,7 +423,11 @@ describe('value-decoder utils', () => { it('uses safe integer limits for bigint dynamic field sizes', () => { const buffer = new Uint8Array(10) - const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const view = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ) view.setBigUint64(0, 9223372036854775807n, true) const parsed = parseBinaryBuffer(buffer, [ @@ -437,8 +463,7 @@ describe('value-decoder utils', () => { it('stops parsing sibling fields when repeat decoding is capped', () => { const repeatCount = MAX_REPEAT_DECODE_ITERATIONS + 1 const bufferParts = [ - repeatCount & 0xff, - (repeatCount >> 8) & 0xff, + ...toUint16leBytes(repeatCount), ...Array.from({ length: repeatCount }, () => 0xaa), 0xbb, ] @@ -474,15 +499,15 @@ describe('value-decoder utils', () => { ]) expect(countGroupNodes(parsed)).toBe(MAX_REPEAT_DECODE_ITERATIONS) - expect(parsed.some((node) => node.kind === 'field' && node.name === 'tail')).toBe( - false, - ) + expect( + parsed.some((node) => node.kind === 'field' && node.name === 'tail'), + ).toBe(false) }) it('caps nested repeat decoding with a shared global budget', () => { const outerCount = 100 const innerCount = 100 - const bufferParts = [outerCount & 0xff, (outerCount >> 8) & 0xff] + const bufferParts = [...toUint16leBytes(outerCount)] for (let outer = 0; outer < outerCount; outer += 1) { bufferParts.push(innerCount) @@ -538,7 +563,12 @@ describe('value-decoder utils', () => { const repeatBlock = createEmptyRepeatBlock() repeatBlock.countFieldRef = '1' repeatBlock.fields = [ - { ...createEmptyField(), name: 'anchor', dataType: 'uint16le', size: 2 }, + { + ...createEmptyField(), + name: 'anchor', + dataType: 'uint16le', + size: 2, + }, { ...createEmptyField(), name: 'focus', dataType: 'uint16le', size: 2 }, ] @@ -602,7 +632,9 @@ describe('value-decoder utils', () => { it('formats flat parsed rows', () => { expect( - formatParsedFields([{ kind: 'field', name: 'id', size: 1, value: '7' }]), + formatParsedFields([ + { kind: 'field', name: 'id', size: 1, value: '7' }, + ]), ).toBe('[id] [1] [7]') }) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts index d41c306bce..32f1fc1d0f 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/utils.ts @@ -1,7 +1,11 @@ import { bufferToUint8Array } from 'uiSrc/utils/formatters/bufferFormatters' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { BinaryDataType, isRepeatNode, MAX_REPEAT_DECODE_ITERATIONS } from './constants' +import { + BinaryDataType, + isRepeatNode, + MAX_REPEAT_DECODE_ITERATIONS, +} from './constants' import { isNumericCountType } from './schemaUtils' import { BinaryFieldDefinition, @@ -15,9 +19,14 @@ export const getFixedSize = (type: string): number | 'custom' => { if (['uint8', 'int8', 'boolean'].includes(type)) return 1 if (['uint16le', 'uint16be', 'int16le', 'int16be'].includes(type)) return 2 if ( - ['uint32le', 'uint32be', 'int32le', 'int32be', 'floatle', 'floatbe'].includes( - type, - ) + [ + 'uint32le', + 'uint32be', + 'int32le', + 'int32be', + 'floatle', + 'floatbe', + ].includes(type) ) { return 4 } @@ -41,7 +50,7 @@ export const formatHexBytes = (bytes: Uint8Array | Iterable): string => .map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()) .join(' ') -const REGEX_SPECIAL_CHARS = /[.+^${}()|[\]\\]/g +const REGEX_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g const GLOB_ESCAPABLE_CHARS = new Set(['*', '?', '\\', '[', ']']) @@ -590,5 +599,4 @@ export const parseBinaryBuffer = ( export const parseBufferWithRule = ( buffer: RedisResponseBuffer, schema: SchemaNode[], -): ParsedBinaryNode[] => - parseBinaryBuffer(bufferToUint8Array(buffer), schema) +): ParsedBinaryNode[] => parseBinaryBuffer(bufferToUint8Array(buffer), schema) diff --git a/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts b/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts index 316135aee8..7f1a02568f 100644 --- a/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/valueDecoderStorage.ts @@ -42,10 +42,7 @@ export const setValueDecoderRules = ( return } - localStorageService?.set( - getValueDecoderRulesStorageKey(instanceId), - decoders, - ) + localStorageService?.set(getValueDecoderRulesStorageKey(instanceId), decoders) } export const removeValueDecoderRules = (instanceId: string): void => { diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx index 8f20f77e05..9164ffc674 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -44,9 +44,7 @@ import { } from 'uiSrc/pages/vector-search/hooks/useIsKeyIndexed' import { ViewIndexDataButton } from 'uiSrc/pages/browser/components/view-index-data-button' import { MakeSearchableButton } from 'uiSrc/pages/browser/components/make-searchable-button' -import { - ConfigValueDecoderButton, -} from 'uiSrc/pages/browser/components/value-decoder' +import { ConfigValueDecoderButton } from 'uiSrc/pages/browser/components/value-decoder' import { KeyDetailsHeaderName } from './components/key-details-header-name' import { KeyDetailsHeaderTTL } from './components/key-details-header-ttl' import { KeyDetailsHeaderDelete } from './components/key-details-header-delete' diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx index 5e24268c96..43aec21191 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.spec.tsx @@ -47,7 +47,7 @@ describe('DynamicTypeDetails', () => { expect(queryByTestId('too-long-key-name-details')).toBeInTheDocument() }) - it('does not render array-details when dev-array flag is disabled', () => { + it('does not render array-details when array flag is disabled', () => { const { queryByTestId } = render( { expect(queryByTestId('unsupported-type-details')).toBeInTheDocument() }) - it('renders array-details when dev-array flag is enabled', () => { + it('renders array-details when array flag is enabled', () => { const stateWithFlag = set( cloneDeep(initialStateDefault), - `app.features.featureFlags.features.${FeatureFlags.devArray}`, + `app.features.featureFlags.features.${FeatureFlags.array}`, { flag: true }, ) const { queryByTestId } = render( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx index 1f12dfca89..4a0e5aa74e 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/dynamic-type-details/DynamicTypeDetails.tsx @@ -8,7 +8,7 @@ import { import { KeyDetailsHeaderProps } from 'uiSrc/pages/browser/modules' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' import { - isDevArrayEnabledSelector, + isArrayEnabledSelector, isValueDecoderEnabledSelector, isVectorSetEnabledSelector, } from 'uiSrc/slices/app/features' @@ -37,7 +37,7 @@ export interface Props extends KeyDetailsHeaderProps { const DynamicTypeDetails = (props: Props) => { const { keyType: selectedKeyType, keyProp } = props const isVectorSet = useAppSelector(isVectorSetEnabledSelector) - const isArray = useAppSelector(isDevArrayEnabledSelector) + const isArray = useAppSelector(isArrayEnabledSelector) const isValueDecoderEnabled = useAppSelector(isValueDecoderEnabledSelector) const hashDetails = isValueDecoderEnabled ? ( diff --git a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx index 8ad7b28071..110f648115 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details/components/hash-details/hash-details-table/HashDetailsTable.tsx @@ -3,7 +3,7 @@ import React, { Ref, useCallback, useEffect, useRef, useState } from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { CellMeasurerCache } from 'react-virtualized' -import { isNumber, toNumber } from 'lodash' +import { isNumber, isString, toNumber } from 'lodash' import { Text } from 'uiSrc/components/base/text' import { getColumnWidth } from 'uiSrc/components/virtual-grid' import { StopPropagation } from 'uiSrc/components/virtual-table' @@ -488,9 +488,7 @@ const HashDetailsTable = (props: Props) => { value={formattedValue} expanded={expanded} title={ - isValid - ? 'Value' - : TEXT_FAILED_CONVENT_FORMATTER(viewFormatProp) + isValid ? 'Value' : TEXT_FAILED_CONVENT_FORMATTER(viewFormatProp) } tooltipContent={tooltipContent} /> @@ -524,7 +522,9 @@ const HashDetailsTable = (props: Props) => { testIdPrefix="hash" >
- {isValueDecoderEnabled && !isTruncatedFieldOrValue ? ( + {isValueDecoderEnabled && + !isTruncatedFieldOrValue && + !isString(decompressedValueItem) ? ( { return features[FeatureFlags.vectorSet]?.flag ?? false } -export const isDevArrayEnabledSelector = (state: RootState): boolean => { +export const isArrayEnabledSelector = (state: RootState): boolean => { if (isDevelopment) { return true } const features = state.app.features.featureFlags.features - return features[FeatureFlags.devArray]?.flag ?? false + return features[FeatureFlags.array]?.flag ?? false } export const isDevLanguageEnabledSelector = (state: RootState): boolean => { diff --git a/redisinsight/ui/src/slices/tests/app/features.spec.ts b/redisinsight/ui/src/slices/tests/app/features.spec.ts index e582bfab66..7ebbf12925 100644 --- a/redisinsight/ui/src/slices/tests/app/features.spec.ts +++ b/redisinsight/ui/src/slices/tests/app/features.spec.ts @@ -15,7 +15,7 @@ import reducer, { getFeatureFlagsFailure, fetchFeatureFlags, isAzureEntraIdEnabledSelector, - isDevArrayEnabledSelector, + isArrayEnabledSelector, } from 'uiSrc/slices/app/features' import { FeatureFlags } from 'uiSrc/constants' import { @@ -611,7 +611,7 @@ describe('slices', () => { }) }) - describe('isDevArrayEnabledSelector', () => { + describe('isArrayEnabledSelector', () => { const createRootState = (features: Record) => ({ ...initialStateDefault, app: { @@ -626,26 +626,26 @@ describe('slices', () => { }, }) - it('should return true when devArray flag is enabled', () => { + it('should return true when array flag is enabled', () => { const rootState = createRootState({ - [FeatureFlags.devArray]: { flag: true }, + [FeatureFlags.array]: { flag: true }, }) - expect(isDevArrayEnabledSelector(rootState)).toBe(true) + expect(isArrayEnabledSelector(rootState)).toBe(true) }) - it('should return false when devArray flag is disabled', () => { + it('should return false when array flag is disabled', () => { const rootState = createRootState({ - [FeatureFlags.devArray]: { flag: false }, + [FeatureFlags.array]: { flag: false }, }) - expect(isDevArrayEnabledSelector(rootState)).toBe(false) + expect(isArrayEnabledSelector(rootState)).toBe(false) }) - it('should return false when devArray flag is missing', () => { + it('should return false when array flag is missing', () => { const rootState = createRootState({}) - expect(isDevArrayEnabledSelector(rootState)).toBe(false) + expect(isArrayEnabledSelector(rootState)).toBe(false) }) }) }) From 83c87e5f9dbb1f674838d32af145da88b953ea6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:24:35 +0300 Subject: [PATCH 032/166] chore(deps): bump dorny/paths-filter from 4.0.1 to 4.0.2 (#6171) Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 4.0.1 to 4.0.2. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/v4.0.1...v4.0.2) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3f29a4e819..2e7745151d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: infra: ${{ steps.filter.outputs.infra }} steps: - uses: actions/checkout@v7.0.0 - - uses: dorny/paths-filter@v4.0.1 + - uses: dorny/paths-filter@v4.0.2 id: filter with: # Compare against main so each push's gating reflects the From 1d4a45fd0b73473a28c47d28471f0f37df00527f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:27:20 +0300 Subject: [PATCH 033/166] chore(deps): bump slackapi/slack-github-action from 3.0.3 to 3.0.5 (#6205) Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 3.0.3 to 3.0.5. - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/v3.0.3...v3.0.5) --- updated-dependencies: - dependency-name: slackapi/slack-github-action dependency-version: 3.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/tests-e2e-playwright-v2.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-e2e-playwright-v2.yml b/.github/workflows/tests-e2e-playwright-v2.yml index 8f3b7d355a..6d506970c0 100644 --- a/.github/workflows/tests-e2e-playwright-v2.yml +++ b/.github/workflows/tests-e2e-playwright-v2.yml @@ -138,7 +138,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Post to Slack - uses: slackapi/slack-github-action@v3.0.3 + uses: slackapi/slack-github-action@v3.0.5 with: method: chat.postMessage token: ${{ secrets.SLACK_TEST_REPORT_KEY }} From e9d3f04fbe77364018b6ce78a4e7e2de25bedfb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:33:05 +0300 Subject: [PATCH 034/166] chore(deps): bump github/codeql-action from 4.36.2 to 4.37.0 (#6206) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.2 to 4.37.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.36.2...v4.37.0) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7f6bef6a24..527af060c4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.36.2 + uses: github/codeql-action/init@v4.37.0 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/config.yml @@ -54,7 +54,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4.36.2 + uses: github/codeql-action/autobuild@v4.37.0 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.36.2 + uses: github/codeql-action/analyze@v4.37.0 From bcab4dc1ba06264f837a7387804e6197c45df5ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:22:18 +0300 Subject: [PATCH 035/166] chore(deps): bump actions/setup-node from 4 to 6.4.0 (#6207) * chore(deps): bump actions/setup-node from 4 to 6.4.0 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.4.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6.4.0) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.4.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore(deps): align remaining setup-node usages to v6.4.0 The Dependabot PR bumped actions/setup-node to v6.4.0 only in i18n-locale-check.yml, leaving the two composite actions on v5. Align them so all setup-node references use the same version. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Pavel Angelov --- .github/actions/install-all-build-libs/action.yml | 2 +- .github/actions/setup-e2e-playwright/action.yml | 2 +- .github/workflows/i18n-locale-check.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/install-all-build-libs/action.yml b/.github/actions/install-all-build-libs/action.yml index 681e4db7e6..e040a0644e 100644 --- a/.github/actions/install-all-build-libs/action.yml +++ b/.github/actions/install-all-build-libs/action.yml @@ -23,7 +23,7 @@ runs: using: 'composite' steps: - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6.4.0 with: node-version-file: '.nvmrc' diff --git a/.github/actions/setup-e2e-playwright/action.yml b/.github/actions/setup-e2e-playwright/action.yml index 5daf33d094..35979e6df4 100644 --- a/.github/actions/setup-e2e-playwright/action.yml +++ b/.github/actions/setup-e2e-playwright/action.yml @@ -15,7 +15,7 @@ runs: using: 'composite' steps: - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6.4.0 with: node-version-file: '.nvmrc' # Note: We don't use setup-node's npm cache here because: diff --git a/.github/workflows/i18n-locale-check.yml b/.github/workflows/i18n-locale-check.yml index 34442d2a90..cfd6517b2a 100644 --- a/.github/workflows/i18n-locale-check.yml +++ b/.github/workflows/i18n-locale-check.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v7.0.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6.4.0 with: node-version-file: '.nvmrc' From b285d210e1694d637fad2d35ce48ae6c44841ab1 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Thu, 16 Jul 2026 10:11:21 +0300 Subject: [PATCH 036/166] ci: remove failing yarn audit dependency scans (#6212) Drop the yarn audit steps (UI/API prod & dev) that were failing and blocking the pipeline, along with the now-unused Slack audit report env vars and the deps-audit-report.js helper. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/deps-audit-report.js | 87 ------------------------- .github/workflows/tests-backend.yml | 18 ----- .github/workflows/tests-frontend.yml | 18 ----- .github/workflows/tests-integration.yml | 2 - 4 files changed, 125 deletions(-) delete mode 100644 .github/deps-audit-report.js diff --git a/.github/deps-audit-report.js b/.github/deps-audit-report.js deleted file mode 100644 index 228c7d8542..0000000000 --- a/.github/deps-audit-report.js +++ /dev/null @@ -1,87 +0,0 @@ -const fs = require('fs'); -const { exec } = require('child_process'); - -const FILENAME = process.env.FILENAME; -const DEPS = process.env.DEPS || ''; -const file = `${FILENAME}`; -const outputFile = `slack.${FILENAME}`; - -function generateSlackMessage(summary) { - const message = { - text: - `DEPS AUDIT: *${DEPS}* result (Branch: *${process.env.GITHUB_REF_NAME}*)` + - `\nScanned ${summary.totalDependencies} dependencies` + - `\n`, - attachments: [], - }; - - if (summary.totalVulnerabilities) { - if (summary.vulnerabilities.critical) { - message.attachments.push({ - title: 'Critical', - color: '#641E16', - text: `${summary.vulnerabilities.critical}`, - }); - } - if (summary.vulnerabilities.high) { - message.attachments.push({ - title: 'High', - color: '#C0392B', - text: `${summary.vulnerabilities.high}`, - }); - } - if (summary.vulnerabilities.moderate) { - message.attachments.push({ - title: 'Moderate', - color: '#F5B041', - text: `${summary.vulnerabilities.moderate}`, - }); - } - if (summary.vulnerabilities.low) { - message.attachments.push({ - title: 'Low', - color: '#F9E79F', - text: `${summary.vulnerabilities.low}`, - }); - } - if (summary.vulnerabilities.info) { - message.attachments.push({ - title: 'Info', - text: `${summary.vulnerabilities.info}`, - }); - } - } else { - message.attachments.push({ - title: 'No vulnerabilities found', - color: 'good', - }); - } - - return message; -} - -async function main() { - const lastAuditLine = await new Promise((resolve, reject) => { - exec(`tail -n 1 ${file}`, (error, stdout, stderr) => { - if (error) { - return reject(error); - } - resolve(stdout); - }); - }); - - const { data: summary } = JSON.parse(`${lastAuditLine}`); - const vulnerabilities = summary?.vulnerabilities || {}; - summary.totalVulnerabilities = Object.values(vulnerabilities).reduce( - (totalVulnerabilities, val) => totalVulnerabilities + val, - ); - fs.writeFileSync( - outputFile, - JSON.stringify({ - channel: process.env.SLACK_AUDIT_REPORT_CHANNEL, - ...generateSlackMessage(summary), - }), - ); -} - -main(); diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index 342682eb78..f97dfe451a 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -9,8 +9,6 @@ on: required: false env: - SLACK_AUDIT_REPORT_CHANNEL: ${{ secrets.SLACK_AUDIT_REPORT_CHANNEL }} - SLACK_AUDIT_REPORT_KEY: ${{ secrets.SLACK_AUDIT_REPORT_KEY }} REPORT_NAME: 'report-be' jobs: @@ -25,22 +23,6 @@ jobs: with: skip-system-deps: '1' - - name: API PROD dependencies scan - run: | - FILENAME=api.prod.deps.audit.json - - yarn --cwd redisinsight/api audit --groups dependencies --json > $FILENAME || true && - FILENAME=$FILENAME DEPS="API prod" node .github/deps-audit-report.js && - curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer $SLACK_AUDIT_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage - - - name: API DEV dependencies scan - run: | - FILENAME=api.dev.deps.audit.json - - yarn --cwd redisinsight/api audit --groups devDependencies --json > $FILENAME || true && - FILENAME=$FILENAME DEPS="API dev" node .github/deps-audit-report.js && - curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer $SLACK_AUDIT_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage - - name: Unit tests API timeout-minutes: 20 run: yarn --cwd redisinsight/api/ test:cov --ci --silent diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index ff8c136c7a..b5eb36f223 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -3,8 +3,6 @@ on: workflow_call: env: - SLACK_AUDIT_REPORT_CHANNEL: ${{ secrets.SLACK_AUDIT_REPORT_CHANNEL }} - SLACK_AUDIT_REPORT_KEY: ${{ secrets.SLACK_AUDIT_REPORT_KEY }} REPORT_NAME: 'report-fe' jobs: @@ -19,22 +17,6 @@ jobs: with: skip-system-deps: '1' - - name: UI PROD dependencies audit - run: | - FILENAME=ui.prod.deps.audit.json - - yarn audit --groups dependencies --json > $FILENAME || true && - FILENAME=$FILENAME DEPS="UI prod" node .github/deps-audit-report.js && - curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer $SLACK_AUDIT_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage - - - name: UI DEV dependencies audit - run: | - FILENAME=ui.dev.deps.audit.json - - yarn audit --groups devDependencies --json > $FILENAME || true && - FILENAME=$FILENAME DEPS="UI dev" node .github/deps-audit-report.js && - curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer $SLACK_AUDIT_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage - - name: Unit tests UI timeout-minutes: 30 run: yarn test:cov --ci --silent diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index d7906ad4f4..c1f1b6c4e9 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -45,8 +45,6 @@ on: type: boolean default: false env: - SLACK_AUDIT_REPORT_KEY: ${{ secrets.SLACK_AUDIT_REPORT_KEY }} - SLACK_AUDIT_REPORT_CHANNEL: ${{ secrets.SLACK_AUDIT_REPORT_CHANNEL }} TEST_MEDIUM_DB_DUMP: ${{ secrets.TEST_MEDIUM_DB_DUMP }} TEST_BIG_DB_DUMP: ${{ secrets.TEST_BIG_DB_DUMP }} REPORT_NAME: 'report-it' From 1197f67062afc38dd8636e920bc3a39aadb4e9f0 Mon Sep 17 00:00:00 2001 From: dantovska Date: Thu, 16 Jul 2026 10:36:05 +0300 Subject: [PATCH 037/166] revert(browser): remove tree-list searchable namespaces (RI-8319) (#6210) Removes the Browser tree-list "searchable namespaces" logic added in #5634 (RI-7944), while keeping the per-key "Make searchable" button in the key details header. API - delete the POST keys/get-namespace-searchable endpoint, its DTOs and the getNamespaceSearchable / findFirstSearchableKey service logic + spec UI - remove the tree-list Index button (Node) and the namespace-searchable scanning in VirtualTree (checkSearchable / firstSearchableKey) - drop KEYS_NAMESPACE_SEARCHABLE, the fetchNamespaceSearchable thunk and the NamespaceSearchableResult interface - update/trim the related tree unit tests Kept: the make-searchable button/modal used from the key details header, the key-indexes ViewIndexDataButton and the vector-search create-index page. Co-authored-by: Claude Opus 4.8 --- .../keys/dto/get.namespace-searchable.dto.ts | 45 ---- .../api/src/modules/browser/keys/dto/index.ts | 1 - .../modules/browser/keys/keys.controller.ts | 21 -- .../modules/browser/keys/keys.service.spec.ts | 99 -------- .../src/modules/browser/keys/keys.service.ts | 123 ---------- redisinsight/ui/src/constants/api.ts | 1 - .../components/key-tree/KeyTree.spec.tsx | 23 +- .../virtual-tree/VirtualTree.spec.tsx | 13 +- .../components/virtual-tree/VirtualTree.tsx | 60 +---- .../virtual-tree/VirtualTree.types.ts | 9 - .../components/Node/Node.spec.tsx | 214 +----------------- .../components/Node/Node.styles.ts | 16 -- .../virtual-tree/components/Node/Node.tsx | 76 +------ redisinsight/ui/src/slices/browser/keys.ts | 38 ---- redisinsight/ui/src/slices/interfaces/keys.ts | 9 - .../ui/src/slices/tests/browser/keys.spec.ts | 69 ------ 16 files changed, 19 insertions(+), 798 deletions(-) delete mode 100644 redisinsight/api/src/modules/browser/keys/dto/get.namespace-searchable.dto.ts diff --git a/redisinsight/api/src/modules/browser/keys/dto/get.namespace-searchable.dto.ts b/redisinsight/api/src/modules/browser/keys/dto/get.namespace-searchable.dto.ts deleted file mode 100644 index 816ae4ee2e..0000000000 --- a/redisinsight/api/src/modules/browser/keys/dto/get.namespace-searchable.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ArrayNotEmpty, IsDefined, IsString } from 'class-validator'; -import { RedisStringType } from 'src/common/decorators'; -import { RedisString } from 'src/common/constants'; - -export class GetNamespaceSearchableDto { - @ApiProperty({ - description: 'List of namespace prefixes to check for searchable keys', - type: [String], - example: ['user:', 'session:'], - }) - @IsDefined() - @IsString({ each: true }) - @ArrayNotEmpty() - prefixes: string[]; -} - -export class NamespaceSearchableKeyResponse { - @ApiProperty({ - description: 'Key name', - type: String, - }) - @RedisStringType() - name: RedisString; - - @ApiProperty({ - description: 'Key type (hash or ReJSON-RL)', - type: String, - }) - type: string; -} - -export class NamespaceSearchableResponse { - @ApiProperty({ - description: 'Namespace prefix', - type: String, - }) - prefix: string; - - @ApiPropertyOptional({ - description: 'First searchable key found in the namespace, if any', - type: NamespaceSearchableKeyResponse, - }) - key?: NamespaceSearchableKeyResponse; -} diff --git a/redisinsight/api/src/modules/browser/keys/dto/index.ts b/redisinsight/api/src/modules/browser/keys/dto/index.ts index b317f191f5..2b06962260 100644 --- a/redisinsight/api/src/modules/browser/keys/dto/index.ts +++ b/redisinsight/api/src/modules/browser/keys/dto/index.ts @@ -12,4 +12,3 @@ export * from './rename.key.response'; export * from './scan-data-type.dto'; export * from './update.key-ttl.dto'; export * from './update.key-ttl.response'; -export * from './get.namespace-searchable.dto'; diff --git a/redisinsight/api/src/modules/browser/keys/keys.controller.ts b/redisinsight/api/src/modules/browser/keys/keys.controller.ts index 172ac73e5e..f11e35d56a 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.controller.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.controller.ts @@ -35,8 +35,6 @@ import { UpdateKeyTtlDto, KeyTtlResponse, GetKeysInfoDto, - GetNamespaceSearchableDto, - NamespaceSearchableResponse, } from 'src/modules/browser/keys/dto'; import { BrowserSerializeInterceptor } from 'src/common/interceptors'; @@ -110,25 +108,6 @@ export class KeysController { ); } - @Post('get-namespace-searchable') - @HttpCode(200) - @ApiOperation({ - description: 'Check if namespaces contain searchable keys (hash/json)', - }) - @ApiBody({ type: GetNamespaceSearchableDto }) - @ApiRedisParams() - @ApiOkResponse({ - description: 'Searchable key info per namespace prefix', - type: [NamespaceSearchableResponse], - }) - @ApiQueryRedisStringEncoding() - async getNamespaceSearchable( - @BrowserClientMetadata() clientMetadata: ClientMetadata, - @Body() dto: GetNamespaceSearchableDto, - ): Promise { - return this.keysService.getNamespaceSearchable(clientMetadata, dto); - } - @Delete('') @ApiOperation({ description: 'Delete key' }) @ApiRedisParams() diff --git a/redisinsight/api/src/modules/browser/keys/keys.service.spec.ts b/redisinsight/api/src/modules/browser/keys/keys.service.spec.ts index b0a7a3d6ef..e4e3e5f784 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.service.spec.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.service.spec.ts @@ -466,105 +466,6 @@ describe('KeysService', () => { }); }); - describe('getNamespaceSearchable', () => { - const dto = { prefixes: ['user:', 'session:'] }; - - beforeEach(() => { - mockStandaloneRedisClient.sendPipeline.mockReset(); - }); - - it('should return searchable key when hash key found', async () => { - mockStandaloneRedisClient.sendPipeline - .mockResolvedValueOnce([ - [null, ['0', ['user:1']]], - [null, ['0', []]], - ]) - .mockResolvedValueOnce([ - [null, ['0', []]], - [null, ['0', []]], - ]); - - const result = await service.getNamespaceSearchable( - mockBrowserClientMetadata, - dto, - ); - - expect(result).toEqual([ - { prefix: 'user:', key: { name: 'user:1', type: 'hash' } }, - { prefix: 'session:' }, - ]); - }); - - it('should return searchable key when json key found', async () => { - mockStandaloneRedisClient.sendPipeline - .mockResolvedValueOnce([ - [null, ['0', []]], - [null, ['0', ['user:json1']]], - ]) - .mockResolvedValueOnce([ - [null, ['0', []]], - [null, ['0', []]], - ]); - - const result = await service.getNamespaceSearchable( - mockBrowserClientMetadata, - dto, - ); - - expect(result).toEqual([ - { prefix: 'user:', key: { name: 'user:json1', type: 'ReJSON-RL' } }, - { prefix: 'session:' }, - ]); - }); - - it('should return empty when no searchable keys found', async () => { - mockStandaloneRedisClient.sendPipeline.mockResolvedValue([ - [null, ['0', []]], - [null, ['0', []]], - ]); - - const result = await service.getNamespaceSearchable( - mockBrowserClientMetadata, - dto, - ); - - expect(result).toEqual([{ prefix: 'user:' }, { prefix: 'session:' }]); - }); - - it('should iterate scan until key is found', async () => { - const singleDto = { prefixes: ['user:'] }; - - mockStandaloneRedisClient.sendPipeline - .mockResolvedValueOnce([ - [null, ['42', []]], - [null, ['0', []]], - ]) - .mockResolvedValueOnce([[null, ['0', ['user:2']]]]); - - const result = await service.getNamespaceSearchable( - mockBrowserClientMetadata, - singleDto, - ); - - expect(result).toEqual([ - { prefix: 'user:', key: { name: 'user:2', type: 'hash' } }, - ]); - expect(mockStandaloneRedisClient.sendPipeline).toHaveBeenCalledTimes(2); - }); - - it('should throw on ACL error', async () => { - const replyError = { - ...mockRedisNoPermError, - command: 'SCAN', - }; - mockStandaloneRedisClient.sendPipeline.mockRejectedValue(replyError); - - await expect( - service.getNamespaceSearchable(mockBrowserClientMetadata, dto), - ).rejects.toThrow(ForbiddenException); - }); - }); - describe('removeKeyExpiration', () => { const keyName = 'testString'; it('should remove key expiration', async () => { diff --git a/redisinsight/api/src/modules/browser/keys/keys.service.ts b/redisinsight/api/src/modules/browser/keys/keys.service.ts index 5d0e7a4f23..974287e1ca 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.service.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.service.ts @@ -18,16 +18,13 @@ import { GetKeysDto, GetKeysInfoDto, GetKeysWithDetailsResponse, - GetNamespaceSearchableDto, KeyTtlResponse, - NamespaceSearchableResponse, RenameKeyDto, RenameKeyResponse, UpdateKeyTtlDto, } from 'src/modules/browser/keys/dto'; import { RedisDataType } from 'src/modules/browser/keys/dto/key.dto'; import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; -import { RedisClientCommand } from 'src/modules/redis/client'; import { ClientMetadata } from 'src/common/models'; import { Scanner } from 'src/modules/browser/keys/scanner/scanner'; import { BrowserHistoryMode, RedisString } from 'src/common/constants'; @@ -277,126 +274,6 @@ export class KeysService { } } - private static readonly SEARCHABLE_TYPES = [ - RedisDataType.Hash, - RedisDataType.JSON, - ]; - - private static readonly SCAN_SEARCHABLE_COUNT = 500; - - /** - * Check if namespaces contain searchable keys (hash/json) - * Uses SCAN with TYPE filter, fully iterating until a match - * is found or the keyspace is exhausted - * @param clientMetadata - * @param dto - */ - public async getNamespaceSearchable( - clientMetadata: ClientMetadata, - dto: GetNamespaceSearchableDto, - ): Promise { - try { - this.logger.debug('Checking namespace searchable keys.', clientMetadata); - - const client = - await this.databaseClientFactory.getOrCreateClient(clientMetadata); - - const results = await Promise.all( - dto.prefixes.map((prefix) => - this.findFirstSearchableKey(client, prefix), - ), - ); - - this.logger.debug( - 'Succeed to check namespace searchable keys.', - clientMetadata, - ); - - return results; - } catch (error) { - this.logger.error( - `Failed to check namespace searchable keys. ${error.message}.`, - error, - clientMetadata, - ); - - if (error.message?.includes(RedisErrorCodes.CommandSyntaxError)) { - return dto.prefixes.map((prefix) => ({ prefix })); - } - - throw catchAclError(error); - } - } - - private async findFirstSearchableKey( - client: any, - prefix: string, - ): Promise { - const scanCursors = KeysService.SEARCHABLE_TYPES.map(() => '0'); - const isTypeExhausted = KeysService.SEARCHABLE_TYPES.map(() => false); - - while (!isTypeExhausted.every(Boolean)) { - const scanCommands: RedisClientCommand[] = []; - const pendingTypeIndexes: number[] = []; - - for ( - let typeIndex = 0; - typeIndex < KeysService.SEARCHABLE_TYPES.length; - typeIndex++ - ) { - if (isTypeExhausted[typeIndex]) continue; - pendingTypeIndexes.push(typeIndex); - scanCommands.push([ - BrowserToolKeysCommands.Scan, - scanCursors[typeIndex], - 'MATCH', - `${prefix}*`, - 'COUNT', - `${KeysService.SCAN_SEARCHABLE_COUNT}`, - 'TYPE', - KeysService.SEARCHABLE_TYPES[typeIndex], - ]); - } - - const pipelineResults = (await client.sendPipeline(scanCommands, { - replyEncoding: 'utf8', - })) as [any, [string, string[]]][]; - - for ( - let resultIndex = 0; - resultIndex < pipelineResults.length; - resultIndex++ - ) { - const typeIndex = pendingTypeIndexes[resultIndex]; - const [scanError, scanResult] = pipelineResults[resultIndex]; - - if (scanError || !scanResult) { - isTypeExhausted[typeIndex] = true; - continue; - } - - const [nextCursor, matchedKeys] = scanResult; - - if (matchedKeys?.length > 0) { - return plainToInstance(NamespaceSearchableResponse, { - prefix, - key: { - name: matchedKeys[0], - type: KeysService.SEARCHABLE_TYPES[typeIndex], - }, - }); - } - - scanCursors[typeIndex] = nextCursor; - if (nextCursor === '0') { - isTypeExhausted[typeIndex] = true; - } - } - } - - return plainToInstance(NamespaceSearchableResponse, { prefix }); - } - public async updateTtl( clientMetadata: ClientMetadata, dto: UpdateKeyTtlDto, diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 248b8d10dd..fc7dc6d796 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -36,7 +36,6 @@ enum ApiEndpoints { KEY_INFO = 'keys/get-info', KEY_NAME = 'keys/name', KEY_TTL = 'keys/ttl', - KEYS_NAMESPACE_SEARCHABLE = 'keys/get-namespace-searchable', ZSET = 'zSet', ZSET_MEMBERS = 'zSet/members', diff --git a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx index a0fa5b6db3..d3a601e99a 100644 --- a/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-tree/KeyTree.spec.tsx @@ -6,7 +6,6 @@ import { setBrowserTreeNodesOpen } from 'uiSrc/slices/app/context' import { stringToBuffer } from 'uiSrc/utils' import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' import { KeyTypes } from 'uiSrc/constants' -import { MakeSearchableModalProvider } from 'uiSrc/pages/browser/components/make-searchable-modal' import KeyTree from './KeyTree' let store: typeof mockedStore @@ -128,21 +127,13 @@ jest.mock('uiSrc/telemetry', () => ({ describe('KeyTree', () => { it('should be rendered', () => { - expect( - render( - - - , - ), - ).toBeTruthy() + expect(render()).toBeTruthy() }) it('"setBrowserTreeNodesOpen" to be called after click on folder', () => { const onSelectedKeyMock = jest.fn() const { getByTestId } = render( - - - , + , ) // set open state @@ -158,9 +149,7 @@ describe('KeyTree', () => { it('"selectKey" to be called after click on leaf', async () => { const onSelectedKeyMock = jest.fn() const { getByTestId } = render( - - - , + , ) // open parent folder @@ -182,11 +171,7 @@ describe('KeyTree', () => { selectedKeyDataSelectorMock, ) - const { getByTestId } = render( - - - , - ) + const { getByTestId } = render() expect(getByTestId(`node-item_${leaf2FullName}`)).toBeInTheDocument() }) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx index cbbfeabbea..fcb250005f 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.spec.tsx @@ -2,7 +2,6 @@ import React from 'react' import { mock, instance } from 'ts-mockito' import { render } from 'uiSrc/utils/test-utils' -import { MakeSearchableModalProvider } from 'uiSrc/pages/browser/components/make-searchable-modal' import VirtualTree, { Props } from './VirtualTree' const mockedProps = mock() @@ -74,13 +73,11 @@ describe('VirtualTree', () => { it('should render items', async () => { const mockFn = jest.fn() const { queryByTestId } = render( - - - , + , ) expect(queryByTestId('node-item_test')).toBeInTheDocument() diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx index a711ddcf41..7b5cf58287 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.tsx @@ -1,18 +1,14 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' import { debounce, get, set } from 'lodash' import { TreeWalker, TreeWalkerValue, FixedSizeTree as Tree } from 'react-vtree' import { useAppDispatch } from 'uiSrc/slices/hooks' -import { bufferToString, Nullable, stringToBuffer } from 'uiSrc/utils' +import { bufferToString, Nullable } from 'uiSrc/utils' import { useDisposableWebworker } from 'uiSrc/services' import { DEFAULT_TREE_SORTING, KeyTypes } from 'uiSrc/constants' import { RedisString } from 'uiSrc/slices/interfaces' -import { - fetchKeysMetadataTree, - fetchNamespaceSearchable, -} from 'uiSrc/slices/browser/keys' -import { NamespaceSearchableResult } from 'uiSrc/slices/interfaces/keys' +import { fetchKeysMetadataTree } from 'uiSrc/slices/browser/keys' import { Loader, ProgressBarLoader, @@ -61,7 +57,6 @@ const VirtualTree = (props: VirtualTreeProps) => { const [rerenderState, rerender] = useState({}) const controller = useRef>(null) const elements = useRef({}) - const searchableElements = useRef>({}) const nodes = useRef([]) const { result, run: runWebworker } = useDisposableWebworker(webworkerFn) @@ -72,7 +67,6 @@ const VirtualTree = (props: VirtualTreeProps) => { () => () => { nodes.current = [] elements.current = {} - searchableElements.current = {} }, [], ) @@ -178,50 +172,6 @@ const VirtualTree = (props: VirtualTreeProps) => { [commonFilterType], ) - const onSuccessFetchedSearchable = (results: NamespaceSearchableResult[]) => { - results.forEach((item) => { - if (!item.path) return - const update: Record = { searchableChecked: true } - if (item.key) { - update.firstSearchableKey = { - nameBuffer: stringToBuffer(item.key.name), - nameString: item.key.name, - type: item.key.type, - } - } - updateNodeByPath(item.path, update) - }) - rerender({}) - } - - const getSearchable = useCallback((entries: [string, string][]): void => { - dispatch( - fetchNamespaceSearchable(entries, controller.current?.signal, (results) => - onSuccessFetchedSearchable(results), - ), - ) - }, []) - - const getSearchableDebounced = useMemo( - () => - debounce(() => { - const entries = Object.entries(searchableElements.current) - if (entries.length === 0) return - - getSearchable(entries) - searchableElements.current = {} - }, 100), - [getSearchable], - ) - - const checkSearchable = useCallback( - (prefix: string, path: string) => { - searchableElements.current[path] = prefix - getSearchableDebounced() - }, - [getSearchableDebounced], - ) - // This helper function constructs the object that will be sent back at the step // [2] during the treeWalker function work. Except for the mandatory `data` // field you can put any additional data here. @@ -255,10 +205,6 @@ const VirtualTree = (props: VirtualTreeProps) => { onDelete: onDeleteLeaf, onDeleteFolder, keyApproximate: node.keyApproximate, - hasSearchableKeys: !!node.firstSearchableKey, - firstSearchableKey: node.firstSearchableKey, - checkSearchable: - !node.isLeaf && !node.searchableChecked ? checkSearchable : undefined, isSelected: !!node.isLeaf && statusSelected === node?.nameString, isOpenByDefault: statusOpen[node.fullName], visibleColumns, diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.types.ts b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.types.ts index 17b0a2ab89..8358e63c1b 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.types.ts +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/VirtualTree.types.ts @@ -39,12 +39,6 @@ export interface NodeMetaData { isOpenByDefault: boolean } -export interface FirstSearchableKey { - nameBuffer: RedisResponseBuffer - nameString: string - type: KeyTypes -} - export interface TreeData extends FixedSizeNodeData { isLeaf: boolean name: string @@ -63,9 +57,6 @@ export interface TreeData extends FixedSizeNodeData { isSelected: boolean delimiters: string[] children?: TreeData[] - hasSearchableKeys?: boolean - firstSearchableKey?: FirstSearchableKey - checkSearchable?: (prefix: string, path: string) => void updateStatusOpen: (fullName: string, value: boolean) => void updateStatusSelected: (key: RedisString) => void getMetadata: (key: RedisString, path: string) => void diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx index 509ba29212..d9e10b258a 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.spec.tsx @@ -3,21 +3,9 @@ import { instance, mock } from 'ts-mockito' import { cloneDeep } from 'lodash' import reactRouterDom from 'react-router-dom' import { faker } from '@faker-js/faker' -import { - cleanup, - mockedStore, - mockFeatureFlags, - render, - screen, - fireEvent, -} from 'uiSrc/utils/test-utils' +import { cleanup, mockedStore, render, screen } from 'uiSrc/utils/test-utils' import { stringToBuffer } from 'uiSrc/utils' -import { FeatureFlags, KeyTypes, BrowserColumns, Pages } from 'uiSrc/constants' -import { RedisearchIndexKeyType } from 'uiSrc/pages/browser/components/create-redisearch-index/constants' -import { CreateIndexMode } from 'uiSrc/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.types' -import { MakeSearchableModalProvider } from 'uiSrc/pages/browser/components/make-searchable-modal' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { SearchBrowserSource } from 'uiSrc/pages/vector-search/telemetry.constants' +import { KeyTypes, BrowserColumns } from 'uiSrc/constants' import Node, { NodeProps } from './Node' import { TreeData } from '../../VirtualTree.types' import { mockVirtualTreeResult } from '../../VirtualTree.spec' @@ -85,12 +73,9 @@ const renderNode = ( options?: { store?: any }, ) => { const mergedProps = { ...instance(mockedProps), ...props } - return render( - - - , - { store: options?.store ?? store }, - ) + return render(, { + store: options?.store ?? store, + }) } describe('Node', () => { @@ -507,11 +492,7 @@ describe('Node', () => { stateSnapshot = { ...updatedState, ...connectionState } - rerender( - - - , - ) + rerender() expect(mockGetMetadata).toHaveBeenCalledWith( mockData.nameBuffer, @@ -565,187 +546,4 @@ describe('Node', () => { ).toBeInTheDocument() }) }) - - describe('Index button (folder searchable)', () => { - const mockFolderName = 'users' - const mockFirstSearchableKey = { - nameBuffer: stringToBuffer('users:1'), - nameString: 'users:1', - type: KeyTypes.Hash, - } - - const baseFolderData: TreeData = { - ...mockedData, - isLeaf: false, - fullName: mockFolderName, - keyCount: 10, - delimiters: [':'], - onDeleteFolder: jest.fn(), - showFolderMetadata: true, - } - - it('should render Index button when hasSearchableKeys is true and feature flag is on', () => { - const spy = mockFeatureFlags({ - [FeatureFlags.vectorSearchV2]: { flag: true }, - }) - - const mockData: TreeData = { - ...baseFolderData, - hasSearchableKeys: true, - firstSearchableKey: mockFirstSearchableKey, - } - - renderNode({ data: mockData }) - - expect( - screen.getByTestId(`index-folder-btn-${mockFolderName}`), - ).toBeInTheDocument() - - spy.mockRestore() - }) - - it('should not render Index button when hasSearchableKeys is false', () => { - const spy = mockFeatureFlags({ - [FeatureFlags.vectorSearchV2]: { flag: true }, - }) - - const mockData: TreeData = { - ...baseFolderData, - hasSearchableKeys: false, - } - - renderNode({ data: mockData }) - - expect( - screen.queryByTestId(`index-folder-btn-${mockFolderName}`), - ).not.toBeInTheDocument() - - spy.mockRestore() - }) - - it('should not render Index button when feature flag is off', () => { - const spy = mockFeatureFlags({ - [FeatureFlags.vectorSearchV2]: { flag: false }, - }) - - const mockData: TreeData = { - ...baseFolderData, - hasSearchableKeys: true, - firstSearchableKey: mockFirstSearchableKey, - } - - renderNode({ data: mockData }) - - expect( - screen.queryByTestId(`index-folder-btn-${mockFolderName}`), - ).not.toBeInTheDocument() - - spy.mockRestore() - }) - - it('should send SEARCH_MAKE_SEARCHABLE_CLICKED telemetry with tree_view source on Index button click', () => { - const spy = mockFeatureFlags({ - [FeatureFlags.vectorSearchV2]: { flag: true }, - }) - - const mockData: TreeData = { - ...baseFolderData, - hasSearchableKeys: true, - firstSearchableKey: mockFirstSearchableKey, - } - - renderNode({ data: mockData }) - - const indexFolderBtn = screen.getByTestId( - `index-folder-btn-${mockFolderName}`, - ) - fireEvent.click(indexFolderBtn) - - expect(sendEventTelemetry).toHaveBeenCalledWith({ - event: TelemetryEvent.SEARCH_MAKE_SEARCHABLE_CLICKED, - eventData: { - databaseId: mockInstanceId, - keyType: RedisearchIndexKeyType.HASH, - source: SearchBrowserSource.TreeView, - }, - }) - - spy.mockRestore() - }) - - it('should open modal on Index button click', () => { - const spy = mockFeatureFlags({ - [FeatureFlags.vectorSearchV2]: { flag: true }, - }) - - const mockData: TreeData = { - ...baseFolderData, - hasSearchableKeys: true, - firstSearchableKey: mockFirstSearchableKey, - } - - renderNode({ data: mockData }) - - fireEvent.click(screen.getByTestId(`index-folder-btn-${mockFolderName}`)) - - expect( - screen.getByTestId('make-searchable-modal-body'), - ).toBeInTheDocument() - - spy.mockRestore() - }) - - it('should navigate to create index page with correct query params on confirm', () => { - const spy = mockFeatureFlags({ - [FeatureFlags.vectorSearchV2]: { flag: true }, - }) - - const mockData: TreeData = { - ...baseFolderData, - hasSearchableKeys: true, - firstSearchableKey: mockFirstSearchableKey, - } - - renderNode({ data: mockData }) - - fireEvent.click(screen.getByTestId(`index-folder-btn-${mockFolderName}`)) - fireEvent.click(screen.getByTestId('make-searchable-modal-confirm')) - - expect(mockPush).toHaveBeenCalledWith({ - pathname: Pages.vectorSearchCreateIndex(mockInstanceId), - search: - `mode=${CreateIndexMode.ExistingData}&initialKey=users%3A1` + - `&initialKeyType=${RedisearchIndexKeyType.HASH}&initialPrefix=users%3A`, - }) - - spy.mockRestore() - }) - - it('should call checkSearchable on mount when prop is provided', () => { - const mockCheckSearchable = jest.fn() - const mockData: TreeData = { - ...baseFolderData, - checkSearchable: mockCheckSearchable, - } - - renderNode({ data: mockData }) - - expect(mockCheckSearchable).toHaveBeenCalledWith( - `${mockFolderName}:`, - mockData.path, - ) - }) - - it('should not call checkSearchable when prop is not provided', () => { - const mockData: TreeData = { - ...baseFolderData, - } - - renderNode({ data: mockData }) - - expect( - screen.getByTestId(`node-item_${mockFolderName}`), - ).toBeInTheDocument() - }) - }) }) diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.styles.ts b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.styles.ts index f91e22af05..6e497a3623 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.styles.ts +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.styles.ts @@ -31,18 +31,6 @@ export const NodeContainer = styled.div< export const FOLDER_ANCHOR_CLASS = 'node-folder-anchor' -export const IndexButton = styled.button< - React.ButtonHTMLAttributes ->` - all: unset; - display: none; - cursor: pointer; - padding: 0 ${({ theme }) => theme.core.space.space100}; - color: ${({ theme }) => theme.semantic.color.text.informative400}; - font-size: inherit; - white-space: nowrap; -` - export const NodeContent = styled(Row).attrs({ align: 'center', justify: 'between', @@ -86,10 +74,6 @@ export const NodeContent = styled(Row).attrs({ .showOnHoverKey { display: flex; } - - ${IndexButton} { - display: inline; - } } ` diff --git a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx index fc5191d7de..b964563196 100644 --- a/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx +++ b/redisinsight/ui/src/pages/browser/components/virtual-tree/components/Node/Node.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { NodeComponentProps, NodePublicState } from 'react-vtree/dist/es/Tree' import { useAppSelector } from 'uiSrc/slices/hooks' @@ -25,11 +25,6 @@ import { IconButton } from 'uiSrc/components/base/forms/buttons' import { DeleteIcon } from 'uiSrc/components/base/icons' import { Flex } from 'uiSrc/components/base/layout/flex' import { ColorText, Text } from 'uiSrc/components/base/text' -import { KEY_TYPE_MAP } from 'uiSrc/pages/vector-search/constants' -import { useMakeSearchableModal } from 'uiSrc/pages/browser/components/make-searchable-modal' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { SearchBrowserSource } from 'uiSrc/pages/vector-search/telemetry.constants' import * as S from './Node.styles' import { TreeData } from '../../VirtualTree.types' import { DeleteKeyPopover } from '../../../delete-key-popover/DeleteKeyPopover' @@ -66,9 +61,6 @@ const Node = ({ data, isOpen, index, style, setOpen }: NodeProps) => { keyApproximate, isSelected, delimiters = [], - hasSearchableKeys, - firstSearchableKey, - checkSearchable, getMetadata, onDelete, onDeleteClicked, @@ -82,16 +74,12 @@ const Node = ({ data, isOpen, index, style, setOpen }: NodeProps) => { } = data const delimiterView = delimiters.length === 1 ? delimiters[0] : '-' - const folderPrefix = `${fullName}${delimiterView}` const { shownColumns } = useAppSelector(appContextDbConfig) const visibleColumns = visibleColumnsProp ?? shownColumns const includeSize = visibleColumns.includes(BrowserColumns.Size) const includeTTL = visibleColumns.includes(BrowserColumns.TTL) - const { openMakeSearchableModal } = useMakeSearchableModal() - const { id: instanceId } = useAppSelector(connectedInstanceSelector) - const [deletePopoverId, setDeletePopoverId] = useState>(undefined) const prevIncludeSize = useRef(includeSize) @@ -113,12 +101,6 @@ const Node = ({ data, isOpen, index, style, setOpen }: NodeProps) => { prevIncludeTTL.current = includeTTL }, [includeSize, includeTTL, isLeaf, nameBuffer, size, ttl]) - useEffect(() => { - if (checkSearchable) { - checkSearchable(folderPrefix, path) - } - }, [checkSearchable, folderPrefix, path]) - const handleClick = () => { if (isLeaf) { updateStatusSelected?.(nameBuffer) @@ -156,41 +138,6 @@ const Node = ({ data, isOpen, index, style, setOpen }: NodeProps) => { onDeleteFolder?.(deletePattern, fullName, keyCount) } - const getKeyPrefix = useCallback( - (keyName: string) => { - const lastDelimiterIndex = keyName.lastIndexOf(delimiterView) - if (lastDelimiterIndex === -1) return folderPrefix - return keyName.substring(0, lastDelimiterIndex + delimiterView.length) - }, - [delimiterView, folderPrefix], - ) - - const handleIndexClick = (e: React.MouseEvent) => { - e.stopPropagation() - const source = SearchBrowserSource.TreeView - const keyType = firstSearchableKey - ? KEY_TYPE_MAP[firstSearchableKey.type] - : undefined - sendEventTelemetry({ - event: TelemetryEvent.SEARCH_MAKE_SEARCHABLE_CLICKED, - eventData: { - databaseId: instanceId, - keyType, - source, - }, - }) - const initialPrefix = firstSearchableKey?.nameString - ? getKeyPrefix(firstSearchableKey.nameString) - : folderPrefix - openMakeSearchableModal({ - prefix: folderPrefix, - initialKey: firstSearchableKey?.nameBuffer, - initialKeyType: keyType, - initialPrefix, - source, - }) - } - const hasUnprintableChars = fullName?.includes('\uFFFD') || nameString?.includes('\uFFFD') @@ -251,27 +198,6 @@ const Node = ({ data, isOpen, index, style, setOpen }: NodeProps) => { {keyCount ?? ''} - {hasSearchableKeys && ( - - - Index data with the "{folderPrefix}"{' '} - prefix so you can query it using full-text, vector, exact - matching, and geospatial search. - - } - > - - Index - - - - )} void, - onFailAction?: () => void, -) { - return async (_dispatch: AppDispatch, stateInit: () => RootState) => { - const state = stateInit() - - try { - const { data, status } = await apiService.post< - NamespaceSearchableResult[] - >( - getUrl( - state.connections.instances.connectedInstance?.id, - ApiEndpoints.KEYS_NAMESPACE_SEARCHABLE, - ), - { prefixes: prefixes.map(([, prefix]) => prefix) }, - { signal }, - ) - - if (isStatusSuccessful(status)) { - const results = data.map((item, i) => ({ - ...item, - path: prefixes[i][0], - })) - - onSuccessAction?.(results) - } - } catch (_err) { - if (axios.isCancel(_err)) return - - onFailAction?.() - } - } -} - export function fetchPatternHistoryAction( onSuccess?: () => void, onFailed?: () => void, diff --git a/redisinsight/ui/src/slices/interfaces/keys.ts b/redisinsight/ui/src/slices/interfaces/keys.ts index 8bdc446454..ec61a77192 100644 --- a/redisinsight/ui/src/slices/interfaces/keys.ts +++ b/redisinsight/ui/src/slices/interfaces/keys.ts @@ -88,12 +88,3 @@ export interface KeysStoreData { lastRefreshTime: Nullable maxResults?: Nullable } - -export interface NamespaceSearchableResult { - prefix: string - key?: { - name: string - type: string - } - path?: string -} diff --git a/redisinsight/ui/src/slices/tests/browser/keys.spec.ts b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts index 87e4313350..049ad51357 100644 --- a/redisinsight/ui/src/slices/tests/browser/keys.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts @@ -114,7 +114,6 @@ import reducer, { setLastBatchPatternKeys, updateSelectedKeyRefreshTime, refreshKey, - fetchNamespaceSearchable, } from '../../browser/keys' const riConfig = getConfig() @@ -2374,74 +2373,6 @@ describe('keys slice', () => { }) }) - describe('fetchNamespaceSearchable', () => { - it('should call API with correct prefixes and invoke onSuccess', async () => { - const prefixes: [string, string][] = [ - ['0.0', 'user:'], - ['0.1', 'session:'], - ] - const apiResponse = [ - { prefix: 'user:', key: { name: 'user:1', type: 'hash' } }, - { prefix: 'session:' }, - ] - const responsePayload = { data: apiResponse, status: 200 } - const apiServiceMock = jest.fn().mockResolvedValue(responsePayload) - const onSuccessMock = jest.fn() - apiService.post = apiServiceMock - - await store.dispatch( - fetchNamespaceSearchable(prefixes, undefined, onSuccessMock), - ) - - expect(apiServiceMock).toBeCalledWith( - '/databases//keys/get-namespace-searchable', - { prefixes: ['user:', 'session:'] }, - { signal: undefined }, - ) - - expect(onSuccessMock).toBeCalledWith([ - { - prefix: 'user:', - key: { name: 'user:1', type: 'hash' }, - path: '0.0', - }, - { prefix: 'session:', path: '0.1' }, - ]) - }) - - it('should call onFail on error', async () => { - const prefixes: [string, string][] = [['0.0', 'user:']] - const responsePayload = { - response: { - status: 500, - data: { message: 'Internal error' }, - }, - } - apiService.post = jest.fn().mockRejectedValue(responsePayload) - const onFailMock = jest.fn() - - await store.dispatch( - fetchNamespaceSearchable(prefixes, undefined, undefined, onFailMock), - ) - - expect(onFailMock).toHaveBeenCalled() - }) - - it('should not throw or call onFail on cancelled request', async () => { - const prefixes: [string, string][] = [['0.0', 'user:']] - const cancelError = { __CANCEL__: true } - Object.defineProperty(cancelError, '__CANCEL__', { value: true }) - apiService.post = jest.fn().mockRejectedValue(cancelError) - const onFailMock = jest.fn() - - await store.dispatch( - fetchNamespaceSearchable(prefixes, undefined, undefined, onFailMock), - ) - - expect(onFailMock).not.toHaveBeenCalled() - }) - }) - describe('deleteSearchHistoryAction', () => { it('success delete history', async () => { // Arrange From 313a14126d1ef648499b40755866e3672d37936f Mon Sep 17 00:00:00 2001 From: Krum Tyukenov Date: Thu, 16 Jul 2026 11:02:18 +0300 Subject: [PATCH 038/166] feat(ui): add What's New content for 3.8.0 release (#6213) Add the 3.8.0 version entry to the What's New feed (Array data type, IPv4/IPv6 selection, Markdown value format) and register it as the latest release. Drop the unused `persistent` prop from WhatsNewModal. Pin the modal spec's card-level assertions to a shipped version (3.6.0) so they no longer break when a new release is added to the top of the feed. References: #RI-8320 Co-authored-by: Claude Opus 4.8 --- .../whats-new/WhatsNewModal.spec.tsx | 21 ++++++++++--- .../components/whats-new/WhatsNewModal.tsx | 2 +- .../src/constants/content/whats-new/index.ts | 2 ++ .../content/whats-new/versions/v3.8.0.ts | 31 +++++++++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 redisinsight/ui/src/constants/content/whats-new/versions/v3.8.0.ts diff --git a/redisinsight/ui/src/components/whats-new/WhatsNewModal.spec.tsx b/redisinsight/ui/src/components/whats-new/WhatsNewModal.spec.tsx index aa0c496e02..2bf0346b53 100644 --- a/redisinsight/ui/src/components/whats-new/WhatsNewModal.spec.tsx +++ b/redisinsight/ui/src/components/whats-new/WhatsNewModal.spec.tsx @@ -22,10 +22,15 @@ jest.mock('uiSrc/telemetry', () => ({ const latestVersion = whatsNewFeed[0].version -const getOpenState = (flagsOn = false) => { +// Card-level assertions pin to a shipped version so they don't churn when a +// new release is added to the top of the feed. 3.6.0 has both a flag-gated +// card (vector-sets) and an unflagged one (geodata-workbench). +const CONTENT_VERSION = '3.6.0' + +const getOpenState = (flagsOn = false, version = latestVersion) => { let state = set(cloneDeep(initialStateDefault), 'app.whatsNew', { isOpen: true, - selectedVersion: latestVersion, + selectedVersion: version, lastVersionSeen: null, }) if (flagsOn) { @@ -70,7 +75,9 @@ describe('WhatsNewModal', () => { }) it('should show where to find a feature', () => { - render(, { store: mockStore(getOpenState()) }) + render(, { + store: mockStore(getOpenState(false, CONTENT_VERSION)), + }) expect( screen.getByTestId('whats-new-card-location-geodata-workbench'), @@ -78,7 +85,9 @@ describe('WhatsNewModal', () => { }) it('should show flag-gated cards marked as coming soon when their flags are off', () => { - render(, { store: mockStore(getOpenState(false)) }) + render(, { + store: mockStore(getOpenState(false, CONTENT_VERSION)), + }) expect(screen.getByTestId('whats-new-card-vector-sets')).toBeInTheDocument() expect( @@ -106,7 +115,9 @@ describe('WhatsNewModal', () => { }) it('should not mark flag-gated cards when their flags are on', () => { - render(, { store: mockStore(getOpenState(true)) }) + render(, { + store: mockStore(getOpenState(true, CONTENT_VERSION)), + }) expect(screen.getByTestId('whats-new-card-vector-sets')).toBeInTheDocument() expect( diff --git a/redisinsight/ui/src/components/whats-new/WhatsNewModal.tsx b/redisinsight/ui/src/components/whats-new/WhatsNewModal.tsx index 9805048aaf..0db6ebd29f 100644 --- a/redisinsight/ui/src/components/whats-new/WhatsNewModal.tsx +++ b/redisinsight/ui/src/components/whats-new/WhatsNewModal.tsx @@ -92,7 +92,7 @@ const WhatsNewModal = () => { return ( - + diff --git a/redisinsight/ui/src/constants/content/whats-new/index.ts b/redisinsight/ui/src/constants/content/whats-new/index.ts index 936140af9e..e545f57e2b 100644 --- a/redisinsight/ui/src/constants/content/whats-new/index.ts +++ b/redisinsight/ui/src/constants/content/whats-new/index.ts @@ -1,10 +1,12 @@ import { WhatsNewVersion } from './types' +import { version380 } from './versions/v3.8.0' import { version360 } from './versions/v3.6.0' import { version341 } from './versions/v3.4.1' import { version320 } from './versions/v3.2.0' // One module per release — add the new version here in each release PR. export const WHATS_NEW_VERSIONS: WhatsNewVersion[] = [ + version380, version360, version341, version320, diff --git a/redisinsight/ui/src/constants/content/whats-new/versions/v3.8.0.ts b/redisinsight/ui/src/constants/content/whats-new/versions/v3.8.0.ts new file mode 100644 index 0000000000..0ba5250651 --- /dev/null +++ b/redisinsight/ui/src/constants/content/whats-new/versions/v3.8.0.ts @@ -0,0 +1,31 @@ +import { FeatureFlags } from 'uiSrc/constants/featureFlags' +import { WhatsNewVersion, WhatsNewVersionType } from '../types' + +export const version380: WhatsNewVersion = { + version: '3.8.0', + releaseDate: '2026-07-21', + type: WhatsNewVersionType.Major, + cards: [ + { + id: 'arrays', + title: 'Support for new Array data type', + body: "Arrays are a new indexed type in Redis 8.8 where each element's position is meaningful: sensor readings by time, calendar slots by interval, workflow steps by stage. Sparse data stays memory-cheap, and you can search and aggregate server-side instead of pulling everything client-side. In Redis Insight, create Arrays manually or from a sample, then browse, edit, search with AND/OR queries, and aggregate.", + location: 'Browser — add a key of type Array', + featureFlag: FeatureFlags.array, + }, + { + id: 'ipv4-ipv6-selection', + tag: 'Improved', + title: 'IPv4 / IPv6 selection on connection', + body: 'Pick IPv4 or IPv6 explicitly when connecting to a database. Gives you a reliable connection in environments where one protocol does not resolve correctly.', + location: 'Database list — add or edit a database connection', + }, + { + id: 'markdown-format', + tag: 'Improved', + title: 'Markdown value format', + body: 'View stored values rendered as formatted Markdown, for any key type. Makes documents, notes, and generated content readable without copying them out to another tool.', + location: 'Key details — switch the value format to Markdown', + }, + ], +} From a99860f62e5e263fc54718f7913bfc6801ae1ebf Mon Sep 17 00:00:00 2001 From: dantovska Date: Thu, 16 Jul 2026 12:38:13 +0300 Subject: [PATCH 039/166] feat(browser): refresh key indexes on key details refresh (#6208) The key-indexes lookup behind View index / Make searchable is cached per key, so refreshing the key details kept showing a stale state after indexes were created or dropped elsewhere. Force a re-fetch as part of the key refresh action. References: #RI-8318 Co-authored-by: Claude Fable 5 --- .../KeyDetailsHeader.spec.tsx | 32 +++++++++++++++++++ .../key-details-header/KeyDetailsHeader.tsx | 9 ++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx index 2952b6dee5..2a2418dff4 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.spec.tsx @@ -10,8 +10,17 @@ import { } from 'uiSrc/utils/test-utils' import { KeyTypes } from 'uiSrc/constants' import { deleteSelectedKey } from 'uiSrc/slices/browser/keys' +import { + useIsKeyIndexed, + UseIsKeyIndexedStatus, +} from 'uiSrc/pages/vector-search/hooks/useIsKeyIndexed' import { KeyDetailsHeaderProps, KeyDetailsHeader } from './KeyDetailsHeader' +jest.mock('uiSrc/pages/vector-search/hooks/useIsKeyIndexed', () => ({ + ...jest.requireActual('uiSrc/pages/vector-search/hooks/useIsKeyIndexed'), + useIsKeyIndexed: jest.fn(), +})) + const mockedProps = mock() const KEY_INPUT_TEST_ID = 'edit-key-input' @@ -25,6 +34,13 @@ beforeEach(() => { cleanup() store = cloneDeep(mockedStore) store.clearActions() + + jest.mocked(useIsKeyIndexed).mockReturnValue({ + isIndexed: false, + indexes: [], + status: UseIsKeyIndexedStatus.Idle, + refresh: jest.fn(), + }) }) jest.mock('uiSrc/slices/browser/string', () => ({ @@ -106,6 +122,22 @@ describe('KeyDetailsHeader', () => { ) }) + it('should refresh the key indexes on key refresh', () => { + const refresh = jest.fn() + jest.mocked(useIsKeyIndexed).mockReturnValue({ + isIndexed: false, + indexes: [], + status: UseIsKeyIndexedStatus.Idle, + refresh, + }) + + render() + + fireEvent.click(screen.getByTestId('key-refresh-btn')) + + expect(refresh).toHaveBeenCalled() + }) + describe('should call onDelete', () => { test.each(Object.values(KeyTypes))( 'should call onDelete for keyType: %s', diff --git a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx index 9164ffc674..399c442d0a 100644 --- a/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/modules/key-details-header/KeyDetailsHeader.tsx @@ -88,14 +88,17 @@ const KeyDetailsHeader = ({ const { viewType } = useAppSelector(keysSelector) const isSearchableType = SEARCHABLE_KEY_TYPES.includes(type as KeyTypes) - const { indexes, status: keyIndexedStatus } = useIsKeyIndexed( - isSearchableType ? keyName || '' : '', - ) + const { + indexes, + status: keyIndexedStatus, + refresh: refreshKeyIndexes, + } = useIsKeyIndexed(isSearchableType ? keyName || '' : '') const dispatch = useAppDispatch() const handleRefreshKey = () => { dispatch(refreshKey(keyBuffer!, type, undefined, length)) + refreshKeyIndexes() } const handleEditTTL = (key: RedisResponseBuffer, ttl: number) => { From 53892467c1534b3b480cd292ebd0b9e2c29e242d Mon Sep 17 00:00:00 2001 From: dantovska Date: Thu, 16 Jul 2026 12:40:37 +0300 Subject: [PATCH 040/166] RI-8171 Create index when no data exists (#6195) * feat(vector-search): allow index creation when no data exists * feat(vector-search): key type selector for manual index creation * fix(vector-search): enable index creation without a prefix in manual mode * fix(vector-search): show empty command view in manual creation * fix(vector-search): reliably detect existing keys for the create index flow * fix(vector-search): fall back to browse mode on failed/inconclusive keys probe * refactor(vector-search): scope the keys probe to the create-index page * test(vector-search): dedupe create-index page spec * chore(vector-search): add Bulgarian translations for manual index creation --- redisinsight/ui/src/i18n/locales/bg.json | 9 +- redisinsight/ui/src/i18n/locales/en.json | 9 +- .../welcome-screen/WelcomeScreen.spec.tsx | 34 +--- .../welcome-screen/WelcomeScreen.stories.tsx | 13 +- .../welcome-screen/WelcomeScreen.tsx | 24 +-- .../welcome-screen/WelcomeScreen.types.ts | 10 +- .../CreateIndexPageContext.types.ts | 5 +- .../CreateIndexPageProvider.tsx | 13 +- .../VectorSearchContext.types.ts | 2 - .../vector-search/VectorSearchProvider.tsx | 13 +- .../useHasExistingKeys.spec.ts | 61 ++++++- .../useHasExistingKeys/useHasExistingKeys.ts | 38 +++-- .../VectorSearchCreateIndexPage.spec.tsx | 160 +++++++++++++++++- .../VectorSearchCreateIndexPage.tsx | 31 +++- .../components/CreateIndexContent.tsx | 46 +++-- .../components/CreateIndexHeader.tsx | 15 +- .../components/CreateIndexToolbar.tsx | 32 ++++ .../CreateIndexMenu.spec.tsx | 74 +------- .../create-index-menu/CreateIndexMenu.tsx | 40 +---- .../VectorSearchWelcomePage.tsx | 18 +- .../vector-search/components/WelcomeScreen.ts | 2 +- 21 files changed, 398 insertions(+), 251 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index b287084b6a..da63656b82 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -452,7 +452,9 @@ "vectorSearch.createIndex.confirmKeyChange.keepEditing": "Продължи редактирането", "vectorSearch.createIndex.confirmKeyChange.title": "Незапазени промени", "vectorSearch.createIndex.content.emptyState": "Схемата на индексиране ще се появи тук, след като\nизберете ключ от браузъра вляво.", + "vectorSearch.createIndex.content.emptyStateManual": "Изградете своя индекс за търсене, като ръчно добавите полетата, които искате да индексирате.\nЩе трябва да зададете име на индекса и префикс, за да определите кои ключове да бъдат включени.", "vectorSearch.createIndex.createDisabledReason": "Изберете ключ и поне едно поле за индексиране.", + "vectorSearch.createIndex.createDisabledReasonManual": "Добавете поне едно поле за индексиране.", "vectorSearch.createIndex.displayNameFallback": "съществуващи данни", "vectorSearch.createIndex.footer.cancel": "Отказ", "vectorSearch.createIndex.footer.createIndex": "Създай индекс", @@ -467,6 +469,7 @@ "vectorSearch.createIndex.toolbar.addField": "+ Добави поле", "vectorSearch.createIndex.toolbar.commandView": "Изглед за напреднали", "vectorSearch.createIndex.toolbar.indexPrefix": "Префикс на индекса:", + "vectorSearch.createIndex.toolbar.keyType": "Тип на ключа:", "vectorSearch.createIndex.toolbar.tableView": "Табличен изглед", "vectorSearch.fallback.getStarted": "Започнете безплатно", "vectorSearch.fallback.learnMore": "Научете повече", @@ -567,10 +570,8 @@ "vectorSearch.list.column.records": "Записи", "vectorSearch.list.column.terms": "Термини", "vectorSearch.list.column.types": "Типове на индекс", - "vectorSearch.list.createMenu.checkingKeys": "Проверка за съществуващи ключове…", "vectorSearch.list.createMenu.create": "+ Създай индекс за търсене", "vectorSearch.list.createMenu.existingData": "Използвай съществуващи данни", - "vectorSearch.list.createMenu.noKeys": "Няма намерени Hash или JSON ключове във вашата база данни", "vectorSearch.list.createMenu.sampleData": "Използвай примерни данни", "vectorSearch.list.delete.cancel": "Запази индекса", "vectorSearch.list.delete.confirm": "Изтрий индекса", @@ -694,7 +695,6 @@ "vectorSearch.versionNotSupported.ctaText": "Създайте безплатна база данни Redis Cloud, за да започнете да използвате тези функционалности.", "vectorSearch.versionNotSupported.description": "Тази функционалност изисква Redis Search 2.0 или по-нова версия (включена в Redis 6+). По-старите версии на Redis Search не са съвместими с командите, използвани тук.", "vectorSearch.versionNotSupported.title": "Изисква се Redis Search 2.0+", - "vectorSearch.welcome.checkingKeys": "Проверка за съществуващи ключове…", "vectorSearch.welcome.feature.fullText.description": "Намирайте и филтрирайте данните си мигновено чрез мощни заявки по ключови думи и полета.", "vectorSearch.welcome.feature.fullText.title": "Пълнотекстово търсене", "vectorSearch.welcome.feature.hybrid.description": "Комбинирайте векторно търсене и търсене по ключови думи за по-висока точност и по-добри резултати.", @@ -703,11 +703,10 @@ "vectorSearch.welcome.feature.performance.title": "Висока производителност, малко усилия", "vectorSearch.welcome.feature.vector.description": "Извличайте резултати по смисъл, а не само по думи. Идеално за AI, семантични и припоръчващи приложения.", "vectorSearch.welcome.feature.vector.title": "Векторно търсене", - "vectorSearch.welcome.noKeysFound": "Не са намерени Hash или JSON ключове във вашата база данни", "vectorSearch.welcome.subtitle": "Вижте как Redis позволява пълнотекстовото и векторното търсене. Бързо, лесно и ефективно.", "vectorSearch.welcome.title": "Търсете със скоростта на светлината", "vectorSearch.welcome.trySampleData": "Опитайте с примерни данни", - "vectorSearch.welcome.useMyDatabase": "Използвайте данни от моята база данни", + "vectorSearch.welcome.useMyDatabase": "Създай индекс", "whatsNew.button.gotIt": "Разбрах", "whatsNew.card.comingSoon": "Очаквайте скоро", "whatsNew.card.locationLabel": "Къде да го намерите:", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index a663b689e7..e773169c2a 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -452,7 +452,9 @@ "vectorSearch.createIndex.confirmKeyChange.keepEditing": "Keep editing", "vectorSearch.createIndex.confirmKeyChange.title": "Unsaved changes", "vectorSearch.createIndex.content.emptyState": "The indexing schema will appear here once you\nselect a key from the browser on the left.", + "vectorSearch.createIndex.content.emptyStateManual": "Build your search index by manually adding the fields you want to index.\nYou'll need to provide an index name and a prefix to define which keys are included.", "vectorSearch.createIndex.createDisabledReason": "Select a key and at least one field to index.", + "vectorSearch.createIndex.createDisabledReasonManual": "Add at least one field to index.", "vectorSearch.createIndex.displayNameFallback": "existing data", "vectorSearch.createIndex.footer.cancel": "Cancel", "vectorSearch.createIndex.footer.createIndex": "Create index", @@ -467,6 +469,7 @@ "vectorSearch.createIndex.toolbar.addField": "+ Add field", "vectorSearch.createIndex.toolbar.commandView": "Command view", "vectorSearch.createIndex.toolbar.indexPrefix": "Index prefix:", + "vectorSearch.createIndex.toolbar.keyType": "Key type:", "vectorSearch.createIndex.toolbar.tableView": "Table view", "vectorSearch.fallback.getStarted": "Get started for free", "vectorSearch.fallback.learnMore": "Learn more", @@ -567,10 +570,8 @@ "vectorSearch.list.column.records": "Records", "vectorSearch.list.column.terms": "Terms", "vectorSearch.list.column.types": "Index types", - "vectorSearch.list.createMenu.checkingKeys": "Checking for existing keys…", "vectorSearch.list.createMenu.create": "+ Create search index", "vectorSearch.list.createMenu.existingData": "Use existing data", - "vectorSearch.list.createMenu.noKeys": "No Hash or JSON keys found in your database", "vectorSearch.list.createMenu.sampleData": "Use sample data", "vectorSearch.list.delete.cancel": "Keep index", "vectorSearch.list.delete.confirm": "Delete index", @@ -694,7 +695,6 @@ "vectorSearch.versionNotSupported.ctaText": "Create a free Redis Cloud database to start exploring these capabilities.", "vectorSearch.versionNotSupported.description": "This page requires Redis Search 2.0 or later (included with Redis 6+). Older versions of Redis Search are not compatible with the commands used here.", "vectorSearch.versionNotSupported.title": "Redis Search 2.0+ required", - "vectorSearch.welcome.checkingKeys": "Checking for existing keys…", "vectorSearch.welcome.feature.fullText.description": "Find and filter your data instantly using powerful keyword and field-based queries.", "vectorSearch.welcome.feature.fullText.title": "Full-text search", "vectorSearch.welcome.feature.hybrid.description": "Combine vector and keyword search for higher accuracy and more relevant results.", @@ -703,11 +703,10 @@ "vectorSearch.welcome.feature.performance.title": "High performance, low effort", "vectorSearch.welcome.feature.vector.description": "Retrieve results by meaning, not just words. Ideal for AI, semantic, and recommendation apps.", "vectorSearch.welcome.feature.vector.title": "Vector search", - "vectorSearch.welcome.noKeysFound": "No Hash or JSON keys found in your database", "vectorSearch.welcome.subtitle": "Discover how Redis enables full-text and vector search. Fast, simple, and production-ready.", "vectorSearch.welcome.title": "Search your data at in-memory speed", "vectorSearch.welcome.trySampleData": "Try with sample data", - "vectorSearch.welcome.useMyDatabase": "Use data from my database", + "vectorSearch.welcome.useMyDatabase": "Create index", "whatsNew.button.gotIt": "Got it", "whatsNew.card.comingSoon": "Coming soon", "whatsNew.card.locationLabel": "Where to find it:", diff --git a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx index 113eff6603..d096f3c868 100644 --- a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.spec.tsx @@ -1,11 +1,5 @@ import React from 'react' -import { act } from '@testing-library/react' -import { - fireEvent, - render, - screen, - waitForRiTooltipVisible, -} from 'uiSrc/utils/test-utils' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import i18n from 'uiSrc/i18n' import { WelcomeScreen } from './WelcomeScreen' @@ -54,6 +48,10 @@ describe('WelcomeScreen', () => { 'welcome-screen--use-my-database-btn', ) expect(useMyDatabaseBtn).toBeInTheDocument() + expect(useMyDatabaseBtn).toHaveTextContent( + i18n.t('vectorSearch.welcome.useMyDatabase'), + ) + expect(useMyDatabaseBtn).toBeEnabled() const background = screen.getByTestId('welcome-screen--background') expect(background).toBeInTheDocument() @@ -82,26 +80,4 @@ describe('WelcomeScreen', () => { expect(onUseMyDatabaseClick).toHaveBeenCalledTimes(1) }) - - it('should disable secondary button when useMyDatabaseDisabled is provided', async () => { - const onUseMyDatabaseClick = jest.fn() - renderComponent({ - onUseMyDatabaseClick, - useMyDatabaseDisabled: { tooltip: 'Feature disabled' }, - }) - - const button = screen.getByTestId('welcome-screen--use-my-database-btn') - expect(button).toBeDisabled() - - fireEvent.click(button) - expect(onUseMyDatabaseClick).not.toHaveBeenCalled() - - await act(async () => { - fireEvent.focus(button) - }) - await waitForRiTooltipVisible() - - const tooltipText = screen.getAllByText('Feature disabled')[0] - expect(tooltipText).toBeInTheDocument() - }) }) diff --git a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.stories.tsx b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.stories.tsx index 910f9c28fb..dea1a62a0f 100644 --- a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.stories.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.stories.tsx @@ -25,17 +25,6 @@ export const Default: Story = { // eslint-disable-next-line no-alert onTrySampleDataClick: () => alert('Try with sample data clicked!'), // eslint-disable-next-line no-alert - onUseMyDatabaseClick: () => alert('Use data from my database clicked!'), - }, -} - -export const UseMyDatabaseDisabled: Story = { - name: '"Use data from my database" disabled', - args: { - // eslint-disable-next-line no-alert - onTrySampleDataClick: () => alert('Try with sample data clicked!'), - useMyDatabaseDisabled: { - tooltip: "You don't have any data in your database yet", - }, + onUseMyDatabaseClick: () => alert('Create index clicked!'), }, } diff --git a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx index 63ba34e06e..6c105b8fdc 100644 --- a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx +++ b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.tsx @@ -8,7 +8,6 @@ import { PrimaryButton, SecondaryButton, } from 'uiSrc/components/base/forms/buttons' -import { RiTooltip } from 'uiSrc/components/base/tooltip' import { useTranslation } from 'uiSrc/i18n' import { getFeatures } from './WelcomeScreen.constants' @@ -18,11 +17,8 @@ import * as S from './WelcomeScreen.styles' export const WelcomeScreen = ({ onTrySampleDataClick, onUseMyDatabaseClick, - useMyDatabaseDisabled, }: WelcomeScreenProps) => { const { t } = useTranslation() - const isUseMyDatabaseDisabled = !!useMyDatabaseDisabled - const useMyDatabaseTooltip = useMyDatabaseDisabled?.tooltip const features = getFeatures() return ( @@ -86,20 +82,14 @@ export const WelcomeScreen = ({ {t('vectorSearch.welcome.trySampleData')} - - - {t('vectorSearch.welcome.useMyDatabase')} - - + {t('vectorSearch.welcome.useMyDatabase')} + diff --git a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.types.ts b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.types.ts index 4613e6ffdc..516cc07489 100644 --- a/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.types.ts +++ b/redisinsight/ui/src/pages/vector-search/components/welcome-screen/WelcomeScreen.types.ts @@ -5,17 +5,9 @@ export interface WelcomeScreenProps { onTrySampleDataClick?: () => void /** - * Callback when "Use data from my database" button is clicked. + * Callback when "Create index" button is clicked. */ onUseMyDatabaseClick?: () => void - - /** - * Disable "Use data from my database" button and show tooltip. - * Tooltip text is required when button is disabled. - */ - useMyDatabaseDisabled?: { - tooltip: string - } } export interface Feature { diff --git a/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageContext.types.ts b/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageContext.types.ts index 53f4e99abc..5282af0bc0 100644 --- a/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageContext.types.ts +++ b/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageContext.types.ts @@ -34,6 +34,9 @@ export interface CreateIndexPageContextValue { /** Whether the KeysBrowser panel should be shown (browse mode). */ showBrowser: boolean + /** Whether the index is defined manually (no keys in the database). */ + isManualCreation: boolean + /** Pre-selected key from navigation (triggers auto-selection on mount). */ initialKey?: RedisResponseBuffer @@ -101,7 +104,7 @@ export interface CreateIndexPageProviderProps { instanceId: string mode?: CreateIndexMode sampleData?: SampleDataContent - showBrowser?: boolean + isManualCreation?: boolean initialKey?: RedisResponseBuffer initialKeyType?: RedisearchIndexKeyType initialPrefix?: string diff --git a/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx b/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx index 258f83a91e..5f7518677a 100644 --- a/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx +++ b/redisinsight/ui/src/pages/vector-search/context/create-index-page/CreateIndexPageProvider.tsx @@ -61,7 +61,7 @@ export const CreateIndexPageProvider = ({ instanceId, sampleData, mode: modeProp, - showBrowser: showBrowserProp = true, + isManualCreation: isManualCreationProp = false, initialKey: initialKeyProp, initialKeyType: initialKeyTypeProp, initialPrefix: initialPrefixProp, @@ -70,6 +70,7 @@ export const CreateIndexPageProvider = ({ const { t } = useTranslation() const mode = modeProp ?? CreateIndexMode.SampleData const isSampleData = mode === CreateIndexMode.SampleData + const isManualCreation = !isSampleData && isManualCreationProp const [activeTab, setActiveTab] = useState( CreateIndexTab.Table, @@ -177,7 +178,7 @@ export const CreateIndexPageProvider = ({ return t('vectorSearch.createIndex.displayNameFallback') }, [isSampleData, sampleData, t]) - const showBrowser = !isSampleData && showBrowserProp + const showBrowser = !isSampleData && !initialKeyProp && !isManualCreation const selectedFields = useMemo(() => { if (isSampleData) return fields @@ -205,10 +206,12 @@ export const CreateIndexPageProvider = ({ const createDisabledReason = useMemo((): string | null => { if (isSampleData) return null if (selectedFields.length === 0) - return t('vectorSearch.createIndex.createDisabledReason') + return isManualCreation + ? t('vectorSearch.createIndex.createDisabledReasonManual') + : t('vectorSearch.createIndex.createDisabledReason') if (indexNameError !== null) return indexNameError return null - }, [isSampleData, indexNameError, selectedFields, t]) + }, [isSampleData, isManualCreation, indexNameError, selectedFields, t]) const isCreateDisabled = createDisabledReason !== null @@ -426,6 +429,7 @@ export const CreateIndexPageProvider = ({ setActiveTab: changeActiveTab, isReadonly, showBrowser, + isManualCreation, initialKey: initialKeyProp, initialKeyType: initialKeyTypeProp, displayName, @@ -461,6 +465,7 @@ export const CreateIndexPageProvider = ({ changeActiveTab, isReadonly, showBrowser, + isManualCreation, initialKeyProp, initialKeyTypeProp, displayName, diff --git a/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchContext.types.ts b/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchContext.types.ts index ebfea86142..812a8c1734 100644 --- a/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchContext.types.ts +++ b/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchContext.types.ts @@ -3,8 +3,6 @@ import { SearchTelemetrySource } from '../../telemetry.constants' export interface VectorSearchContextValue { openPickSampleDataModal: (source: SearchTelemetrySource) => void navigateToExistingDataFlow: (source: SearchTelemetrySource) => void - hasExistingKeys: boolean - hasExistingKeysLoading: boolean } export interface VectorSearchProviderProps { diff --git a/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchProvider.tsx b/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchProvider.tsx index ccfdcef5fc..74f8dc777d 100644 --- a/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchProvider.tsx +++ b/redisinsight/ui/src/pages/vector-search/context/vector-search/VectorSearchProvider.tsx @@ -7,7 +7,7 @@ import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { IndexField } from '../../components/index-details/IndexDetails.types' import { PickSampleDataModal } from '../../components/pick-sample-data-modal' import { SampleDataContent } from '../../components/pick-sample-data-modal/PickSampleDataModal.types' -import { useCreateIndexFlow, useHasExistingKeys } from '../../hooks' +import { useCreateIndexFlow } from '../../hooks' import { CreateIndexMode } from '../../pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.types' import { SearchTelemetryCancelStep, @@ -31,8 +31,6 @@ export const VectorSearchProvider = ({ const { run: createIndexFlow, loading: createIndexLoading } = useCreateIndexFlow() - const { hasKeys: hasExistingKeys, loading: hasExistingKeysLoading } = - useHasExistingKeys() const openPickSampleDataModal = useCallback( (source: SearchTelemetrySource) => { @@ -166,15 +164,8 @@ export const VectorSearchProvider = ({ () => ({ openPickSampleDataModal, navigateToExistingDataFlow, - hasExistingKeys, - hasExistingKeysLoading, }), - [ - openPickSampleDataModal, - navigateToExistingDataFlow, - hasExistingKeys, - hasExistingKeysLoading, - ], + [openPickSampleDataModal, navigateToExistingDataFlow], ) return ( diff --git a/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.spec.ts b/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.spec.ts index 8885d2bb46..262f5a6635 100644 --- a/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.spec.ts +++ b/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.spec.ts @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react-hooks' import { apiService } from 'uiSrc/services' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import { useHasExistingKeys } from './useHasExistingKeys' @@ -42,7 +43,7 @@ describe('useHasExistingKeys', () => { it('should return hasKeys=true when Hash keys exist', async () => { mockApiPost.mockResolvedValue({ status: 200, - data: [{ keys: [{ name: 'key:1' }], total: 1 }], + data: [{ keys: [{ name: 'key:1' }], total: 1, cursor: 0 }], }) const { result, waitForNextUpdate } = renderHook(() => useHasExistingKeys()) @@ -53,10 +54,56 @@ describe('useHasExistingKeys', () => { expect(result.current.loading).toBe(false) }) + it('should detect keys held by any cluster node', async () => { + mockApiPost.mockResolvedValue({ + status: 200, + data: [ + { keys: [], total: 0, cursor: 0 }, + { keys: [{ name: 'key:1' }], total: 1, cursor: 0 }, + ], + }) + + const { result, waitForNextUpdate } = renderHook(() => useHasExistingKeys()) + + await waitForNextUpdate() + + expect(result.current.hasKeys).toBe(true) + }) + + it('should treat an incomplete scan as having keys', async () => { + mockApiPost.mockResolvedValue({ + status: 200, + data: [{ keys: [], total: 50000, cursor: 12345 }], + }) + + const { result, waitForNextUpdate } = renderHook(() => useHasExistingKeys()) + + await waitForNextUpdate() + + expect(result.current.hasKeys).toBe(true) + }) + + it('should scan with the default scan count', async () => { + mockApiPost.mockResolvedValue({ + status: 200, + data: [{ keys: [], total: 0, cursor: 0 }], + }) + + const { waitForNextUpdate } = renderHook(() => useHasExistingKeys()) + + await waitForNextUpdate() + + expect(mockApiPost).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ count: SCAN_COUNT_DEFAULT }), + expect.any(Object), + ) + }) + it('should return hasKeys=false when no keys exist', async () => { mockApiPost.mockResolvedValue({ status: 200, - data: [{ keys: [], total: 0 }], + data: [{ keys: [], total: 0, cursor: 0 }], }) const { result, waitForNextUpdate } = renderHook(() => useHasExistingKeys()) @@ -67,7 +114,7 @@ describe('useHasExistingKeys', () => { expect(result.current.loading).toBe(false) }) - it('should return hasKeys=false on API error', async () => { + it('should report an error on API failure', async () => { mockApiPost.mockRejectedValue(new Error('Network error')) const { result, waitForNextUpdate } = renderHook(() => useHasExistingKeys()) @@ -76,6 +123,7 @@ describe('useHasExistingKeys', () => { expect(result.current.hasKeys).toBe(false) expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(true) }) it('should be loading initially', () => { @@ -85,4 +133,11 @@ describe('useHasExistingKeys', () => { expect(result.current.loading).toBe(true) }) + + it('should not scan when disabled', () => { + const { result } = renderHook(() => useHasExistingKeys(false)) + + expect(mockApiPost).not.toHaveBeenCalled() + expect(result.current.loading).toBe(false) + }) }) diff --git a/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.ts b/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.ts index 4f3d536939..ff64a9a8d3 100644 --- a/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.ts +++ b/redisinsight/ui/src/pages/vector-search/hooks/useHasExistingKeys/useHasExistingKeys.ts @@ -4,6 +4,7 @@ import { useAppSelector } from 'uiSrc/slices/hooks' import { apiService } from 'uiSrc/services' import { ApiEndpoints, KeyTypes } from 'uiSrc/constants' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' import { getUrl, isStatusSuccessful } from 'uiSrc/utils' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { appInfoSelector } from 'uiSrc/slices/app/info' @@ -11,16 +12,21 @@ import { appInfoSelector } from 'uiSrc/slices/app/info' interface ScanResponse { keys: unknown[] total: number + cursor: number } export interface UseHasExistingKeysResult { hasKeys: boolean loading: boolean + error: boolean } -export const useHasExistingKeys = (): UseHasExistingKeysResult => { +export const useHasExistingKeys = ( + enabled: boolean = true, +): UseHasExistingKeysResult => { const [hasKeys, setHasKeys] = useState(false) - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(enabled) + const [error, setError] = useState(false) const { pathname } = useLocation() const { id: instanceId } = useAppSelector(connectedInstanceSelector) @@ -44,11 +50,10 @@ export const useHasExistingKeys = (): UseHasExistingKeysResult => { getUrl(instanceId, ApiEndpoints.KEYS), { cursor: '0', - count: 1, + count: SCAN_COUNT_DEFAULT, type, match: '*', keysInfo: false, - scanThreshold: 1, }, { params: { encoding }, signal }, ), @@ -57,19 +62,26 @@ export const useHasExistingKeys = (): UseHasExistingKeysResult => { if (signal?.aborted) return + // The endpoint returns one entry per cluster node — check them all. + // A scan stopped at the threshold (cursor !== 0) is inconclusive, + // not an empty database, so it counts as having keys. const foundAny = results.some(({ data, status }) => { if (!isStatusSuccessful(status)) return false - const keys = Array.isArray(data) - ? data[0]?.keys - : (data as unknown as ScanResponse)?.keys - return keys && keys.length > 0 + const nodes = Array.isArray(data) + ? data + : [data as unknown as ScanResponse] + return nodes.some( + (node) => !!node?.keys?.length || node?.cursor !== 0, + ) }) setHasKeys(foundAny) - } catch (error) { + setError(results.some(({ status }) => !isStatusSuccessful(status))) + } catch (err) { if (signal?.aborted) return - console.error('Failed to check for existing keys', error) + console.error('Failed to check for existing keys', err) setHasKeys(false) + setError(true) } finally { if (!signal?.aborted) { setLoading(false) @@ -80,6 +92,8 @@ export const useHasExistingKeys = (): UseHasExistingKeysResult => { ) useEffect(() => { + if (!enabled) return undefined + // Abort in-flight requests on unmount to prevent state updates after cleanup const controller = new AbortController() checkForKeys(controller.signal) @@ -87,7 +101,7 @@ export const useHasExistingKeys = (): UseHasExistingKeysResult => { return () => { controller.abort() } - }, [checkForKeys, pathname]) + }, [enabled, checkForKeys, pathname]) - return { hasKeys, loading } + return { hasKeys, loading, error } } diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.spec.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.spec.tsx index 23e1ec270c..6612db477b 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.spec.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.spec.tsx @@ -1,8 +1,19 @@ import React from 'react' import reactRouterDom from 'react-router-dom' -import { cleanup, render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { + cleanup, + render, + screen, + fireEvent, + waitFor, +} from 'uiSrc/utils/test-utils' import { VectorSearchCreateIndexPage } from './VectorSearchCreateIndexPage' +import { useHasExistingKeys } from '../../hooks/useHasExistingKeys' + +jest.mock('../../hooks/useHasExistingKeys', () => ({ + useHasExistingKeys: jest.fn(), +})) jest.mock('../../components/index-details', () => { const MockReact = require('react') @@ -23,33 +34,58 @@ jest.mock('../../components/command-view', () => { MockReact.createElement( 'div', { 'data-testid': props.dataTestId }, - 'CommandView', + props.command ?? 'CommandView', ), } }) const mockPush = jest.fn() -const setupRouterMocks = (sampleData?: string) => { +const setupRouterMocks = (search: string) => { reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: mockPush }) reactRouterDom.useParams = jest .fn() .mockReturnValue({ instanceId: 'test-instance' }) reactRouterDom.useLocation = jest.fn().mockReturnValue({ pathname: '/test-instance/vector-search/create-index', - search: sampleData ? `?sampleData=${sampleData}` : '', + search, hash: '', }) } +const mockUseHasExistingKeys = ( + overrides: Partial> = {}, +) => { + jest.mocked(useHasExistingKeys).mockReturnValue({ + hasKeys: true, + loading: false, + error: false, + ...overrides, + }) +} + +const addField = async (name: string) => { + fireEvent.click( + screen.getByTestId('vector-search--create-index--add-field-btn'), + ) + fireEvent.change(screen.getByTestId('field-type-modal-field-name'), { + target: { value: name }, + }) + await waitFor(() => + expect(screen.getByTestId('field-type-modal-save')).toBeEnabled(), + ) + fireEvent.click(screen.getByTestId('field-type-modal-save')) +} + describe('VectorSearchCreateIndexPage', () => { beforeEach(() => { cleanup() jest.clearAllMocks() + mockUseHasExistingKeys() }) it('should render all page elements', () => { - setupRouterMocks('e-commerce-discovery') + setupRouterMocks('?sampleData=e-commerce-discovery') render() @@ -96,7 +132,7 @@ describe('VectorSearchCreateIndexPage', () => { }) it('should switch to command view when clicking Command view button', () => { - setupRouterMocks('e-commerce-discovery') + setupRouterMocks('?sampleData=e-commerce-discovery') render() @@ -116,7 +152,7 @@ describe('VectorSearchCreateIndexPage', () => { }) it('should navigate back on cancel', () => { - setupRouterMocks('e-commerce-discovery') + setupRouterMocks('?sampleData=e-commerce-discovery') render() @@ -130,4 +166,114 @@ describe('VectorSearchCreateIndexPage', () => { expect.stringContaining('vector-search'), ) }) + + describe('existing data mode with no keys in the database', () => { + beforeEach(() => { + setupRouterMocks('?mode=existingData') + mockUseHasExistingKeys({ hasKeys: false }) + }) + + it('should show a loader while checking for existing keys', () => { + mockUseHasExistingKeys({ hasKeys: false, loading: true }) + + render() + + expect( + screen.getByTestId('vector-search--create-index--loading'), + ).toBeInTheDocument() + }) + + it('should keep the key browser when the keys check fails', () => { + mockUseHasExistingKeys({ hasKeys: false, error: true }) + + render() + + expect( + screen.getByTestId('vector-search--create-index--browser-panel'), + ).toBeInTheDocument() + expect( + screen.queryByTestId('vector-search--create-index--empty-state'), + ).toHaveTextContent('select a key from the browser on the left') + }) + + it('should hide the key browser and render the manual creation empty state', () => { + render() + + expect( + screen.queryByTestId('vector-search--create-index--browser-panel'), + ).not.toBeInTheDocument() + + expect( + screen.getByTestId('vector-search--create-index--empty-state'), + ).toHaveTextContent( + 'Build your search index by manually adding the fields you want to index.', + ) + expect( + screen.getByTestId('vector-search--create-index--add-field-btn'), + ).toBeEnabled() + expect( + screen.getByTestId('vector-search--create-index--prefix-input'), + ).toBeInTheDocument() + expect( + screen.getByTestId('vector-search--create-index--submit-btn'), + ).toBeDisabled() + }) + + it('should show the command view before any fields are added', () => { + render() + + fireEvent.click( + screen.getByTestId('vector-search--create-index--command-view-btn'), + ) + + expect( + screen.getByTestId('vector-search--create-index--command-view'), + ).toBeInTheDocument() + expect( + screen.queryByTestId('vector-search--create-index--empty-state'), + ).not.toBeInTheDocument() + + fireEvent.click( + screen.getByTestId('vector-search--create-index--table-view-btn'), + ) + + expect( + screen.getByTestId('vector-search--create-index--empty-state'), + ).toBeInTheDocument() + }) + + it('should build the command with the chosen key type', async () => { + render() + + await addField('title') + await waitFor(() => + expect( + screen.getByTestId('vector-search--create-index--submit-btn'), + ).toBeEnabled(), + ) + + fireEvent.click( + screen.getByTestId('vector-search--create-index--key-type-json-btn'), + ) + fireEvent.click( + screen.getByTestId('vector-search--create-index--command-view-btn'), + ) + + expect( + screen.getByTestId('vector-search--create-index--command-view'), + ).toHaveTextContent('ON JSON') + }) + + it('should enable the create button once a field is added manually', async () => { + render() + + await addField('title') + + await waitFor(() => + expect( + screen.getByTestId('vector-search--create-index--submit-btn'), + ).toBeEnabled(), + ) + }) + }) }) diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.tsx index 3fc5418229..6c5519e382 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/VectorSearchCreateIndexPage.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useLocation, useParams, Redirect } from 'react-router-dom' import { Pages } from 'uiSrc/constants' +import { Loader } from 'uiSrc/components/base/display' import { CreateIndexMode } from './VectorSearchCreateIndexPage.types' import { @@ -10,6 +11,7 @@ import { hasPreselectedKey, parseCreateIndexSearchParams, } from '../../utils' +import { useHasExistingKeys } from '../../hooks' import { CreateIndexPageProvider } from '../../context/create-index-page' import { CreateIndexOnboardingProvider } from '../../context/create-index-onboarding' import { CreateIndexHeader } from './components/CreateIndexHeader' @@ -27,21 +29,42 @@ export const VectorSearchCreateIndexPage = () => { : CreateIndexMode.SampleData const sampleData = isSampleDataState(state) ? state.sampleData : undefined + const existingState = isExistingDataState(state) ? state : undefined + const preselected = hasPreselectedKey(state) + const isBrowseFlow = mode === CreateIndexMode.ExistingData && !preselected + + const { + hasKeys: hasExistingKeys, + loading: hasExistingKeysLoading, + error: hasExistingKeysError, + } = useHasExistingKeys(isBrowseFlow) if (mode === CreateIndexMode.SampleData && !sampleData) { return } - const existingState = isExistingDataState(state) ? state : undefined - const preselected = hasPreselectedKey(state) - const showBrowser = mode === CreateIndexMode.ExistingData && !preselected + if (isBrowseFlow && hasExistingKeysLoading) { + return ( + + + + ) + } + + // A failed/inconclusive probe keeps browse mode rather than hiding the browser + const isManualCreation = + isBrowseFlow && !hasExistingKeys && !hasExistingKeysError return ( { fields, command, isReadonly, + isManualCreation, rowSelection, onRowSelectionChange, fieldModal, @@ -42,19 +43,44 @@ export const CreateIndexContent = () => { const EmptyStateImg = theme === Theme.Dark ? SelectDataImgDark : SelectDataImg if (isExistingData && fields.length === 0) { + const showCommandView = + isManualCreation && activeTab === CreateIndexTab.Command + return ( + {isManualCreation && } + - - - - {t('vectorSearch.createIndex.content.emptyState')} - - + {showCommandView ? ( + + ) : ( + + + + {isManualCreation + ? t('vectorSearch.createIndex.content.emptyStateManual') + : t('vectorSearch.createIndex.content.emptyState')} + + + )} + + {isManualCreation && ( + + )} diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx index fe2f19c633..72ebc66e06 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchCreateIndexPage/components/CreateIndexHeader.tsx @@ -16,8 +16,15 @@ import * as S from '../VectorSearchCreateIndexPage.styles' export const CreateIndexHeader = () => { const { t } = useTranslation() - const { mode, displayName, indexName, setIndexName, indexNameError, fields } = - useCreateIndexPage() + const { + mode, + displayName, + indexName, + setIndexName, + indexNameError, + fields, + isManualCreation, + } = useCreateIndexPage() const { startOnboarding } = useCreateIndexOnboarding() const onboardingTriggeredRef = useRef(false) @@ -51,7 +58,7 @@ export const CreateIndexHeader = () => { : t('vectorSearch.createIndex.header.defineTitle')} - {!isSampleData && !hasFields && ( + {!isSampleData && !hasFields && !isManualCreation && ( { - {!isSampleData && hasFields && ( + {!isSampleData && (hasFields || isManualCreation) && ( { indexPrefix, setIndexPrefix, isReadonly, + isManualCreation, + keyType, + setKeyType, openAddFieldModal, } = useCreateIndexPage() @@ -74,6 +78,34 @@ export const CreateIndexToolbar = () => { align="center" data-testid="vector-search--create-index--toolbar-right" > + {isManualCreation && ( + <> + + + {t('vectorSearch.createIndex.toolbar.keyType')} + + + setKeyType(RedisearchIndexKeyType.HASH)} + data-testid="vector-search--create-index--key-type-hash-btn" + > + HASH + + setKeyType(RedisearchIndexKeyType.JSON)} + data-testid="vector-search--create-index--key-type-json-btn" + > + JSON + + + + + + + )} + ({ const mockUseVectorSearch = jest.mocked(useVectorSearch) -const renderComponent = ({ - hasExistingKeys = false, - hasExistingKeysLoading = false, -} = {}) => { +const renderComponent = () => { mockUseVectorSearch.mockReturnValue({ - hasExistingKeys, - hasExistingKeysLoading, openPickSampleDataModal: jest.fn(), navigateToExistingDataFlow: jest.fn(), } as unknown as ReturnType) @@ -75,40 +63,8 @@ describe('CreateIndexMenu', () => { expect(mockUseVectorSearch().openPickSampleDataModal).toHaveBeenCalled() }) - it('should have "Use existing data" option disabled when no keys exist', async () => { - renderComponent({ hasExistingKeys: false }) - - const btn = screen.getByTestId('vector-search--list--create-index-btn') - await userEvent.click(btn) - - const existingDataItem = screen.getByTestId( - 'vector-search--list--create-index--existing-data', - ) - expect(existingDataItem).toHaveAttribute('aria-disabled', 'true') - }) - - it('should show tooltip when no keys exist', async () => { - renderComponent({ hasExistingKeys: false }) - - const btn = screen.getByTestId('vector-search--list--create-index-btn') - await userEvent.click(btn) - - const existingDataItem = screen.getByTestId( - 'vector-search--list--create-index--existing-data', - ) - - await act(async () => { - fireEvent.focus(existingDataItem) - }) - await waitForRiTooltipVisible() - - expect( - screen.getAllByText('No Hash or JSON keys found in your database')[0], - ).toBeInTheDocument() - }) - - it('should show loading tooltip when keys are being checked', async () => { - renderComponent({ hasExistingKeysLoading: true }) + it('should call navigateToExistingDataFlow when "Use existing data" is clicked', async () => { + renderComponent() const btn = screen.getByTestId('vector-search--list--create-index-btn') await userEvent.click(btn) @@ -116,26 +72,10 @@ describe('CreateIndexMenu', () => { const existingDataItem = screen.getByTestId( 'vector-search--list--create-index--existing-data', ) + expect(existingDataItem).not.toHaveAttribute('aria-disabled', 'true') - await act(async () => { - fireEvent.focus(existingDataItem) - }) - await waitForRiTooltipVisible() - - expect( - screen.getAllByText('Checking for existing keys…')[0], - ).toBeInTheDocument() - }) - - it('should enable "Use existing data" when keys exist', async () => { - renderComponent({ hasExistingKeys: true }) - - const btn = screen.getByTestId('vector-search--list--create-index-btn') - await userEvent.click(btn) + await userEvent.click(existingDataItem) - const existingDataItem = screen.getByTestId( - 'vector-search--list--create-index--existing-data', - ) - expect(existingDataItem).not.toHaveAttribute('aria-disabled', 'true') + expect(mockUseVectorSearch().navigateToExistingDataFlow).toHaveBeenCalled() }) }) diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx index b4c941188e..c93492dc44 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchListPage/components/create-index-menu/CreateIndexMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback } from 'react' import { useTranslation } from 'uiSrc/i18n' import { ToggleButton } from 'uiSrc/components/base/forms/buttons' @@ -9,33 +9,14 @@ import { MenuTrigger, MenuDropdownArrow, } from 'uiSrc/components/base/layout/menu' -import { RiTooltip } from 'uiSrc/components/base/tooltip' import { useVectorSearch } from '../../../../context/vector-search' import { SearchTelemetrySource } from '../../../../telemetry.constants' export const CreateIndexMenu = () => { const { t } = useTranslation() - const { - openPickSampleDataModal, - navigateToExistingDataFlow, - hasExistingKeys, - hasExistingKeysLoading, - } = useVectorSearch() - - const isExistingDataDisabled = hasExistingKeysLoading || !hasExistingKeys - - const existingDataTooltip = useMemo(() => { - if (hasExistingKeysLoading) { - return t('vectorSearch.list.createMenu.checkingKeys') - } - - if (!hasExistingKeys) { - return t('vectorSearch.list.createMenu.noKeys') - } - - return null - }, [hasExistingKeysLoading, hasExistingKeys, t]) + const { openPickSampleDataModal, navigateToExistingDataFlow } = + useVectorSearch() const handleSampleData = useCallback( () => openPickSampleDataModal(SearchTelemetrySource.List), @@ -60,16 +41,11 @@ export const CreateIndexMenu = () => { onClick={handleSampleData} data-testid="vector-search--list--create-index--sample-data" /> - - - + diff --git a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx index 356cada462..ab5c2567ed 100644 --- a/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx +++ b/redisinsight/ui/src/pages/vector-search/pages/VectorSearchWelcomePage/VectorSearchWelcomePage.tsx @@ -1,7 +1,5 @@ import React, { useCallback } from 'react' -import { useTranslation } from 'uiSrc/i18n' - import { WelcomeScreen } from '../../components/welcome-screen' import { useVectorSearch } from '../../context/vector-search' import { SearchTelemetrySource } from '../../telemetry.constants' @@ -12,19 +10,8 @@ import { SearchTelemetrySource } from '../../telemetry.constants' * context, providing callbacks and configuration. */ export const VectorSearchWelcomePage = () => { - const { t } = useTranslation() - const { - openPickSampleDataModal, - navigateToExistingDataFlow, - hasExistingKeys, - hasExistingKeysLoading, - } = useVectorSearch() - - const useMyDatabaseDisabled = hasExistingKeysLoading - ? { tooltip: t('vectorSearch.welcome.checkingKeys') } - : !hasExistingKeys - ? { tooltip: t('vectorSearch.welcome.noKeysFound') } - : undefined + const { openPickSampleDataModal, navigateToExistingDataFlow } = + useVectorSearch() const handleTrySampleData = useCallback( () => openPickSampleDataModal(SearchTelemetrySource.Welcome), @@ -40,7 +27,6 @@ export const VectorSearchWelcomePage = () => { ) } diff --git a/tests/e2e-playwright/pages/vector-search/components/WelcomeScreen.ts b/tests/e2e-playwright/pages/vector-search/components/WelcomeScreen.ts index e311704fc9..a52a19da0d 100644 --- a/tests/e2e-playwright/pages/vector-search/components/WelcomeScreen.ts +++ b/tests/e2e-playwright/pages/vector-search/components/WelcomeScreen.ts @@ -18,6 +18,6 @@ export class WelcomeScreen { this.subtitle = page.getByText('Discover how Redis enables full-text and vector search'); this.features = page.getByTestId('welcome-screen--features'); this.trySampleDataButton = page.getByRole('button', { name: 'Try with sample data' }); - this.useMyDatabaseButton = page.getByRole('button', { name: 'Use data from my database' }); + this.useMyDatabaseButton = page.getByRole('button', { name: 'Create index' }); } } From bdbb6eca1fcca1bb8a79ac28dc26a7a27fa30541 Mon Sep 17 00:00:00 2001 From: Vasko Atanasov Date: Thu, 16 Jul 2026 12:44:55 +0300 Subject: [PATCH 041/166] chore: bump version to 3.8.0 (#6214) --- .github/build/release-docker.sh | 2 +- redisinsight/api/config/default.ts | 2 +- redisinsight/api/config/swagger.ts | 2 +- redisinsight/api/package.json | 2 +- redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts | 2 +- redisinsight/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/build/release-docker.sh b/.github/build/release-docker.sh index 1ade23da49..fbbe810bea 100755 --- a/.github/build/release-docker.sh +++ b/.github/build/release-docker.sh @@ -2,7 +2,7 @@ set -e HELP="Args: --v - Semver (3.6.0) +-v - Semver (3.8.0) -d - Build image repository (Ex: -d redisinsight) -r - Target repository (Ex: -r redis/redisinsight) " diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 5d0130b556..91b405a568 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -126,7 +126,7 @@ export default { : true, buildType: process.env.RI_BUILD_TYPE || 'DOCKER_ON_PREMISE', appType: process.env.RI_APP_TYPE, - appVersion: process.env.RI_APP_VERSION || '3.6.0', + appVersion: process.env.RI_APP_VERSION || '3.8.0', buildCommitSha: resolveBuildCommitSha(), requestTimeout: parseInt(process.env.RI_REQUEST_TIMEOUT, 10) || 25000, excludeRoutes: [], diff --git a/redisinsight/api/config/swagger.ts b/redisinsight/api/config/swagger.ts index 84097e0521..c0a179a2ee 100644 --- a/redisinsight/api/config/swagger.ts +++ b/redisinsight/api/config/swagger.ts @@ -5,7 +5,7 @@ const SWAGGER_CONFIG: Omit = { info: { title: 'Redis Insight Backend API', description: 'Redis Insight Backend API', - version: '3.6.0', + version: '3.8.0', }, tags: [], }; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index e2a34b5033..e74a3bc6d5 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -1,6 +1,6 @@ { "name": "redisinsight-api", - "version": "3.6.0", + "version": "3.8.0", "description": "Redis Insight API", "private": true, "author": { diff --git a/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts b/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts index adc7e27c4a..1e5718d97d 100644 --- a/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts +++ b/redisinsight/desktop/src/lib/aboutPanel/aboutPanel.ts @@ -7,7 +7,7 @@ const ICON_PATH = app.isPackaged : path.join(__dirname, '../resources', 'icon.png') const appVersionPrefix = config.isEnterprise ? 'Enterprise - ' : '' -const appVersion = app.getVersion() || '3.6.0' +const appVersion = app.getVersion() || '3.8.0' const appVersionSuffix = !config.isProduction ? `-dev-${process.getCreationTime()}` : '' diff --git a/redisinsight/package.json b/redisinsight/package.json index 3dc0a4461e..328b310e28 100644 --- a/redisinsight/package.json +++ b/redisinsight/package.json @@ -3,7 +3,7 @@ "appName": "Redis Insight", "productName": "RedisInsight", "private": true, - "version": "3.6.0", + "version": "3.8.0", "description": "Redis Insight", "main": "./dist/main/main.js", "author": { From 6f0a087e5b37648f3c52cad7951aeb2cc68c938a Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Thu, 16 Jul 2026 15:00:11 +0300 Subject: [PATCH 042/166] Run tests on fork PRs via unified pull_request triggers (#6204) --- .github/workflows/release-prod.yml | 11 +---- .github/workflows/release-stage.yml | 11 +---- .github/workflows/tests.yml | 71 +++++++++++++++-------------- 3 files changed, 40 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index fab4eb897c..51c40b2db6 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -6,18 +6,11 @@ on: - "latest" jobs: - tests-prod: - name: Run all tests - uses: ./.github/workflows/tests.yml - secrets: inherit - with: - short_rte_list: false - pre_release: true - + # Tests run on the release PR (tests.yml), not here — the build is gated by + # that PR's run. builds-prod: name: Create all builds for release uses: ./.github/workflows/build.yml - needs: tests-prod secrets: inherit with: environment: "production" diff --git a/.github/workflows/release-stage.yml b/.github/workflows/release-stage.yml index 9fa5814568..21327559bc 100644 --- a/.github/workflows/release-stage.yml +++ b/.github/workflows/release-stage.yml @@ -6,18 +6,11 @@ on: - "release/**" jobs: - tests: - name: Release stage tests - uses: ./.github/workflows/tests.yml - secrets: inherit - with: - short_rte_list: false - pre_release: true - + # Tests run on the release PR (tests.yml), not here — the build is gated by + # that PR's run. builds: name: Release stage builds uses: ./.github/workflows/build.yml - needs: tests secrets: inherit with: environment: "staging" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e7745151d..883a835ee9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,13 +1,8 @@ name: ✅ Tests on: - push: - branches-ignore: - - main - - latest - - 'release/**' pull_request: - types: [labeled] + types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: @@ -44,21 +39,16 @@ on: default: false type: boolean -# Cancel a previous run workflow. -# Key by head identity (not github.ref) so the push-triggered run and the -# pull_request-triggered run for the same branch share one concurrency group -# and cancel each other, instead of running the full suite twice in parallel. -# - push: head_ref is empty -> falls back to ref_name (branch name) -# - pull_request: head_ref is the source branch name -# Qualify by the head repo so fork PRs that happen to share a branch name -# (e.g. "main") don't collide and cancel each other's runs; for same-repo -# events this resolves to github.repository, preserving the push<->PR merge. +# One in-flight run per PR; a new commit cancels the previous run. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: changes: + permissions: + contents: read + pull-requests: read runs-on: ubuntu-latest outputs: frontend: ${{ steps.filter.outputs.frontend }} @@ -113,9 +103,15 @@ jobs: desktop: ${{ steps.eval.outputs.desktop }} integration: ${{ steps.eval.outputs.integration }} docker: ${{ steps.eval.outputs.docker }} + short_rte: ${{ steps.eval.outputs.short_rte }} steps: - id: eval env: + IS_FORK: ${{ github.event.pull_request.head.repo.fork == true }} + # Release-branch PRs (head or target release/** or latest) get the + # full suite + full RTE list. + IS_RELEASE: ${{ startsWith(github.head_ref, 'release/') || github.head_ref == 'latest' || startsWith(github.base_ref, 'release/') || github.base_ref == 'latest' }} + INPUT_SHORT_RTE: ${{ inputs.short_rte_list }} PRE_RELEASE: ${{ inputs.pre_release == true }} DISPATCH: ${{ github.event_name == 'workflow_dispatch' }} LABEL_ALL: ${{ contains(github.event.pull_request.labels.*.name, 'run-all-tests') }} @@ -128,14 +124,21 @@ jobs: CHG_DOCKER: ${{ needs.changes.outputs.docker }} CHG_INFRA: ${{ needs.changes.outputs.infra }} run: | - # "Run everything" — pre-release builds, manual dispatch, the - # run-all-tests label, or shared toolchain/CI changes. - if [[ "$PRE_RELEASE" == "true" || "$DISPATCH" == "true" || "$LABEL_ALL" == "true" || "$CHG_INFRA" == "true" ]]; then + # "Run everything" — pre-release builds, release-branch PRs, manual + # dispatch, the run-all-tests label, or shared toolchain/CI changes. + if [[ "$PRE_RELEASE" == "true" || "$IS_RELEASE" == "true" || "$DISPATCH" == "true" || "$LABEL_ALL" == "true" || "$CHG_INFRA" == "true" ]]; then ALL=true else ALL=false fi + # Full RTE list for release testing; short list otherwise, unless a + # caller overrides it. + short_rte=true + if [[ "$PRE_RELEASE" == "true" || "$IS_RELEASE" == "true" || "$INPUT_SHORT_RTE" == "false" ]]; then + short_rte=false + fi + ui=$ALL api=$ALL desktop=$ALL @@ -148,12 +151,20 @@ jobs: [[ "$CHG_BACKEND" == "true" || "$LABEL_IT" == "true" ]] && integration=true [[ "$CHG_FRONTEND" == "true" || "$CHG_BACKEND" == "true" || "$CHG_DOCKER" == "true" ]] && docker=true + # Forks run in isolation with no secrets, so the secret/infra suites + # can't run there. Keep lint/type-check/unit on; force the rest off. + if [[ "$IS_FORK" == "true" ]]; then + integration=false + docker=false + fi + { echo "ui=$ui" echo "api=$api" echo "desktop=$desktop" echo "integration=$integration" echo "docker=$docker" + echo "short_rte=$short_rte" } >> "$GITHUB_OUTPUT" lint: @@ -182,7 +193,7 @@ jobs: frontend-tests-coverage: needs: frontend-tests - if: ${{ github.actor != 'dependabot[bot]' }} + if: ${{ github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.fork != true }} uses: ./.github/workflows/code-coverage.yml secrets: inherit with: @@ -197,7 +208,7 @@ jobs: backend-tests-coverage: needs: backend-tests - if: ${{ github.actor != 'dependabot[bot]' }} + if: ${{ github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.fork != true }} uses: ./.github/workflows/code-coverage.yml secrets: inherit with: @@ -214,7 +225,7 @@ jobs: contents: read checks: write with: - short_rte_list: ${{ inputs.short_rte_list || true }} + short_rte_list: ${{ needs.should-run.outputs.short_rte == 'true' }} redis_client: ${{ inputs.redis_client || '' }} debug: ${{ inputs.debug || false }} @@ -229,28 +240,18 @@ jobs: clean: uses: ./.github/workflows/clean-deployments.yml - if: ${{ always() && github.actor != 'dependabot[bot]' }} + if: ${{ always() && github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.fork != true }} permissions: actions: write contents: read deployments: write - needs: - [ - frontend-tests, - backend-tests, - integration-tests, - ] + needs: [frontend-tests, backend-tests, integration-tests] # Remove artifacts from github actions remove-artifacts: name: Remove artifacts if: ${{ github.actor != 'dependabot[bot]' }} - needs: - [ - frontend-tests, - backend-tests, - integration-tests, - ] + needs: [frontend-tests, backend-tests, integration-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.0 From 80f7a9ed6651b66d75ecbc5b0ef9adaaa1451184 Mon Sep 17 00:00:00 2001 From: dantovska Date: Thu, 16 Jul 2026 15:28:49 +0300 Subject: [PATCH 043/166] RI-8316 Match key type when finding indexes for a key (#6200) * fix(api): match key type when finding indexes covering a key The key-indexes endpoint matched indexes by prefix only, so a JSON key matched FT.CREATE ... ON HASH indexes with the same prefix and the key details panel wrongly offered "View index". Resolve the key's type via TYPE and require it to match the index definition's key_type; keys of unsearchable types return no indexes. Fixes #RI-8316 --- redisinsight/api/.tscheck.rec.json | 2 +- .../redisearch/key-indexes.service.spec.ts | 58 ++++++++++++++++++- .../browser/redisearch/key-indexes.service.ts | 28 ++++++++- ...atabases-id-redisearch-key-indexes.test.ts | 14 +---- 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/redisinsight/api/.tscheck.rec.json b/redisinsight/api/.tscheck.rec.json index 10407eb224..ad12961465 100644 --- a/redisinsight/api/.tscheck.rec.json +++ b/redisinsight/api/.tscheck.rec.json @@ -1821,7 +1821,7 @@ "test/api/redisearch/POST-databases-id-redisearch-key-indexes.test.ts": { "TS18047": 3, "TS7006": 1, - "TS7031": 3 + "TS7031": 2 }, "test/api/redisearch/POST-databases-id-redisearch-search.test.ts": { "TS18047": 8, diff --git a/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.spec.ts b/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.spec.ts index 77aeb5ce00..ec8956284a 100644 --- a/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.spec.ts +++ b/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.spec.ts @@ -11,6 +11,7 @@ import { import { buildIndexInfoRaw } from 'src/__mocks__/redisearch'; import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { KeyIndexesService } from 'src/modules/browser/redisearch/key-indexes.service'; +import { RedisDataType } from 'src/modules/browser/keys/dto'; const mockMovieInfoRaw = buildIndexInfoRaw({ indexName: 'idx:movie', @@ -42,6 +43,16 @@ describe('KeyIndexesService', () => { const standaloneClient = mockStandaloneRedisClient; const clusterClient = mockClusterRedisClient; let service: KeyIndexesService; + let databaseClientFactory: DatabaseClientFactory; + + const mockKeyType = ( + key: string | Buffer, + type: string = RedisDataType.Hash, + ) => { + when(standaloneClient.sendCommand) + .calledWith(['TYPE', key], expect.anything()) + .mockResolvedValue(type); + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -55,6 +66,9 @@ describe('KeyIndexesService', () => { }).compile(); service = module.get(KeyIndexesService); + databaseClientFactory = module.get( + DatabaseClientFactory, + ); standaloneClient.sendCommand = jest.fn().mockResolvedValue(undefined); clusterClient.sendCommand = jest.fn().mockResolvedValue(undefined); @@ -66,6 +80,7 @@ describe('KeyIndexesService', () => { describe('getKeyIndexes', () => { it('should return matching index when key matches a prefix', async () => { + mockKeyType('movie:1'); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([Buffer.from('idx:movie')]); @@ -84,6 +99,7 @@ describe('KeyIndexesService', () => { }); it('should return empty array when key matches no prefix', async () => { + mockKeyType('session:abc'); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([Buffer.from('idx:movie')]); @@ -99,6 +115,7 @@ describe('KeyIndexesService', () => { }); it('should return multiple indexes when key matches several', async () => { + mockKeyType('user:42'); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([ @@ -123,6 +140,7 @@ describe('KeyIndexesService', () => { }); it('should match index with empty prefixes to any key', async () => { + mockKeyType('anything:here'); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([Buffer.from('idx:global')]); @@ -139,6 +157,7 @@ describe('KeyIndexesService', () => { }); it('should match key against index with multiple prefixes', async () => { + mockKeyType('item:99', RedisDataType.JSON); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([Buffer.from('idx:multi')]); @@ -156,6 +175,7 @@ describe('KeyIndexesService', () => { }); it('should return empty when no indexes exist', async () => { + mockKeyType('movie:1'); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([]); @@ -168,6 +188,7 @@ describe('KeyIndexesService', () => { }); it('should skip indexes whose FT.INFO fails', async () => { + mockKeyType('movie:1'); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([ @@ -190,9 +211,12 @@ describe('KeyIndexesService', () => { }); it('should deduplicate index names from cluster shards', async () => { + databaseClientFactory.getOrCreateClient = jest + .fn() + .mockResolvedValue(clusterClient); when(clusterClient.sendCommand) - .calledWith(['FT._LIST']) - .mockResolvedValue([Buffer.from('idx:movie')]); + .calledWith(['TYPE', 'movie:1'], expect.anything()) + .mockResolvedValue(RedisDataType.Hash); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([Buffer.from('idx:movie')]); @@ -216,6 +240,7 @@ describe('KeyIndexesService', () => { }); it('should handle Buffer keys', async () => { + mockKeyType(Buffer.from('movie:1')); when(standaloneClient.sendCommand) .calledWith(['FT._LIST']) .mockResolvedValue([Buffer.from('idx:movie')]); @@ -230,5 +255,34 @@ describe('KeyIndexesService', () => { expect(result.indexes).toHaveLength(1); expect(result.indexes[0].name).toBe('idx:movie'); }); + + it('should not match an index of a different key type', async () => { + mockKeyType('movie:1', RedisDataType.JSON); + when(standaloneClient.sendCommand) + .calledWith(['FT._LIST']) + .mockResolvedValue([Buffer.from('idx:movie')]); + when(standaloneClient.sendCommand) + .calledWith(['FT.INFO', 'idx:movie'], expect.anything()) + .mockResolvedValue(mockMovieInfoRaw); + + const result = await service.getKeyIndexes(mockBrowserClientMetadata, { + key: 'movie:1', + }); + + expect(result.indexes).toHaveLength(0); + }); + + it('should return empty for unsupported key types without listing indexes', async () => { + mockKeyType('mylist', RedisDataType.List); + + const result = await service.getKeyIndexes(mockBrowserClientMetadata, { + key: 'mylist', + }); + + expect(result.indexes).toHaveLength(0); + expect(standaloneClient.sendCommand).not.toHaveBeenCalledWith([ + 'FT._LIST', + ]); + }); }); }); diff --git a/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.ts b/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.ts index 2cfca265e4..1227a5a74c 100644 --- a/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.ts +++ b/redisinsight/api/src/modules/browser/redisearch/key-indexes.service.ts @@ -5,6 +5,7 @@ import { ClientMetadata } from 'src/common/models'; import { plainToInstance } from 'class-transformer'; import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { RedisClient } from 'src/modules/redis/client'; +import { RedisDataType } from 'src/modules/browser/keys/dto'; import { IndexInfoDto, IndexSummaryDto, @@ -19,6 +20,12 @@ interface IndexEntry { info: IndexInfoDto; } +// Redis TYPE reply -> FT.INFO key_type +const REDIS_TYPE_TO_INDEX_KEY_TYPE: Record = { + [RedisDataType.Hash]: 'HASH', + [RedisDataType.JSON]: 'JSON', +}; + @Injectable() export class KeyIndexesService { private logger = new Logger('KeyIndexesService'); @@ -26,7 +33,7 @@ export class KeyIndexesService { constructor(private databaseClientFactory: DatabaseClientFactory) {} /** - * Find all indexes whose prefixes cover the given key. + * Find all indexes whose key_type and prefixes cover the given key. * An index with no prefixes matches all keys of its key_type. */ public async getKeyIndexes( @@ -42,9 +49,22 @@ export class KeyIndexesService { const client: RedisClient = await this.databaseClientFactory.getOrCreateClient(clientMetadata); + const keyType = (await client.sendCommand(['TYPE', key], { + replyEncoding: 'utf8', + })) as string; + const targetKeyType = REDIS_TYPE_TO_INDEX_KEY_TYPE[keyType]; + + if (!targetKeyType) { + return plainToInstance(KeyIndexesResponse, { indexes: [] }); + } + const indexNames = await this.listIndexNames(client); const entries = await this.fetchIndexesInfo(client, indexNames); - const matchingIndexes = this.findMatchingIndexes(keyStr, entries); + const matchingIndexes = this.findMatchingIndexes( + keyStr, + targetKeyType, + entries, + ); return plainToInstance(KeyIndexesResponse, { indexes: matchingIndexes }); } catch (e) { @@ -93,6 +113,7 @@ export class KeyIndexesService { private findMatchingIndexes( keyStr: string, + targetKeyType: string, entries: IndexEntry[], ): IndexSummaryDto[] { const matching: IndexSummaryDto[] = []; @@ -107,7 +128,8 @@ export class KeyIndexesService { const { prefixes = [], key_type: keyType = '' } = definition; const isMatch = - prefixes.length === 0 || prefixes.some((p) => keyStr.startsWith(p)); + keyType.toUpperCase() === targetKeyType && + (prefixes.length === 0 || prefixes.some((p) => keyStr.startsWith(p))); if (isMatch) { matching.push( diff --git a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-key-indexes.test.ts b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-key-indexes.test.ts index 73fafe8b62..1134addbad 100644 --- a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-key-indexes.test.ts +++ b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-key-indexes.test.ts @@ -22,7 +22,7 @@ const dataSchema = Joi.object({ }).strict(); const validInputData = { - key: `${constants.TEST_SEARCH_HASH_KEY_PREFIX_1}1`, + key: `${constants.TEST_RUN_ID}_hash_key_0`, }; const INDEX_SUMMARY_SCHEMA = Joi.object({ @@ -59,7 +59,7 @@ describe('POST /databases/:id/redisearch/key-indexes', () => { describe('Common', () => { [ { - name: 'Should return matching indexes for a key that matches a prefix', + name: 'Should return the indexes covering an existing hash key', data: validInputData, responseSchema: RESPONSE_SCHEMA, checkFn: async ({ body }) => { @@ -68,16 +68,6 @@ describe('POST /databases/:id/redisearch/key-indexes', () => { expect(names).to.include(constants.TEST_SEARCH_HASH_INDEX_1); }, }, - { - name: 'Should still return indexes with no prefix for an unrelated key', - data: { - key: 'nonexistent_prefix_zzz:1', - }, - responseSchema: RESPONSE_SCHEMA, - checkFn: async ({ body }) => { - expect(body.indexes).to.be.an('array'); - }, - }, { name: 'Should return indexes array with correct structure', data: validInputData, From 062b3db5e4dd9c9b20a4c95d5a9e760a5430e905 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Thu, 16 Jul 2026 15:38:17 +0300 Subject: [PATCH 044/166] feat(i18n): migrate database-analysis page (RI-8276) (#6199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the Database Analysis page through i18n under analytics.databaseAnalysis.* with Bulgarian translations: page title, header (report select, scan progress, new-report button + tooltip title/body), the Data Summary / Tips tabs, empty states (reports/keys/encrypt — the keys variant links Workbench via ), recommendations empty state and tutorial, summary-per-data and TTL sections, and the top-namespaces / top-keys tables (headers, tooltips, toggles). The no-namespaces empty state links Tree View via . Also migrates the analytics navigation tabs (AnalyticsTabs: Overview / Database Analysis / Slow Log) under analytics.nav.*, and translates the new-report tooltip body at the use site (leaving the shared recommendations constant untouched for the still-English side panels). Extends analytics.units.* with %/B resolved through t(). The TTL tooltip seconds suffix stays literal to match the untranslated duration from the shared truncateNumberToDuration util (same rationale as cluster-details). Co-authored-by: Claude Opus 4.8 (1M context) --- .../analytics-tabs/AnalyticsTabs.tsx | 10 ++-- redisinsight/ui/src/i18n/locales/bg.json | 52 +++++++++++++++++ redisinsight/ui/src/i18n/locales/en.json | 52 +++++++++++++++++ .../DatabaseAnalysisPage.tsx | 4 +- .../ExpirationGroupsView.tsx | 9 ++- .../data-nav-tabs/DatabaseAnalysisTabs.tsx | 8 ++- .../EmptyAnalysisMessage.tsx | 57 ++++++++++--------- .../components/header/Header.tsx | 45 +++++++++------ .../recommendations-view/Recommendations.tsx | 20 +++++-- .../summary-per-data/SummaryPerData.tsx | 22 ++++--- .../components/top-keys/TopKeys.tsx | 12 ++-- .../components/top-keys/TopKeysTable.tsx | 30 ++++++---- .../components/top-namespace/TopNamespace.tsx | 39 ++++++++----- .../top-namespace/TopNamespacesTable.tsx | 16 +++--- 14 files changed, 271 insertions(+), 105 deletions(-) diff --git a/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.tsx b/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.tsx index 05235cdccb..606f75794b 100644 --- a/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.tsx +++ b/redisinsight/ui/src/components/analytics-tabs/AnalyticsTabs.tsx @@ -20,8 +20,10 @@ import { useConnectionType } from 'uiSrc/components/hooks/useConnectionType' import { ONBOARDING_FEATURES } from 'uiSrc/components/onboarding-features' import Tabs, { TabInfo } from 'uiSrc/components/base/layout/tabs' import { Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' const AnalyticsTabs = () => { + const { t } = useTranslation() const { viewTab } = useAppSelector(analyticsSettingsSelector) const connectionType = useConnectionType() const { currentStep } = useAppSelector(appFeatureOnboardingSelector) @@ -46,7 +48,7 @@ const AnalyticsTabs = () => { value: AnalyticsViewTab.DatabaseAnalysis, content: null, label: renderOnboardingTourWithChild( - Database Analysis, + {t('analytics.nav.databaseAnalysis')}, { options: ONBOARDING_FEATURES?.ANALYTICS_DATABASE_ANALYSIS, anchorPosition: 'downLeft', @@ -59,7 +61,7 @@ const AnalyticsTabs = () => { value: AnalyticsViewTab.SlowLog, content: null, label: renderOnboardingTourWithChild( - Slow Log, + {t('analytics.nav.slowLog')}, { options: ONBOARDING_FEATURES?.ANALYTICS_SLOW_LOG, anchorPosition: 'downLeft', @@ -75,7 +77,7 @@ const AnalyticsTabs = () => { value: AnalyticsViewTab.ClusterDetails, content: null, label: renderOnboardingTourWithChild( - Overview, + {t('analytics.nav.overview')}, { options: ONBOARDING_FEATURES?.ANALYTICS_OVERVIEW, anchorPosition: 'downLeft', @@ -87,7 +89,7 @@ const AnalyticsTabs = () => { } return visibleTabs - }, [viewTab, connectionType]) + }, [t, viewTab, connectionType]) const handleTabChange = (id: string) => { if (viewTab === id) return diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index da63656b82..4796872cea 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -16,6 +16,58 @@ "analytics.clusterDetails.table.primaryNodes_other": "{{count}} първични възела", "analytics.clusterDetails.table.totalKeys": "Общо ключове", "analytics.clusterDetails.table.totalMemory": "Обща памет", + "analytics.databaseAnalysis.empty.encrypt.text": "Не може да се декриптира. Проверете системния ключодържател или изпълнете отново генерирането на отчета.", + "analytics.databaseAnalysis.empty.encrypt.title": "Криптирани данни", + "analytics.databaseAnalysis.empty.keys.text": "Използвайте ръководствата и уроците на Работна среда, за да заредите бързо данните.", + "analytics.databaseAnalysis.empty.keys.title": "Няма ключове за показване", + "analytics.databaseAnalysis.empty.reports.text": "Щракнете върху „Анализирай“, за да генерирате първия отчет.", + "analytics.databaseAnalysis.empty.reports.title": "Не са намерени отчети", + "analytics.databaseAnalysis.expiration.showNoExpiry": "Показване на „Без изтичане“", + "analytics.databaseAnalysis.expiration.title": "ПАМЕТ, КОЯТО ВЕРОЯТНО ЩЕ БЪДЕ ОСВОБОДЕНА С ВРЕМЕТО", + "analytics.databaseAnalysis.extrapolateResults": "Екстраполиране на резултатите", + "analytics.databaseAnalysis.header.newReport": "Нов отчет", + "analytics.databaseAnalysis.header.newReportAria": "Нови отчети", + "analytics.databaseAnalysis.header.reportGeneratedOn": "Отчетът е генериран на:", + "analytics.databaseAnalysis.header.scanned": "Сканирани {{percentage}}", + "analytics.databaseAnalysis.header.scannedKeys": "({{processed}}/{{total}} ключа)", + "analytics.databaseAnalysis.header.tooltipContent": "Анализирайте до 10 000 ключа, за да получите преглед на вашите данни и съвети как да спестите памет и да оптимизирате използването на базата данни.", + "analytics.databaseAnalysis.header.tooltipContentCluster": "Анализирайте до 10 000 ключа на шард, за да получите преглед на вашите данни и съвети как да спестите памет и да оптимизирате използването на базата данни.", + "analytics.databaseAnalysis.header.tooltipTitle": "Анализ на базата данни", + "analytics.databaseAnalysis.pageTitle": "{{dbName}} - Анализ на базата данни", + "analytics.databaseAnalysis.recommendations.empty.line1": "Няма съвети в момента,", + "analytics.databaseAnalysis.recommendations.empty.line2": "продължавайте в същия дух!", + "analytics.databaseAnalysis.recommendations.empty.title": "ОТЛИЧНА РАБОТА!", + "analytics.databaseAnalysis.recommendations.redisStackTooltip": "Redis Stack", + "analytics.databaseAnalysis.recommendations.tutorial": "Урок", + "analytics.databaseAnalysis.summaryPerData.keys": "Ключове", + "analytics.databaseAnalysis.summaryPerData.memory": "Памет", + "analytics.databaseAnalysis.summaryPerData.title": "ОБОБЩЕНИЕ ПО ТИП ДАННИ", + "analytics.databaseAnalysis.tabs.dataSummary": "Обобщение на данните", + "analytics.databaseAnalysis.tabs.tips": "Съвети", + "analytics.databaseAnalysis.topKeys.byLength": "по дължина", + "analytics.databaseAnalysis.topKeys.byMemory": "по памет", + "analytics.databaseAnalysis.topKeys.considerSplitting": "Обмислете разделянето му на няколко ключа", + "analytics.databaseAnalysis.topKeys.keyName": "Име на ключа", + "analytics.databaseAnalysis.topKeys.keySize": "Размер на ключа", + "analytics.databaseAnalysis.topKeys.keyType": "Тип на ключа", + "analytics.databaseAnalysis.topKeys.length": "Дължина", + "analytics.databaseAnalysis.topKeys.noLimit": "Без ограничение", + "analytics.databaseAnalysis.topKeys.timeToLive": "Време на живот", + "analytics.databaseAnalysis.topKeys.title": "НАЙ-ГОЛЕМИ КЛЮЧОВЕ", + "analytics.databaseAnalysis.topKeys.titleMax": "НАЙ-ГОЛЕМИ {{max}} КЛЮЧА", + "analytics.databaseAnalysis.topKeys.ttl": "TTL", + "analytics.databaseAnalysis.topNamespaces.byMemory": "по памет", + "analytics.databaseAnalysis.topNamespaces.byNumberOfKeys": "по брой ключове", + "analytics.databaseAnalysis.topNamespaces.dataType": "Тип данни", + "analytics.databaseAnalysis.topNamespaces.empty.text": "Конфигурирайте разделителя в Дървовиден изглед, за да персонализирате показваните именни пространства.", + "analytics.databaseAnalysis.topNamespaces.empty.title": "Няма именни пространства за показване", + "analytics.databaseAnalysis.topNamespaces.keyPattern": "Шаблон на ключа", + "analytics.databaseAnalysis.topNamespaces.title": "НАЙ-ГОЛЕМИ ИМЕННИ ПРОСТРАНСТВА", + "analytics.databaseAnalysis.topNamespaces.totalKeys": "Общо ключове", + "analytics.databaseAnalysis.topNamespaces.totalMemory": "Обща памет", + "analytics.nav.databaseAnalysis": "Анализ на базата данни", + "analytics.nav.overview": "Преглед", + "analytics.nav.slowLog": "Бавни команди", "analytics.units.bytes": "Б", "analytics.units.kbps": "кб/с", "analytics.units.percent": "%", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index e773169c2a..f618969c9c 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -16,6 +16,58 @@ "analytics.clusterDetails.table.primaryNodes_other": "{{count}} Primary nodes", "analytics.clusterDetails.table.totalKeys": "Total Keys", "analytics.clusterDetails.table.totalMemory": "Total Memory", + "analytics.databaseAnalysis.empty.encrypt.text": "Unable to decrypt. Check the system keychain or re-run the report generation.", + "analytics.databaseAnalysis.empty.encrypt.title": "Encrypted data", + "analytics.databaseAnalysis.empty.keys.text": "Use Workbench Guides and Tutorials to quickly load the data.", + "analytics.databaseAnalysis.empty.keys.title": "No keys to display", + "analytics.databaseAnalysis.empty.reports.text": "Click \"Analyze\" to generate the first report.", + "analytics.databaseAnalysis.empty.reports.title": "No Reports found", + "analytics.databaseAnalysis.expiration.showNoExpiry": "Show \"No Expiry\"", + "analytics.databaseAnalysis.expiration.title": "MEMORY LIKELY TO BE FREED OVER TIME", + "analytics.databaseAnalysis.extrapolateResults": "Extrapolate results", + "analytics.databaseAnalysis.header.newReport": "New Report", + "analytics.databaseAnalysis.header.newReportAria": "New reports", + "analytics.databaseAnalysis.header.reportGeneratedOn": "Report generated on:", + "analytics.databaseAnalysis.header.scanned": "Scanned {{percentage}}", + "analytics.databaseAnalysis.header.scannedKeys": "({{processed}}/{{total}} keys)", + "analytics.databaseAnalysis.header.tooltipContent": "Analyze up to 10 000 keys to get an overview of your data and tips on how to save memory and optimize the usage of your database.", + "analytics.databaseAnalysis.header.tooltipContentCluster": "Analyze up to 10 000 keys per shard to get an overview of your data and tips on how to save memory and optimize the usage of your database.", + "analytics.databaseAnalysis.header.tooltipTitle": "Database Analysis", + "analytics.databaseAnalysis.pageTitle": "{{dbName}} - Database Analysis", + "analytics.databaseAnalysis.recommendations.empty.line1": "No Tips at the moment,", + "analytics.databaseAnalysis.recommendations.empty.line2": "keep up the good work!", + "analytics.databaseAnalysis.recommendations.empty.title": "AMAZING JOB!", + "analytics.databaseAnalysis.recommendations.redisStackTooltip": "Redis Stack", + "analytics.databaseAnalysis.recommendations.tutorial": "Tutorial", + "analytics.databaseAnalysis.summaryPerData.keys": "Keys", + "analytics.databaseAnalysis.summaryPerData.memory": "Memory", + "analytics.databaseAnalysis.summaryPerData.title": "SUMMARY PER DATA TYPE", + "analytics.databaseAnalysis.tabs.dataSummary": "Data Summary", + "analytics.databaseAnalysis.tabs.tips": "Tips", + "analytics.databaseAnalysis.topKeys.byLength": "by Length", + "analytics.databaseAnalysis.topKeys.byMemory": "by Memory", + "analytics.databaseAnalysis.topKeys.considerSplitting": "Consider splitting it into multiple keys", + "analytics.databaseAnalysis.topKeys.keyName": "Key Name", + "analytics.databaseAnalysis.topKeys.keySize": "Key Size", + "analytics.databaseAnalysis.topKeys.keyType": "Key Type", + "analytics.databaseAnalysis.topKeys.length": "Length", + "analytics.databaseAnalysis.topKeys.noLimit": "No limit", + "analytics.databaseAnalysis.topKeys.timeToLive": "Time to Live", + "analytics.databaseAnalysis.topKeys.title": "TOP KEYS", + "analytics.databaseAnalysis.topKeys.titleMax": "TOP {{max}} KEYS", + "analytics.databaseAnalysis.topKeys.ttl": "TTL", + "analytics.databaseAnalysis.topNamespaces.byMemory": "by Memory", + "analytics.databaseAnalysis.topNamespaces.byNumberOfKeys": "by Number of Keys", + "analytics.databaseAnalysis.topNamespaces.dataType": "Data Type", + "analytics.databaseAnalysis.topNamespaces.empty.text": "Configure the delimiter in Tree View to customize the namespaces displayed.", + "analytics.databaseAnalysis.topNamespaces.empty.title": "No namespaces to display", + "analytics.databaseAnalysis.topNamespaces.keyPattern": "Key Pattern", + "analytics.databaseAnalysis.topNamespaces.title": "TOP NAMESPACES", + "analytics.databaseAnalysis.topNamespaces.totalKeys": "Total Keys", + "analytics.databaseAnalysis.topNamespaces.totalMemory": "Total Memory", + "analytics.nav.databaseAnalysis": "Database Analysis", + "analytics.nav.overview": "Overview", + "analytics.nav.slowLog": "Slow Log", "analytics.units.bytes": "B", "analytics.units.kbps": "kb/s", "analytics.units.percent": "%", diff --git a/redisinsight/ui/src/pages/database-analysis/DatabaseAnalysisPage.tsx b/redisinsight/ui/src/pages/database-analysis/DatabaseAnalysisPage.tsx index 729e27b557..bc6c479158 100644 --- a/redisinsight/ui/src/pages/database-analysis/DatabaseAnalysisPage.tsx +++ b/redisinsight/ui/src/pages/database-analysis/DatabaseAnalysisPage.tsx @@ -22,9 +22,11 @@ import { TelemetryPageView, } from 'uiSrc/telemetry' import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' import { DatabaseAnalysisPageView } from './DatabaseAnalysisPageView' export const DatabaseAnalysisPage = () => { + const { t } = useTranslation() const { viewTab } = useAppSelector(analyticsSettingsSelector) const { loading: analysisLoading, data } = useAppSelector(dbAnalysisSelector) const { data: reports, selectedAnalysis } = useAppSelector( @@ -42,7 +44,7 @@ export const DatabaseAnalysisPage = () => { const dispatch = useAppDispatch() const dbName = `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)}` - setTitle(`${dbName} - Database Analysis`) + setTitle(t('analytics.databaseAnalysis.pageTitle', { dbName })) useEffect(() => { dispatch(fetchDBAnalysisReportsHistory(instanceId)) diff --git a/redisinsight/ui/src/pages/database-analysis/components/analysis-ttl-view/ExpirationGroupsView.tsx b/redisinsight/ui/src/pages/database-analysis/components/analysis-ttl-view/ExpirationGroupsView.tsx index 09a2320a3d..41df1fe8cd 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/analysis-ttl-view/ExpirationGroupsView.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/analysis-ttl-view/ExpirationGroupsView.tsx @@ -2,6 +2,8 @@ import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import React, { useEffect, useState } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' +import { useTranslation } from 'uiSrc/i18n' + import { DEFAULT_EXTRAPOLATION, SectionName, @@ -49,6 +51,7 @@ export interface Props { const ExpirationGroupsView = (props: Props) => { const { data, loading, extrapolation, onSwitchExtrapolation } = props + const { t } = useTranslation() const { totalMemory, totalKeys } = data || {} const { showNoExpiryGroup } = useAppSelector(dbAnalysisReportsSelector) @@ -115,11 +118,11 @@ const ExpirationGroupsView = (props: Props) => { - MEMORY LIKELY TO BE FREED OVER TIME + {t('analytics.databaseAnalysis.expiration.title')} {extrapolation !== DEFAULT_EXTRAPOLATION && ( { setIsExtrapolated(checked) @@ -133,7 +136,7 @@ const ExpirationGroupsView = (props: Props) => { )} { const { loading, reports, data } = props + const { t } = useTranslation() const viewTab = useAppSelector(dbAnalysisViewTabSelector) const { id: instanceId = '', provider } = useAppSelector( connectedInstanceSelector, @@ -47,14 +49,14 @@ const DatabaseAnalysisTabs = (props: Props) => { const tabs: TabInfo[] = useMemo( () => [ { - label: Data Summary, + label: {t('analytics.databaseAnalysis.tabs.dataSummary')}, value: DatabaseAnalysisViewTab.DataSummary, content: , }, { label: renderOnboardingTourWithChild( - Tips{' '} + {t('analytics.databaseAnalysis.tabs.tips')}{' '} {data?.recommendations?.length ? `(${data.recommendations.length})` : ''} @@ -70,7 +72,7 @@ const DatabaseAnalysisTabs = (props: Props) => { content: , }, ], - [viewTab, data?.recommendations], + [t, viewTab, data?.recommendations], ) const handleTabChange = (id: string) => { diff --git a/redisinsight/ui/src/pages/database-analysis/components/empty-analysis-message/EmptyAnalysisMessage.tsx b/redisinsight/ui/src/pages/database-analysis/components/empty-analysis-message/EmptyAnalysisMessage.tsx index 72fff008d7..ad15600d0e 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/empty-analysis-message/EmptyAnalysisMessage.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/empty-analysis-message/EmptyAnalysisMessage.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom' import { Text } from 'uiSrc/components/base/text' import { Pages } from 'uiSrc/constants' +import { Trans, useTranslation } from 'uiSrc/i18n' import { EmptyMessage, Content } from 'uiSrc/pages/database-analysis/constants' import { getRouterLinkProps } from 'uiSrc/services' @@ -13,38 +14,40 @@ interface Props { name: EmptyMessage } -const emptyMessageContent: { [key in EmptyMessage]: Content } = { - [EmptyMessage.Reports]: { - title: 'No Reports found', - text: () => 'Click "Analyze" to generate the first report.', - }, - [EmptyMessage.Keys]: { - title: 'No keys to display', - text: (path) => ( - <> - - Use Workbench Guides and Tutorials - - {' to quickly load the data.'} - - ), - }, - [EmptyMessage.Encrypt]: { - title: 'Encrypted data', - text: () => - 'Unable to decrypt. Check the system keychain or re-run the report generation.', - }, -} - const EmptyAnalysisMessage = (props: Props) => { const { name } = props + const { t } = useTranslation() const { instanceId = '' } = useParams<{ instanceId: string }>() + const emptyMessageContent: { [key in EmptyMessage]: Content } = { + [EmptyMessage.Reports]: { + title: t('analytics.databaseAnalysis.empty.reports.title'), + text: () => t('analytics.databaseAnalysis.empty.reports.text'), + }, + [EmptyMessage.Keys]: { + title: t('analytics.databaseAnalysis.empty.keys.title'), + text: (path) => ( + + ), + }} + /> + ), + }, + [EmptyMessage.Encrypt]: { + title: t('analytics.databaseAnalysis.empty.encrypt.title'), + text: () => t('analytics.databaseAnalysis.empty.encrypt.text'), + }, + } + const { text, title } = emptyMessageContent[name] return ( diff --git a/redisinsight/ui/src/pages/database-analysis/components/header/Header.tsx b/redisinsight/ui/src/pages/database-analysis/components/header/Header.tsx index d1085eb269..92ca498dcc 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/header/Header.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/header/Header.tsx @@ -1,6 +1,7 @@ import React from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' +import { useTranslation } from 'uiSrc/i18n' import { CaretRightIcon } from 'uiSrc/components/base/icons' import { createNewAnalysis } from 'uiSrc/slices/analytics/dbAnalysis' import { numberWithSpaces } from 'uiSrc/utils/numbers' @@ -11,10 +12,6 @@ import { ConnectionType } from 'uiSrc/slices/interfaces' import { comboBoxToArray, getDbIndex, Nullable } from 'uiSrc/utils' import { AnalyticsPageHeader } from 'uiSrc/pages/database-analysis/components/analytics-page-header' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { - ANALYZE_CLUSTER_TOOLTIP_MESSAGE, - ANALYZE_TOOLTIP_MESSAGE, -} from 'uiSrc/constants/recommendations' import { FormatedDate, RiTooltip } from 'uiSrc/components' import { DEFAULT_DELIMITER } from 'uiSrc/constants' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' @@ -43,6 +40,7 @@ const Header = (props: Props) => { analysisLoading, } = props + const { t } = useTranslation() const { connectionType, provider } = useAppSelector(connectedInstanceSelector) const { instanceId } = useParams<{ instanceId: string }>() const dispatch = useAppDispatch() @@ -87,7 +85,11 @@ const Header = (props: Props) => { - Report generated on: + + {t( + 'analytics.databaseAnalysis.header.reportGeneratedOn', + )} + @@ -121,14 +123,17 @@ const Header = (props: Props) => { size="s" data-testid="analysis-progress" > - {`Scanned ${getApproximatePercentage( - progress.total, - progress.processed, - )}`} - - {` (${numberWithSpaces(progress.processed)}`}/ - {numberWithSpaces(progress.total)} - {' keys) '} + {t('analytics.databaseAnalysis.header.scanned', { + percentage: getApproximatePercentage( + progress.total, + progress.processed, + ), + })} + {' '} + {t('analytics.databaseAnalysis.header.scannedKeys', { + processed: numberWithSpaces(progress.processed), + total: numberWithSpaces(progress.total), + })} )} @@ -138,7 +143,9 @@ const Header = (props: Props) => { { disabled={analysisLoading} onClick={handleClick} > - New Report + {t('analytics.databaseAnalysis.header.newReport')} diff --git a/redisinsight/ui/src/pages/database-analysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/database-analysis/components/recommendations-view/Recommendations.tsx index 7dbb8abba3..2bbabae46d 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/recommendations-view/Recommendations.tsx @@ -5,6 +5,7 @@ import { isNull } from 'lodash' import cx from 'classnames' import styled from 'styled-components' +import { useTranslation } from 'uiSrc/i18n' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { FeatureFlagComponent, @@ -43,6 +44,7 @@ const RecommendationContent = styled(Card)` ` const Recommendations = () => { + const { t } = useTranslation() const { data, loading } = useAppSelector(dbAnalysisSelector) const { provider } = useAppSelector(connectedInstanceSelector) const { content: recommendationsContent } = useAppSelector( @@ -107,7 +109,9 @@ const Recommendations = () => { data-testid={`${id}-redis-stack-link`} > @@ -147,10 +151,16 @@ const Recommendations = () => { className={styles.noRecommendationsIcon} data-testid="no=recommendations-icon" /> - AMAZING JOB! - No Tips at the moment, + + {t('analytics.databaseAnalysis.recommendations.empty.title')} + + + {t('analytics.databaseAnalysis.recommendations.empty.line1')} +
- keep up the good work! + + {t('analytics.databaseAnalysis.recommendations.empty.line2')} +
) } @@ -218,7 +228,7 @@ const Recommendations = () => { onClick={() => goToTutorial(tutorialId, id)} data-testid={`${id}-to-tutorial-btn`} > - Tutorial + {t('analytics.databaseAnalysis.recommendations.tutorial')} )} diff --git a/redisinsight/ui/src/pages/database-analysis/components/summary-per-data/SummaryPerData.tsx b/redisinsight/ui/src/pages/database-analysis/components/summary-per-data/SummaryPerData.tsx index b16bdb4591..a533488f1b 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/summary-per-data/SummaryPerData.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/summary-per-data/SummaryPerData.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { DonutChart } from 'uiSrc/components/charts' import { ChartData } from 'uiSrc/components/charts/donut-chart/DonutChart' import { GROUP_TYPES_COLORS, GroupTypesColors } from 'uiSrc/constants' @@ -89,6 +90,7 @@ const SummaryPerData = ({ extrapolation, onSwitchExtrapolation, }: Props) => { + const { t } = useTranslation() const { totalMemory, totalKeys } = data || {} const [memoryData, setMemoryData] = useState([]) const [keysData, setKeysData] = useState([]) @@ -123,7 +125,8 @@ const SummaryPerData = ({ {name}: - {getPercentage(value, totalMemory?.total)}% + {getPercentage(value, totalMemory?.total)} + {t('analytics.units.percent')} (  @@ -136,7 +139,7 @@ const SummaryPerData = ({ ), - [totalMemory, extrapolation, isExtrapolated], + [t, totalMemory, extrapolation, isExtrapolated], ) const renderKeysTooltip = useCallback( @@ -144,7 +147,8 @@ const SummaryPerData = ({ {name}: - {getPercentage(value, totalKeys?.total)}% + {getPercentage(value, totalKeys?.total)} + {t('analytics.units.percent')} (  @@ -157,7 +161,7 @@ const SummaryPerData = ({ ), - [totalKeys, extrapolation, isExtrapolated], + [t, totalKeys, extrapolation, isExtrapolated], ) if (loading) { @@ -181,10 +185,12 @@ const SummaryPerData = ({ return (
- SUMMARY PER DATA TYPE + + {t('analytics.databaseAnalysis.summaryPerData.title')} + {extrapolation !== DEFAULT_EXTRAPOLATION && ( { setIsExtrapolated(checked) @@ -206,7 +212,7 @@ const SummaryPerData = ({ title={ { + const { t } = useTranslation() const { topKeysLength = [], topKeysMemory = [], delimiter } = data || {} const [tableView, setTableView] = useState(TableView.MEMORY) @@ -37,8 +39,10 @@ const TopKeys = ({ data, loading }: Props) => { {topKeysLength.length < MAX_TOP_KEYS && topKeysMemory?.length < MAX_TOP_KEYS - ? 'TOP KEYS' - : `TOP ${MAX_TOP_KEYS} KEYS`} + ? t('analytics.databaseAnalysis.topKeys.title') + : t('analytics.databaseAnalysis.topKeys.titleMax', { + max: MAX_TOP_KEYS, + })} { disabled={tableView === TableView.MEMORY} data-testid="btn-change-table-memory" > - by Memory + {t('analytics.databaseAnalysis.topKeys.byMemory')} { disabled={tableView === TableView.KEYS} data-testid="btn-change-table-keys" > - by Length + {t('analytics.databaseAnalysis.topKeys.byLength')} diff --git a/redisinsight/ui/src/pages/database-analysis/components/top-keys/TopKeysTable.tsx b/redisinsight/ui/src/pages/database-analysis/components/top-keys/TopKeysTable.tsx index cee628da2d..b7326ad708 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/top-keys/TopKeysTable.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/top-keys/TopKeysTable.tsx @@ -3,6 +3,7 @@ import React from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { useHistory, useParams } from 'react-router-dom' +import { useTranslation } from 'uiSrc/i18n' import { GroupBadge, RiTooltip } from 'uiSrc/components' import { Pages } from 'uiSrc/constants' import { @@ -54,6 +55,7 @@ const TopKeysTable = ({ delimiter = ':', dataTestid = '', }: Props) => { + const { t } = useTranslation() const history = useHistory() const dispatch = useAppDispatch() @@ -89,7 +91,7 @@ const TopKeysTable = ({ const columns: ColumnDef[] = [ { - header: 'Key Type', + header: t('analytics.databaseAnalysis.topKeys.keyType'), id: 'type', accessorKey: 'type', enableSorting: true, @@ -100,7 +102,7 @@ const TopKeysTable = ({ }) => , }, { - header: 'Key Name', + header: t('analytics.databaseAnalysis.topKeys.keyName'), id: 'name', accessorKey: 'name', enableSorting: true, @@ -118,7 +120,7 @@ const TopKeysTable = ({ return (
@@ -131,7 +133,7 @@ const TopKeysTable = ({ }, }, { - header: 'TTL', + header: t('analytics.databaseAnalysis.topKeys.ttl'), id: 'ttl', accessorKey: 'ttl', enableSorting: true, @@ -145,17 +147,21 @@ const TopKeysTable = ({ } if (value === -1) { return ( - No limit + + {t('analytics.databaseAnalysis.topKeys.noLimit')} + ) } return ( + {/* seconds suffix kept literal to match the untranslated + duration from truncateNumberToDuration below */} {`${truncateTTLToSeconds(value)} s`}
{`(${truncateNumberToDuration(value)})`} @@ -170,7 +176,7 @@ const TopKeysTable = ({ }, }, { - header: 'Key Size', + header: t('analytics.databaseAnalysis.topKeys.keySize'), id: 'memory', accessorKey: 'memory', enableSorting: true, @@ -190,11 +196,11 @@ const TopKeysTable = ({ <> {isHighlight ? ( <> - Consider splitting it into multiple keys + {t('analytics.databaseAnalysis.topKeys.considerSplitting')}
) : null} - {numberWithSpaces(value)} B + {numberWithSpaces(value)} {t('analytics.units.bytes')} } data-testid="usedMemory-tooltip" @@ -209,7 +215,7 @@ const TopKeysTable = ({ }, }, { - header: 'Length', + header: t('analytics.databaseAnalysis.topKeys.length'), id: 'length', accessorKey: 'length', enableSorting: true, @@ -226,7 +232,9 @@ const TopKeysTable = ({ return ( diff --git a/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespace.tsx b/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespace.tsx index 513b8d4f5c..5c846def69 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespace.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespace.tsx @@ -2,6 +2,7 @@ import { isNull } from 'lodash' import React, { useEffect, useState } from 'react' import { useAppDispatch } from 'uiSrc/slices/hooks' import { useHistory, useParams } from 'react-router-dom' +import { Trans, useTranslation } from 'uiSrc/i18n' import { Pages } from 'uiSrc/constants' import { DEFAULT_EXTRAPOLATION, @@ -39,6 +40,7 @@ export interface Props { const TopNamespace = (props: Props) => { const { data, loading, extrapolation, onSwitchExtrapolation } = props + const { t } = useTranslation() const [tableView, setTableView] = useState(TableView.MEMORY) const [isExtrapolated, setIsExtrapolated] = useState(true) @@ -74,20 +76,27 @@ const TopNamespace = (props: Props) => { return (
- TOP NAMESPACES + + {t('analytics.databaseAnalysis.topNamespaces.title')} + - No namespaces to display + + {t('analytics.databaseAnalysis.topNamespaces.empty.title')} + - {'Configure the delimiter in '} - - Tree View - - {' to customize the namespaces displayed.'} + + ), + }} + /> @@ -98,7 +107,9 @@ const TopNamespace = (props: Props) => { return (
- TOP NAMESPACES + + {t('analytics.databaseAnalysis.topNamespaces.title')} + { disabled={tableView === TableView.MEMORY} data-testid="btn-change-table-memory" > - by Memory + {t('analytics.databaseAnalysis.topNamespaces.byMemory')} { disabled={tableView === TableView.KEYS} data-testid="btn-change-table-keys" > - by Number of Keys + {t('analytics.databaseAnalysis.topNamespaces.byNumberOfKeys')} {extrapolation !== DEFAULT_EXTRAPOLATION && ( { setIsExtrapolated(checked) diff --git a/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespacesTable.tsx b/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespacesTable.tsx index 0d6fb8a619..44e7b19bf8 100644 --- a/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespacesTable.tsx +++ b/redisinsight/ui/src/pages/database-analysis/components/top-namespace/TopNamespacesTable.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useHistory, useParams } from 'react-router-dom' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' +import { useTranslation } from 'uiSrc/i18n' import { extrapolate, formatBytes, @@ -54,6 +55,7 @@ const NameSpacesTable = ({ extrapolation, dataTestid = '', }: Props) => { + const { t } = useTranslation() const history = useHistory() const dispatch = useAppDispatch() @@ -103,7 +105,7 @@ const NameSpacesTable = ({ > @@ -140,7 +142,7 @@ const NameSpacesTable = ({ const columns: ColumnDef[] = [ { - header: 'Key Pattern', + header: t('analytics.databaseAnalysis.topNamespaces.keyPattern'), id: 'nsp', accessorKey: 'nsp', enableSorting: true, @@ -155,7 +157,7 @@ const NameSpacesTable = ({ const tooltipContent = formatLongName(textWithDelimiter) return ( @@ -169,7 +171,7 @@ const NameSpacesTable = ({ }, }, { - header: 'Data Type', + header: t('analytics.databaseAnalysis.topNamespaces.dataType'), id: 'types', accessorKey: 'types', cell: ({ @@ -185,7 +187,7 @@ const NameSpacesTable = ({ ), }, { - header: 'Total Memory', + header: t('analytics.databaseAnalysis.topNamespaces.totalMemory'), id: 'memory', accessorKey: 'memory', enableSorting: true, @@ -209,7 +211,7 @@ const NameSpacesTable = ({ return ( @@ -220,7 +222,7 @@ const NameSpacesTable = ({ }, }, { - header: 'Total Keys', + header: t('analytics.databaseAnalysis.topNamespaces.totalKeys'), id: 'keys', accessorKey: 'keys', enableSorting: true, From f346e68638007c8f9d7adb7d1de2e4586145a525 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Thu, 16 Jul 2026 16:17:12 +0300 Subject: [PATCH 045/166] feat(i18n): migrate slow-log analytics page (RI-8276) (#6196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the Slow Log analytics page through i18n under analytics.slowLog.* with Bulgarian translations: page/table/actions/config/empty/clear-modal strings, the info tooltip and cluster/help texts via , and the count-based entries summary via i18next plurals. Introduces a shared analytics.units.* group (µs/ms/msec) resolved through t() everywhere units render (dropdown, converter, table header, tooltip, empty state) so they localize to Bulgarian (мкс/мс/мсек). The "Slow Log" feature name is translated to "Бавни команди"; Redis config directive names (slowlog-*) stay literal. Co-authored-by: Claude Opus 4.8 (1M context) --- redisinsight/ui/src/i18n/locales/bg.json | 35 +++++++++++ redisinsight/ui/src/i18n/locales/en.json | 35 +++++++++++ .../ui/src/pages/slow-log/SlowLogPage.tsx | 61 +++++++++++-------- .../slow-log/components/Actions/Actions.tsx | 34 +++++++---- .../ClearSlowLogModal/ClearSlowLogModal.tsx | 15 +++-- .../components/EmptySlowLog/EmptySlowLog.tsx | 28 +++++---- .../SlowLogConfig/SlowLogConfig.tsx | 60 ++++++++++-------- .../components/SlowLogTable/SlowLogTable.tsx | 17 ++++-- 8 files changed, 204 insertions(+), 81 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 4796872cea..d7d7da5ff5 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -68,8 +68,43 @@ "analytics.nav.databaseAnalysis": "Анализ на базата данни", "analytics.nav.overview": "Преглед", "analytics.nav.slowLog": "Бавни команди", + "analytics.slowLog.actions.clear": "Изчистване на бавни команди", + "analytics.slowLog.actions.configure": "Конфигуриране", + "analytics.slowLog.actions.tooltip.body": "Бавните команди са списък с бавни операции за вашата Redis инстанция. Те могат да се използват за отстраняване на проблеми с производителността.Всеки запис в списъка показва командата, продължителността и времевия печат. Всяка транзакция, която надвишава slowlog-log-slower-than {{unit}}, се записва до максимум slowlog-max-len, след което по-старите записи се премахват.", + "analytics.slowLog.actions.tooltip.title": "Бавни команди", + "analytics.slowLog.clearModal.button.cancel": "Отказ", + "analytics.slowLog.clearModal.button.clear": "Изчистване", + "analytics.slowLog.clearModal.message": "Бавните команди ще бъдат изчистени за {{name}}", + "analytics.slowLog.clearModal.note": "ЗАБЕЛЕЖКА: Това е конфигурация на сървъра", + "analytics.slowLog.clearModal.title": "Изчистване на бавни команди", + "analytics.slowLog.config.button.cancel": "Отказ", + "analytics.slowLog.config.button.default": "По подразбиране", + "analytics.slowLog.config.button.ok": "Ок", + "analytics.slowLog.config.button.save": "Запазване", + "analytics.slowLog.config.cluster": "Всеки възел може да има различна конфигурация на бавните команди в клъстерирана база данни.Използвайте CONFIG SET slowlog-log-slower-than или CONFIG SET slowlog-max-len за конкретен възел в redis-cli, за да я конфигурирате.", + "analytics.slowLog.config.maxLen.help": "Дължината на списъка с бавни команди. Когато се записва нова команда, най-старата
се премахва от опашката със записани команди.", + "analytics.slowLog.config.note": "ЗАБЕЛЕЖКА: Това е конфигурация на сървъра", + "analytics.slowLog.config.slowerThan.help": "Време за изпълнение, което да бъде надвишено, за да се запише командата.
-1 деактивира записването на бавни команди. 0 записва всяка команда.", + "analytics.slowLog.empty.description": "Или не са намерени команди, надвишаващи {{value}} {{unit}}, или записването на бавни команди е деактивирано на сървъра.", + "analytics.slowLog.empty.imageAlt": "Няма бавни команди", + "analytics.slowLog.empty.title": "Не са намерени бавни команди", + "analytics.slowLog.page.displayPerNode": "Показване на възел:", + "analytics.slowLog.page.displayUpTo": "Показване до:", + "analytics.slowLog.page.entriesFrom": "от", + "analytics.slowLog.page.entries_one": "{{count}} запис", + "analytics.slowLog.page.entries_other": "{{count}} записа", + "analytics.slowLog.page.executionInfo": "Време за изпълнение: {{time}} {{unit}}, Макс. дължина: {{maxLen}}", + "analytics.slowLog.page.maxAvailable": "Максимално налични", + "analytics.slowLog.page.pageTitle": "{{dbName}} - Бавни команди", + "analytics.slowLog.page.title": "Бавни команди", + "analytics.slowLog.table.command": "Команда", + "analytics.slowLog.table.duration": "Продължителност, {{unit}}", + "analytics.slowLog.table.timestamp": "Времеви печат", "analytics.units.bytes": "Б", "analytics.units.kbps": "кб/с", + "analytics.units.microseconds": "мкс", + "analytics.units.milliseconds": "мс", + "analytics.units.msec": "мсек", "analytics.units.percent": "%", "api.agreement.analytics.description": "Помогнете за подобряването на Redis Insight, като споделяте анонимни данни за употреба. Това ни помага да разберем използването на функциите и да направим приложението по-добро. Активирайки това, се съгласявате с нашата ", "api.agreement.analytics.label": "Данни за употреба", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index f618969c9c..75d8fa5726 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -68,8 +68,43 @@ "analytics.nav.databaseAnalysis": "Database Analysis", "analytics.nav.overview": "Overview", "analytics.nav.slowLog": "Slow Log", + "analytics.slowLog.actions.clear": "Clear Slow Log", + "analytics.slowLog.actions.configure": "Configure", + "analytics.slowLog.actions.tooltip.body": "Slow Log is a list of slow operations for your Redis instance. These can be used to troubleshoot performance issues.Each entry in the list displays the command, duration and timestamp. Any transaction that exceeds slowlog-log-slower-than {{unit}} are recorded up to a maximum of slowlog-max-len after which older entries are discarded.", + "analytics.slowLog.actions.tooltip.title": "Slow Log", + "analytics.slowLog.clearModal.button.cancel": "Cancel", + "analytics.slowLog.clearModal.button.clear": "Clear", + "analytics.slowLog.clearModal.message": "Slow Log will be cleared for {{name}}", + "analytics.slowLog.clearModal.note": "NOTE: This is server configuration", + "analytics.slowLog.clearModal.title": "Clear slow log", + "analytics.slowLog.config.button.cancel": "Cancel", + "analytics.slowLog.config.button.default": "Default", + "analytics.slowLog.config.button.ok": "Ok", + "analytics.slowLog.config.button.save": "Save", + "analytics.slowLog.config.cluster": "Each node can have different Slow Log configuration in a clustered database.Use CONFIG SET slowlog-log-slower-than or CONFIG SET slowlog-max-len for a specific node in redis-cli to configure it.", + "analytics.slowLog.config.maxLen.help": "The length of the Slow Log. When a new command is logged the oldest
one is removed from the queue of logged commands.", + "analytics.slowLog.config.note": "NOTE: This is server configuration", + "analytics.slowLog.config.slowerThan.help": "Execution time to exceed in order to log the command.
-1 disables Slow Log. 0 logs each command.", + "analytics.slowLog.empty.description": "Either no commands exceeding {{value}} {{unit}} were found or Slow Log is disabled on the server.", + "analytics.slowLog.empty.imageAlt": "No Slow Logs", + "analytics.slowLog.empty.title": "No Slow Logs found", + "analytics.slowLog.page.displayPerNode": "Display per node:", + "analytics.slowLog.page.displayUpTo": "Display up to:", + "analytics.slowLog.page.entriesFrom": "from", + "analytics.slowLog.page.entries_one": "{{count}} entry", + "analytics.slowLog.page.entries_other": "{{count}} entries", + "analytics.slowLog.page.executionInfo": "Execution time: {{time}} {{unit}}, Max length: {{maxLen}}", + "analytics.slowLog.page.maxAvailable": "Max available", + "analytics.slowLog.page.pageTitle": "{{dbName}} - Slow Log", + "analytics.slowLog.page.title": "Slow Log", + "analytics.slowLog.table.command": "Command", + "analytics.slowLog.table.duration": "Duration, {{unit}}", + "analytics.slowLog.table.timestamp": "Timestamp", "analytics.units.bytes": "B", "analytics.units.kbps": "kb/s", + "analytics.units.microseconds": "µs", + "analytics.units.milliseconds": "ms", + "analytics.units.msec": "msec", "analytics.units.percent": "%", "api.agreement.analytics.description": "Help improve Redis Insight by sharing anonymous usage data. This helps us understand feature usage and make the app better. By enabling this, you agree to our ", "api.agreement.analytics.label": "Usage Data", diff --git a/redisinsight/ui/src/pages/slow-log/SlowLogPage.tsx b/redisinsight/ui/src/pages/slow-log/SlowLogPage.tsx index 3fbeb34a3c..6c10c6590e 100644 --- a/redisinsight/ui/src/pages/slow-log/SlowLogPage.tsx +++ b/redisinsight/ui/src/pages/slow-log/SlowLogPage.tsx @@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' import { AutoSizer } from 'react-virtualized' +import { useTranslation } from 'uiSrc/i18n' import { DEFAULT_SLOWLOG_MAX_LEN, DurationUnits } from 'uiSrc/constants' import { convertNumberByUnits } from 'uiSrc/pages/slow-log/utils' import { appContextDbConfig } from 'uiSrc/slices/app/context' @@ -46,15 +47,9 @@ import { Container } from '../database-analysis/components/header/Header.styles' const HIDE_TIMESTAMP_FROM_WIDTH = 850 const DEFAULT_COUNT_VALUE = '50' const MAX_COUNT_VALUE = '-1' -const countOptions = [ - { value: '10', inputDisplay: '10' }, - { value: '25', inputDisplay: '25' }, - { value: '50', inputDisplay: '50' }, - { value: '100', inputDisplay: '100' }, - { value: MAX_COUNT_VALUE, inputDisplay: 'Max available' }, -] const SlowLogPage = () => { + const { t } = useTranslation() const { connectionType, name: connectedInstanceName, @@ -74,9 +69,20 @@ const SlowLogPage = () => { const dispatch = useAppDispatch() + const countOptions = [ + { value: '10', inputDisplay: '10' }, + { value: '25', inputDisplay: '25' }, + { value: '50', inputDisplay: '50' }, + { value: '100', inputDisplay: '100' }, + { + value: MAX_COUNT_VALUE, + inputDisplay: t('analytics.slowLog.page.maxAvailable'), + }, + ] + const lastTimestamp = minBy(data, 'time')?.time const dbName = `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)}` - setTitle(`${dbName} - Slow Log`) + setTitle(t('analytics.slowLog.page.pageTitle', { dbName })) useEffect(() => { getConfig() @@ -153,18 +159,19 @@ const SlowLogPage = () => { {connectionType !== ConnectionType.Cluster && config && ( - Execution time:{' '} - {numberWithSpaces( - convertNumberByUnits( - slowlogLogSlowerThan, - durationUnit, + {t('analytics.slowLog.page.executionInfo', { + time: numberWithSpaces( + convertNumberByUnits( + slowlogLogSlowerThan, + durationUnit, + ), ), - )} -   - {durationUnit === DurationUnits.milliSeconds - ? DurationUnits.mSeconds - : DurationUnits.microSeconds} - , Max length: {numberWithSpaces(slowlogMaxLen)} + unit: + durationUnit === DurationUnits.milliSeconds + ? t('analytics.units.msec') + : t('analytics.units.microseconds'), + maxLen: numberWithSpaces(slowlogMaxLen), + })} )} @@ -188,7 +195,7 @@ const SlowLogPage = () => { - Slow Log + {t('analytics.slowLog.page.title')} @@ -196,8 +203,8 @@ const SlowLogPage = () => { {connectionType === ConnectionType.Cluster - ? 'Display per node:' - : 'Display up to:'} + ? t('analytics.slowLog.page.displayPerNode') + : t('analytics.slowLog.page.displayUpTo')} @@ -216,10 +223,16 @@ const SlowLogPage = () => { color="secondary" data-testid="entries-from-timestamp" > - ({data.length} entries + ( + {t('analytics.slowLog.page.entries', { + count: data.length, + })} {lastTimestamp && ( <> -  from   + +  {t('analytics.slowLog.page.entriesFrom')} +   + )} diff --git a/redisinsight/ui/src/pages/slow-log/components/Actions/Actions.tsx b/redisinsight/ui/src/pages/slow-log/components/Actions/Actions.tsx index 59cc70ec5b..146ee5fe06 100644 --- a/redisinsight/ui/src/pages/slow-log/components/Actions/Actions.tsx +++ b/redisinsight/ui/src/pages/slow-log/components/Actions/Actions.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' +import { Trans, useTranslation } from 'uiSrc/i18n' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { DurationUnits } from 'uiSrc/constants' import { slowLogSelector } from 'uiSrc/slices/analytics/slowlog' @@ -36,6 +37,7 @@ const Actions = (props: Props) => { onClear = () => {}, onRefresh, } = props + const { t } = useTranslation() const { instanceId } = useParams<{ instanceId: string }>() const { name = '' } = useAppSelector(connectedInstanceSelector) const { loading, lastRefreshTime } = useAppSelector(slowLogSelector) @@ -114,11 +116,11 @@ const Actions = (props: Props) => { showConfigPopover()} data-testid="configure-btn" > - Configure + {t('analytics.slowLog.actions.configure')} } > @@ -133,7 +135,7 @@ const Actions = (props: Props) => { <> showClearModal()} data-testid="clear-btn" /> @@ -149,18 +151,26 @@ const Actions = (props: Props) => { - Slow Log is a list of slow operations for your Redis instance. - These can be used to troubleshoot performance issues. - - Each entry in the list displays the command, duration and - timestamp. Any transaction that exceeds{' '} - slowlog-log-slower-than {durationUnit} are recorded up to a - maximum of slowlog-max-len after which older entries are - discarded. + , + bold: , + }} + /> } > diff --git a/redisinsight/ui/src/pages/slow-log/components/ClearSlowLogModal/ClearSlowLogModal.tsx b/redisinsight/ui/src/pages/slow-log/components/ClearSlowLogModal/ClearSlowLogModal.tsx index 5318bde055..f445121fdd 100644 --- a/redisinsight/ui/src/pages/slow-log/components/ClearSlowLogModal/ClearSlowLogModal.tsx +++ b/redisinsight/ui/src/pages/slow-log/components/ClearSlowLogModal/ClearSlowLogModal.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Button, DestructiveButton } from 'uiSrc/components/base/forms/buttons' import { Col, FlexGroup, Row } from 'uiSrc/components/base/layout/flex' import { Title, Text } from 'uiSrc/components/base/text' @@ -21,6 +22,8 @@ export const ClearSlowLogModal = ({ onClose, onClear, }: ClearSlowLogModalProps) => { + const { t } = useTranslation() + const handleClearClick = () => { onClear() onClose() @@ -31,7 +34,9 @@ export const ClearSlowLogModal = ({ isOpen={isOpen} onClose={onClose} data-testid="clear-slow-log-modal" - header={Clear slow log} + header={ + {t('analytics.slowLog.clearModal.title')} + } footer={ handleClearClick()} data-testid="reset-confirm-btn" > - Clear + {t('analytics.slowLog.clearModal.button.clear')} } @@ -57,10 +62,10 @@ export const ClearSlowLogModal = ({
- Slow Log will be cleared for {name} + {t('analytics.slowLog.clearModal.message', { name })} - NOTE: This is server configuration + {t('analytics.slowLog.clearModal.note')} diff --git a/redisinsight/ui/src/pages/slow-log/components/EmptySlowLog/EmptySlowLog.tsx b/redisinsight/ui/src/pages/slow-log/components/EmptySlowLog/EmptySlowLog.tsx index 378ba60464..3cabd738bd 100644 --- a/redisinsight/ui/src/pages/slow-log/components/EmptySlowLog/EmptySlowLog.tsx +++ b/redisinsight/ui/src/pages/slow-log/components/EmptySlowLog/EmptySlowLog.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useTheme } from '@redis-ui/styles' +import { useTranslation } from 'uiSrc/i18n' import { DurationUnits } from 'uiSrc/constants' import { Title } from 'uiSrc/components/base/text/Title' import { convertNumberByUnits } from 'uiSrc/pages/slow-log/utils' @@ -18,28 +19,33 @@ export interface Props { const EmptySlowLog = (props: Props) => { const { durationUnit, slowlogLogSlowerThan } = props + const { t } = useTranslation() const theme = useTheme() const icon = theme.name === 'dark' ? NoQueryResultsIconDark : NoQueryResultsIcon + const value = numberWithSpaces( + convertNumberByUnits(slowlogLogSlowerThan, durationUnit), + ) + const unit = + durationUnit === DurationUnits.milliSeconds + ? t('analytics.units.msec') + : t('analytics.units.microseconds') + return ( - + - No Slow Logs found + {t('analytics.slowLog.empty.title')} - Either no commands exceeding  - {numberWithSpaces( - convertNumberByUnits(slowlogLogSlowerThan, durationUnit), - )} -   - {durationUnit === DurationUnits.milliSeconds - ? DurationUnits.mSeconds - : DurationUnits.microSeconds} -  were found or Slow Log is disabled on the server. + {t('analytics.slowLog.empty.description', { value, unit })} diff --git a/redisinsight/ui/src/pages/slow-log/components/SlowLogConfig/SlowLogConfig.tsx b/redisinsight/ui/src/pages/slow-log/components/SlowLogConfig/SlowLogConfig.tsx index 5b8dd67b16..54d89fd0f2 100644 --- a/redisinsight/ui/src/pages/slow-log/components/SlowLogConfig/SlowLogConfig.tsx +++ b/redisinsight/ui/src/pages/slow-log/components/SlowLogConfig/SlowLogConfig.tsx @@ -2,6 +2,7 @@ import { toNumber } from 'lodash' import React, { useState } from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' +import { Trans, useTranslation } from 'uiSrc/i18n' import { DEFAULT_SLOWLOG_DURATION_UNIT, DEFAULT_SLOWLOG_MAX_LEN, @@ -42,7 +43,14 @@ export interface Props { } const SlowLogConfig = ({ closePopover, onRefresh }: Props) => { - const options = DURATION_UNITS + const { t } = useTranslation() + const options = DURATION_UNITS.map((option) => ({ + ...option, + inputDisplay: + option.value === DurationUnits.milliSeconds + ? t('analytics.units.msec') + : t('analytics.units.microseconds'), + })) const { instanceId } = useParams<{ instanceId: string }>() const connectionType = useConnectionType() const { loading } = useAppSelector(slowLogSelector) @@ -120,45 +128,46 @@ const SlowLogConfig = ({ closePopover, onRefresh }: Props) => { const clusterContent = () => ( <> - Each node can have different Slow Log configuration in a clustered - database. - - {'Use '} - CONFIG SET slowlog-log-slower-than - {' or '} - CONFIG SET slowlog-max-len - {' for a specific node in redis-cli to configure it.'} + , + code: , + }} + /> - Ok + {t('analytics.slowLog.config.button.ok')} ) const unitConverter = () => { + const msecLabel = t('analytics.units.msec') + if (Number.isNaN(toNumber(slowerThan))) { - return `- ${DurationUnits.mSeconds}` + return `- ${msecLabel}` } if (slowerThan === `${MINUS_ONE}`) { - return `-1 ${DurationUnits.mSeconds}` + return `-1 ${msecLabel}` } if (durationUnit === DurationUnits.microSeconds) { const value = numberWithSpaces( convertNumberByUnits(toNumber(slowerThan), DurationUnits.milliSeconds), ) - return `${value} ${DurationUnits.mSeconds}` + return `${value} ${msecLabel}` } if (durationUnit === DurationUnits.milliSeconds) { const value = numberWithSpaces(toNumber(slowerThan) * 1000) - return `${value} ${DurationUnits.microSeconds}` + return `${value} ${t('analytics.units.microseconds')}` } return null } @@ -190,9 +199,10 @@ const SlowLogConfig = ({ closePopover, onRefresh }: Props) => { size="s" data-testid="unit-converter" > - Execution time to exceed in order to log the command. -
- -1 disables Slow Log. 0 logs each command. + }} + /> } @@ -224,10 +234,10 @@ const SlowLogConfig = ({ closePopover, onRefresh }: Props) => { label={slowlog-max-len} additionalText={ - The length of the Slow Log. When a new command is logged the - oldest -
- one is removed from the queue of logged commands. + }} + />
} > @@ -249,7 +259,7 @@ const SlowLogConfig = ({ closePopover, onRefresh }: Props) => { - NOTE: This is server configuration + {t('analytics.slowLog.config.note')} @@ -258,20 +268,20 @@ const SlowLogConfig = ({ closePopover, onRefresh }: Props) => { onClick={handleDefault} data-testid="slowlog-config-default-btn" > - Default + {t('analytics.slowLog.config.button.default')} - Cancel + {t('analytics.slowLog.config.button.cancel')} - Save + {t('analytics.slowLog.config.button.save')} diff --git a/redisinsight/ui/src/pages/slow-log/components/SlowLogTable/SlowLogTable.tsx b/redisinsight/ui/src/pages/slow-log/components/SlowLogTable/SlowLogTable.tsx index 6038f92ddb..0901e2412b 100644 --- a/redisinsight/ui/src/pages/slow-log/components/SlowLogTable/SlowLogTable.tsx +++ b/redisinsight/ui/src/pages/slow-log/components/SlowLogTable/SlowLogTable.tsx @@ -1,6 +1,7 @@ import React from 'react' import { useParams } from 'react-router-dom' -import { DURATION_UNITS, DurationUnits, SortOrder } from 'uiSrc/constants' +import { useTranslation } from 'uiSrc/i18n' +import { DurationUnits, SortOrder } from 'uiSrc/constants' import { convertNumberByUnits } from 'uiSrc/pages/slow-log/utils' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { numberWithSpaces } from 'uiSrc/utils/numbers' @@ -27,12 +28,18 @@ export interface Props { const SlowLogTable = (props: Props) => { const { items = [], durationUnit } = props + const { t } = useTranslation() const { instanceId } = useParams<{ instanceId: string }>() + const durationUnitLabel = + durationUnit === DurationUnits.milliSeconds + ? t('analytics.units.msec') + : t('analytics.units.microseconds') + const columns: ColumnDef[] = [ { id: 'time', - header: 'Timestamp', + header: t('analytics.slowLog.table.timestamp'), accessorKey: 'time', size: 200, cell: ({ getValue }) => { @@ -43,7 +50,9 @@ const SlowLogTable = (props: Props) => { }, { id: 'durationUs', - header: `Duration, ${DURATION_UNITS.find(({ value }) => value === durationUnit)?.inputDisplay}`, + header: t('analytics.slowLog.table.duration', { + unit: durationUnitLabel, + }), accessorKey: 'durationUs', size: 150, cell: ({ getValue }) => { @@ -58,7 +67,7 @@ const SlowLogTable = (props: Props) => { }, { id: 'args', - header: 'Command', + header: t('analytics.slowLog.table.command'), accessorKey: 'args', size: 850, cell: ({ getValue }) => { From bcf558570cd73d8f7b8d4cf72625f9d342550059 Mon Sep 17 00:00:00 2001 From: Krum Tyukenov Date: Fri, 17 Jul 2026 10:08:23 +0300 Subject: [PATCH 046/166] feat(feature-flags): roll out whatsNew to 50% of users (#6220) Reduce the whatsNew feature flag rollout percentage from 100% to 50% and bump the features config version. Co-authored-by: Claude Opus 4.8 --- redisinsight/api/config/features-config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 5443eed385..d4b7fac67d 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 7, + "version": 8, "features": { "dev-language": { "flag": true, @@ -158,7 +158,7 @@ }, "whatsNew": { "flag": true, - "perc": [[0, 100]] + "perc": [[0, 50]] }, "valueDecoder": { "flag": false, From 474f4750786d22f642138e2d79e814223c7c7029 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Fri, 17 Jul 2026 11:30:56 +0300 Subject: [PATCH 047/166] feat(i18n): migrate pub-sub page (RI-8277) (#6218) Route the Pub/Sub page through i18n under pubsub.* with Bulgarian translations: page title, empty state (title/description, production and SPUBLISH warnings, image alt), messages/status header and Subscribed/Unsubscribed badges, publish form (labels, placeholders, Publish button, published/published-with-clients result), patterns info, subscribe form (pattern placeholder, aria-labels, subscribe/ unsubscribe, clear tooltip), and the message cell tooltip/copy label. The subscribe-information glob-patterns sentence uses for its inline docs link. Table column headers (Timestamp/Channel/Message) are resolved at render via getPubSubTableColumns(t) instead of a module-level map, so they localize correctly under ?lang=bg. Co-authored-by: Claude Opus 4.8 --- redisinsight/ui/src/i18n/locales/bg.json | 33 ++++++++ redisinsight/ui/src/i18n/locales/en.json | 33 ++++++++ .../ui/src/pages/pub-sub/PubSubPage.tsx | 4 +- .../EmptyMessagesList/EmptyMessagesList.tsx | 79 ++++++++++--------- .../MessagesListTable.config.tsx | 18 ++--- .../MessagesListTable.constants.ts | 9 --- .../MessagesListTable/MessagesListTable.tsx | 26 ++++-- .../MessagesListTableCellMessage.tsx | 9 ++- .../components/patternsInfo/PatternsInfo.tsx | 8 +- .../publish-message/PublishMessage.tsx | 16 ++-- .../subscribe-form/SubscribeForm.tsx | 14 ++-- .../SubscribeInformation.tsx | 64 ++++++++------- 12 files changed, 203 insertions(+), 110 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index d7d7da5ff5..3abb5921c3 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -442,6 +442,39 @@ "notification.success.vectorSearchSampleDataCreated.title": "Примерните данни вече са достъпни за търсене.", "notification.success.vectorSearchSampleDataExists.message": "Можете да започнете да пишете нови заявки или да разгледате съществуващи в Библиотеката.", "notification.success.vectorSearchSampleDataExists.title": "Примерните данни вече са достъпни за търсене чрез съществуващ индекс.", + "pubsub.empty.description": "Абонирайте се за канала, за да видите всички съобщения, публикувани във вашата база данни", + "pubsub.empty.imageAlt": "Pub/Sub", + "pubsub.empty.productionWarning": "Изпълнението в производствена среда може да намали производителността и наличната памет.", + "pubsub.empty.spublishWarning": "Съобщенията, публикувани със SPUBLISH, няма да се появят в този канал", + "pubsub.empty.title": "Не сте абонирани", + "pubsub.messageCell.copyAriaLabel": "Копиране на съобщението", + "pubsub.messageCell.title": "Съобщение", + "pubsub.messages.label": "Съобщения:", + "pubsub.pageTitle": "{{dbName}} - Pub/Sub", + "pubsub.patterns.all": "Всички", + "pubsub.patterns.label": "Шаблони: {{value}}", + "pubsub.publish.button": "Публикуване", + "pubsub.publish.channelLabel": "Име на канал", + "pubsub.publish.channelPlaceholder": "Въведете име на канал", + "pubsub.publish.messageLabel": "Съобщение", + "pubsub.publish.messagePlaceholder": "Въведете съобщение", + "pubsub.publish.published": "Публикувано", + "pubsub.publish.publishedWithClients": "Публикувано ({{clients}})", + "pubsub.status.label": "Статус:", + "pubsub.status.subscribed": "Абониран", + "pubsub.status.unsubscribed": "Неабониран", + "pubsub.subscribe.button.subscribe": "Абониране", + "pubsub.subscribe.button.unsubscribe": "Отписване", + "pubsub.subscribe.channelsAriaLabel": "имена на канали за филтриране", + "pubsub.subscribe.clearAriaLabel": "изчистване на pub sub", + "pubsub.subscribe.clearTooltip": "Изчистване на съобщенията", + "pubsub.subscribe.info.channels": "Абонирайте се за един или повече канали или шаблони, като ги въведете, разделени с интервали.", + "pubsub.subscribe.info.patterns": "Поддържаните glob-style шаблони са описани тук.", + "pubsub.subscribe.patternPlaceholder": "Въведете шаблон", + "pubsub.table.column.channel": "Канал", + "pubsub.table.column.message": "Съобщение", + "pubsub.table.column.timestamp": "Времеви печат", + "pubsub.table.empty": "Все още няма публикувани съобщения", "query.actions.groupMode.label": "Групиране на резултатите", "query.actions.groupMode.tooltip": "Групира резултатите от командите в един прозорец.Когато са групирани, резултатите могат да се визуализират само в текстов формат.", "query.actions.rawMode.label": "Необработен режим", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 75d8fa5726..8e1fc8d9e4 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -442,6 +442,39 @@ "notification.success.vectorSearchSampleDataCreated.title": "Your sample data is now searchable.", "notification.success.vectorSearchSampleDataExists.message": "You can start building new queries or explore existing ones in the Query Library.", "notification.success.vectorSearchSampleDataExists.title": "Your sample data is already searchable using an existing index.", + "pubsub.empty.description": "Subscribe to the Channel to see all the messages published to your database", + "pubsub.empty.imageAlt": "Pub/Sub", + "pubsub.empty.productionWarning": "Running in production may decrease performance and memory available.", + "pubsub.empty.spublishWarning": "Messages published with SPUBLISH will not appear in this channel", + "pubsub.empty.title": "You are not subscribed", + "pubsub.messageCell.copyAriaLabel": "Copy message", + "pubsub.messageCell.title": "Message", + "pubsub.messages.label": "Messages:", + "pubsub.pageTitle": "{{dbName}} - Pub/Sub", + "pubsub.patterns.all": "All", + "pubsub.patterns.label": "Patterns: {{value}}", + "pubsub.publish.button": "Publish", + "pubsub.publish.channelLabel": "Channel name", + "pubsub.publish.channelPlaceholder": "Enter Channel Name", + "pubsub.publish.messageLabel": "Message", + "pubsub.publish.messagePlaceholder": "Enter Message", + "pubsub.publish.published": "Published", + "pubsub.publish.publishedWithClients": "Published ({{clients}})", + "pubsub.status.label": "Status:", + "pubsub.status.subscribed": "Subscribed", + "pubsub.status.unsubscribed": "Unsubscribed", + "pubsub.subscribe.button.subscribe": "Subscribe", + "pubsub.subscribe.button.unsubscribe": "Unsubscribe", + "pubsub.subscribe.channelsAriaLabel": "channel names for filtering", + "pubsub.subscribe.clearAriaLabel": "clear pub sub", + "pubsub.subscribe.clearTooltip": "Clear Messages", + "pubsub.subscribe.info.channels": "Subscribe to one or more channels or patterns by entering them, separated by spaces.", + "pubsub.subscribe.info.patterns": "Supported glob-style patterns are described here.", + "pubsub.subscribe.patternPlaceholder": "Enter Pattern", + "pubsub.table.column.channel": "Channel", + "pubsub.table.column.message": "Message", + "pubsub.table.column.timestamp": "Timestamp", + "pubsub.table.empty": "No messages published yet", "query.actions.groupMode.label": "Group results", "query.actions.groupMode.tooltip": "Groups the command results into a single window.When grouped, the results can be visualized only in the text format.", "query.actions.rawMode.label": "Raw mode", diff --git a/redisinsight/ui/src/pages/pub-sub/PubSubPage.tsx b/redisinsight/ui/src/pages/pub-sub/PubSubPage.tsx index 18b49b5716..bedafa180e 100644 --- a/redisinsight/ui/src/pages/pub-sub/PubSubPage.tsx +++ b/redisinsight/ui/src/pages/pub-sub/PubSubPage.tsx @@ -11,6 +11,7 @@ import { } from 'uiSrc/telemetry' import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' import { OnboardingTour } from 'uiSrc/components' import { ONBOARDING_FEATURES } from 'uiSrc/components/onboarding-features' import { incrementOnboardStepAction } from 'uiSrc/slices/app/features' @@ -27,6 +28,7 @@ const FooterPanel = styled(FlexItem)` ` const PubSubPage = () => { + const { t } = useTranslation() const { name: connectedInstanceName, db } = useAppSelector( connectedInstanceSelector, ) @@ -37,7 +39,7 @@ const PubSubPage = () => { const dispatch = useAppDispatch() const dbName = `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)}` - setTitle(`${dbName} - Pub/Sub`) + setTitle(t('pubsub.pageTitle', { dbName })) useEffect( () => () => { diff --git a/redisinsight/ui/src/pages/pub-sub/components/messages-list/EmptyMessagesList/EmptyMessagesList.tsx b/redisinsight/ui/src/pages/pub-sub/components/messages-list/EmptyMessagesList/EmptyMessagesList.tsx index 8b9433760d..d3b9b1cac6 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/messages-list/EmptyMessagesList/EmptyMessagesList.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/messages-list/EmptyMessagesList/EmptyMessagesList.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useTranslation } from 'uiSrc/i18n' import { ConnectionType } from 'uiSrc/slices/interfaces' import { Text, Title } from 'uiSrc/components/base/text' import { Col } from 'uiSrc/components/base/layout/flex' @@ -17,43 +18,45 @@ export interface Props { const EmptyMessagesList = ({ connectionType, isSpublishNotSupported, -}: Props) => ( - - - - -
- You are not subscribed - - - Subscribe to the Channel to see all the messages published to your - database - - - - - - - Running in production may decrease performance and memory available. - - - {connectionType === ConnectionType.Cluster && isSpublishNotSupported && ( - <> - - - )} - - -) +}: Props) => { + const { t } = useTranslation() + + return ( + + + + + + {t('pubsub.empty.title')} + + {t('pubsub.empty.description')} + + + + + + {t('pubsub.empty.productionWarning')} + + + {connectionType === ConnectionType.Cluster && + isSpublishNotSupported && ( + <> + + + )} + + + ) +} export default EmptyMessagesList diff --git a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.config.tsx b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.config.tsx index b399cc120c..9fce62cb3d 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.config.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.config.tsx @@ -1,22 +1,20 @@ +import { TFunction } from 'i18next' import { ColumnDef, PaginationState } from 'uiSrc/components/base/layout/table' import { BrowserStorageItem } from 'uiSrc/constants' import { TableStorageKey } from 'uiSrc/constants/storage' import { setObjectStorageField, getObjectStorageField } from 'uiSrc/services' import { PubSubMessage } from 'uiSrc/slices/interfaces' -import { - PUB_SUB_TABLE_COLUMN_FIELD_NAME_MAP, - PubSubTableColumn, -} from './MessagesListTable.constants' +import { PubSubTableColumn } from './MessagesListTable.constants' import MessagesListTableCellTimestamp from './components/MessagesListTableCellTimestamp' import MessagesListTableCellMessage from './components/MessagesListTableCellMessage' -export const PUB_SUB_TABLE_COLUMNS: ColumnDef[] = [ +export const getPubSubTableColumns = ( + t: TFunction, +): ColumnDef[] => [ { id: PubSubTableColumn.Timestamp, accessorKey: PubSubTableColumn.Timestamp, - header: PUB_SUB_TABLE_COLUMN_FIELD_NAME_MAP.get( - PubSubTableColumn.Timestamp, - ), + header: t('pubsub.table.column.timestamp'), size: 200, enableSorting: true, cell: MessagesListTableCellTimestamp, @@ -24,13 +22,13 @@ export const PUB_SUB_TABLE_COLUMNS: ColumnDef[] = [ { id: PubSubTableColumn.Channel, accessorKey: PubSubTableColumn.Channel, - header: PUB_SUB_TABLE_COLUMN_FIELD_NAME_MAP.get(PubSubTableColumn.Channel), + header: t('pubsub.table.column.channel'), size: 200, }, { id: PubSubTableColumn.Message, accessorKey: PubSubTableColumn.Message, - header: PUB_SUB_TABLE_COLUMN_FIELD_NAME_MAP.get(PubSubTableColumn.Message), + header: t('pubsub.table.column.message'), size: 800, cell: MessagesListTableCellMessage, }, diff --git a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.constants.ts b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.constants.ts index e2818cf7a0..e5c91ce6ab 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.constants.ts +++ b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.constants.ts @@ -3,12 +3,3 @@ export enum PubSubTableColumn { Channel = 'channel', Message = 'message', } - -export const PUB_SUB_TABLE_COLUMN_FIELD_NAME_MAP = new Map< - PubSubTableColumn, - string ->([ - [PubSubTableColumn.Timestamp, 'Timestamp'], - [PubSubTableColumn.Channel, 'Channel'], - [PubSubTableColumn.Message, 'Message'], -]) diff --git a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.tsx b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.tsx index 9530b89401..cf8ce51fdd 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/MessagesListTable.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' +import { useTranslation } from 'uiSrc/i18n' import { connectedInstanceOverviewSelector } from 'uiSrc/slices/instances/instances' import { pubSubSelector } from 'uiSrc/slices/pubsub/pubsub' import { isVersionHigherOrEquals } from 'uiSrc/utils' @@ -16,8 +17,8 @@ import { Table } from 'uiSrc/components/base/layout/table' import { Wrapper } from './MessagesListTable.styles' import { getDefaultPagination, + getPubSubTableColumns, handlePaginationChange, - PUB_SUB_TABLE_COLUMNS, } from './MessagesListTable.config' import { PubSubTableColumn } from './MessagesListTable.constants' import PatternsInfo from '../../patternsInfo' @@ -25,6 +26,7 @@ import SubscribeForm from '../../subscribe-form' import EmptyMessagesList from '../EmptyMessagesList' const MessagesListTable = () => { + const { t } = useTranslation() const { messages = [], isSubscribed, @@ -51,6 +53,8 @@ const MessagesListTable = () => { const hasMessages = messages.length > 0 + const columns = useMemo(() => getPubSubTableColumns(t), [t]) + if (hasMessages || isSubscribed) { return ( @@ -59,7 +63,7 @@ const MessagesListTable = () => { - Messages: + {t('pubsub.messages.label')} {messages.length} @@ -72,11 +76,17 @@ const MessagesListTable = () => { gap="s" data-testid="pub-sub-status" > - Status: + {t('pubsub.status.label')} {isSubscribed ? ( - + ) : ( - + )} @@ -86,7 +96,7 @@ const MessagesListTable = () => {
{ defaultSorting={[{ id: PubSubTableColumn.Timestamp, desc: true }]} onPaginationChange={handlePaginationChange} defaultPagination={getDefaultPagination()} - emptyState="No messages published yet" + emptyState={t('pubsub.table.empty')} /> diff --git a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/components/MessagesListTableCellMessage.tsx b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/components/MessagesListTableCellMessage.tsx index 9ab97e8173..51d58c1840 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/components/MessagesListTableCellMessage.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/messages-list/MessagesListTable/components/MessagesListTableCellMessage.tsx @@ -6,17 +6,22 @@ import { CopyBtnWrapper, } from 'uiSrc/components/auto-discover' import { RiTooltip } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' import { IMessagesListTableCell } from '../MessagesListTable.types' const MessagesListTableCellMessage: IMessagesListTableCell = ({ row }) => { + const { t } = useTranslation() const { message = '' } = row.original return ( - + {message} - + ) } diff --git a/redisinsight/ui/src/pages/pub-sub/components/patternsInfo/PatternsInfo.tsx b/redisinsight/ui/src/pages/pub-sub/components/patternsInfo/PatternsInfo.tsx index dc638099e5..abcf34aee2 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/patternsInfo/PatternsInfo.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/patternsInfo/PatternsInfo.tsx @@ -1,5 +1,6 @@ import React from 'react' import { RiTooltip } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' import { DEFAULT_SEARCH_MATCH } from 'uiSrc/constants/api' import { Text } from 'uiSrc/components/base/text' @@ -12,15 +13,18 @@ export interface PatternsInfoProps { } const PatternsInfo = ({ channels }: PatternsInfoProps) => { + const { t } = useTranslation() + const getChannelsCount = () => { - if (!channels || channels?.trim() === DEFAULT_SEARCH_MATCH) return 'All' + if (!channels || channels?.trim() === DEFAULT_SEARCH_MATCH) + return t('pubsub.patterns.all') return channels.trim().split(' ').length } return ( - Patterns: {getChannelsCount()} + {t('pubsub.patterns.label', { value: getChannelsCount() })} diff --git a/redisinsight/ui/src/pages/pub-sub/components/publish-message/PublishMessage.tsx b/redisinsight/ui/src/pages/pub-sub/components/publish-message/PublishMessage.tsx index bae4d7b984..bda6f96fa1 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/publish-message/PublishMessage.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/publish-message/PublishMessage.tsx @@ -8,6 +8,7 @@ import { import { ConnectionType } from 'uiSrc/slices/interfaces' import { publishMessageAction } from 'uiSrc/slices/pubsub/pubsub' import { useConnectionType } from 'uiSrc/components/hooks/useConnectionType' +import { useTranslation } from 'uiSrc/i18n' import { Col, FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' @@ -24,6 +25,7 @@ import { const HIDE_BADGE_TIMER = 3000 const PublishMessage = () => { + const { t } = useTranslation() const { channel: channelContext, message: messageContext } = useAppSelector(appContextPubSub) const connectionType = useConnectionType() @@ -76,19 +78,21 @@ const PublishMessage = () => { } const getClientsText = (clients?: number) => - typeof clients !== 'number' ? 'Published' : `Published (${clients})` + typeof clients !== 'number' + ? t('pubsub.publish.published') + : t('pubsub.publish.publishedWithClients', { clients }) return ( - Channel name + {t('pubsub.publish.channelLabel')} setChannel(value)} autoComplete="off" @@ -98,11 +102,11 @@ const PublishMessage = () => { - Message + {t('pubsub.publish.messageLabel')} setMessage(value)} autoComplete="off" @@ -136,7 +140,7 @@ const PublishMessage = () => { type="submit" data-testid="publish-message-submit" > - Publish + {t('pubsub.publish.button')} diff --git a/redisinsight/ui/src/pages/pub-sub/components/subscribe-form/SubscribeForm.tsx b/redisinsight/ui/src/pages/pub-sub/components/subscribe-form/SubscribeForm.tsx index ed3c96052f..11933e7189 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/subscribe-form/SubscribeForm.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/subscribe-form/SubscribeForm.tsx @@ -19,11 +19,13 @@ import { DEFAULT_SEARCH_MATCH } from 'uiSrc/constants/api' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { RiTooltip } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' import type { SubscribeFormProps } from './SubscribeForm.types' import * as S from './SubscribeForm.styles' import SubscribeInformation from '../subscribe-information' const SubscribeForm = (props: SubscribeFormProps) => { + const { t } = useTranslation() const dispatch = useAppDispatch() const { isSubscribed, subscriptions, loading, count } = @@ -65,8 +67,8 @@ const SubscribeForm = (props: SubscribeFormProps) => { disabled={isSubscribed} onChange={(value) => setChannels(value)} onBlur={onFocusOut} - placeholder="Enter Pattern" - aria-label="channel names for filtering" + placeholder={t('pubsub.subscribe.patternPlaceholder')} + aria-label={t('pubsub.subscribe.channelsAriaLabel')} data-testid="channels-input" /> @@ -81,14 +83,16 @@ const SubscribeForm = (props: SubscribeFormProps) => { onClick={toggleSubscribe} disabled={loading} > - {isSubscribed ? 'Unsubscribe' : 'Subscribe'} + {isSubscribed + ? t('pubsub.subscribe.button.unsubscribe') + : t('pubsub.subscribe.button.subscribe')} - + diff --git a/redisinsight/ui/src/pages/pub-sub/components/subscribe-information/SubscribeInformation.tsx b/redisinsight/ui/src/pages/pub-sub/components/subscribe-information/SubscribeInformation.tsx index 364152d1c2..b6376b67d2 100644 --- a/redisinsight/ui/src/pages/pub-sub/components/subscribe-information/SubscribeInformation.tsx +++ b/redisinsight/ui/src/pages/pub-sub/components/subscribe-information/SubscribeInformation.tsx @@ -1,5 +1,6 @@ import React from 'react' import { getUtmExternalLink } from 'uiSrc/utils/links' +import { Trans, useTranslation } from 'uiSrc/i18n' import { Text } from 'uiSrc/components/base/text' import { EXTERNAL_LINKS, @@ -11,35 +12,40 @@ import { RiTooltip } from 'uiSrc/components/base' import { Col } from 'uiSrc/components/base/layout/flex' import { InfoIcon } from './SubscribeInformation.styles' -const SubscribeInformation = () => ( - - - Subscribe to one or more channels or patterns by entering them, - separated by spaces. - +const SubscribeInformation = () => { + const { t } = useTranslation() - - Supported glob-style patterns are described  - - here. - - - - } - > - - -) + return ( + + {t('pubsub.subscribe.info.channels')} + + + + ), + }} + /> + + + } + > + + + ) +} export default SubscribeInformation From c2767e7809eb3150c80ef3aeea183bbae5ee70e5 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Fri, 17 Jul 2026 16:51:47 +0300 Subject: [PATCH 048/166] feat(i18n): migrate redis-cluster autodiscovery page (RI-8277) (#6219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the Auto-Discover Redis Enterprise Databases pages through i18n under cluster.* with Bulgarian translations: page/document titles, databases subtitle and result title (count-based plurals), add/view buttons, cancel-confirm popover, loading/not-found/no-results messages, summary text (plural success/fail counts), and the column headers and cell tooltips (Database/Endpoint/Result/Error, copy aria-label). Column headers previously came from the RedisClusterTitles enum; that enum is removed and headers now resolve via i18n.t() inside the column factory functions (render-time), so they localize under ?lang=bg. The Timestamp-style term "Времеви печат" is reused for consistency. Co-authored-by: Claude Opus 4.8 --- redisinsight/ui/src/i18n/locales/bg.json | 27 ++++++++ redisinsight/ui/src/i18n/locales/en.json | 27 ++++++++ .../redis-cluster/RedisClusterDatabases.tsx | 34 ++++------ .../RedisClusterDatabasesResult.tsx | 25 +++---- .../columns/capabilities.tsx | 8 +-- .../column-definitions/columns/database.tsx | 8 +-- .../column-definitions/columns/endpoint.tsx | 8 +-- .../column-definitions/columns/options.tsx | 8 +-- .../column-definitions/columns/result.tsx | 8 +-- .../column-definitions/columns/status.ts | 8 +-- .../components/DatabaseCell.tsx | 4 +- .../components/EndpointCell.tsx | 6 +- .../components/ResultCell.tsx | 10 ++- .../components/CancelButton/CancelButton.tsx | 68 ++++++++++--------- .../components/SummaryText/SummaryText.tsx | 33 +++++---- .../redis-cluster/constants/constants.ts | 9 --- .../useClusterDatabasesConfig.tsx | 4 +- 17 files changed, 165 insertions(+), 130 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 3abb5921c3..04af87426e 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -308,6 +308,33 @@ "browser.array.delete.range.trigger": "Изтриване на диапазон", "browser.array.delete.row.message": "Този елемент ще бъде премахнат за постоянно от масива.", "browser.array.delete.row.title": "Изтриване на елемент", + "cluster.cancel.button": "Отказ", + "cluster.cancel.confirm": "Промените ви не са запазени. Искате ли да продължите към списъка с бази данни?", + "cluster.cancel.proceed": "Продължаване", + "cluster.column.capabilities": "Възможности", + "cluster.column.database": "База данни", + "cluster.column.endpoint": "Крайна точка", + "cluster.column.options": "Опции", + "cluster.column.result": "Резултат", + "cluster.column.status": "Статус", + "cluster.databases.addButton": "Добавяне на избраните бази данни", + "cluster.databases.noResults": "Вашият Redis Enterprise клъстер няма налични бази данни.", + "cluster.databases.subtitle_one": "Това е базата данни във вашия Redis Enterprise клъстер. Изберете базата данни, която искате да добавите.", + "cluster.databases.subtitle_other": "Това са базите данни във вашия Redis Enterprise клъстер. Изберете базите данни, които искате да добавите.", + "cluster.databases.title": "Автоматично откриване на бази данни на Redis Enterprise", + "cluster.endpoint.copyAriaLabel": "Копиране на публичната крайна точка", + "cluster.loadingMsg": "Моля изчакайте...", + "cluster.notFound": "Не успяхме да намерим нищо", + "cluster.result.error": "Грешка", + "cluster.result.pageTitle": "Добавени бази данни на Redis Enterprise", + "cluster.result.title_one": "Добавена база данни на Redis Enterprise", + "cluster.result.title_other": "Добавени бази данни на Redis Enterprise", + "cluster.result.viewButton": "Преглед на базите данни", + "cluster.summary.fail_one": "Неуспешно добавяне на {{count}} база данни.", + "cluster.summary.fail_other": "Неуспешно добавяне на {{count}} бази данни.", + "cluster.summary.label": "Обобщение: ", + "cluster.summary.success_one": "Успешно добавена {{count}} база данни", + "cluster.summary.success_other": "Успешно добавени {{count}} бази данни", "common.fullScreen.enter": "Цял екран", "common.fullScreen.exit": "Изход от цял екран", "common.fullScreen.openAria": "Отвори на цял екран", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 8e1fc8d9e4..c4bd1a3726 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -308,6 +308,33 @@ "browser.array.delete.range.trigger": "Delete range", "browser.array.delete.row.message": "This element will be permanently removed from the array.", "browser.array.delete.row.title": "Delete element", + "cluster.cancel.button": "Cancel", + "cluster.cancel.confirm": "Your changes have not been saved. Do you want to proceed to the list of databases?", + "cluster.cancel.proceed": "Proceed", + "cluster.column.capabilities": "Capabilities", + "cluster.column.database": "Database", + "cluster.column.endpoint": "Endpoint", + "cluster.column.options": "Options", + "cluster.column.result": "Result", + "cluster.column.status": "Status", + "cluster.databases.addButton": "Add selected Databases", + "cluster.databases.noResults": "Your Redis Enterprise Cluster has no databases available.", + "cluster.databases.subtitle_one": "These are the database in your Redis Enterprise Cluster. Select the database that you want to add.", + "cluster.databases.subtitle_other": "These are the databases in your Redis Enterprise Cluster. Select the databases that you want to add.", + "cluster.databases.title": "Auto-Discover Redis Enterprise Databases", + "cluster.endpoint.copyAriaLabel": "Copy public endpoint", + "cluster.loadingMsg": "loading...", + "cluster.notFound": "Not found", + "cluster.result.error": "Error", + "cluster.result.pageTitle": "Redis Enterprise Databases Added", + "cluster.result.title_one": "Redis Enterprise Database Added", + "cluster.result.title_other": "Redis Enterprise Databases Added", + "cluster.result.viewButton": "View Databases", + "cluster.summary.fail_one": "Failed to add {{count}} database.", + "cluster.summary.fail_other": "Failed to add {{count}} databases.", + "cluster.summary.label": "Summary: ", + "cluster.summary.success_one": "Successfully added {{count}} database", + "cluster.summary.success_other": "Successfully added {{count}} databases", "common.fullScreen.enter": "Full Screen", "common.fullScreen.exit": "Exit Full Screen", "common.fullScreen.openAria": "Open full screen", diff --git a/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabases.tsx b/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabases.tsx index 12510e8389..da532582e7 100644 --- a/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabases.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabases.tsx @@ -4,6 +4,7 @@ import { RiTooltip } from 'uiSrc/components/base' import type { InstanceRedisCluster } from 'uiSrc/slices/interfaces' import validationErrors from 'uiSrc/constants/validationErrors' import { AutodiscoveryPageTemplate } from 'uiSrc/templates' +import { useTranslation } from 'uiSrc/i18n' import { Row } from 'uiSrc/components/base/layout/flex' import { InfoIcon } from 'uiSrc/components/base/icons' @@ -32,22 +33,6 @@ interface Props { loading: boolean } -const loadingMsg = 'loading...' -const notFoundMsg = 'Not found' -const noResultsMessage = - 'Your Redis Enterprise Cluster has no databases available.' - -function getSubtitle(items: InstanceRedisCluster[]) { - if (!items.length) { - return null - } - - return `These are the ${items.length > 1 ? 'databases ' : 'database '} -in your Redis Enterprise Cluster. Select the -${items.length > 1 ? ' databases ' : ' database '} that you want -to add.` -} - const hasSelection = (selection: RowSelectionState) => Object.values(selection).some(Boolean) const RedisClusterDatabases = ({ @@ -58,8 +43,9 @@ const RedisClusterDatabases = ({ instances, loading, }: Props) => { + const { t } = useTranslation() const [items, setItems] = useState([]) - const [message, setMessage] = useState(loadingMsg) + const [message, setMessage] = useState(t('cluster.loadingMsg')) const [isPopoverOpen, setIsPopoverOpen] = useState(false) const [selection, setSelection] = useState({}) @@ -72,7 +58,7 @@ const RedisClusterDatabases = ({ useEffect(() => { if (instances?.length === 0) { - setMessage(noResultsMessage) + setMessage(t('cluster.databases.noResults')) } }, [instances]) @@ -109,7 +95,7 @@ const RedisClusterDatabases = ({ ) ?? [] if (!itemsTemp?.length) { - setMessage(notFoundMsg) + setMessage(t('cluster.notFound')) } setItems(itemsTemp) } @@ -118,10 +104,14 @@ const RedisClusterDatabases = ({
@@ -169,7 +159,7 @@ const RedisClusterDatabases = ({ icon={isSubmitDisabled() ? InfoIcon : undefined} data-testid="btn-add-databases" > - Add selected Databases + {t('cluster.databases.addButton')} diff --git a/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabasesResult.tsx b/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabasesResult.tsx index 4c2f0b7c78..721cc7fa5b 100644 --- a/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabasesResult.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/RedisClusterDatabasesResult.tsx @@ -6,6 +6,7 @@ import { setTitle } from 'uiSrc/utils' import MessageBar from 'uiSrc/components/message-bar/MessageBar' import { riToast } from 'uiSrc/components/base/display/toast' import { AutodiscoveryPageTemplate } from 'uiSrc/templates' +import { useTranslation } from 'uiSrc/i18n' import { Row } from 'uiSrc/components/base/layout/flex' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' @@ -27,20 +28,18 @@ export interface Props { onBack: (sendEvent?: boolean) => void } -const loadingMsg = 'loading...' -const notFoundMsg = 'Not found' - const RedisClusterDatabasesResult = ({ columns, instances, onBack, onView, }: Props) => { + const { t } = useTranslation() const [items, setItems] = useState([]) - const [message, setMessage] = useState(loadingMsg) + const [message, setMessage] = useState(t('cluster.loadingMsg')) useEffect(() => { - setTitle('Redis Enterprise Databases Added') + setTitle(t('cluster.result.pageTitle')) }, []) useEffect(() => { @@ -65,7 +64,7 @@ const RedisClusterDatabasesResult = ({ ) if (!itemsTemp.length) { - setMessage(notFoundMsg) + setMessage(t('cluster.notFound')) } setItems(itemsTemp) } @@ -74,15 +73,9 @@ const RedisClusterDatabasesResult = ({
1 - ? ' Databases ' - : ' Database ' - } - Added - `} + title={t('cluster.result.title', { + count: countSuccessAdded + countFailAdded, + })} onBack={onBack} onQueryChange={onQueryChange} /> @@ -118,7 +111,7 @@ const RedisClusterDatabasesResult = ({ onClick={() => onView(false)} data-testid="btn-view-databases" > - View Databases + {t('cluster.result.viewButton')} diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/capabilities.tsx b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/capabilities.tsx index 1164d79eab..c188770a92 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/capabilities.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/capabilities.tsx @@ -2,14 +2,12 @@ import React from 'react' import { DatabaseListModules } from 'uiSrc/components' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCluster } from 'uiSrc/slices/interfaces' -import { - RedisClusterIds, - RedisClusterTitles, -} from 'uiSrc/pages/redis-cluster/constants/constants' +import i18n from 'uiSrc/i18n' +import { RedisClusterIds } from 'uiSrc/pages/redis-cluster/constants/constants' export const capabilitiesColumn = (): ColumnDef => { return { - header: RedisClusterTitles.Capabilities, + header: i18n.t('cluster.column.capabilities'), id: RedisClusterIds.Capabilities, accessorKey: RedisClusterIds.Capabilities, enableSorting: true, diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/database.tsx b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/database.tsx index 1d85c95890..bfb7ec2caf 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/database.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/database.tsx @@ -2,15 +2,13 @@ import React from 'react' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCluster } from 'uiSrc/slices/interfaces' +import i18n from 'uiSrc/i18n' import { DatabaseCell } from '../components/DatabaseCell' -import { - RedisClusterIds, - RedisClusterTitles, -} from 'uiSrc/pages/redis-cluster/constants/constants' +import { RedisClusterIds } from 'uiSrc/pages/redis-cluster/constants/constants' export const databaseColumn = (): ColumnDef => { return { - header: RedisClusterTitles.Database, + header: i18n.t('cluster.column.database'), id: RedisClusterIds.Name, accessorKey: RedisClusterIds.Name, minSize: 180, diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/endpoint.tsx b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/endpoint.tsx index b9a1fe30bd..089e855195 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/endpoint.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/endpoint.tsx @@ -2,15 +2,13 @@ import React from 'react' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCluster } from 'uiSrc/slices/interfaces' +import i18n from 'uiSrc/i18n' import { EndpointCell } from '../components/EndpointCell' -import { - RedisClusterIds, - RedisClusterTitles, -} from 'uiSrc/pages/redis-cluster/constants/constants' +import { RedisClusterIds } from 'uiSrc/pages/redis-cluster/constants/constants' export const endpointColumn = (): ColumnDef => { return { - header: RedisClusterTitles.Endpoint, + header: i18n.t('cluster.column.endpoint'), id: RedisClusterIds.Endpoint, accessorKey: RedisClusterIds.Endpoint, enableSorting: true, diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/options.tsx b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/options.tsx index e41f7e8931..eef8cd79d6 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/options.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/options.tsx @@ -3,16 +3,14 @@ import { DatabaseListOptions } from 'uiSrc/components' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCluster } from 'uiSrc/slices/interfaces' import { parseInstanceOptionsCluster } from 'uiSrc/utils' -import { - RedisClusterIds, - RedisClusterTitles, -} from 'uiSrc/pages/redis-cluster/constants/constants' +import i18n from 'uiSrc/i18n' +import { RedisClusterIds } from 'uiSrc/pages/redis-cluster/constants/constants' export const optionsColumn = ( instances: InstanceRedisCluster[], ): ColumnDef => { return { - header: RedisClusterTitles.Options, + header: i18n.t('cluster.column.options'), id: RedisClusterIds.Options, accessorKey: RedisClusterIds.Options, enableSorting: true, diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/result.tsx b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/result.tsx index 1dc60fcb29..af35227e18 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/result.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/result.tsx @@ -2,15 +2,13 @@ import React from 'react' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCluster } from 'uiSrc/slices/interfaces' +import i18n from 'uiSrc/i18n' import { ResultCell } from '../components/ResultCell' -import { - RedisClusterIds, - RedisClusterTitles, -} from 'uiSrc/pages/redis-cluster/constants/constants' +import { RedisClusterIds } from 'uiSrc/pages/redis-cluster/constants/constants' export const resultColumn = (): ColumnDef => { return { - header: RedisClusterTitles.Result, + header: i18n.t('cluster.column.result'), id: RedisClusterIds.Result, accessorKey: RedisClusterIds.Result, enableSorting: true, diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/status.ts b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/status.ts index 85f760615a..f726c343e3 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/status.ts +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/columns/status.ts @@ -1,13 +1,11 @@ import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCluster } from 'uiSrc/slices/interfaces' -import { - RedisClusterIds, - RedisClusterTitles, -} from 'uiSrc/pages/redis-cluster/constants/constants' +import i18n from 'uiSrc/i18n' +import { RedisClusterIds } from 'uiSrc/pages/redis-cluster/constants/constants' export const statusColumn = (): ColumnDef => { return { - header: RedisClusterTitles.Status, + header: i18n.t('cluster.column.status'), id: RedisClusterIds.Status, accessorKey: RedisClusterIds.Status, enableSorting: true, diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/DatabaseCell.tsx b/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/DatabaseCell.tsx index e2e989921c..36f1890223 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/DatabaseCell.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/DatabaseCell.tsx @@ -2,6 +2,7 @@ import React from 'react' import { RiTooltip } from 'uiSrc/components' import { formatLongName } from 'uiSrc/utils' import { CellText } from 'uiSrc/components/auto-discover' +import { useTranslation } from 'uiSrc/i18n' import styles from '../../styles.module.scss' @@ -10,6 +11,7 @@ export interface DatabaseCellProps { } export const DatabaseCell = ({ name }: DatabaseCellProps) => { + const { t } = useTranslation() const cellContent = (name || '') .substring(0, 200) .replace(/\s\s/g, '\u00a0\u00a0') @@ -18,7 +20,7 @@ export const DatabaseCell = ({ name }: DatabaseCellProps) => {
{ + const { t } = useTranslation() if (!dnsName) { return null } @@ -22,7 +24,7 @@ export const EndpointCell = ({ dnsName, port }: EndpointCellProps) => { {text} @@ -30,7 +32,7 @@ export const EndpointCell = ({ dnsName, port }: EndpointCellProps) => { diff --git a/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/ResultCell.tsx b/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/ResultCell.tsx index 6a3a9d0c6f..791ffebf08 100644 --- a/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/ResultCell.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/column-definitions/components/ResultCell.tsx @@ -4,6 +4,7 @@ import { ColorText, Text } from 'uiSrc/components/base/text' import { RiTooltip } from 'uiSrc/components' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { RiIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' export interface ResultCellProps { statusAdded: AddRedisDatabaseStatus | undefined @@ -11,12 +12,17 @@ export interface ResultCellProps { } export const ResultCell = ({ statusAdded, messageAdded }: ResultCellProps) => { + const { t } = useTranslation() if (statusAdded === 'success') { return {messageAdded} } return ( - + @@ -24,7 +30,7 @@ export const ResultCell = ({ statusAdded, messageAdded }: ResultCellProps) => { - Error + {t('cluster.result.error')} diff --git a/redisinsight/ui/src/pages/redis-cluster/components/CancelButton/CancelButton.tsx b/redisinsight/ui/src/pages/redis-cluster/components/CancelButton/CancelButton.tsx index f71c913ffe..fa5b0fba9e 100644 --- a/redisinsight/ui/src/pages/redis-cluster/components/CancelButton/CancelButton.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/components/CancelButton/CancelButton.tsx @@ -6,6 +6,7 @@ import { SecondaryButton, } from 'uiSrc/components/base/forms/buttons' import { Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import styles from './CancelButton.style' import type { CancelButtonProps } from './CancelButton.types' @@ -15,36 +16,37 @@ export const CancelButton = ({ onShowPopover, onClosePopover, onProceed, -}: CancelButtonProps) => ( - - Cancel - - } - > - - Your changes have not been saved. Do you want to proceed to the - list of databases? - -
-
- - Proceed - -
-
-) +}: CancelButtonProps) => { + const { t } = useTranslation() + + return ( + + {t('cluster.cancel.button')} + + } + > + {t('cluster.cancel.confirm')} +
+
+ + {t('cluster.cancel.proceed')} + +
+
+ ) +} diff --git a/redisinsight/ui/src/pages/redis-cluster/components/SummaryText/SummaryText.tsx b/redisinsight/ui/src/pages/redis-cluster/components/SummaryText/SummaryText.tsx index 13eff4f96b..195be3f4e9 100644 --- a/redisinsight/ui/src/pages/redis-cluster/components/SummaryText/SummaryText.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/components/SummaryText/SummaryText.tsx @@ -1,22 +1,27 @@ import React from 'react' import { ColorText, Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import type { SummaryTextProps } from './SummaryText.types' export const SummaryText = ({ countSuccessAdded, countFailAdded, -}: SummaryTextProps) => ( - - Summary: - {countSuccessAdded ? ( - - Successfully added {countSuccessAdded} database(s) - {countFailAdded ? '. ' : '.'} - - ) : null} - {countFailAdded ? ( - Failed to add {countFailAdded} database(s). - ) : null} - -) +}: SummaryTextProps) => { + const { t } = useTranslation() + + return ( + + {t('cluster.summary.label')} + {countSuccessAdded ? ( + + {t('cluster.summary.success', { count: countSuccessAdded })} + {countFailAdded ? '. ' : '.'} + + ) : null} + {countFailAdded ? ( + {t('cluster.summary.fail', { count: countFailAdded })} + ) : null} + + ) +} diff --git a/redisinsight/ui/src/pages/redis-cluster/constants/constants.ts b/redisinsight/ui/src/pages/redis-cluster/constants/constants.ts index 3299a0212c..3c28003a4a 100644 --- a/redisinsight/ui/src/pages/redis-cluster/constants/constants.ts +++ b/redisinsight/ui/src/pages/redis-cluster/constants/constants.ts @@ -6,12 +6,3 @@ export enum RedisClusterIds { Result = 'messageAdded', Status = 'status', } - -export enum RedisClusterTitles { - Capabilities = 'Capabilities', - Database = 'Database', - Endpoint = 'Endpoint', - Options = 'Options', - Result = 'Result', - Status = 'Status', -} diff --git a/redisinsight/ui/src/pages/redis-cluster/useClusterDatabasesConfig.tsx b/redisinsight/ui/src/pages/redis-cluster/useClusterDatabasesConfig.tsx index 3e296c116c..c979a55634 100644 --- a/redisinsight/ui/src/pages/redis-cluster/useClusterDatabasesConfig.tsx +++ b/redisinsight/ui/src/pages/redis-cluster/useClusterDatabasesConfig.tsx @@ -9,6 +9,7 @@ import { resetInstancesRedisCluster, } from 'uiSrc/slices/instances/cluster' import { Maybe, Nullable, setTitle } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { Pages } from 'uiSrc/constants' import { type InstanceRedisCluster } from 'uiSrc/slices/interfaces' @@ -50,6 +51,7 @@ const sendCancelEvent = () => { }) } export const useClusterDatabasesConfig = () => { + const { t } = useTranslation() const dispatch = useAppDispatch() const history = useHistory() @@ -61,7 +63,7 @@ export const useClusterDatabasesConfig = () => { } = useAppSelector(clusterSelector) useEffect(() => { - setTitle('Auto-Discover Redis Enterprise Databases') + setTitle(t('cluster.databases.title')) }, []) const handleClose = useCallback( From a75a0801c9839c4e7a48a221f040bb78005b867e Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Fri, 17 Jul 2026 16:53:27 +0300 Subject: [PATCH 049/166] RI-8277 Migrate vector-search sample datasets to i18n (#6224) * feat(i18n): migrate vector-search sample datasets to i18n (RI-8277) Route the remaining hardcoded copy in the vector-search sample datasets (bikes.ts, movies.ts) through i18n under vectorSearch.sampleData.bikes.* and .movies.* with Bulgarian translations: the sample query names and descriptions shown in the Query Library. The dataset displayNames reuse the existing vectorSearch.sampleData.ecommerce.label / sampleData.content.label keys (reconciling the earlier casing drift). Completes the vector-search i18n coverage started in RI-8275. Co-Authored-By: Claude Opus 4.8 --- redisinsight/ui/src/i18n/locales/bg.json | 20 +++ redisinsight/ui/src/i18n/locales/en.json | 20 +++ .../hooks/useQueryLibrary.ts | 128 ++++++++++-------- .../constants/sample-data/bikes.ts | 22 ++- .../constants/sample-data/movies.ts | 27 ++-- .../pages/vector-search/utils/sampleData.ts | 6 +- 6 files changed, 134 insertions(+), 89 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 04af87426e..107326d28a 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -822,11 +822,31 @@ "vectorSearch.queryLibrary.save.placeholder": "Въведете име на командата", "vectorSearch.queryLibrary.save.title": "Запазване на заявка", "vectorSearch.queryLibrary.searchPlaceholder": "Търсене на заявка", + "vectorSearch.sampleData.bikes.displayName": "Eлектронна търговия", + "vectorSearch.sampleData.bikes.query1.description": "Извършва просто векторно търсене по метода на K най-близки съседи (KNN), за да намери 3-те велосипеда, най-семантично близки до „Удобен велосипед за ежедневно придвижване“. Връща оценката за сходство заедно с полетата марка, тип и описание.", + "vectorSearch.sampleData.bikes.query1.name": "Основно семантично търсене", + "vectorSearch.sampleData.bikes.query2.description": "Търси велосипеди, съответстващи на заявката на естествен език „Велосипед за ежедневно придвижване за хора над 60“. Демонстрира как векторното търсене може да разбира намерението и контекста отвъд съвпадението на ключови думи, намирайки велосипеди, подходящи за по-възрастни колоездачи, които приоритизират комфорта и лесната употреба.", + "vectorSearch.sampleData.bikes.query2.name": "Семантично търсене, насочено по възраст", + "vectorSearch.sampleData.bikes.query3.description": "Намира планински велосипеди, семантично близки до „Планински велосипед специално за жени“. Показва как вгражданията могат да улавят продуктови характеристики като геометрия, размери и дизайн, специфични за пола, без да изискват точно съвпадение на ключови думи.", + "vectorSearch.sampleData.bikes.query3.name": "Търсене на продукти според пола", + "vectorSearch.sampleData.bikes.query4.description": "Комбинира семантично векторно търсене с традиционно филтриране по атрибути. Търси „Планински велосипед специално за жени“, но ограничава резултатите до велосипеди от тип „Планински велосипеди“ с цени между $3000 и $3500. Демонстрира предварително филтриране преди KNN, за да се стесни наборът от кандидати.", + "vectorSearch.sampleData.bikes.query4.name": "Хибридно търсене (вектор + филтри)", "vectorSearch.sampleData.cancel": "Отказ", "vectorSearch.sampleData.content.description": "Откривайте съдържание по тема или сюжет.", "vectorSearch.sampleData.content.label": "Препоръки за съдържание", "vectorSearch.sampleData.ecommerce.description": "Откривайте продукти, които отговарят на очакванията ви, а не само на текста", "vectorSearch.sampleData.ecommerce.label": "Откриване в електронната търговия", + "vectorSearch.sampleData.movies.displayName": "Препоръки за съдържание", + "vectorSearch.sampleData.movies.query1.description": "Извършва търсене по метода на K най-близки съседи, за да намери филми с вграждания на сюжета, най-близки до векторната заявка. Връща първите 3 съвпадения със заглавие, сюжет и оценка за сходство. Демонстрира чисто семантично търсене — „Играта на играчките“ се класира първо въз основа на смисъла, а не на съвпадение на ключови думи.", + "vectorSearch.sampleData.movies.query1.name": "Основно търсене по сходство на сюжета", + "vectorSearch.sampleData.movies.query2.description": "Комбинира филтър по жанров таг с векторно сходство, за да намери свързани с музика филми, съответстващи на „Позитивен филм за музика и студенти“. Предварително филтрира до жанра Музика, преди да изпълни KNN, показвайки как хибридното търсене подобрява релевантността чрез стесняване на кандидатите.", + "vectorSearch.sampleData.movies.query2.name": "Семантично търсене с филтър по жанр", + "vectorSearch.sampleData.movies.query3.description": "Извлича съхраненото векторно вграждане от съществуващ филмов документ (Inception). Този вектор след това може да се използва като вход за заявка от типа „подобни на този“, позволявайки препоръки въз основа на съдържанието, без да се регенерират вгражданията.", + "vectorSearch.sampleData.movies.query3.name": "Извличане на вграждане на документ", + "vectorSearch.sampleData.movies.query4.description": "Комбинира множество филтри по метаданни (жанр: Музика, година: 1970–1979) с векторно търсене по сходство. Намира класически музикални филми от 70-те, съответстващи на семантичното намерение на заявката, показвайки как числовите диапазони и филтрите по тагове работят безпроблемно с KNN.", + "vectorSearch.sampleData.movies.query4.name": "Хибридно търсене с множество филтри", + "vectorSearch.sampleData.movies.query5.description": "Филтрира резултатите до предпочитаните от потребителя жанрове (Анимация ИЛИ Sci-Fi), преди да изпълни векторно сходство. Демонстрира персонализация — стесняване на препоръките до категориите, които потребителят харесва, като същевременно се класира по семантична релевантност.", + "vectorSearch.sampleData.movies.query5.name": "Персонализирано търсене в множество жанрове", "vectorSearch.sampleData.seeIndexDefinition": "Виж дефиницията на индекса", "vectorSearch.sampleData.startQuerying": "Създай и започни да търсиш", "vectorSearch.sampleData.subtitle1": "Изберете примерен набор от данни.", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index c4bd1a3726..e29a10db92 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -822,11 +822,31 @@ "vectorSearch.queryLibrary.save.placeholder": "Enter command name", "vectorSearch.queryLibrary.save.title": "Save query", "vectorSearch.queryLibrary.searchPlaceholder": "Search query", + "vectorSearch.sampleData.bikes.displayName": "E-commerce discovery", + "vectorSearch.sampleData.bikes.query1.description": "Performs a simple K-nearest neighbors (KNN) vector search to find the 3 bikes most semantically similar to \"Comfortable commuter bike.\" Returns the similarity score along with brand, type, and description fields.", + "vectorSearch.sampleData.bikes.query1.name": "Basic semantic search", + "vectorSearch.sampleData.bikes.query2.description": "Searches for bikes matching the natural language query \"Commuter bike for people over 60.\" Demonstrates how vector search can understand intent and context beyond keyword matching, finding bikes suited for older riders prioritizing comfort and ease of use.", + "vectorSearch.sampleData.bikes.query2.name": "Age-targeted semantic search", + "vectorSearch.sampleData.bikes.query3.description": "Finds mountain bikes semantically similar to \"Female specific mountain bike.\" Shows how embeddings can capture product attributes like gender-specific geometry, sizing, and design features without requiring exact keyword matches.", + "vectorSearch.sampleData.bikes.query3.name": "Gender-specific product search", + "vectorSearch.sampleData.bikes.query4.description": "Combines semantic vector search with traditional attribute filtering. Searches for \"Female specific mountain bike\" but restricts results to bikes of type \"Mountain Bikes\" with prices between $3,000–$3,500. Demonstrates pre-filtering before KNN to narrow the candidate set.", + "vectorSearch.sampleData.bikes.query4.name": "Hybrid search (vector + filters)", "vectorSearch.sampleData.cancel": "Cancel", "vectorSearch.sampleData.content.description": "Discover content by theme or plot.", "vectorSearch.sampleData.content.label": "Content recommendations", "vectorSearch.sampleData.ecommerce.description": "Discover products that match intent, not just text", "vectorSearch.sampleData.ecommerce.label": "E-commerce Discovery", + "vectorSearch.sampleData.movies.displayName": "Content recommendations", + "vectorSearch.sampleData.movies.query1.description": "Performs a K-nearest neighbors search to find movies with plot embeddings most similar to the query vector. Returns the top 3 matches with title, plot, and similarity score. Demonstrates pure semantic search—Toy Story ranks first based on meaning, not keyword matches.", + "vectorSearch.sampleData.movies.query1.name": "Basic plot similarity search", + "vectorSearch.sampleData.movies.query2.description": "Combines a genre tag filter with vector similarity to find music-related movies matching \"A feel-good film about music and students.\" Pre-filters to the Music genre before running KNN, showing how hybrid search improves relevance by narrowing candidates.", + "vectorSearch.sampleData.movies.query2.name": "Genre-filtered semantic search", + "vectorSearch.sampleData.movies.query3.description": "Extracts the stored embedding vector from an existing movie document (Inception). This vector can then be used as input for a \"more like this\" recommendation query, enabling content-based recommendations without regenerating embeddings.", + "vectorSearch.sampleData.movies.query3.name": "Retrieve document embedding", + "vectorSearch.sampleData.movies.query4.description": "Combines multiple metadata filters (genre: Music, year: 1970–1979) with vector similarity search. Finds classic 70s music films matching the query's semantic intent, showing how numeric ranges and tag filters work seamlessly with KNN.", + "vectorSearch.sampleData.movies.query4.name": "Multi-filter hybrid search", + "vectorSearch.sampleData.movies.query5.description": "Filters results to user-preferred genres (Animated OR Sci-Fi) before running vector similarity. Demonstrates personalization—narrowing recommendations to categories the user enjoys while still ranking by semantic relevance.", + "vectorSearch.sampleData.movies.query5.name": "Personalized multi-genre search", "vectorSearch.sampleData.seeIndexDefinition": "See index definition", "vectorSearch.sampleData.startQuerying": "Start querying", "vectorSearch.sampleData.subtitle1": "Select a sample dataset.", diff --git a/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts b/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts index 086878755b..dcebfc4848 100644 --- a/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts +++ b/redisinsight/ui/src/pages/vector-search/components/query-library-view/hooks/useQueryLibrary.ts @@ -1,15 +1,36 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { TFunction } from 'i18next' import { useAppDispatch } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' -import { debounce } from 'lodash' import { useTranslation } from 'uiSrc/i18n' import { addMessageNotification } from 'uiSrc/slices/app/notifications' import { QueryLibraryService } from 'uiSrc/services/query-library/QueryLibraryService' -import { QueryLibraryItem } from 'uiSrc/services/query-library/types' +import { + QueryLibraryItem, + QueryLibraryType, +} from 'uiSrc/services/query-library/types' import { queryLibraryNotifications } from 'uiSrc/pages/vector-search/constants' -const SEARCH_DEBOUNCE_MS = 300 +// Sample items are seeded with i18n keys as their name/description (so they +// follow runtime language changes); resolve those keys here for display and +// search. User-saved items are returned untouched. +const translateSampleItem = ( + item: QueryLibraryItem, + t: TFunction, +): QueryLibraryItem => { + if (item.type !== QueryLibraryType.Sample) { + return item + } + + return { + ...item, + name: t(item.name as never), + description: item.description + ? t(item.description as never) + : item.description, + } +} export const useQueryLibrary = () => { const { t } = useTranslation() @@ -21,11 +42,7 @@ export const useQueryLibrary = () => { const indexName = rawIndexName ? decodeURIComponent(rawIndexName) : '' - const [items, setItems] = useState([]) - // Whether the library contains any items at all, ignoring the active search filter. - // Updated only on unfiltered fetches and deletions, so it stays stable - // during debounced search transitions and avoids UI flicker. - const [hasItemsBeforeSearch, setHasItems] = useState(false) + const [allItems, setAllItems] = useState([]) const [loading, setLoading] = useState() const [error, setError] = useState(null) const [search, setSearch] = useState('') @@ -33,56 +50,55 @@ export const useQueryLibrary = () => { const serviceRef = useRef(new QueryLibraryService()) - const fetchItems = useCallback( - async (searchTerm?: string) => { - if (!databaseId || !indexName) return - - setLoading(true) - setError(null) - - try { - const data = await serviceRef.current.getList(databaseId, { - indexName, - search: searchTerm || undefined, - }) - setItems(data) - if (!searchTerm) { - setHasItems(data.length > 0) - } - } catch { - setItems([]) - setError(t('vectorSearch.queryLibrary.error.load')) - } finally { - setLoading(false) - } - }, - [databaseId, indexName, t], - ) + const fetchItems = useCallback(async () => { + if (!databaseId || !indexName) return - const debouncedFetch = useMemo( - () => debounce((term: string) => fetchItems(term), SEARCH_DEBOUNCE_MS), - [fetchItems], - ) + setLoading(true) + setError(null) - useEffect( - () => () => { - debouncedFetch.cancel() - }, - [debouncedFetch], - ) + try { + const data = await serviceRef.current.getList(databaseId, { indexName }) + setAllItems(data) + } catch { + setAllItems([]) + setError(t('vectorSearch.queryLibrary.error.load')) + } finally { + setLoading(false) + } + }, [databaseId, indexName, t]) useEffect(() => { fetchItems() }, [fetchItems]) - const handleSearchChange = useCallback( - (value: string) => { - setSearch(value) - debouncedFetch(value) - }, - [debouncedFetch], + // Resolve sample-item keys to the active language; recomputed on language + // change via the `t` dependency so displayed/seeded items stay in sync. + const translatedItems = useMemo( + () => allItems.map((item) => translateSampleItem(item, t)), + [allItems, t], ) + // Search client-side over the translated text so matches reflect what the + // user actually sees (the DB stores i18n keys for sample items). + const items = useMemo(() => { + const term = search.trim().toLowerCase() + if (!term) return translatedItems + + return translatedItems.filter( + (item) => + item.name?.toLowerCase().includes(term) || + item.description?.toLowerCase().includes(term) || + item.query?.toLowerCase().includes(term), + ) + }, [translatedItems, search]) + + // Reflects whether the library has any items at all, independent of search. + const hasItemsBeforeSearch = translatedItems.length > 0 + + const handleSearchChange = useCallback((value: string) => { + setSearch(value) + }, []) + const deleteItem = useCallback( async (id: string): Promise => { if (!databaseId) { @@ -91,13 +107,7 @@ export const useQueryLibrary = () => { try { await serviceRef.current.delete(databaseId, id) - setItems((prev) => { - const remaining = prev.filter((item) => item.id !== id) - if (remaining.length === 0 && !search) { - setHasItems(false) - } - return remaining - }) + setAllItems((prev) => prev.filter((item) => item.id !== id)) setOpenItemId((prev) => (prev === id ? null : prev)) dispatch( addMessageNotification(queryLibraryNotifications.queryDeleted()), @@ -107,7 +117,7 @@ export const useQueryLibrary = () => { return false } }, - [databaseId, dispatch, search], + [databaseId, dispatch], ) const toggleItemOpen = useCallback((id: string) => { @@ -115,8 +125,8 @@ export const useQueryLibrary = () => { }, []) const getItemById = useCallback( - (id: string) => items.find((item) => item.id === id), - [items], + (id: string) => translatedItems.find((item) => item.id === id), + [translatedItems], ) return { diff --git a/redisinsight/ui/src/pages/vector-search/constants/sample-data/bikes.ts b/redisinsight/ui/src/pages/vector-search/constants/sample-data/bikes.ts index f4ec429f40..0f51933f0b 100644 --- a/redisinsight/ui/src/pages/vector-search/constants/sample-data/bikes.ts +++ b/redisinsight/ui/src/pages/vector-search/constants/sample-data/bikes.ts @@ -2,7 +2,7 @@ import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-ind import { SampleDatasetConfig } from './types' export const BIKES_DATASET: SampleDatasetConfig = { - displayName: 'E-commerce discovery', + displayName: 'vectorSearch.sampleData.bikes.displayName', indexName: 'idx:bikes_vss', indexPrefix: 'bikes:', collectionName: 'bikes', @@ -27,9 +27,8 @@ export const BIKES_DATASET: SampleDatasetConfig = { ], sampleQueries: [ { - name: 'Basic semantic search', - description: - 'Performs a simple K-nearest neighbors (KNN) vector search to find the 3 bikes most semantically similar to "Comfortable commuter bike." Returns the similarity score along with brand, type, and description fields.', + name: 'vectorSearch.sampleData.bikes.query1.name', + description: 'vectorSearch.sampleData.bikes.query1.description', query: 'FT.SEARCH idx:bikes_vss ' + '"*=>[KNN 3 @description_embeddings $my_blob AS score ]" ' + @@ -39,9 +38,8 @@ export const BIKES_DATASET: SampleDatasetConfig = { 'DIALECT 2', }, { - name: 'Age-targeted semantic search', - description: - 'Searches for bikes matching the natural language query "Commuter bike for people over 60." Demonstrates how vector search can understand intent and context beyond keyword matching, finding bikes suited for older riders prioritizing comfort and ease of use.', + name: 'vectorSearch.sampleData.bikes.query2.name', + description: 'vectorSearch.sampleData.bikes.query2.description', query: 'FT.SEARCH idx:bikes_vss ' + '"*=>[KNN 3 @description_embeddings $my_blob AS score ]" ' + @@ -51,9 +49,8 @@ export const BIKES_DATASET: SampleDatasetConfig = { 'DIALECT 2', }, { - name: 'Gender-specific product search', - description: - 'Finds mountain bikes semantically similar to "Female specific mountain bike." Shows how embeddings can capture product attributes like gender-specific geometry, sizing, and design features without requiring exact keyword matches.', + name: 'vectorSearch.sampleData.bikes.query3.name', + description: 'vectorSearch.sampleData.bikes.query3.description', query: 'FT.SEARCH idx:bikes_vss ' + '"*=>[KNN 3 @description_embeddings $my_blob AS score ]" ' + @@ -63,9 +60,8 @@ export const BIKES_DATASET: SampleDatasetConfig = { 'DIALECT 2', }, { - name: 'Hybrid search (vector + filters)', - description: - 'Combines semantic vector search with traditional attribute filtering. Searches for "Female specific mountain bike" but restricts results to bikes of type "Mountain Bikes" with prices between $3,000–$3,500. Demonstrates pre-filtering before KNN to narrow the candidate set.', + name: 'vectorSearch.sampleData.bikes.query4.name', + description: 'vectorSearch.sampleData.bikes.query4.description', query: 'FT.SEARCH idx:bikes_vss ' + '"(@type:{Mountain Bikes} @price:[3000 3500])=>[KNN 3 @description_embeddings $my_blob AS score ]" ' + diff --git a/redisinsight/ui/src/pages/vector-search/constants/sample-data/movies.ts b/redisinsight/ui/src/pages/vector-search/constants/sample-data/movies.ts index b5d8c44b9f..7100be5efd 100644 --- a/redisinsight/ui/src/pages/vector-search/constants/sample-data/movies.ts +++ b/redisinsight/ui/src/pages/vector-search/constants/sample-data/movies.ts @@ -2,7 +2,7 @@ import { FieldTypes } from 'uiSrc/pages/browser/components/create-redisearch-ind import { SampleDatasetConfig } from './types' export const MOVIES_DATASET: SampleDatasetConfig = { - displayName: 'Content recommendations', + displayName: 'vectorSearch.sampleData.movies.displayName', indexName: 'idx:movies_vss', indexPrefix: 'movie:', collectionName: 'movies', @@ -30,9 +30,8 @@ export const MOVIES_DATASET: SampleDatasetConfig = { ], sampleQueries: [ { - name: 'Basic plot similarity search', - description: - 'Performs a K-nearest neighbors search to find movies with plot embeddings most similar to the query vector. Returns the top 3 matches with title, plot, and similarity score. Demonstrates pure semantic search—Toy Story ranks first based on meaning, not keyword matches.', + name: 'vectorSearch.sampleData.movies.query1.name', + description: 'vectorSearch.sampleData.movies.query1.description', query: 'FT.SEARCH idx:movies_vss "*=>[KNN 3 @embedding $vec AS score]" ' + 'PARAMS 2 vec "\\x9a\\x99\\x19\\x3f\\xcd\\xcc\\xcc\\x3d\\x9a\\x99\\x4c\\x3f\\x9a\\x99\\x33\\x3e\\x9a\\x99\\x33\\x3f\\xcd\\xcc\\x66\\x3e\\xcd\\xcc\\xcc\\x3d\\xcd\\xcc\\x4c\\x3e" ' + @@ -41,9 +40,8 @@ export const MOVIES_DATASET: SampleDatasetConfig = { 'DIALECT 2', }, { - name: 'Genre-filtered semantic search', - description: - 'Combines a genre tag filter with vector similarity to find music-related movies matching "A feel-good film about music and students." Pre-filters to the Music genre before running KNN, showing how hybrid search improves relevance by narrowing candidates.', + name: 'vectorSearch.sampleData.movies.query2.name', + description: 'vectorSearch.sampleData.movies.query2.description', query: 'FT.SEARCH idx:movies_vss "@genres:{Music} =>[KNN 5 @embedding $vec AS score]" ' + 'PARAMS 2 vec "\\x9a\\x99\\x1d\\x3e\\xcd\\xcc\\x4c\\xbd\\x9a\\x99\\x99\\x3e\\x9a\\x99\\x19\\x3e\\x9a\\x99\\x19\\xbe\\x9a\\x99\\x1d\\x3e\\xcd\\xcc\\x0c\\x3e\\x9a\\x99\\xf1\\xbc" ' + @@ -52,9 +50,8 @@ export const MOVIES_DATASET: SampleDatasetConfig = { 'DIALECT 2', }, { - name: 'Retrieve document embedding', - description: - 'Extracts the stored embedding vector from an existing movie document (Inception). This vector can then be used as input for a "more like this" recommendation query, enabling content-based recommendations without regenerating embeddings.', + name: 'vectorSearch.sampleData.movies.query3.name', + description: 'vectorSearch.sampleData.movies.query3.description', query: 'FT.SEARCH idx:movies_vss "*=>[KNN 5 @embedding $vec AS score]" ' + 'PARAMS 2 vec "\\xCD\\xCC\\x56\\x3E\\x9A\\x99\\xF3\\xBC\\xCD\\xCC\\x00\\x3F\\x66\\x66\\x34\\x3E\\xC6\\xF5\\x1B\\xBE\\x9A\\x99\\x4D\\x3E\\x9A\\x99\\x99\\x3D\\x9A\\x99\\xB5\\xBD" ' + @@ -63,9 +60,8 @@ export const MOVIES_DATASET: SampleDatasetConfig = { 'DIALECT 2', }, { - name: 'Multi-filter hybrid search', - description: - "Combines multiple metadata filters (genre: Music, year: 1970–1979) with vector similarity search. Finds classic 70s music films matching the query's semantic intent, showing how numeric ranges and tag filters work seamlessly with KNN.", + name: 'vectorSearch.sampleData.movies.query4.name', + description: 'vectorSearch.sampleData.movies.query4.description', query: 'FT.SEARCH idx:movies_vss "(@genres:{Music} @year:[1970 1979]) =>[KNN 5 @embedding $vec AS score]" ' + 'PARAMS 2 vec "\\x9a\\x99\\x1d\\x3e\\xcd\\xcc\\x4c\\xbd\\x9a\\x99\\x99\\x3e\\x9a\\x99\\x19\\x3e\\x9a\\x99\\x19\\xbe\\x9a\\x99\\x1d\\x3e\\xcd\\xcc\\x0c\\x3e\\x9a\\x99\\xf1\\xbc" ' + @@ -74,9 +70,8 @@ export const MOVIES_DATASET: SampleDatasetConfig = { 'DIALECT 2', }, { - name: 'Personalized multi-genre search', - description: - 'Filters results to user-preferred genres (Animated OR Sci-Fi) before running vector similarity. Demonstrates personalization—narrowing recommendations to categories the user enjoys while still ranking by semantic relevance.', + name: 'vectorSearch.sampleData.movies.query5.name', + description: 'vectorSearch.sampleData.movies.query5.description', query: 'FT.SEARCH idx:movies_vss "@genres:{\\"Animated\\"|\\"Sci-Fi\\"} =>[KNN 5 @embedding $vec AS score]" ' + 'PARAMS 2 vec "\\x9a\\x99\\x1d\\x3e\\xcd\\xcc\\x4c\\xbd\\x9a\\x99\\x99\\x3e\\x9a\\x99\\x19\\x3e\\x9a\\x99\\x19\\xbe\\x9a\\x99\\x1d\\x3e\\xcd\\xcc\\x0c\\x3e\\x9a\\x99\\xf1\\xbc" ' + diff --git a/redisinsight/ui/src/pages/vector-search/utils/sampleData.ts b/redisinsight/ui/src/pages/vector-search/utils/sampleData.ts index adf46bc504..a4b2a29f17 100644 --- a/redisinsight/ui/src/pages/vector-search/utils/sampleData.ts +++ b/redisinsight/ui/src/pages/vector-search/utils/sampleData.ts @@ -1,3 +1,4 @@ +import i18n from 'uiSrc/i18n' import { SampleDataContent } from '../components/pick-sample-data-modal/PickSampleDataModal.types' import { IndexField } from '../components/index-details/IndexDetails.types' import { SampleQuery } from '../constants/sample-data/types' @@ -25,12 +26,15 @@ export const getCollectionNameBySampleData = ( export const getDisplayNameBySampleData = ( sampleData: SampleDataContent, -): string => SAMPLE_DATASETS[sampleData].displayName +): string => i18n.t(SAMPLE_DATASETS[sampleData].displayName as never) export const getIndexPrefixBySampleData = ( sampleData: SampleDataContent, ): string => SAMPLE_DATASETS[sampleData].indexPrefix +// Returns raw sample queries whose name/description are i18n keys. They are +// seeded into the Query Library as keys (see seedSampleQueries) and translated +// on the fly when displayed, so seeded items follow runtime language changes. export const getSampleQueriesBySampleData = ( sampleData: SampleDataContent, ): SampleQuery[] => SAMPLE_DATASETS[sampleData].sampleQueries From c1015d397a967bc96ac341a6e75a3f74d257da5b Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Fri, 17 Jul 2026 17:17:00 +0300 Subject: [PATCH 050/166] feat(i18n): migrate rdi home & statistics pages (RI-8277) (#6221) Route the RDI home (endpoint list) and statistics (pipeline status) pages through i18n under rdi.home.* and rdi.statistics.* with Bulgarian translations: page/document titles, the connection form (labels, URL/ auth info tooltips, placeholders, add/edit/apply/cancel buttons, modal titles, required-field names), the empty state, header add button, instances-list column headers and empty/loading messages, row controls (edit/remove/controls aria + delete text), copy-URL aria, the bulk delete subtitle (count-based plural), and the statistics empty state and error message. RDI instances-list column headers moved from the module-level RDI_COLUMN_FIELD_NAME_MAP to a getRdiColumnFieldNameMap(t) factory and BASE_COLUMNS to getBaseColumns(t), both resolved at render, so headers (and the columns-config popover) localize under ?lang=bg. API-driven statistics content (section names, table headers, block labels) stays as returned by the RDI API. Co-authored-by: Claude Opus 4.8 --- redisinsight/ui/src/constants/rdiList.ts | 19 ++- redisinsight/ui/src/i18n/locales/bg.json | 43 ++++++ redisinsight/ui/src/i18n/locales/en.json | 43 ++++++ .../ui/src/pages/rdi/home/RdiPage.tsx | 4 +- .../RdiInstancesList.config.ts | 123 +++++++++--------- .../BulkItemsActions/BulkItemsActions.tsx | 5 +- .../RdiInstancesListCell.tsx | 4 +- .../RdiInstancesListCellControls.tsx | 12 +- .../hooks/useRdiInstancesListData.ts | 16 ++- .../home/connection-form/ConnectionForm.tsx | 41 +++--- .../connection-form/ConnectionFormWrapper.tsx | 6 +- .../rdi/home/empty-message/EmptyMessage.tsx | 18 +-- .../src/pages/rdi/home/header/RdiHeader.tsx | 8 +- .../pages/rdi/home/search/SearchRdiList.tsx | 6 +- .../pages/rdi/statistics/StatisticsPage.tsx | 8 +- .../src/pages/rdi/statistics/empty/Empty.tsx | 8 +- 16 files changed, 238 insertions(+), 126 deletions(-) diff --git a/redisinsight/ui/src/constants/rdiList.ts b/redisinsight/ui/src/constants/rdiList.ts index 7df38791dc..72e04b2b80 100644 --- a/redisinsight/ui/src/constants/rdiList.ts +++ b/redisinsight/ui/src/constants/rdiList.ts @@ -1,3 +1,5 @@ +import { TFunction } from 'i18next' + export enum RdiListColumn { Name = 'name', Url = 'url', @@ -6,13 +8,16 @@ export enum RdiListColumn { Controls = 'controls', } -export const RDI_COLUMN_FIELD_NAME_MAP = new Map([ - [RdiListColumn.Name, 'RDI alias'], - [RdiListColumn.Url, 'URL'], - [RdiListColumn.Version, 'RDI version'], - [RdiListColumn.LastConnection, 'Last connection'], - [RdiListColumn.Controls, 'Controls'], -]) +// Built via a factory so headers resolve against the active language at render +// time (a module-level map would freeze the English values at import). +export const getRdiColumnFieldNameMap = (t: TFunction) => + new Map([ + [RdiListColumn.Name, t('rdi.home.column.name')], + [RdiListColumn.Url, t('rdi.home.column.url')], + [RdiListColumn.Version, t('rdi.home.column.version')], + [RdiListColumn.LastConnection, t('rdi.home.column.lastConnection')], + [RdiListColumn.Controls, t('rdi.home.column.controls')], + ]) export const DEFAULT_RDI_SHOWN_COLUMNS = [ RdiListColumn.Name, diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 107326d28a..becfbb7b51 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -533,6 +533,49 @@ "query.runShortcut.label": "Изпълнение на командите", "query.runShortcut.labelNonMac": "Изпълнение", "query.tutorials.title": "Ръководства:", + "rdi.home.bulkDelete.subtitle_one": "Избраният {{count}} елемент ще бъде изтрит от RedisInsight:", + "rdi.home.bulkDelete.subtitle_other": "Избраните {{count}} елемента ще бъдат изтрити от RedisInsight:", + "rdi.home.column.controls": "Контроли", + "rdi.home.column.lastConnection": "Последна връзка", + "rdi.home.column.name": "RDI псевдоним", + "rdi.home.column.url": "URL", + "rdi.home.column.version": "RDI версия", + "rdi.home.empty.button": "Нека се свържем с RDI", + "rdi.home.empty.description": "Redis Data Integration (RDI) предава данни към Redis Cloud, осигурявайки синхронизация в реално време, като спестява време и разходи. Премахва пропуските в кеша и опростява управлението на данни.", + "rdi.home.empty.title": "Създаване на конвейер за данни", + "rdi.home.form.addButton": "Добавяне на крайна точка", + "rdi.home.form.addTitle": "Добавяне на RDI крайна точка", + "rdi.home.form.applyButton": "Прилагане на промените", + "rdi.home.form.auth.info": "Удостоверяването на RDI REST API използва потребителското име и паролата на RDI Redis.", + "rdi.home.form.cancel": "Отказ", + "rdi.home.form.editTitle": "Редактиране на крайна точка", + "rdi.home.form.name.label": "RDI псевдоним", + "rdi.home.form.name.placeholder": "Въведете RDI псевдоним", + "rdi.home.form.password.label": "Парола", + "rdi.home.form.password.placeholder": "Въведете паролата за RDI Redis", + "rdi.home.form.url.info": "RDI машината обслужва REST API през порт 443. Уверете се, че Redis Insight има достъп до RDI хоста през порт 443.", + "rdi.home.form.url.label": "URL", + "rdi.home.form.url.placeholder": "Въведете IP на RDI хоста като: https://[IP-адрес]", + "rdi.home.form.username.label": "Потребителско име", + "rdi.home.form.username.placeholder": "Въведете потребителското име за RDI Redis", + "rdi.home.form.wrapperTitle": "Добавяне на крайна точка", + "rdi.home.header.addButton": "RDI инстанция", + "rdi.home.instanceCell.copyUrlAria": "Копиране на URL", + "rdi.home.instanceControls.controlsAria": "Икона за контроли", + "rdi.home.instanceControls.deleteText": "ще бъде премахната от RedisInsight.", + "rdi.home.instanceControls.editAria": "Редактиране на инстанция", + "rdi.home.instanceControls.removeButton": "Премахване на инстанция", + "rdi.home.list.empty.loading": "Моля изчакайте...", + "rdi.home.list.empty.noEndpoints": "Няма добавени крайни точки", + "rdi.home.list.empty.noResults": "Няма намерени резултати", + "rdi.home.pageTitle": "Redis Data Integration", + "rdi.home.search.ariaLabel": "Търсене в списъка с RDI инстанции", + "rdi.home.search.placeholder": "Търсене в списъка с крайни точки", + "rdi.statistics.empty.addButton": "Добавяне на конвейер", + "rdi.statistics.empty.description": "Създайте първия си конвейер, за да започнете!", + "rdi.statistics.empty.title": "Все още няма разгърнат конвейер", + "rdi.statistics.error": "Неочаквана грешка във вашата RDI крайна точка, моля, презаредете страницата", + "rdi.statistics.pageTitle": "{{name}} - Състояние на конвейера", "settings.advanced.keysToScan.label": "Ключове за сканиране:", "settings.advanced.keysToScan.summary": "Задава броя ключове, сканирани на една итерация. Филтрирането по шаблон при голям брой ключове може да намали производителността.", "settings.advanced.keysToScan.title": "Ключове за сканиране в изглед Списък", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index e29a10db92..b4798650b4 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -533,6 +533,49 @@ "query.runShortcut.label": "Run commands", "query.runShortcut.labelNonMac": "Run", "query.tutorials.title": "Tutorials:", + "rdi.home.bulkDelete.subtitle_one": "Selected {{count}} item will be deleted from RedisInsight:", + "rdi.home.bulkDelete.subtitle_other": "Selected {{count}} items will be deleted from RedisInsight:", + "rdi.home.column.controls": "Controls", + "rdi.home.column.lastConnection": "Last connection", + "rdi.home.column.name": "RDI alias", + "rdi.home.column.url": "URL", + "rdi.home.column.version": "RDI version", + "rdi.home.empty.button": "Let’s connect to RDI", + "rdi.home.empty.description": "Redis data integration (RDI) streams data to Redis Cloud, ensuring real-time sync while saving time and costs. It eliminates cache misses and simplifies data management.", + "rdi.home.empty.title": "Create data pipeline", + "rdi.home.form.addButton": "Add Endpoint", + "rdi.home.form.addTitle": "Add RDI endpoint", + "rdi.home.form.applyButton": "Apply Changes", + "rdi.home.form.auth.info": "The RDI REST API authentication is using the RDI Redis username and password.", + "rdi.home.form.cancel": "Cancel", + "rdi.home.form.editTitle": "Edit endpoint", + "rdi.home.form.name.label": "RDI Alias", + "rdi.home.form.name.placeholder": "Enter RDI Alias", + "rdi.home.form.password.label": "Password", + "rdi.home.form.password.placeholder": "Enter the RDI Redis password", + "rdi.home.form.url.info": "The RDI machine servers REST API via port 443. Ensure that Redis Insight can access the RDI host over port 443.", + "rdi.home.form.url.label": "URL", + "rdi.home.form.url.placeholder": "Enter the RDI host IP as: https://[IP-Address]", + "rdi.home.form.username.label": "Username", + "rdi.home.form.username.placeholder": "Enter the RDI Redis username", + "rdi.home.form.wrapperTitle": "Add endpoint", + "rdi.home.header.addButton": "RDI Instance", + "rdi.home.instanceCell.copyUrlAria": "Copy URL", + "rdi.home.instanceControls.controlsAria": "Controls icon", + "rdi.home.instanceControls.deleteText": "will be removed from RedisInsight.", + "rdi.home.instanceControls.editAria": "Edit instance", + "rdi.home.instanceControls.removeButton": "Remove instance", + "rdi.home.list.empty.loading": "Loading...", + "rdi.home.list.empty.noEndpoints": "No added endpoints", + "rdi.home.list.empty.noResults": "No results found", + "rdi.home.pageTitle": "Redis Data Integration", + "rdi.home.search.ariaLabel": "Search rdi instance list", + "rdi.home.search.placeholder": "Endpoint List Search", + "rdi.statistics.empty.addButton": "Add Pipeline", + "rdi.statistics.empty.description": "Create your first pipeline to get started!", + "rdi.statistics.empty.title": "No pipeline deployed yet", + "rdi.statistics.error": "Unexpected error in your RDI endpoint, please refresh the page", + "rdi.statistics.pageTitle": "{{name}} - Pipeline Status", "settings.advanced.keysToScan.label": "Keys to Scan:", "settings.advanced.keysToScan.summary": "Sets the amount of keys to scan per one iteration. Filtering by pattern per a large number of keys may decrease performance.", "settings.advanced.keysToScan.title": "Keys to Scan in List view", diff --git a/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx b/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx index 82b4da4650..b5c2e03c0d 100644 --- a/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx +++ b/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx @@ -17,6 +17,7 @@ import { } from 'uiSrc/telemetry' import HomePageTemplate from 'uiSrc/templates/home-page-template' import { setTitle } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' import { Page, PageBody } from 'uiSrc/components/base/layout/page' import { Rdi as RdiInstanceResponse } from 'apiClient' import { dispatch } from 'uiSrc/slices/store' @@ -41,6 +42,7 @@ const handleOpenPage = (data: RdiInstance[]) => { } const RdiPage = () => { + const { t } = useTranslation() const { editInstance, setEditInstance, @@ -54,7 +56,7 @@ const RdiPage = () => { useEffect(() => { dispatch(fetchInstancesAction(handleOpenPage)) - setTitle('Redis Data Integration') + setTitle(t('rdi.home.pageTitle')) }, []) const handleFormSubmit = (instance: Partial) => { diff --git a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/RdiInstancesList.config.ts b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/RdiInstancesList.config.ts index 242ba161cc..b63b2abf00 100644 --- a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/RdiInstancesList.config.ts +++ b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/RdiInstancesList.config.ts @@ -1,5 +1,6 @@ +import { TFunction } from 'i18next' import { ColumnDef, Table } from 'uiSrc/components/base/layout/table' -import { RDI_COLUMN_FIELD_NAME_MAP, RdiListColumn } from 'uiSrc/constants' +import { getRdiColumnFieldNameMap, RdiListColumn } from 'uiSrc/constants' import { RdiInstance } from 'uiSrc/slices/interfaces' import RdiInstancesListCellSelect from './components/RdiInstancesListCellSelect/RdiInstancesListCellSelect' @@ -9,62 +10,66 @@ import RdiInstancesListCell from './components/RdiInstancesListCell/RdiInstances export const SELECT_COL_ID = 'select-col-rdi' export const ENABLE_PAGINATION_COUNT = 15 -export const BASE_COLUMNS: ColumnDef[] = [ - { - id: SELECT_COL_ID, - size: 40, - isHeaderCustom: true, - enableSorting: false, - header: Table.HeaderMultiRowSelectionButton, - cell: RdiInstancesListCellSelect, - }, - { - id: RdiListColumn.Name, - accessorKey: RdiListColumn.Name, - header: RDI_COLUMN_FIELD_NAME_MAP.get(RdiListColumn.Name), - enableSorting: true, - cell: RdiInstancesListCell, - sortingFn: (rowA, rowB) => - `${rowA.original.name?.toLowerCase()}`.localeCompare( - `${rowB.original.name?.toLowerCase()}`, - ), - }, - { - id: RdiListColumn.Url, - accessorKey: RdiListColumn.Url, - header: RDI_COLUMN_FIELD_NAME_MAP.get(RdiListColumn.Url), - enableSorting: true, - cell: RdiInstancesListCell, - sortingFn: (rowA, rowB) => - `${rowA.original.url?.toLowerCase()}`.localeCompare( - `${rowB.original.url?.toLowerCase()}`, - ), - }, - { - id: RdiListColumn.Version, - accessorKey: RdiListColumn.Version, - header: RDI_COLUMN_FIELD_NAME_MAP.get(RdiListColumn.Version), - enableSorting: true, - cell: RdiInstancesListCell, - }, - { - id: RdiListColumn.LastConnection, - accessorKey: RdiListColumn.LastConnection, - header: RDI_COLUMN_FIELD_NAME_MAP.get(RdiListColumn.LastConnection), - enableSorting: true, - cell: RdiInstancesListCell, - sortingFn: (rowA, rowB) => { - const a = rowA.original.lastConnection - const b = rowB.original.lastConnection - const getTime = (v: any) => (v ? new Date(`${v}`).getTime() : -Infinity) - return getTime(a) - getTime(b) +export const getBaseColumns = (t: TFunction): ColumnDef[] => { + const columnNameMap = getRdiColumnFieldNameMap(t) + + return [ + { + id: SELECT_COL_ID, + size: 40, + isHeaderCustom: true, + enableSorting: false, + header: Table.HeaderMultiRowSelectionButton, + cell: RdiInstancesListCellSelect, + }, + { + id: RdiListColumn.Name, + accessorKey: RdiListColumn.Name, + header: columnNameMap.get(RdiListColumn.Name), + enableSorting: true, + cell: RdiInstancesListCell, + sortingFn: (rowA, rowB) => + `${rowA.original.name?.toLowerCase()}`.localeCompare( + `${rowB.original.name?.toLowerCase()}`, + ), + }, + { + id: RdiListColumn.Url, + accessorKey: RdiListColumn.Url, + header: columnNameMap.get(RdiListColumn.Url), + enableSorting: true, + cell: RdiInstancesListCell, + sortingFn: (rowA, rowB) => + `${rowA.original.url?.toLowerCase()}`.localeCompare( + `${rowB.original.url?.toLowerCase()}`, + ), + }, + { + id: RdiListColumn.Version, + accessorKey: RdiListColumn.Version, + header: columnNameMap.get(RdiListColumn.Version), + enableSorting: true, + cell: RdiInstancesListCell, + }, + { + id: RdiListColumn.LastConnection, + accessorKey: RdiListColumn.LastConnection, + header: columnNameMap.get(RdiListColumn.LastConnection), + enableSorting: true, + cell: RdiInstancesListCell, + sortingFn: (rowA, rowB) => { + const a = rowA.original.lastConnection + const b = rowB.original.lastConnection + const getTime = (v: any) => (v ? new Date(`${v}`).getTime() : -Infinity) + return getTime(a) - getTime(b) + }, + }, + { + id: RdiListColumn.Controls, + accessorKey: RdiListColumn.Controls, + header: '', + enableSorting: false, + cell: RdiInstancesListCellControls, }, - }, - { - id: RdiListColumn.Controls, - accessorKey: RdiListColumn.Controls, - header: '', - enableSorting: false, - cell: RdiInstancesListCellControls, - }, -] + ] +} diff --git a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/BulkItemsActions/BulkItemsActions.tsx b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/BulkItemsActions/BulkItemsActions.tsx index 4e4bb2dc67..4a2a75583c 100644 --- a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/BulkItemsActions/BulkItemsActions.tsx +++ b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/BulkItemsActions/BulkItemsActions.tsx @@ -2,6 +2,7 @@ import React, { memo } from 'react' import { ActionBar, DeleteAction } from 'uiSrc/components/item-list/components' import { RdiInstance } from 'uiSrc/slices/interfaces' +import { useTranslation } from 'uiSrc/i18n' import { handleDeleteInstances } from './methods/handlers' @@ -11,6 +12,8 @@ type BulkItemsActionsProps = { } const BulkItemsActions = ({ items, onClose }: BulkItemsActionsProps) => { + const { t } = useTranslation() + if (!items.length) return null return ( @@ -24,7 +27,7 @@ const BulkItemsActions = ({ items, onClose }: BulkItemsActionsProps) => { handleDeleteInstances(items) onClose() }} - subTitle={`Selected ${items.length} items will be deleted from RedisInsight:`} + subTitle={t('rdi.home.bulkDelete.subtitle', { count: items.length })} />, ]} /> diff --git a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCell/RdiInstancesListCell.tsx b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCell/RdiInstancesListCell.tsx index 90fe47e563..062e32296b 100644 --- a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCell/RdiInstancesListCell.tsx +++ b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCell/RdiInstancesListCell.tsx @@ -4,6 +4,7 @@ import { CopyButton } from 'uiSrc/components/copy-button' import { Text } from 'uiSrc/components/base/text' import { lastConnectionFormat } from 'uiSrc/utils' import { RdiListColumn } from 'uiSrc/constants' +import { useTranslation } from 'uiSrc/i18n' import { sendCopyUrlTelemetry } from '../../methods/handlers' import { IRdiListCell } from '../../RdiInstancesList.types' @@ -18,6 +19,7 @@ const fieldFormatters: Record string> = { } const RdiInstancesListCell: IRdiListCell = ({ row, column }) => { + const { t } = useTranslation() const item = row.original const id = item.id const field = column.id as keyof typeof item @@ -41,7 +43,7 @@ const RdiInstancesListCell: IRdiListCell = ({ row, column }) => { sendCopyUrlTelemetry(id)} - aria-label="Copy URL" + aria-label={t('rdi.home.instanceCell.copyUrlAria')} /> )} diff --git a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCellControls/RdiInstancesListCellControls.tsx b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCellControls/RdiInstancesListCellControls.tsx index 29bf93fead..67a8a23e68 100644 --- a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCellControls/RdiInstancesListCellControls.tsx +++ b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/components/RdiInstancesListCellControls/RdiInstancesListCellControls.tsx @@ -11,6 +11,7 @@ import { RiPopover } from 'uiSrc/components' import { useRdiPageDataProvider } from 'uiSrc/pages/rdi/home/contexts/RdiPageDataProvider' import { dispatch } from 'uiSrc/slices/store' import { deleteInstancesAction } from 'uiSrc/slices/rdi/instances' +import { useTranslation } from 'uiSrc/i18n' import { IRdiListCell } from '../../RdiInstancesList.types' const suffix = '_rdi_instance' @@ -23,6 +24,7 @@ const handleClickDeleteInstance = (id: string) => { } const RdiInstancesListCellControls: IRdiListCell = ({ row }) => { + const { t } = useTranslation() const instance = row.original as RdiInstance const [isDeletePopoverOpen, setIsDeletePopoverOpen] = useState(false) const { setEditInstance, setIsConnectionFormOpen } = useRdiPageDataProvider() @@ -55,7 +57,7 @@ const RdiInstancesListCellControls: IRdiListCell = ({ row }) => { button={ } @@ -65,15 +67,15 @@ const RdiInstancesListCellControls: IRdiListCell = ({ row }) => { - Edit endpoint + {t('rdi.home.form.editTitle')} { handleDeleteItem={handleConfirmDelete} handleButtonClick={() => handleClickDeleteInstance(instance.id)} testid={`delete-instance-${instance.id}`} - buttonLabel="Remove instance" + buttonLabel={t('rdi.home.instanceControls.removeButton')} /> diff --git a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/hooks/useRdiInstancesListData.ts b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/hooks/useRdiInstancesListData.ts index bc59ed957d..3ab4fa31d5 100644 --- a/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/hooks/useRdiInstancesListData.ts +++ b/redisinsight/ui/src/pages/rdi/home/components/rdi-instances-list/hooks/useRdiInstancesListData.ts @@ -8,14 +8,16 @@ import { import { RdiInstance } from 'uiSrc/slices/interfaces' import { instancesSelector } from 'uiSrc/slices/rdi/instances' import { RdiListColumn } from 'uiSrc/constants' +import { useTranslation } from 'uiSrc/i18n' import { ENABLE_PAGINATION_COUNT, - BASE_COLUMNS, + getBaseColumns, SELECT_COL_ID, } from '../RdiInstancesList.config' const useRdiInstancesListData = () => { + const { t } = useTranslation() const { data: instances, loading, @@ -31,12 +33,12 @@ const useRdiInstancesListData = () => { const columns: ColumnDef[] = useMemo( () => - BASE_COLUMNS.filter( + getBaseColumns(t).filter( (col) => col.id === SELECT_COL_ID || (shownColumns as RdiListColumn[]).includes(col.id as RdiListColumn), ), - [shownColumns], + [shownColumns, t], ) const visibleInstances = useMemo( @@ -53,10 +55,10 @@ const useRdiInstancesListData = () => { ) const emptyMessage = useMemo(() => { - if (loading) return 'Loading...' - if (!instances.length) return 'No added endpoints' - return 'No results found' - }, [loading, instances.length]) + if (loading) return t('rdi.home.list.empty.loading') + if (!instances.length) return t('rdi.home.list.empty.noEndpoints') + return t('rdi.home.list.empty.noResults') + }, [loading, instances.length, t]) return { loading, diff --git a/redisinsight/ui/src/pages/rdi/home/connection-form/ConnectionForm.tsx b/redisinsight/ui/src/pages/rdi/home/connection-form/ConnectionForm.tsx index 152f738d3f..f96ab59699 100644 --- a/redisinsight/ui/src/pages/rdi/home/connection-form/ConnectionForm.tsx +++ b/redisinsight/ui/src/pages/rdi/home/connection-form/ConnectionForm.tsx @@ -25,6 +25,7 @@ import { InfoIcon } from 'uiSrc/components/base/icons' import { FormField } from 'uiSrc/components/base/forms/FormField' import { PasswordInput, TextInput } from 'uiSrc/components/base/inputs' import { Title } from 'uiSrc/components/base/text/Title' +import { useTranslation } from 'uiSrc/i18n' import ValidationTooltip from './components/ValidationTooltip' export interface AppendInfoProps @@ -57,6 +58,7 @@ const getInitialValues = ( const ConnectionForm = (props: Props) => { const { onSubmit, onCancel, editInstance, isLoading } = props + const { t } = useTranslation() const [initialFormValues, setInitialFormValues] = useState( getInitialValues(editInstance), @@ -67,7 +69,9 @@ const ConnectionForm = (props: Props) => { setInitialFormValues(getInitialValues(editInstance)) setModalHeader( - {editInstance ? 'Edit endpoint' : 'Add RDI endpoint'} + {editInstance + ? t('rdi.home.form.editTitle') + : t('rdi.home.form.addTitle')} , ) }, [editInstance]) @@ -76,10 +80,10 @@ const ConnectionForm = (props: Props) => { const errors: FormikErrors = {} if (!values.name) { - errors.name = 'RDI Alias' + errors.name = t('rdi.home.form.name.label') } if (!values.url) { - errors.url = 'URL' + errors.url = t('rdi.home.form.url.label') } return errors @@ -112,7 +116,7 @@ const ConnectionForm = (props: Props) => { data-testid="connection-form-cancel-button" onClick={onCancel} > - Cancel + {t('rdi.home.form.cancel')} @@ -125,7 +129,9 @@ const ConnectionForm = (props: Props) => { disabled={!isValid} onClick={onSubmit} > - {editInstance ? 'Apply Changes' : 'Add Endpoint'} + {editInstance + ? t('rdi.home.form.applyButton') + : t('rdi.home.form.addButton')} @@ -147,12 +153,12 @@ const ConnectionForm = (props: Props) => { {({ isValid, errors, values }) => (
- + {({ field }: { field: FieldInputProps }) => ( { {({ field }: { field: FieldInputProps }) => ( { {({ field }: { field: FieldInputProps }) => ( { {({ @@ -234,7 +237,7 @@ const ConnectionForm = (props: Props) => { }) => ( { const { isOpen, onCancel } = props + const { t } = useTranslation() const [modalHeader, setModalHeader] = useState>(null) @@ -20,7 +22,9 @@ const ConnectionFormWrapper = (props: Props) => { Add endpoint} + header={ + modalHeader ?? {t('rdi.home.form.wrapperTitle')} + } footer={} > diff --git a/redisinsight/ui/src/pages/rdi/home/empty-message/EmptyMessage.tsx b/redisinsight/ui/src/pages/rdi/home/empty-message/EmptyMessage.tsx index 434dce23b1..a6247094c0 100644 --- a/redisinsight/ui/src/pages/rdi/home/empty-message/EmptyMessage.tsx +++ b/redisinsight/ui/src/pages/rdi/home/empty-message/EmptyMessage.tsx @@ -11,6 +11,7 @@ import { Spacer } from 'uiSrc/components/base/layout/spacer' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { RiImage } from 'uiSrc/components/base/display' import { EmptyPageContainer } from 'uiSrc/pages/rdi/home/empty-message/styles' +import { useTranslation } from 'uiSrc/i18n' export interface Props { onAddInstanceClick: () => void @@ -18,26 +19,17 @@ export interface Props { const EmptyMessage = ({ onAddInstanceClick }: Props) => { const { theme } = useContext(ThemeContext) + const { t } = useTranslation() return ( - Create data pipeline + {t('rdi.home.empty.title')} - - Redis data integration (RDI) streams data to Redis Cloud, - ensuring - - - real-time sync while saving time and costs. It eliminates - cache - - - misses and simplifies data management. - + {t('rdi.home.empty.description')} @@ -48,7 +40,7 @@ const EmptyMessage = ({ onAddInstanceClick }: Props) => { size="l" onClick={onAddInstanceClick} > - Let’s connect to RDI + {t('rdi.home.empty.button')} diff --git a/redisinsight/ui/src/pages/rdi/home/header/RdiHeader.tsx b/redisinsight/ui/src/pages/rdi/home/header/RdiHeader.tsx index 0b7913e3a2..40d455b94a 100644 --- a/redisinsight/ui/src/pages/rdi/home/header/RdiHeader.tsx +++ b/redisinsight/ui/src/pages/rdi/home/header/RdiHeader.tsx @@ -5,10 +5,11 @@ import { instancesSelector, setShownColumns } from 'uiSrc/slices/rdi/instances' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' -import { RDI_COLUMN_FIELD_NAME_MAP, RdiListColumn } from 'uiSrc/constants' +import { getRdiColumnFieldNameMap, RdiListColumn } from 'uiSrc/constants' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import ColumnsConfigPopover from 'uiSrc/components/columns-config/ColumnsConfigPopover' import { PlusIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import SearchRdiList from '../search/SearchRdiList' export interface Props { @@ -16,6 +17,7 @@ export interface Props { } const RdiHeader = ({ onRdiInstanceClick }: Props) => { + const { t } = useTranslation() const dispatch = useAppDispatch() const { data: instances, shownColumns } = useAppSelector(instancesSelector) @@ -43,13 +45,13 @@ const RdiHeader = ({ onRdiInstanceClick }: Props) => { data-testid="rdi-instance" icon={PlusIcon} > - RDI Instance + {t('rdi.home.header.addButton')} {instances.length > 0 && ( diff --git a/redisinsight/ui/src/pages/rdi/home/search/SearchRdiList.tsx b/redisinsight/ui/src/pages/rdi/home/search/SearchRdiList.tsx index aa29b4359b..0bf801e88c 100644 --- a/redisinsight/ui/src/pages/rdi/home/search/SearchRdiList.tsx +++ b/redisinsight/ui/src/pages/rdi/home/search/SearchRdiList.tsx @@ -9,8 +9,10 @@ import { } from 'uiSrc/slices/rdi/instances' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { lastConnectionFormat } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' const SearchRdiList = () => { + const { t } = useTranslation() const { data: instances } = useAppSelector(instancesSelector) const dispatch = useAppDispatch() @@ -41,9 +43,9 @@ const SearchRdiList = () => { return ( ) diff --git a/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx b/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx index 10da0ffa9e..e4364e6c66 100644 --- a/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx +++ b/redisinsight/ui/src/pages/rdi/statistics/StatisticsPage.tsx @@ -16,6 +16,7 @@ import { sendPageViewTelemetry, } from 'uiSrc/telemetry' import { formatLongName, Nullable, setTitle } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' import { setLastPageContext } from 'uiSrc/slices/app/context' import { PageNames } from 'uiSrc/constants' import { Loader } from 'uiSrc/components/base/display' @@ -51,6 +52,7 @@ const renderStatisticsSection = (section: IStatisticsSection) => { } const StatisticsPage = () => { + const { t } = useTranslation() const [pageLoading, setPageLoading] = useState(true) const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() const [lastRefreshTime, setLastRefreshTime] = React.useState(Date.now()) @@ -63,7 +65,7 @@ const StatisticsPage = () => { connectedInstanceSelector, ) const rdiInstanceName = formatLongName(connectedRdiInstanceName, 33, 0, '...') - setTitle(`${rdiInstanceName} - Pipeline Status`) + setTitle(t('rdi.statistics.pageTitle', { name: rdiInstanceName })) const onRefresh = (section: string) => { dispatch(fetchRdiStatistics(rdiInstanceId, section)) @@ -130,9 +132,7 @@ const StatisticsPage = () => { // todo add interface if (statisticsResults.status === 'failed') { return ( - - Unexpected error in your RDI endpoint, please refresh the page - + {t('rdi.statistics.error')} ) } diff --git a/redisinsight/ui/src/pages/rdi/statistics/empty/Empty.tsx b/redisinsight/ui/src/pages/rdi/statistics/empty/Empty.tsx index b0177e94c6..9ff8280343 100644 --- a/redisinsight/ui/src/pages/rdi/statistics/empty/Empty.tsx +++ b/redisinsight/ui/src/pages/rdi/statistics/empty/Empty.tsx @@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom' import EmptyPipelineIcon from 'uiSrc/assets/img/rdi/empty_pipeline.svg' import { Pages } from 'uiSrc/constants' +import { useTranslation } from 'uiSrc/i18n' import { Text } from 'uiSrc/components/base/text' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' @@ -17,6 +18,7 @@ interface Props { const Empty = ({ rdiInstanceId }: Props) => { const history = useHistory() + const { t } = useTranslation() return ( @@ -26,9 +28,9 @@ const Empty = ({ rdiInstanceId }: Props) => { > - No pipeline deployed yet + {t('rdi.statistics.empty.title')} - Create your first pipeline to get started! + {t('rdi.statistics.empty.description')} { history.push(Pages.rdiPipelineConfig(rdiInstanceId)) }} > - Add Pipeline + {t('rdi.statistics.empty.addButton')} From d1e369ee912d17b2390f2f22b35f0fcff6488daa Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Mon, 20 Jul 2026 08:24:25 +0300 Subject: [PATCH 051/166] RI-8277 Migrate rdi instance pipeline header to i18n (#6222) * feat(i18n): migrate rdi instance pipeline header to i18n (RI-8277) Route the RDI instance pipeline header through i18n under rdi.instance.* with Bulgarian translations: the deploy modal (confirm title, errors warning, overwrite/flush texts, reset checkbox + info, Deploy button), the start/stop/reset pipeline buttons (tooltips, aria-labels, labels), the current pipeline status title and the state labels (initial sync / streaming / not running / error), and the config-file action menu items (download deployed / import ZIP / save ZIP). --- redisinsight/ui/src/i18n/locales/bg.json | 37 ++++++++++++ redisinsight/ui/src/i18n/locales/en.json | 37 ++++++++++++ .../DeployPipelineButton.tsx | 24 ++++---- .../ResetPipelineButton.tsx | 57 +++++++++---------- .../StartPipelineButton.tsx | 37 ++++++------ .../StopPipelineButton.tsx | 37 ++++++------ .../CurrentPipelineStatus.tsx | 4 +- .../current-pipeline-status/utils.ts | 31 ++++++++-- .../RdiConfigFileActionMenu.tsx | 8 ++- 9 files changed, 188 insertions(+), 84 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index becfbb7b51..004a39f1b4 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -571,6 +571,43 @@ "rdi.home.pageTitle": "Redis Data Integration", "rdi.home.search.ariaLabel": "Търсене в списъка с RDI инстанции", "rdi.home.search.placeholder": "Търсене в списъка с крайни точки", + "rdi.instance.configMenu.downloadDeployed": "Изтегляне на внедрения конвейер", + "rdi.instance.configMenu.importZip": "Импортиране на конвейер от ZIP файл", + "rdi.instance.configMenu.saveZip": "Запазване на конвейера в ZIP файл", + "rdi.instance.deploy.button": "Внедряване", + "rdi.instance.deploy.confirmTitle": "Сигурни ли сте, че искате да внедрите конвейера?", + "rdi.instance.deploy.errorsWarning": "Вашият RDI конвейер съдържа грешки. Сигурни ли сте, че искате да продължите?", + "rdi.instance.deploy.flushText": "След внедряването обмислете изчистване на целевата Redis база данни и нулиране на конвейера, за да сте сигурни, че всички данни са обработени наново.", + "rdi.instance.deploy.overwriteText": "При внедряване тази локална конфигурация ще замени всеки съществуващ конвейер.", + "rdi.instance.deploy.resetInfo": "Конвейерът ще направи нов снапшот на данните и ще ги обработи, след което ще продължи да следи промените.", + "rdi.instance.deploy.resetLabel": "Нулиране", + "rdi.instance.reset.ariaLabel": "Бутон за нулиране на конвейера", + "rdi.instance.reset.button": "Нулиране", + "rdi.instance.reset.tooltipLine1": "Конвейерът ще направи нов снапшот на данните и ще ги обработи, след което ще продължи да следи промените.", + "rdi.instance.reset.tooltipLine2": "Преди да нулирате RDI конвейера, обмислете спиране на конвейера и изчистване на целевата Redis база данни.", + "rdi.instance.start.ariaLabel": "Стартиране на конвейера", + "rdi.instance.start.button": "Старт", + "rdi.instance.start.tooltip": "Стартирайте конвейера, за да възобновите обработката на нови постъпващи данни.", + "rdi.instance.status.creating": "Създаване", + "rdi.instance.status.deleting": "Изтриване", + "rdi.instance.status.error": "Грешка", + "rdi.instance.status.initialSync": "Първоначална синхронизация", + "rdi.instance.status.notReady": "Не е готов", + "rdi.instance.status.notRunning": "Не работи", + "rdi.instance.status.pending": "В изчакване", + "rdi.instance.status.ready": "Готов", + "rdi.instance.status.resetting": "Нулиране", + "rdi.instance.status.started": "Стартиран", + "rdi.instance.status.starting": "Стартиране", + "rdi.instance.status.stopped": "Спрян", + "rdi.instance.status.stopping": "Спиране", + "rdi.instance.status.streaming": "Стрийминг", + "rdi.instance.status.title": "Състояние на конвейера", + "rdi.instance.status.unknown": "Неизвестно", + "rdi.instance.status.updating": "Обновяване", + "rdi.instance.stop.ariaLabel": "Спиране на конвейера", + "rdi.instance.stop.button": "Стоп", + "rdi.instance.stop.tooltip": "Спрете конвейера, за да предотвратите обработката на нови постъпващи данни.", "rdi.statistics.empty.addButton": "Добавяне на конвейер", "rdi.statistics.empty.description": "Създайте първия си конвейер, за да започнете!", "rdi.statistics.empty.title": "Все още няма разгърнат конвейер", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index b4798650b4..24becd408a 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -571,6 +571,43 @@ "rdi.home.pageTitle": "Redis Data Integration", "rdi.home.search.ariaLabel": "Search rdi instance list", "rdi.home.search.placeholder": "Endpoint List Search", + "rdi.instance.configMenu.downloadDeployed": "Download deployed pipeline", + "rdi.instance.configMenu.importZip": "Import pipeline from ZIP file", + "rdi.instance.configMenu.saveZip": "Save pipeline to ZIP file", + "rdi.instance.deploy.button": "Deploy", + "rdi.instance.deploy.confirmTitle": "Are you sure you want to deploy the pipeline?", + "rdi.instance.deploy.errorsWarning": "Your RDI pipeline contains errors. Are you sure you want to continue?", + "rdi.instance.deploy.flushText": "After deployment, consider flushing the target Redis database and resetting the pipeline to ensure that all data is reprocessed.", + "rdi.instance.deploy.overwriteText": "When deployed, this local configuration will overwrite any existing pipeline.", + "rdi.instance.deploy.resetInfo": "The pipeline will take a new snapshot of the data and process it, then continue tracking changes.", + "rdi.instance.deploy.resetLabel": "Reset", + "rdi.instance.reset.ariaLabel": "Reset pipeline button", + "rdi.instance.reset.button": "Reset", + "rdi.instance.reset.tooltipLine1": "The pipeline will take a new snapshot of the data and process it, then continue tracking changes.", + "rdi.instance.reset.tooltipLine2": "Before resetting the RDI pipeline, consider stopping the pipeline and flushing the target Redis database.", + "rdi.instance.start.ariaLabel": "Start running pipeline", + "rdi.instance.start.button": "Start", + "rdi.instance.start.tooltip": "Start the pipeline to resume processing new data arrivals.", + "rdi.instance.status.creating": "Creating", + "rdi.instance.status.deleting": "Deleting", + "rdi.instance.status.error": "Error", + "rdi.instance.status.initialSync": "Initial sync", + "rdi.instance.status.notReady": "Not-ready", + "rdi.instance.status.notRunning": "Not running", + "rdi.instance.status.pending": "Pending", + "rdi.instance.status.ready": "Ready", + "rdi.instance.status.resetting": "Resetting", + "rdi.instance.status.started": "Started", + "rdi.instance.status.starting": "Starting", + "rdi.instance.status.stopped": "Stopped", + "rdi.instance.status.stopping": "Stopping", + "rdi.instance.status.streaming": "Streaming", + "rdi.instance.status.title": "Pipeline status", + "rdi.instance.status.unknown": "Unknown", + "rdi.instance.status.updating": "Updating", + "rdi.instance.stop.ariaLabel": "Stop running pipeline", + "rdi.instance.stop.button": "Stop", + "rdi.instance.stop.tooltip": "Stop the pipeline to prevent processing of new data arrivals.", "rdi.statistics.empty.addButton": "Add Pipeline", "rdi.statistics.empty.description": "Create your first pipeline to get started!", "rdi.statistics.empty.title": "No pipeline deployed yet", diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx index 046757c16a..dc0d17eb13 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx @@ -21,6 +21,7 @@ import { Checkbox } from 'uiSrc/components/base/forms/checkbox/Checkbox' import { RiTooltip } from 'uiSrc/components/base' import { Modal } from 'uiSrc/components/base/display/modal' import { UploadWarningBanner } from 'uiSrc/components/upload-warning/styles' +import { useTranslation } from 'uiSrc/i18n' export interface Props { loading?: boolean @@ -29,6 +30,7 @@ export interface Props { } const DeployPipelineButton = ({ loading, disabled, onReset }: Props) => { + const { t } = useTranslation() const [resetPipeline, setResetPipeline] = useState(false) const { config, jobs, resetChecked, isPipelineValid } = @@ -89,45 +91,39 @@ const DeployPipelineButton = ({ loading, disabled, onReset }: Props) => { return ( {!isPipelineValid && ( )} - - When deployed, this local configuration will overwrite any - existing pipeline. - - - After deployment, consider flushing the target Redis database and - resetting the pipeline to ensure that all data is reprocessed. - + {t('rdi.instance.deploy.overwriteText')} + {t('rdi.instance.deploy.flushText')} handleSelectReset(e.target.checked)} data-testid="reset-pipeline-checkbox" /> - + } - primaryButtonText="Deploy" + primaryButtonText={t('rdi.instance.deploy.button')} onPrimaryButtonClick={handleDeployPipeline} > { loading={loading} data-testid="deploy-rdi-pipeline" > - Deploy + {t('rdi.instance.deploy.button')} ) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.tsx index cdd5351bff..186ec627e0 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/reset-pipeline-button/ResetPipelineButton.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { RiTooltip } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' import styles from '../styles.module.scss' import { Button, TextButton } from '@redis-ui/components' import { ResetIcon } from '@redis-ui/icons' @@ -16,35 +17,33 @@ const ResetPipelineButton = ({ onClick, disabled, loading, -}: PipelineButtonProps) => ( - -

- The pipeline will take a new snapshot of the data and process it, - then continue tracking changes. -

- -

- Before resetting the RDI pipeline, consider stopping the pipeline - and flushing the target Redis database. -

- - ) : null - } - anchorClassName={disabled || loading ? styles.disabled : styles.tooltip} - > - { + const { t } = useTranslation() + + return ( + +

{t('rdi.instance.reset.tooltipLine1')}

+ +

{t('rdi.instance.reset.tooltipLine2')}

+ + ) : null + } + anchorClassName={disabled || loading ? styles.disabled : styles.tooltip} > - - Reset -
-
-) + + + {t('rdi.instance.reset.button')} + + + ) +} export default ResetPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.tsx index b9c2a8c2cb..505e71dac9 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/start-pipeline-button/StartPipelineButton.tsx @@ -3,6 +3,7 @@ import React from 'react' import { SecondaryButton } from 'uiSrc/components/base/forms/buttons' import { PlayFilledIcon } from 'uiSrc/components/base/icons' import { RiTooltip } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' import { PipelineButtonProps } from '../reset-pipeline-button/ResetPipelineButton' import styles from '../styles.module.scss' @@ -10,22 +11,26 @@ const StartPipelineButton = ({ onClick, disabled, loading, -}: PipelineButtonProps) => ( - - { + const { t } = useTranslation() + + return ( + - Start - - -) + + {t('rdi.instance.start.button')} + + + ) +} export default StartPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.tsx index a24e55b5ba..984d4da184 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/stop-pipeline-button/StopPipelineButton.tsx @@ -3,6 +3,7 @@ import React from 'react' import { SecondaryButton } from 'uiSrc/components/base/forms/buttons' import { RiStopIcon } from 'uiSrc/components/base/icons' import { RiTooltip } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' import { PipelineButtonProps } from '../reset-pipeline-button/ResetPipelineButton' import styles from '../styles.module.scss' @@ -10,22 +11,26 @@ const StopPipelineButton = ({ onClick, disabled, loading, -}: PipelineButtonProps) => ( - - { + const { t } = useTranslation() + + return ( + - Stop - - -) + + {t('rdi.instance.stop.button')} + + + ) +} export default StopPipelineButton diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.tsx index 1697876a0e..b2590eeae1 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/CurrentPipelineStatus.tsx @@ -7,6 +7,7 @@ import { Loader } from 'uiSrc/components/base/display' import { RiTooltip } from 'uiSrc/components' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import { getStatusToShowFromState, getStatusToShowFromStatus } from './utils' export interface Props { @@ -22,6 +23,7 @@ const CurrentPipelineStatus = ({ statusError, headerLoading, }: Props) => { + const { t } = useTranslation() const stateInfo = pipelineState ? getStatusToShowFromState(pipelineState) : getStatusToShowFromStatus(pipelineStatus) @@ -31,7 +33,7 @@ const CurrentPipelineStatus = ({ - Pipeline status + {t('rdi.instance.status.title')} diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/utils.ts b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/utils.ts index bcad44db73..eac31a4f23 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/utils.ts +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/current-pipeline-status/utils.ts @@ -9,6 +9,7 @@ import { PipelineState, PipelineStatus } from 'uiSrc/slices/interfaces' import { IconProps } from 'uiSrc/components/base/icons' import { IconType } from 'uiSrc/components/base/forms/buttons' import { Maybe } from 'uiSrc/utils' +import i18n from 'uiSrc/i18n' export interface StatusInfo { label: string @@ -24,33 +25,53 @@ export const getStatusToShowFromState = ( return { icon: IndicatorSyncingIcon, iconColor: 'success300', - label: 'Initial sync', + label: i18n.t('rdi.instance.status.initialSync'), } case PipelineState.CDC: return { icon: IndicatorSyncedIcon, iconColor: 'success500', - label: 'Streaming', + label: i18n.t('rdi.instance.status.streaming'), } case PipelineState.NotRunning: return { icon: IndicatorSyncstoppedIcon, iconColor: 'attention500', - label: 'Not running', + label: i18n.t('rdi.instance.status.notRunning'), } default: return { icon: IndicatorSyncerrorIcon, iconColor: 'danger500', - label: 'Error', + label: i18n.t('rdi.instance.status.error'), } } } +const STATUS_LABEL_KEYS: Record = { + [PipelineStatus.Ready]: 'rdi.instance.status.ready', + [PipelineStatus.NotReady]: 'rdi.instance.status.notReady', + [PipelineStatus.Stopping]: 'rdi.instance.status.stopping', + [PipelineStatus.Started]: 'rdi.instance.status.started', + [PipelineStatus.Stopped]: 'rdi.instance.status.stopped', + [PipelineStatus.Error]: 'rdi.instance.status.error', + [PipelineStatus.Creating]: 'rdi.instance.status.creating', + [PipelineStatus.Updating]: 'rdi.instance.status.updating', + [PipelineStatus.Deleting]: 'rdi.instance.status.deleting', + [PipelineStatus.Starting]: 'rdi.instance.status.starting', + [PipelineStatus.Resetting]: 'rdi.instance.status.resetting', + [PipelineStatus.Pending]: 'rdi.instance.status.pending', + [PipelineStatus.Unknown]: 'rdi.instance.status.unknown', +} + export const getStatusToShowFromStatus = ( status: Maybe, ): StatusInfo => { - const label = capitalize(status || 'Error') + const label = !status + ? i18n.t('rdi.instance.status.error') + : STATUS_LABEL_KEYS[status] + ? i18n.t(STATUS_LABEL_KEYS[status] as never) + : capitalize(status) switch (status) { case PipelineStatus.Creating: diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.tsx index fe28ff8e95..ffada3ddd3 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/rdi-config-file-action-menu/RdiConfigFileActionMenu.tsx @@ -11,8 +11,10 @@ import { import { Menu } from '@redis-ui/components' import DownloadFromServerModal from 'uiSrc/pages/rdi/pipeline-management/components/download-from-server-modal/DownloadFromServerModal' +import { useTranslation } from 'uiSrc/i18n' const RdiConfigFileActionMenu = () => { + const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const closeMenu = () => setIsOpen(false) @@ -34,7 +36,7 @@ const RdiConfigFileActionMenu = () => { onClose={closeMenu} trigger={ e.preventDefault()} aria-labelledby="Upload pipeline button" @@ -46,7 +48,7 @@ const RdiConfigFileActionMenu = () => { onClose={closeMenu} trigger={ e.preventDefault()} aria-labelledby="Upload file button" @@ -57,7 +59,7 @@ const RdiConfigFileActionMenu = () => { Date: Mon, 20 Jul 2026 09:40:04 +0300 Subject: [PATCH 052/166] RI-8321: Enforce x-window-id auth on WebSocket connections (#6225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): enforce x-window-id auth on web socket connections (RI-8321) The SessionMetadataAdapter was set as the web socket adapter unconditionally after WindowsAuthAdapter, and NestJS keeps only the last adapter — so the x-window-id handshake check never ran and every socket was granted the default session. Unauthenticated cross-origin clients could reach the monitor, pub-sub and bulk-actions namespaces. WindowsAuthAdapter now extends SessionMetadataAdapter so an authorized socket gets both the window-id gate and the session metadata, and it is the only adapter wired for the desktop build. Web/dev builds keep the metadata-only adapter. * fix(api): disconnect unauthorized web socket connections (RI-8321) Returning early only skipped message-handler registration; the socket stayed connected to the root namespace and still received broadcasts such as FeatureGateway and NotificationGateway (wss.of('/').emit(...)). Disconnect the socket when the window id is not authorized so the auth check gates the connection, not just message sending. --- redisinsight/api/src/main.ts | 5 +- .../adapters/window-auth.adapter.spec.ts | 95 +++++++++++++++++++ .../adapters/window-auth.adapter.ts | 10 +- 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.spec.ts diff --git a/redisinsight/api/src/main.ts b/redisinsight/api/src/main.ts index f9dd2825d1..308025e45f 100644 --- a/redisinsight/api/src/main.ts +++ b/redisinsight/api/src/main.ts @@ -95,13 +95,14 @@ export default async function bootstrap(apiPort?: number): Promise { }, }, ); + + app.useWebSocketAdapter(new SessionMetadataAdapter(app)); } else { app.setGlobalPrefix(serverConfig.globalPrefix); + // Must be the only web socket adapter here or the window-id auth gate is lost. app.useWebSocketAdapter(new WindowsAuthAdapter(app)); } - app.useWebSocketAdapter(new SessionMetadataAdapter(app)); - const logFileProvider = app.get(LogFileProvider); const { port, host } = serverConfig; diff --git a/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.spec.ts b/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.spec.ts new file mode 100644 index 0000000000..38b8937387 --- /dev/null +++ b/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.spec.ts @@ -0,0 +1,95 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { Socket } from 'socket.io'; +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { WindowsAuthAdapter } from 'src/modules/auth/window-auth/adapters/window-auth.adapter'; +import { WindowAuthService } from 'src/modules/auth/window-auth/window-auth.service'; +import { mockDefaultSessionMetadata } from 'src/__mocks__'; + +const AUTHORIZED_WINDOW_ID = 'window-1'; + +const createBaseBindMessageHandlersMock = () => { + const mockBaseBindMessageHandlers = jest.fn(); + + jest + .spyOn(IoAdapter.prototype, 'bindMessageHandlers') + .mockImplementation(() => { + mockBaseBindMessageHandlers(); + }); + + return mockBaseBindMessageHandlers; +}; + +const createMockSocket = (windowId?: string) => + ({ + request: {}, + disconnect: jest.fn(), + data: {}, + join: jest.fn(), + handshake: { headers: windowId ? { 'x-window-id': windowId } : {} }, + }) as unknown as Socket; + +describe('WindowsAuthAdapter', () => { + let app: INestApplication; + let adapter: WindowsAuthAdapter; + let windowAuthService: WindowAuthService; + let mockBaseBindMessageHandlers: ReturnType< + typeof createBaseBindMessageHandlersMock + >; + + const mockWindowAuthService = { + isAuthorized: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + mockBaseBindMessageHandlers = createBaseBindMessageHandlersMock(); + }); + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + providers: [ + { provide: WindowAuthService, useValue: mockWindowAuthService }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + adapter = new WindowsAuthAdapter(app); + app.useWebSocketAdapter(adapter); + await app.init(); + windowAuthService = app.get(WindowAuthService); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should attach session metadata, join the user room and bind handlers when the window id is authorized', async () => { + (windowAuthService.isAuthorized as jest.Mock).mockResolvedValue(true); + const socket = createMockSocket(AUTHORIZED_WINDOW_ID); + + await adapter.bindMessageHandlers(socket, [], jest.fn()); + + expect(windowAuthService.isAuthorized).toHaveBeenCalledWith( + AUTHORIZED_WINDOW_ID, + ); + expect(mockBaseBindMessageHandlers).toHaveBeenCalledTimes(1); + expect(socket.data).toEqual({ + sessionMetadata: mockDefaultSessionMetadata, + }); + expect(socket.join).toHaveBeenCalledTimes(1); + expect(socket.join).toHaveBeenCalledWith('user:1'); + }); + + it('should disconnect the socket and bind nothing when the window id is not authorized', async () => { + (windowAuthService.isAuthorized as jest.Mock).mockResolvedValue(false); + const socket = createMockSocket(); + + await adapter.bindMessageHandlers(socket, [], jest.fn()); + + expect(socket.disconnect).toHaveBeenCalledWith(true); + expect(mockBaseBindMessageHandlers).not.toHaveBeenCalled(); + expect(socket.data).toEqual({}); + expect(socket.join).not.toHaveBeenCalled(); + }); +}); diff --git a/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.ts b/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.ts index 9aa45cd15c..2e1c40c10c 100644 --- a/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.ts +++ b/redisinsight/api/src/modules/auth/window-auth/adapters/window-auth.adapter.ts @@ -1,21 +1,21 @@ import { INestApplication, Logger } from '@nestjs/common'; -import { IoAdapter } from '@nestjs/platform-socket.io'; import { MessageMappingProperties } from '@nestjs/websockets'; import { get } from 'lodash'; import { Observable } from 'rxjs'; import { Socket } from 'socket.io'; import { API_HEADER_WINDOW_ID } from 'src/common/constants'; import ERROR_MESSAGES from 'src/constants/error-messages'; +import { SessionMetadataAdapter } from 'src/modules/auth/session-metadata/adapters/session-metadata.adapter'; import { WindowAuthService } from '../window-auth.service'; -export class WindowsAuthAdapter extends IoAdapter { +export class WindowsAuthAdapter extends SessionMetadataAdapter { private windowAuthService: WindowAuthService; private logger = new Logger('WindowsAuthAdapter'); - constructor(private app: INestApplication) { + constructor(app: INestApplication) { super(app); - this.windowAuthService = this.app.get(WindowAuthService); + this.windowAuthService = app.get(WindowAuthService); } async bindMessageHandlers( @@ -30,6 +30,8 @@ export class WindowsAuthAdapter extends IoAdapter { if (!isAuthorized) { this.logger.error(ERROR_MESSAGES.UNDEFINED_WINDOW_ID); + // Drop the connection so it can no longer receive namespace broadcasts. + socket.disconnect(true); return; } From a05d05724a280f81a08d9b41b7d3ffaa2cbc4f20 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 20 Jul 2026 09:41:06 +0300 Subject: [PATCH 053/166] RI-8322: plugin command sandbox bypass via startsWith() (#6227) * fix(api): enforce exact command-word match in plugin sandbox (RI-8322) The plugin command sandbox matched the command line against the read-only whitelist with startsWith(). Because "get" is whitelisted, GETDEL/GETEX/GETSET passed the check and let read-only plugins run destructive operations. Match the first command word exactly instead. * fix(api): tokenize plugin command with executor's CLI parser (RI-8322) Addresses PR review: derive the command word with splitCliCommandLine (same parser the executor uses) instead of split(' '), so validation and execution agree on non-space delimiters (tab/newline/CR/NUL) and quoting. Unparseable input stays rejected (fail closed). --- redisinsight/api/.tscheck.rec.json | 4 -- .../modules/workbench/plugins.service.spec.ts | 62 ++++++++++++++++--- .../src/modules/workbench/plugins.service.ts | 15 ++++- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/redisinsight/api/.tscheck.rec.json b/redisinsight/api/.tscheck.rec.json index ad12961465..e0b6e044cf 100644 --- a/redisinsight/api/.tscheck.rec.json +++ b/redisinsight/api/.tscheck.rec.json @@ -1329,10 +1329,6 @@ "src/modules/tag/tag.service.spec.ts": { "TS2345": 2 }, - "src/modules/workbench/plugins.service.spec.ts": { - "TS7005": 11, - "TS7034": 3 - }, "src/modules/workbench/providers/plugin-commands-whitelist.provider.spec.ts": { "TS7005": 10, "TS7034": 2 diff --git a/redisinsight/api/src/modules/workbench/plugins.service.spec.ts b/redisinsight/api/src/modules/workbench/plugins.service.spec.ts index a893207ddd..98c814ef79 100644 --- a/redisinsight/api/src/modules/workbench/plugins.service.spec.ts +++ b/redisinsight/api/src/modules/workbench/plugins.service.spec.ts @@ -14,6 +14,7 @@ import { WorkbenchCommandsExecutor } from 'src/modules/workbench/providers/workb import { BadRequestException } from '@nestjs/common'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { PluginsService } from 'src/modules/workbench/plugins.service'; +import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; import { PluginCommandsWhitelistProvider } from 'src/modules/workbench/providers/plugin-commands-whitelist.provider'; import { PluginStateRepository } from 'src/modules/workbench/repositories/plugin-state.repository'; import { PluginState } from 'src/modules/workbench/models/plugin-state'; @@ -52,9 +53,13 @@ const mockPluginStateProvider = () => ({ describe('PluginsService', () => { let service: PluginsService; - let workbenchCommandsExecutor; - let pluginsCommandsWhitelistProvider; - let pluginStateProvider; + let workbenchCommandsExecutor: ReturnType< + typeof mockWorkbenchCommandsExecutor + >; + let pluginsCommandsWhitelistProvider: ReturnType< + typeof mockPluginCommandsWhitelistProvider + >; + let pluginStateProvider: ReturnType; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -80,14 +85,15 @@ describe('PluginsService', () => { }).compile(); service = module.get(PluginsService); - workbenchCommandsExecutor = module.get( + workbenchCommandsExecutor = module.get( WorkbenchCommandsExecutor, - ); - pluginsCommandsWhitelistProvider = - module.get( - PluginCommandsWhitelistProvider, - ); - pluginStateProvider = module.get(PluginStateRepository); + ) as unknown as typeof workbenchCommandsExecutor; + pluginsCommandsWhitelistProvider = module.get( + PluginCommandsWhitelistProvider, + ) as unknown as typeof pluginsCommandsWhitelistProvider; + pluginStateProvider = module.get( + PluginStateRepository, + ) as unknown as typeof pluginStateProvider; }); describe('sendCommand', () => { @@ -128,6 +134,42 @@ describe('PluginsService', () => { }); expect(workbenchCommandsExecutor.sendCommand).not.toHaveBeenCalled(); }); + it.each(['getdel foo', 'getex foo', 'getset foo bar', 'getdel\tfoo'])( + 'should reject non-whitelisted command "%s" that shares a prefix with a whitelisted command', + async (command) => { + pluginsCommandsWhitelistProvider.getWhitelistCommands.mockResolvedValueOnce( + mockWhitelistCommandsResponse, + ); + + const dto = { command, mode: RunQueryMode.ASCII }; + + const result = await service.sendCommand( + mockWorkbenchClientMetadata, + dto, + ); + + expect(result.result?.[0]?.status).toEqual(CommandExecutionStatus.Fail); + expect(workbenchCommandsExecutor.sendCommand).not.toHaveBeenCalled(); + }, + ); + it.each(['GET foo', 'get\tfoo'])( + 'should allow whitelisted command "%s" regardless of casing or delimiter', + async (command) => { + pluginsCommandsWhitelistProvider.getWhitelistCommands.mockResolvedValueOnce( + mockWhitelistCommandsResponse, + ); + + const result = await service.sendCommand(mockWorkbenchClientMetadata, { + command, + mode: RunQueryMode.ASCII, + }); + + expect(result.result?.[0]?.status).not.toEqual( + CommandExecutionStatus.Fail, + ); + expect(workbenchCommandsExecutor.sendCommand).toHaveBeenCalled(); + }, + ); it('should throw an error when command execution failed', async () => { pluginsCommandsWhitelistProvider.getWhitelistCommands.mockResolvedValueOnce( mockWhitelistCommandsResponse, diff --git a/redisinsight/api/src/modules/workbench/plugins.service.ts b/redisinsight/api/src/modules/workbench/plugins.service.ts index df23d82e73..635aa6b3c8 100644 --- a/redisinsight/api/src/modules/workbench/plugins.service.ts +++ b/redisinsight/api/src/modules/workbench/plugins.service.ts @@ -13,6 +13,7 @@ import config from 'src/utils/config'; import { ClientMetadata } from 'src/common/models'; import { PluginStateRepository } from 'src/modules/workbench/repositories/plugin-state.repository'; import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; +import { splitCliCommandLine } from 'src/utils/cli-helper'; const PLUGINS_CONFIG = config.get('plugins'); @@ -133,14 +134,22 @@ export class PluginsService { clientMetadata: ClientMetadata, commandLine: string, ) { - const targetCommand = commandLine.toLowerCase(); + let targetCommand = ''; + try { + // Tokenize exactly as the executor does so validation and execution + // agree on the command word; unparseable input stays rejected. + targetCommand = + `${splitCliCommandLine(commandLine)[0] ?? ''}`.toLowerCase(); + } catch (e) { + // ignore parsing errors and fall through to the not-supported error + } const whitelist = await this.getWhitelistCommands(clientMetadata); - if (!whitelist.find((command) => targetCommand.startsWith(command))) { + if (!targetCommand || !whitelist.includes(targetCommand)) { throw new CommandNotSupportedError( ERROR_MESSAGES.PLUGIN_COMMAND_NOT_SUPPORTED( - targetCommand.split(' ')[0].toUpperCase(), + targetCommand.toUpperCase(), ), ); } From 900d23138908135521c9d794d0c59061980d9b48 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Mon, 20 Jul 2026 09:56:19 +0300 Subject: [PATCH 054/166] RI-8277 Migrate rdi pipeline-management to i18n (#6223) * feat(i18n): migrate rdi pipeline-management to i18n (RI-8277) Route the RDI pipeline-management surface through i18n under rdi.pipeline.* with Bulgarian translations: navigation, config & job editor pages, dry-run panel, test-connections panel & log, template form/popover/button, and the upload/download modals. Shared rdiErrorMessages resolves via the i18n singleton; TestConnectionsLog headers via a render-time getColumns(t); for doc links and
tooltips. The template "No template" fallback option is built at render (useMemo[t]) instead of a module-level i18n.t so it follows the active language. Rebased onto main after #6221/#6219/#6224 merged; rdi.* locale keys merged and sorted. --- redisinsight/ui/src/i18n/locales/bg.json | 80 ++++++++ redisinsight/ui/src/i18n/locales/en.json | 80 ++++++++ .../ui/src/pages/rdi/constants/errors.tsx | 7 +- .../PipelineManagementPage.tsx | 4 +- .../DownloadFromServerModal.tsx | 13 +- .../DryRunJobCommands.tsx | 12 +- .../DryRunJobTransformations.tsx | 13 +- .../components/jobs-panel/Panel.tsx | 44 +++-- .../components/navigation/Navigation.tsx | 4 +- .../navigation/cards/ConfigurationCard.tsx | 8 +- .../navigation/cards/jobs/JobNameForm.tsx | 7 +- .../navigation/cards/jobs/JobsCard.tsx | 8 +- .../navigation/cards/jobs/JobsItem.tsx | 178 +++++++++--------- .../SourcePipelineModal.tsx | 10 +- .../template-button/TemplateButton.tsx | 6 +- .../template-form/TemplateForm.spec.tsx | 4 +- .../components/template-form/TemplateForm.tsx | 51 +++-- .../components/template-form/constants.ts | 9 - .../template-popover/TemplatePopover.tsx | 6 +- .../TestConnectionsLog.tsx | 14 +- .../TestConnectionsPanel.tsx | 52 ++--- .../components/upload-modal/UploadModal.tsx | 19 +- .../components/upload-dialog/UploadDialog.tsx | 26 +-- .../pages/config/Config.tsx | 41 ++-- .../rdi/pipeline-management/pages/job/Job.tsx | 44 +++-- 25 files changed, 490 insertions(+), 250 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 004a39f1b4..2955ef3724 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -608,6 +608,86 @@ "rdi.instance.stop.ariaLabel": "Спиране на конвейера", "rdi.instance.stop.button": "Стоп", "rdi.instance.stop.tooltip": "Спрете конвейера, за да предотвратите обработката на нови постъпващи данни.", + "rdi.pipeline.config.description": "Конфигурирайте детайлите за връзка и настройките за прилагане на целевата инстанция.", + "rdi.pipeline.config.testButton": "Тест на връзката", + "rdi.pipeline.config.title": "Конфигурация на целевата база данни", + "rdi.pipeline.download.body": "При изтегляне на конфигурацията на конвейера от сървъра, тя ще замени съществуващата, показана в Redis Insight.", + "rdi.pipeline.download.cancel": "Отказ", + "rdi.pipeline.download.confirm": "Изтегляне от сървъра", + "rdi.pipeline.download.saveToFile": "Запазване във файл", + "rdi.pipeline.download.title": "Изтегляне на конвейер от сървъра", + "rdi.pipeline.dryRun.closeAria": "затваряне на панела за пробно изпълнение", + "rdi.pipeline.dryRun.fullscreenAria": "превключване на цял екран за панела за пробно изпълнение", + "rdi.pipeline.dryRun.inputHelp": "Добавете входни данни, за да тествате логиката на трансформация.", + "rdi.pipeline.dryRun.inputInvalid": "Входните данни трябва да са във формат JSON", + "rdi.pipeline.dryRun.inputTitle": "Вход", + "rdi.pipeline.dryRun.jobOutput": "Изход на задачата", + "rdi.pipeline.dryRun.jobOutputTooltip": "Показва списъка с Redis команди, които ще бъдат генерирани въз основа на детайлите на вашата задача.Не се записват данни в целевата база данни.", + "rdi.pipeline.dryRun.noCommands": "Сървърът не предостави Redis команди.", + "rdi.pipeline.dryRun.noTransformation": "Сървърът не предостави резултати от трансформация.", + "rdi.pipeline.dryRun.runButton": "Пробно изпълнение", + "rdi.pipeline.dryRun.title": "Тестване на логиката на трансформация", + "rdi.pipeline.dryRun.transformationOutput": "Изход на трансформацията", + "rdi.pipeline.dryRun.transformationTooltip": "Показва резултатите от трансформациите, които сте дефинирали. Данните са представени във формат JSON.Не се записват данни в целевата база данни.", + "rdi.pipeline.error.defaultMsg": "Неуспешно преобразуване на YAML в JSON структура", + "rdi.pipeline.error.defaultName": "Стойността", + "rdi.pipeline.invalidStructure": "{{name}} има невалидна структура.", + "rdi.pipeline.job.dedicatedEditorButton": "SQL и JMESPath редактор", + "rdi.pipeline.job.description": "Създайте задача за всяка таблица източник, за да филтрирате, трансформирате и съпоставите данни към Redis.", + "rdi.pipeline.job.dryRunButton": "Пробно изпълнение", + "rdi.pipeline.jobName.inUse": "Името на задачата вече се използва", + "rdi.pipeline.jobName.placeholder": "Въведете име на задача", + "rdi.pipeline.jobName.required": "Името на задачата е задължително", + "rdi.pipeline.loading": "Зареждане...", + "rdi.pipeline.nav.addJobAria": "добавяне на нов файл на задача", + "rdi.pipeline.nav.addJobTooltip": "Добавяне на файл на задача", + "rdi.pipeline.nav.configFile": "Конфигурационен файл", + "rdi.pipeline.nav.configTitle": "Конфигурация", + "rdi.pipeline.nav.deleteConfirm": "Изтриване", + "rdi.pipeline.nav.deleteJobAria": "изтриване на задача", + "rdi.pipeline.nav.deleteJobBody": "Промените няма да бъдат приложени, докато конвейерът не бъде внедрен.", + "rdi.pipeline.nav.deleteJobTitle": "Изтриване на {{name}}", + "rdi.pipeline.nav.deleteJobTooltip": "Изтриване на задача", + "rdi.pipeline.nav.editJobAria": "редактиране на името на файла на задачата", + "rdi.pipeline.nav.editJobTooltip": "Редактиране на името на файла на задачата", + "rdi.pipeline.nav.jobsTitle": "Трансформиране и валидиране", + "rdi.pipeline.nav.title": "Управление на конвейера", + "rdi.pipeline.nav.undeployedChanges": "Този файл съдържа невнедрени промени.", + "rdi.pipeline.pageTitle": "{{name}} - Управление на конвейера", + "rdi.pipeline.source.createNew": "Създаване на нов конвейер", + "rdi.pipeline.source.importZip": "Импортиране на конвейер от ZIP файл", + "rdi.pipeline.source.subtitle": "за да започнете с вашия конвейер", + "rdi.pipeline.source.title": "Изберете опция", + "rdi.pipeline.template.apply": "Прилагане", + "rdi.pipeline.template.cancel": "Отказ", + "rdi.pipeline.template.dbType": "Тип база данни", + "rdi.pipeline.template.editorOnly": "Шаблоните са достъпни само с празен редактор, за да се предотврати потенциална загуба на данни.", + "rdi.pipeline.template.insertAria": "Вмъкване на шаблон", + "rdi.pipeline.template.insertButton": "Вмъкване на шаблон", + "rdi.pipeline.template.noTemplateLabel": "Без шаблон", + "rdi.pipeline.template.noneAvailableLine1": "Няма наличен шаблон.", + "rdi.pipeline.template.noneAvailableLine2": "Затворете формата и опитайте отново.", + "rdi.pipeline.template.pipelineType": "Тип конвейер", + "rdi.pipeline.template.title": "Изберете шаблон", + "rdi.pipeline.testConn.closeAria": "затваряне на панела за тест на връзките", + "rdi.pipeline.testConn.colEndpoint": "Крайна точка", + "rdi.pipeline.testConn.colResults": "Резултати", + "rdi.pipeline.testConn.loading": "Зареждане на резултатите...", + "rdi.pipeline.testConn.noResults": "Няма намерени резултати. Моля, опитайте отново.", + "rdi.pipeline.testConn.source": "Връзки към източника", + "rdi.pipeline.testConn.successful": "Успешно", + "rdi.pipeline.testConn.target": "Връзки към целта", + "rdi.pipeline.testConn.title": "Тест на връзката", + "rdi.pipeline.upload.errorNoConfig": "config.yaml липсва", + "rdi.pipeline.upload.errorNoJobs": "Не е намерена папка jobs", + "rdi.pipeline.upload.errorZip": "Възникна проблем с .zip файла", + "rdi.pipeline.upload.resultFail": "Неуспешно качване на конвейера", + "rdi.pipeline.upload.resultSuccess": "Конвейерът е качен", + "rdi.pipeline.upload.submitButton": "Качване", + "rdi.pipeline.upload.submitResults": "Нов конвейер беше успешно качен.", + "rdi.pipeline.upload.titleArchive": "Качете архив с RDI конвейер", + "rdi.pipeline.upload.titleNew": "Качване на нов конвейер", + "rdi.pipeline.upload.warning": "Ако бъде качен нов конвейер, съществуващата конфигурация на конвейера и задачите за трансформация ще бъдат презаписани. Промените няма да бъдат приложени, докато конвейерът не бъде внедрен.", "rdi.statistics.empty.addButton": "Добавяне на конвейер", "rdi.statistics.empty.description": "Създайте първия си конвейер, за да започнете!", "rdi.statistics.empty.title": "Все още няма разгърнат конвейер", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 24becd408a..8a70fea529 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -608,6 +608,86 @@ "rdi.instance.stop.ariaLabel": "Stop running pipeline", "rdi.instance.stop.button": "Stop", "rdi.instance.stop.tooltip": "Stop the pipeline to prevent processing of new data arrivals.", + "rdi.pipeline.config.description": "Configure target instance connection details and applier settings.", + "rdi.pipeline.config.testButton": "Test Connection", + "rdi.pipeline.config.title": "Target database configuration", + "rdi.pipeline.download.body": "When downloading the pipeline configuration from the server, it will overwrite the existing one displayed in Redis Insight.", + "rdi.pipeline.download.cancel": "Cancel", + "rdi.pipeline.download.confirm": "Download from server", + "rdi.pipeline.download.saveToFile": "Save to file", + "rdi.pipeline.download.title": "Download a pipeline from the server", + "rdi.pipeline.dryRun.closeAria": "close dry run panel", + "rdi.pipeline.dryRun.fullscreenAria": "toggle fullscrenn dry run panel", + "rdi.pipeline.dryRun.inputHelp": "Add input data to test the transformation logic.", + "rdi.pipeline.dryRun.inputInvalid": "Input should have JSON format", + "rdi.pipeline.dryRun.inputTitle": "Input", + "rdi.pipeline.dryRun.jobOutput": "Job output", + "rdi.pipeline.dryRun.jobOutputTooltip": "Displays the list of Redis commands that will be generated based on your job details.No data is written to the target database.", + "rdi.pipeline.dryRun.noCommands": "No Redis commands provided by the server.", + "rdi.pipeline.dryRun.noTransformation": "No transformation results provided by the server.", + "rdi.pipeline.dryRun.runButton": "Dry run", + "rdi.pipeline.dryRun.title": "Test transformation logic", + "rdi.pipeline.dryRun.transformationOutput": "Transformation output", + "rdi.pipeline.dryRun.transformationTooltip": "Displays the results of the transformations you defined. The data is presented in JSON format.No data is written to the target database.", + "rdi.pipeline.error.defaultMsg": "Failed to convert YAML to JSON structure", + "rdi.pipeline.error.defaultName": "Value", + "rdi.pipeline.invalidStructure": "{{name}} has an invalid structure.", + "rdi.pipeline.job.dedicatedEditorButton": "SQL and JMESPath Editor", + "rdi.pipeline.job.description": "Create a job per source table to filter, transform, and map data to Redis.", + "rdi.pipeline.job.dryRunButton": "Dry Run", + "rdi.pipeline.jobName.inUse": "Job name is already in use", + "rdi.pipeline.jobName.placeholder": "Enter job name", + "rdi.pipeline.jobName.required": "Job name is required", + "rdi.pipeline.loading": "Loading...", + "rdi.pipeline.nav.addJobAria": "add new job file", + "rdi.pipeline.nav.addJobTooltip": "Add a job file", + "rdi.pipeline.nav.configFile": "Configuration file", + "rdi.pipeline.nav.configTitle": "Configuration", + "rdi.pipeline.nav.deleteConfirm": "Delete", + "rdi.pipeline.nav.deleteJobAria": "delete job", + "rdi.pipeline.nav.deleteJobBody": "Changes will not be applied until the pipeline is deployed.", + "rdi.pipeline.nav.deleteJobTitle": "Delete {{name}}", + "rdi.pipeline.nav.deleteJobTooltip": "Delete job", + "rdi.pipeline.nav.editJobAria": "edit job file name", + "rdi.pipeline.nav.editJobTooltip": "Edit job file name", + "rdi.pipeline.nav.jobsTitle": "Transform and Validate", + "rdi.pipeline.nav.title": "Pipeline management", + "rdi.pipeline.nav.undeployedChanges": "This file contains undeployed changes.", + "rdi.pipeline.pageTitle": "{{name}} - Pipeline Management", + "rdi.pipeline.source.createNew": "Create new pipeline", + "rdi.pipeline.source.importZip": "Import pipeline from ZIP file", + "rdi.pipeline.source.subtitle": "to start with your pipeline", + "rdi.pipeline.source.title": "Select an option", + "rdi.pipeline.template.apply": "Apply", + "rdi.pipeline.template.cancel": "Cancel", + "rdi.pipeline.template.dbType": "Database type", + "rdi.pipeline.template.editorOnly": "Templates can be accessed only with the empty Editor to prevent potential data loss.", + "rdi.pipeline.template.insertAria": "Insert template", + "rdi.pipeline.template.insertButton": "Insert template", + "rdi.pipeline.template.noTemplateLabel": "No template", + "rdi.pipeline.template.noneAvailableLine1": "No template is available.", + "rdi.pipeline.template.noneAvailableLine2": "Close the form and try again.", + "rdi.pipeline.template.pipelineType": "Pipeline type", + "rdi.pipeline.template.title": "Select a template", + "rdi.pipeline.testConn.closeAria": "close test connections panel", + "rdi.pipeline.testConn.colEndpoint": "Endpoint", + "rdi.pipeline.testConn.colResults": "Results", + "rdi.pipeline.testConn.loading": "Loading results...", + "rdi.pipeline.testConn.noResults": "No results found. Please try again.", + "rdi.pipeline.testConn.source": "Source connections", + "rdi.pipeline.testConn.successful": "Successful", + "rdi.pipeline.testConn.target": "Target connections", + "rdi.pipeline.testConn.title": "Test connection", + "rdi.pipeline.upload.errorNoConfig": "config.yaml is missing", + "rdi.pipeline.upload.errorNoJobs": "No jobs folder found", + "rdi.pipeline.upload.errorZip": "There was a problem with the .zip file", + "rdi.pipeline.upload.resultFail": "Failed to upload pipeline", + "rdi.pipeline.upload.resultSuccess": "Pipeline has been uploaded", + "rdi.pipeline.upload.submitButton": "Upload", + "rdi.pipeline.upload.submitResults": "A new pipeline has been successfully uploaded.", + "rdi.pipeline.upload.titleArchive": "Upload an archive with an RDI pipeline", + "rdi.pipeline.upload.titleNew": "Upload a new pipeline", + "rdi.pipeline.upload.warning": "If a new pipeline is uploaded, existing pipeline configuration and transformation jobs will be overwritten. Changes will not be applied until the pipeline is deployed.", "rdi.statistics.empty.addButton": "Add Pipeline", "rdi.statistics.empty.description": "Create your first pipeline to get started!", "rdi.statistics.empty.title": "No pipeline deployed yet", diff --git a/redisinsight/ui/src/pages/rdi/constants/errors.tsx b/redisinsight/ui/src/pages/rdi/constants/errors.tsx index 8692f1b699..64132fc56c 100644 --- a/redisinsight/ui/src/pages/rdi/constants/errors.tsx +++ b/redisinsight/ui/src/pages/rdi/constants/errors.tsx @@ -1,13 +1,14 @@ import { upperFirst } from 'lodash' import React from 'react' +import i18n from 'uiSrc/i18n' export const rdiErrorMessages = { invalidStructure: ( - name = 'Value', - msg = 'Failed to convert YAML to JSON structure', + name: string = i18n.t('rdi.pipeline.error.defaultName'), + msg: string = i18n.t('rdi.pipeline.error.defaultMsg'), ) => ( <> - {`${upperFirst(name)} has an invalid structure.`} + {i18n.t('rdi.pipeline.invalidStructure', { name: upperFirst(name) })}
{msg} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx index 42bacce2e1..d74aabdf8c 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/PipelineManagementPage.tsx @@ -15,6 +15,7 @@ import { setLastPipelineManagementPage, } from 'uiSrc/slices/app/context' import { formatLongName, setTitle } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' import SourcePipelineDialog from 'uiSrc/pages/rdi/pipeline-management/components/source-pipeline-dialog' import Navigation from 'uiSrc/pages/rdi/pipeline-management/components/navigation' @@ -32,6 +33,7 @@ export interface Props { } const PipelineManagementPage = ({ routes = [] }: Props) => { + const { t } = useTranslation() const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() const { lastViewedPage } = useAppSelector(appContextPipelineManagement) const { name: connectedRdiInstanceName } = useAppSelector( @@ -45,7 +47,7 @@ const PipelineManagementPage = ({ routes = [] }: Props) => { const { pathname } = useLocation() const rdiInstanceName = formatLongName(connectedRdiInstanceName, 33, 0, '...') - setTitle(`${rdiInstanceName} - Pipeline Management`) + setTitle(t('rdi.pipeline.pageTitle', { name: rdiInstanceName })) useEffect(() => { if ( diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/download-from-server-modal/DownloadFromServerModal.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/download-from-server-modal/DownloadFromServerModal.tsx index 7571665dfc..37baf28fdd 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/download-from-server-modal/DownloadFromServerModal.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/download-from-server-modal/DownloadFromServerModal.tsx @@ -13,6 +13,7 @@ import { } from 'uiSrc/slices/rdi/pipeline' import { useParams } from 'react-router-dom' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { useTranslation } from 'uiSrc/i18n' export interface Props { trigger?: React.ReactElement @@ -20,6 +21,7 @@ export interface Props { } const DownloadFromServerModal = (props: Props) => { + const { t } = useTranslation() const { trigger, onClose } = props const { loading, data } = useAppSelector(rdiPipelineSelector) @@ -70,10 +72,9 @@ const DownloadFromServerModal = (props: Props) => { {button && {button}} - + - When downloading the pipeline configuration from the server, it will - overwrite the existing one displayed in Redis Insight. + {t('rdi.pipeline.download.body')} { trigger={ - Save to file + {t('rdi.pipeline.download.saveToFile')} } /> - Cancel + {t('rdi.pipeline.download.cancel')} { loading={loading} data-testid="upload-confirm-btn" > - Download from server + {t('rdi.pipeline.download.confirm')} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-commands/DryRunJobCommands.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-commands/DryRunJobCommands.tsx index 3a4a614014..ab78884fcc 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-commands/DryRunJobCommands.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-commands/DryRunJobCommands.tsx @@ -6,14 +6,14 @@ import { monaco } from 'react-monaco-editor' import { CodeBlock } from 'uiSrc/components' import { rdiDryRunJobSelector } from 'uiSrc/slices/rdi/dryRun' import { MonacoLanguage } from 'uiSrc/constants' +import { useTranslation } from 'uiSrc/i18n' export interface Props { target?: string } -const NO_COMMANDS_MESSAGE = 'No Redis commands provided by the server.' - const DryRunJobCommands = ({ target }: Props) => { + const { t } = useTranslation() const { results } = useAppSelector(rdiDryRunJobSelector) const [commands, setCommands] = useState('') @@ -22,13 +22,15 @@ const DryRunJobCommands = ({ target }: Props) => { return } + const noCommandsMessage = t('rdi.pipeline.dryRun.noCommands') + try { const targetCommands = results?.output?.find( (el) => el.connection === target, )?.commands if (!targetCommands) { - setCommands(NO_COMMANDS_MESSAGE) + setCommands(noCommandsMessage) return } monaco.editor @@ -41,9 +43,9 @@ const DryRunJobCommands = ({ target }: Props) => { setCommands(data) }) } catch (e) { - setCommands(NO_COMMANDS_MESSAGE) + setCommands(noCommandsMessage) } - }, [results, target]) + }, [results, target, t]) return (
diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-transformations/DryRunJobTransformations.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-transformations/DryRunJobTransformations.tsx index bbe23cb55f..3b503bea3c 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-transformations/DryRunJobTransformations.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/dry-run-job-transformations/DryRunJobTransformations.tsx @@ -3,11 +3,10 @@ import { useAppSelector } from 'uiSrc/slices/hooks' import { rdiDryRunJobSelector } from 'uiSrc/slices/rdi/dryRun' import MonacoJson from 'uiSrc/components/monaco-editor/components/monaco-json' - -const NO_TRANSFORMATION_MESSAGE = - 'No transformation results provided by the server.' +import { useTranslation } from 'uiSrc/i18n' const DryRunJobTransformations = () => { + const { t } = useTranslation() const { results } = useAppSelector(rdiDryRunJobSelector) const [transformations, setTransformations] = useState('') @@ -17,13 +16,15 @@ const DryRunJobTransformations = () => { return } + const noTransformationMessage = t('rdi.pipeline.dryRun.noTransformation') + try { const transformations = JSON.stringify(results?.transformation, null, 2) - setTransformations(transformations || NO_TRANSFORMATION_MESSAGE) + setTransformations(transformations || noTransformationMessage) } catch (e) { - setTransformations(NO_TRANSFORMATION_MESSAGE) + setTransformations(noTransformationMessage) } - }, [results]) + }, [results, t]) return ( <> diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-panel/Panel.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-panel/Panel.tsx index 1a8c1ec07f..50e9d56ba8 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-panel/Panel.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-panel/Panel.tsx @@ -36,6 +36,7 @@ import { } from 'uiSrc/components/base/forms/select/RiSelect' import { DryRunPanelContainer } from 'uiSrc/pages/rdi/pipeline-management/components/jobs-panel/styles' import { Button, TextButton } from '@redis-ui/components' +import { Trans, useTranslation } from 'uiSrc/i18n' export interface Props { job: string @@ -55,6 +56,7 @@ const getTargetOption = (value: string) => { } const DryRunJobPanel = (props: Props) => { + const { t } = useTranslation() const { job, name, onClose } = props const { loading: isDryRunning, results } = useAppSelector(rdiDryRunJobSelector) @@ -129,7 +131,11 @@ const DryRunJobPanel = (props: Props) => { createAxiosError({ message: ( <> - {`${upperFirst(name)} has an invalid structure.`} + + {t('rdi.pipeline.invalidStructure', { + name: upperFirst(name), + })} + {msg} ), @@ -159,15 +165,15 @@ const DryRunJobPanel = (props: Props) => { - Displays the results of the transformations you defined. The data - is presented in JSON format. -
- No data is written to the target database. + }} + /> } data-testid="transformation-output-tooltip" > - Transformation output + {t('rdi.pipeline.dryRun.transformationOutput')}
), content: null, @@ -178,15 +184,15 @@ const DryRunJobPanel = (props: Props) => { - Displays the list of Redis commands that will be generated based - on your job details. -
- No data is written to the target database. + }} + /> } data-testid="job-output-tooltip" > - Job output + {t('rdi.pipeline.dryRun.jobOutput')}
), content: null, @@ -209,32 +215,32 @@ const DryRunJobPanel = (props: Props) => { - Test transformation logic + {t('rdi.pipeline.dryRun.title')} - Add input data to test the transformation logic. + {t('rdi.pipeline.dryRun.inputHelp')} {/* Input section */}
- Input + {t('rdi.pipeline.dryRun.inputTitle')} { { data-testid="dry-run-btn" > - Dry run + {t('rdi.pipeline.dryRun.runButton')} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx index 2b36913086..d56f4b05ca 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx @@ -9,6 +9,7 @@ import { Col } from 'uiSrc/components/base/layout/flex' import { LoadingContent } from 'uiSrc/components/base' import { RdiPipelineTabs } from 'uiSrc/slices/interfaces/rdi' import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' +import { useTranslation } from 'uiSrc/i18n' import { ConfigurationCard, JobsCard } from './cards' @@ -27,6 +28,7 @@ const getSelectedTab = (path: string, rdiInstanceId: string) => { } const Navigation = () => { + const { t } = useTranslation() const [selectedTab, setSelectedTab] = useState>(null) @@ -52,7 +54,7 @@ const Navigation = () => { return ( - Pipeline management + {t('rdi.pipeline.nav.title')} {loading && } diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/ConfigurationCard.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/ConfigurationCard.tsx index 633c86548c..838fe63094 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/ConfigurationCard.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/ConfigurationCard.tsx @@ -6,6 +6,7 @@ import { Indicator } from 'uiSrc/components/base/text/text.styles' import { Row } from 'uiSrc/components/base/layout/flex' import { Text } from 'uiSrc/components/base/text' import { Icon, ToastNotificationIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import { useConfigurationState } from './hooks' import BaseCard, { BaseCardProps } from './BaseCard' @@ -22,6 +23,7 @@ const ConfigurationCard = ({ onSelect, isSelected, }: ConfigurationCardProps) => { + const { t } = useTranslation() const { hasChanges, isValid, configValidationErrors } = useConfigurationState() @@ -31,7 +33,7 @@ const ConfigurationCard = ({ return ( )} - Configuration file + {t('rdi.pipeline.nav.configFile')} {!isValid && ( { if (!jobName) { - return buildValidationMessage('Job name is required') + return buildValidationMessage(i18n.t('rdi.pipeline.jobName.required')) } if (jobName === currentJobName) return undefined if (jobs.some((job) => job.name === jobName)) { - return buildValidationMessage('Job name is already in use') + return buildValidationMessage(i18n.t('rdi.pipeline.jobName.inUse')) } return undefined @@ -71,7 +72,7 @@ const JobNameForm = ({ isLoading={isLoading} declineOnUnmount={false} initialValue={currentJobName || ''} - placeholder="Enter job name" + placeholder={i18n.t('rdi.pipeline.jobName.placeholder')} maxLength={250} viewChildrenMode={false} disableEmpty diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsCard.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsCard.tsx index 181722c05d..199b9deff0 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsCard.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsCard.tsx @@ -19,6 +19,7 @@ import { Row } from 'uiSrc/components/base/layout/flex' import { RiTooltip } from 'uiSrc/components' import { IconButton } from 'uiSrc/components/base/forms/buttons' import { PlusIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import BaseCard, { BaseCardProps } from '../BaseCard' import JobNameForm from './JobNameForm' @@ -32,6 +33,7 @@ export type JobsCardProps = Omit< } const JobsCard = (props: JobsCardProps) => { + const { t } = useTranslation() const { onSelect, isSelected } = props const [currentJobName, setCurrentJobName] = useState>(null) @@ -132,10 +134,10 @@ const JobsCard = (props: JobsCardProps) => { return ( @@ -151,7 +153,7 @@ const JobsCard = (props: JobsCardProps) => { setHideTooltip(true) }} disabled={isNewJob} - aria-label="add new job file" + aria-label={t('rdi.pipeline.nav.addJobAria')} data-testid="add-new-job" /> diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsItem.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsItem.tsx index d9a930a8c1..97cb35ce43 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsItem.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/cards/jobs/JobsItem.tsx @@ -13,6 +13,7 @@ import ValidationErrorsList from 'uiSrc/pages/rdi/pipeline-management/components import { Indicator } from 'uiSrc/components/base/text/text.styles' import { ToastNotificationIcon } from '@redis-ui/icons' import { truncateText } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' type JobItemProps = { name: string @@ -34,102 +35,105 @@ const JobItem = ({ onSelect, onEdit, onDelete, -}: JobItemProps) => ( - - - {!hasChanges && } +}: JobItemProps) => { + const { t } = useTranslation() - {hasChanges && ( - - - - )} - + return ( + + + {!hasChanges && } - onSelect(name)} - data-testid={`rdi-nav-job-${name}`} - grow - > - - - - {truncateText(name, 20)} - - - - {!isValid && ( + {hasChanges && ( - } + content={t('rdi.pipeline.nav.undeployedChanges')} + position="top" > - )} - - + - - - - onEdit(name)} - aria-label="edit job file name" - data-testid={`edit-job-name-${name}`} - /> - + onSelect(name)} + data-testid={`rdi-nav-job-${name}`} + grow + > + + + + {truncateText(name, 20)} + + - - - Changes will not be applied until the pipeline is deployed. - - } - submitBtn={ - - Delete - - } - onConfirm={() => onDelete(name)} - button={ - + } + > + - } - /> - - - - -) + + )} + + + + + + + onEdit(name)} + aria-label={t('rdi.pipeline.nav.editJobAria')} + data-testid={`edit-job-name-${name}`} + /> + + + + {t('rdi.pipeline.nav.deleteJobBody')}} + submitBtn={ + + {t('rdi.pipeline.nav.deleteConfirm')} + + } + onConfirm={() => onDelete(name)} + button={ + + } + /> + + + + + ) +} export default JobItem diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/source-pipeline-dialog/SourcePipelineModal.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/source-pipeline-dialog/SourcePipelineModal.tsx index 6d4b6819fd..493a6885ae 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/source-pipeline-dialog/SourcePipelineModal.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/source-pipeline-dialog/SourcePipelineModal.tsx @@ -16,6 +16,7 @@ import { ContractsIcon, UploadIcon } from 'uiSrc/components/base/icons' import { FileChangeType } from 'uiSrc/slices/interfaces' import { Modal } from 'uiSrc/components/base/display' import { Spacer } from 'uiSrc/components/base/layout' +import { useTranslation } from 'uiSrc/i18n' import { ButtonWrapper } from './SourcePipelineModal.styles' @@ -30,6 +31,7 @@ export enum PipelineSourceOptions { } const SourcePipelineDialog = () => { + const { t } = useTranslation() const [isShowDownloadDialog, setIsShowDownloadDialog] = useState(false) const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() @@ -102,10 +104,10 @@ const SourcePipelineDialog = () => { - Select an option + {t('rdi.pipeline.source.title')} - to start with your pipeline + {t('rdi.pipeline.source.subtitle')} @@ -119,7 +121,7 @@ const SourcePipelineDialog = () => { > - Import pipeline from ZIP file + {t('rdi.pipeline.source.importZip')} { > - Create new pipeline + {t('rdi.pipeline.source.createNew')} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-button/TemplateButton.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-button/TemplateButton.tsx index 0bf164957b..3552c5aa7d 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-button/TemplateButton.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-button/TemplateButton.tsx @@ -10,6 +10,7 @@ import { import { RiTooltip } from 'uiSrc/components' import { RdiPipelineTabs } from 'uiSrc/slices/interfaces' import { SecondaryButton } from 'uiSrc/components/base/forms/buttons' +import { useTranslation } from 'uiSrc/i18n' import { getTooltipContent } from '../template-form/TemplateForm' import { INGEST_OPTION } from '../template-form/constants' @@ -19,6 +20,7 @@ export interface TemplateButtonProps { } const TemplateButton = ({ setFieldValue, value }: TemplateButtonProps) => { + const { t } = useTranslation() const dispatch = useAppDispatch() const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() const { loading, data } = useAppSelector(rdiPipelineStrategiesSelector) @@ -53,13 +55,13 @@ const TemplateButton = ({ setFieldValue, value }: TemplateButtonProps) => { - Insert template + {t('rdi.pipeline.template.insertButton')} ) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.spec.tsx index d0b6d3ce06..2eddd133ea 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.spec.tsx @@ -16,7 +16,7 @@ import { rdiPipelineStrategiesSelector, } from 'uiSrc/slices/rdi/pipeline' import { RdiPipelineTabs } from 'uiSrc/slices/interfaces' -import { INGEST_OPTION, NO_TEMPLATE_LABEL } from './constants' +import { INGEST_OPTION } from './constants' import TemplateForm, { Props } from './TemplateForm' const mockedProps = mock() @@ -114,7 +114,7 @@ describe('TemplateForm', () => { ) expect(screen.getByTestId('db-type-select')).toHaveTextContent( - NO_TEMPLATE_LABEL, + 'No template', ) }) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.tsx index b8ad880ca1..649c9c90a4 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/TemplateForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { useParams } from 'react-router-dom' @@ -23,7 +23,8 @@ import { RiSelect, defaultValueRender, } from 'uiSrc/components/base/forms/select/RiSelect' -import { NO_TEMPLATE_VALUE, NO_OPTIONS, INGEST_OPTION } from './constants' +import { NO_TEMPLATE_VALUE, INGEST_OPTION } from './constants' +import i18n, { useTranslation } from 'uiSrc/i18n' import { Col, Row } from 'uiSrc/components/base/layout/flex' @@ -41,32 +42,44 @@ export const getTooltipContent = ( if (isNoTemplateOptions) { return ( <> - No template is available. + {i18n.t('rdi.pipeline.template.noneAvailableLine1')}
- Close the form and try again. + {i18n.t('rdi.pipeline.template.noneAvailableLine2')} ) } if (value) { - return 'Templates can be accessed only with the empty Editor to prevent potential data loss.' + return i18n.t('rdi.pipeline.template.editorOnly') } return null } const TemplateForm = (props: Props) => { + const { t } = useTranslation() const { closePopover, setTemplate, source, value } = props const { loading, data } = useAppSelector(rdiPipelineStrategiesSelector) const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + // Built at render (not module load) so the label follows the active language. + const noOptions: RiSelectOption[] = useMemo( + () => [ + { + value: NO_TEMPLATE_VALUE, + label: t('rdi.pipeline.template.noTemplateLabel'), + }, + ], + [t], + ) + const [pipelineTypeOptions, setPipelineTypeOptions] = useState< RiSelectOption[] >([]) const [dbTypeOptions, setDbTypeOptions] = - useState(NO_OPTIONS) + useState(noOptions) const [selectedDbType, setSelectedDbType] = useState('') const [selectedPipelineType, setSelectedPipelineType] = useState('') @@ -113,8 +126,8 @@ const TemplateForm = (props: Props) => { useEffect(() => { if (!selectedPipelineType || !data.length) { - setDbTypeOptions(NO_OPTIONS) - setSelectedDbType(NO_OPTIONS[0].value) + setDbTypeOptions(noOptions) + setSelectedDbType(NO_TEMPLATE_VALUE) return } @@ -132,10 +145,10 @@ const TemplateForm = (props: Props) => { setDbTypeOptions(newDbTypeOptions) setSelectedDbType(newDbTypeOptions[0].value) } else { - setDbTypeOptions(NO_OPTIONS) - setSelectedDbType(NO_OPTIONS[0].value) + setDbTypeOptions(noOptions) + setSelectedDbType(NO_TEMPLATE_VALUE) } - }, [data, selectedPipelineType]) + }, [data, selectedPipelineType, noOptions]) useEffect(() => { const newPipelineTypeOptions = data.map((strategy) => ({ @@ -144,7 +157,7 @@ const TemplateForm = (props: Props) => { })) setPipelineTypeOptions( - newPipelineTypeOptions.length ? newPipelineTypeOptions : NO_OPTIONS, + newPipelineTypeOptions.length ? newPipelineTypeOptions : noOptions, ) if (data?.length) { @@ -154,9 +167,9 @@ const TemplateForm = (props: Props) => { ) || newPipelineTypeOptions[0] setSelectedPipelineType(initialSelectedOption.value) } else { - setSelectedPipelineType(NO_OPTIONS[0].value) + setSelectedPipelineType(NO_TEMPLATE_VALUE) } - }, [data]) + }, [data, noOptions]) useEffect(() => { dispatch(fetchPipelineStrategies(rdiInstanceId)) @@ -165,12 +178,12 @@ const TemplateForm = (props: Props) => { return (
- Select a template + {t('rdi.pipeline.template.title')} {pipelineTypeOptions?.length > 1 && ( - + { )} {source === RdiPipelineTabs.Config && ( - + { size="m" data-testid="template-cancel-btn" > - Cancel + {t('rdi.pipeline.template.cancel')} { size="m" data-testid="template-apply-btn" > - Apply + {t('rdi.pipeline.template.apply')} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/constants.ts b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/constants.ts index 8e99d12b5b..c55339d3d4 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/constants.ts +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-form/constants.ts @@ -1,12 +1,3 @@ -export const NO_TEMPLATE_LABEL = 'No template' - export const NO_TEMPLATE_VALUE = 'no_template' -export const NO_OPTIONS = [ - { - value: NO_TEMPLATE_VALUE, - label: NO_TEMPLATE_LABEL, - }, -] - export const INGEST_OPTION = 'ingest' diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-popover/TemplatePopover.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-popover/TemplatePopover.tsx index e042c41343..67d6edb34b 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-popover/TemplatePopover.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/template-popover/TemplatePopover.tsx @@ -9,6 +9,7 @@ import { OutsideClickDetector } from 'uiSrc/components/base/utils' import { SecondaryButton } from 'uiSrc/components/base/forms/buttons' import { RiPopover } from 'uiSrc/components/base' +import { useTranslation } from 'uiSrc/i18n' import styles from './styles.module.scss' export interface Props { @@ -21,6 +22,7 @@ export interface Props { } const TemplatePopover = (props: Props) => { + const { t } = useTranslation() const { isPopoverOpen, setIsPopoverOpen, @@ -56,12 +58,12 @@ const TemplatePopover = (props: Props) => { inverted size="s" className={styles.btn} - aria-label="Insert template" + aria-label={t('rdi.pipeline.template.insertAria')} disabled={loading} onClick={handleOpen} data-testid={`template-trigger-${source}`} > - Insert template + {t('rdi.pipeline.template.insertButton')} } > diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-log/TestConnectionsLog.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-log/TestConnectionsLog.tsx index ad3748303f..1162cff307 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-log/TestConnectionsLog.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-log/TestConnectionsLog.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useMemo } from 'react' +import { TFunction } from 'i18next' import { IRdiConnectionResult, TransformGroupResult, @@ -6,15 +7,16 @@ import { import { StyledRdiAnalyticsTable } from 'uiSrc/pages/rdi/statistics/styles' import { ColumnDefinition, Table } from 'uiSrc/components/base/layout/table' import { RiTooltip } from 'uiSrc/components' +import { useTranslation } from 'uiSrc/i18n' -const columns: ColumnDefinition[] = [ +const getColumns = (t: TFunction): ColumnDefinition[] => [ { - header: 'Endpoint', + header: t('rdi.pipeline.testConn.colEndpoint'), id: 'endpoint', accessorKey: 'target', }, { - header: 'Results', + header: t('rdi.pipeline.testConn.colResults'), id: 'results', accessorKey: 'error', cell: ({ @@ -25,7 +27,7 @@ const columns: ColumnDefinition[] = [ if (error) { return {error} } - return 'Successful' + return t('rdi.pipeline.testConn.successful') }, }, ] @@ -35,8 +37,10 @@ export interface Props { } const TestConnectionsLog = (props: Props) => { + const { t } = useTranslation() const { data } = props const statusData = [...data.success, ...data.fail] + const columns = useMemo(() => getColumns(t), [t]) return ( <> diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-panel/TestConnectionsPanel.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-panel/TestConnectionsPanel.tsx index c582eea747..1b83efb44b 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-panel/TestConnectionsPanel.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/test-connections-panel/TestConnectionsPanel.tsx @@ -10,6 +10,7 @@ import { IconButton } from 'uiSrc/components/base/forms/buttons' import { CancelSlimIcon } from 'uiSrc/components/base/icons' import { Loader } from 'uiSrc/components/base/display' import Divider from 'uiSrc/components/divider/Divider' +import { useTranslation } from 'uiSrc/i18n' import { TestConnectionContainer } from 'uiSrc/pages/rdi/pipeline-management/components/test-connections-panel/styles' interface TestConnectionPanelWrapperProps { @@ -20,31 +21,36 @@ interface TestConnectionPanelWrapperProps { const TestConnectionPanelWrapper = ({ children, onClose, -}: TestConnectionPanelWrapperProps) => ( - - - - - Test connection - - - - - - {children} - -) +}: TestConnectionPanelWrapperProps) => { + const { t } = useTranslation() + + return ( + + + + + {t('rdi.pipeline.testConn.title')} + + + + + + {children} + + ) +} export interface Props { onClose: () => void } const TestConnectionsPanel = (props: Props) => { + const { t } = useTranslation() const { onClose } = props const { loading, results } = useAppSelector(rdiTestConnectionsSelector) @@ -53,7 +59,7 @@ const TestConnectionsPanel = (props: Props) => { - Loading results... + {t('rdi.pipeline.testConn.loading')} { return ( - No results found. Please try again. + {t('rdi.pipeline.testConn.noResults')} ) @@ -81,14 +87,14 @@ const TestConnectionsPanel = (props: Props) => { - Source connections + {t('rdi.pipeline.testConn.source')} - Target connections + {t('rdi.pipeline.testConn.target')} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/UploadModal.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/UploadModal.tsx index f5f6c924eb..99ead2b1b2 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/UploadModal.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/UploadModal.tsx @@ -15,8 +15,19 @@ import { setPipelineJobs, } from 'uiSrc/slices/rdi/pipeline' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { useTranslation } from 'uiSrc/i18n' import UploadDialog from './components/upload-dialog/UploadDialog' +const UPLOAD_VALIDATION_ERRORS = { + noConfig: 'config.yaml is missing', + noJobs: 'No jobs folder found', +} + +const UPLOAD_ERROR_LABEL_KEYS: Record = { + [UPLOAD_VALIDATION_ERRORS.noConfig]: 'rdi.pipeline.upload.errorNoConfig', + [UPLOAD_VALIDATION_ERRORS.noJobs]: 'rdi.pipeline.upload.errorNoJobs', +} + export interface Props { trigger?: React.ReactElement onUploadedPipeline?: () => void @@ -25,6 +36,7 @@ export interface Props { } const UploadModal = (props: Props) => { + const { t } = useTranslation() const { trigger, visible, onUploadedPipeline, onClose } = props const [isModalVisible, setIsModalVisible] = useState(visible) @@ -48,7 +60,7 @@ const UploadModal = (props: Props) => { const validateZip = (zip: JSZip) => { // check if config.yaml exists if (zip.file('config.yaml') === null) { - throw new Error('config.yaml is missing') + throw new Error(UPLOAD_VALIDATION_ERRORS.noConfig) } // check if job files exist @@ -56,7 +68,7 @@ const UploadModal = (props: Props) => { filename.startsWith('jobs/'), ) if (!jobFiles.length) { - throw new Error('No jobs folder found') + throw new Error(UPLOAD_VALIDATION_ERRORS.noJobs) } } @@ -150,7 +162,8 @@ const UploadModal = (props: Props) => { }, }) - setError(errorMessage) + const labelKey = UPLOAD_ERROR_LABEL_KEYS[errorMessage] + setError(labelKey ? t(labelKey as never) : errorMessage) } } diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/components/upload-dialog/UploadDialog.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/components/upload-dialog/UploadDialog.tsx index 729cf0c379..6d2d021a9a 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/components/upload-dialog/UploadDialog.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/upload-modal/components/upload-dialog/UploadDialog.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import { Text } from 'uiSrc/components/base/text' import ImportFileModal from 'uiSrc/components/import-file-modal' +import { useTranslation } from 'uiSrc/i18n' export interface Props { onClose: () => void @@ -13,10 +14,6 @@ export interface Props { loading: boolean } -const warningMessage = - 'If a new pipeline is uploaded, existing pipeline configuration and transformation' + - 'jobs will be overwritten. Changes will not be applied until the pipeline is deployed.' - const UploadDialog = ({ onClose, onConfirm, @@ -26,6 +23,7 @@ const UploadDialog = ({ error, loading, }: Props) => { + const { t } = useTranslation() const [isSubmitDisabled, setIsSubmitDisabled] = useState(true) const handleFileChange = (files: FileList | null) => { @@ -44,27 +42,29 @@ const UploadDialog = ({ onSubmit={onConfirm} title={ showWarning - ? 'Upload a new pipeline' - : 'Upload an archive with an RDI pipeline' + ? t('rdi.pipeline.upload.titleNew') + : t('rdi.pipeline.upload.titleArchive') } resultsTitle={ - !error ? 'Pipeline has been uploaded' : 'Failed to upload pipeline' - } - submitResults={ - A new pipeline has been successfully uploaded. + !error + ? t('rdi.pipeline.upload.resultSuccess') + : t('rdi.pipeline.upload.resultFail') } + submitResults={{t('rdi.pipeline.upload.submitResults')}} loading={loading} data={isUploaded} warning={ showWarning ? ( - {warningMessage} + + {t('rdi.pipeline.upload.warning')} + ) : null } error={error} - errorMessage="There was a problem with the .zip file" + errorMessage={t('rdi.pipeline.upload.errorZip')} isInvalid={false} isSubmitDisabled={isSubmitDisabled} - submitBtnText="Upload" + submitBtnText={t('rdi.pipeline.upload.submitButton')} acceptedFileExtension=".zip" /> ) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx index 7fe7871b00..26bebef5d7 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/config/Config.tsx @@ -40,9 +40,11 @@ import { Text, Title } from 'uiSrc/components/base/text' import { Loader } from 'uiSrc/components/base/display' import { Col, FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Link } from '@redis-ui/components' +import { Trans, useTranslation } from 'uiSrc/i18n' import { StyledRdiDatabaseConfigContainer } from 'uiSrc/pages/rdi/pipeline-management/pages/config/styles' const Config = () => { + const { t } = useTranslation() const [isPanelOpen, setIsPanelOpen] = useState(false) const [isPopoverOpen, setIsPopoverOpen] = useState(false) @@ -149,7 +151,7 @@ const Config = () => { - Target database configuration + {t('rdi.pipeline.config.title')} { - {'Configure target instance '} - - connection details - - {' and applier settings.'} + + ), + }} + /> {pipelineLoading ? ( - + ) : ( { aria-labelledby="test target connections" data-testid="rdi-test-connection-btn" > - Test Connection + {t('rdi.pipeline.config.testButton')} diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx index e192218274..c257259e73 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx @@ -34,6 +34,7 @@ import { Loader } from 'uiSrc/components/base/display' import TemplateButton from '../../components/template-button' import { Col, FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Link, TextButton } from '@redis-ui/components' +import { Trans, useTranslation } from 'uiSrc/i18n' import { StyledRdiJobConfigContainer } from 'uiSrc/pages/rdi/pipeline-management/pages/job/styles' export interface Props { @@ -45,6 +46,7 @@ export interface Props { } const Job = (props: Props) => { + const { t } = useTranslation() const { name, value = '', deployedJobValue, jobIndex, rdiInstanceId } = props const [isPanelOpen, setIsPanelOpen] = useState(false) @@ -199,7 +201,7 @@ const Job = (props: Props) => { data-testid="open-dedicated-editor-btn" variant="primary-inline" > - SQL and JMESPath Editor + {t('rdi.pipeline.job.dedicatedEditorButton')} { - {'Create a job per source table to filter, transform, and '} - - map data - - {' to Redis.'} + + ), + }} + /> {loading ? (
- +
) : ( { disabled={isPanelOpen} data-testid="rdi-job-dry-run" > - Dry Run + {t('rdi.pipeline.job.dryRunButton')} From 2848c4cd5e0c953240073573031755d6d9d4ec09 Mon Sep 17 00:00:00 2001 From: dantovska Date: Mon, 20 Jul 2026 13:22:20 +0300 Subject: [PATCH 055/166] Remove obsolete Vector Search E2E specs (RI-8319 / RI-8171) (#6235) * test(vector-search): remove obsolete e2e specs for changed behavior * test(vector-search): assert empty-DB "Crete index" enters manual creation --- tests/e2e-playwright/TEST_PLAN.md | 3 +- .../pages/browser/BrowserPage.ts | 7 ---- .../vector-search/components/IndexList.ts | 3 +- .../browser-integration.spec.ts | 32 ------------------- .../create-index/existing-data.spec.ts | 1 - .../list-indexes/create-index.spec.ts | 29 ++++++++++------- 6 files changed, 20 insertions(+), 55 deletions(-) diff --git a/tests/e2e-playwright/TEST_PLAN.md b/tests/e2e-playwright/TEST_PLAN.md index 598ecd79a5..52097dba67 100644 --- a/tests/e2e-playwright/TEST_PLAN.md +++ b/tests/e2e-playwright/TEST_PLAN.md @@ -839,7 +839,7 @@ The test plan is organized by feature area. Tests are grouped for parallel execu | ✅ | main | should open sample data modal and complete "Start querying" flow | | ✅ | main | should open sample data modal and navigate to "See index definition" | | ✅ | main | should create index from existing data via list page menu | -| ✅ | main | should disable "Use existing data" when no hash or JSON keys exist | +| ✅ | main | should open existing data flow into manual creation with the browser collapsed | ### 8.8 Query Page @@ -898,7 +898,6 @@ The test plan is organized by feature area. Tests are grouped for parallel execu | ✅ | main | should show "View index" button for key indexed by a single index | | ✅ | main | should show "View index" dropdown for key indexed by multiple indexes | | ✅ | main | should show "Make searchable" button for non-indexed key and create index | -| ✅ | main | should show "Index" button on folder node and create index | | ✅ | main | should show RQE not available when navigating to Search tab on Redis without search module | --- diff --git a/tests/e2e-playwright/pages/browser/BrowserPage.ts b/tests/e2e-playwright/pages/browser/BrowserPage.ts index 71d26cadaf..abe109877f 100644 --- a/tests/e2e-playwright/pages/browser/BrowserPage.ts +++ b/tests/e2e-playwright/pages/browser/BrowserPage.ts @@ -108,11 +108,4 @@ export class BrowserPage extends InstancePage { getViewIndexMenuItem(indexName: string): Locator { return this.page.getByRole('menuitem', { name: indexName, exact: true }); } - - /** - * Get the "Index" button that appears on a folder node when hovered - */ - getIndexFolderButton(folderName: string): Locator { - return this.page.getByTestId(`index-folder-btn-${folderName}`); - } } diff --git a/tests/e2e-playwright/pages/vector-search/components/IndexList.ts b/tests/e2e-playwright/pages/vector-search/components/IndexList.ts index ae75e40685..4cc6d91d3e 100644 --- a/tests/e2e-playwright/pages/vector-search/components/IndexList.ts +++ b/tests/e2e-playwright/pages/vector-search/components/IndexList.ts @@ -24,8 +24,7 @@ export class IndexList { } getCreateIndexMenuItem(option: 'sample-data' | 'existing-data'): Locator { - const text = option === 'sample-data' ? 'Use sample data' : 'Use existing data'; - return this.page.getByRole('menuitem', { name: text }); + return this.page.getByTestId(`vector-search--list--create-index--${option}`); } /** diff --git a/tests/e2e-playwright/tests/serial/vector-search/browser-integration/browser-integration.spec.ts b/tests/e2e-playwright/tests/serial/vector-search/browser-integration/browser-integration.spec.ts index aada3d22cd..0749883f24 100644 --- a/tests/e2e-playwright/tests/serial/vector-search/browser-integration/browser-integration.spec.ts +++ b/tests/e2e-playwright/tests/serial/vector-search/browser-integration/browser-integration.spec.ts @@ -133,38 +133,6 @@ test.describe('Vector Search > Browser Page Integration', () => { await expect(vectorSearchPage.queryPageWrapper).toBeVisible(); await expect(vectorSearchPage.indexCreatedToast).toBeVisible(); }); - - test('should show "Index" button on folder node and create index', async ({ - browserPage, - vectorSearchPage, - apiHelper, - }) => { - // Create a key with no matching index - const indexablePrefix = `test-vs-indexable-${uniqueId}:`; - const indexableKeyName = `${indexablePrefix}key1`; - const hashKey = IndexHashKeyFactory.build({ keyName: indexableKeyName }); - await apiHelper.createHashKey(database.id, hashKey.keyName, hashKey.fields); - - await browserPage.keyList.searchKeys(indexableKeyName); - - // Hover folder node to reveal Index button, open modal, then create index - const folderName = indexablePrefix.slice(0, -1); - await browserPage.keyList.hoverFolderNode(folderName); - - const indexButton = browserPage.getIndexFolderButton(folderName); - await expect(indexButton).toBeVisible(); - await indexButton.click(); - - await expect(browserPage.makeSearchableModal.heading).toBeVisible(); - await browserPage.makeSearchableModal.continueButton.click(); - - await expect(vectorSearchPage.createIndexForm.container).toBeVisible(); - await expect(vectorSearchPage.createIndexForm.content).toBeVisible(); - - await vectorSearchPage.createIndexForm.createIndexButton.click(); - await expect(vectorSearchPage.queryPageWrapper).toBeVisible(); - await expect(vectorSearchPage.indexCreatedToast).toBeVisible(); - }); }); test.describe('Vector Search > Browser Page Integration > RQE Not Available', () => { diff --git a/tests/e2e-playwright/tests/serial/vector-search/create-index/existing-data.spec.ts b/tests/e2e-playwright/tests/serial/vector-search/create-index/existing-data.spec.ts index d40926ce07..8414eb07b6 100644 --- a/tests/e2e-playwright/tests/serial/vector-search/create-index/existing-data.spec.ts +++ b/tests/e2e-playwright/tests/serial/vector-search/create-index/existing-data.spec.ts @@ -63,7 +63,6 @@ test.describe('Vector Search > Create Index - Existing Data', () => { localStorage.setItem('vectorSearchCreateIndexOnboarding', 'true'); }); - // Navigate to list page and open "Use existing data" form await expect(vectorSearchPage.listWrapper).toBeVisible(); await vectorSearchPage.indexList.openCreateIndex('existing-data'); await expect(vectorSearchPage.createIndexForm.container).toBeVisible(); diff --git a/tests/e2e-playwright/tests/serial/vector-search/list-indexes/create-index.spec.ts b/tests/e2e-playwright/tests/serial/vector-search/list-indexes/create-index.spec.ts index efb0117fb3..aea4b54580 100644 --- a/tests/e2e-playwright/tests/serial/vector-search/list-indexes/create-index.spec.ts +++ b/tests/e2e-playwright/tests/serial/vector-search/list-indexes/create-index.spec.ts @@ -15,9 +15,8 @@ test.use({ featureFlags: { vectorSearchV2: true } }); /** * Vector Search > Create Index from List Page * - * Tests for creating indexes via the "+ Create search index" menu - * on the list page, including sample data flow, existing data flow, - * and disabled state when no hash/JSON keys exist. + * Tests for creating indexes via the "+ Create search index" menu on the list + * page: sample data, existing data, and the empty-database manual creation flow. */ test.describe('Vector Search > Create Index from List Page', () => { let database: DatabaseInstance; @@ -108,7 +107,6 @@ test.describe('Vector Search > Create Index from List Page', () => { }); test('should create index from existing data via list page menu', async ({ vectorSearchPage }) => { - // Open create index menu → Use existing data await vectorSearchPage.indexList.createIndexButton.click(); const existingDataItem = vectorSearchPage.indexList.getCreateIndexMenuItem('existing-data'); @@ -118,11 +116,9 @@ test.describe('Vector Search > Create Index from List Page', () => { await expect(vectorSearchPage.createIndexWrapper).toBeVisible(); await expect(vectorSearchPage.createIndexForm.browserPanel).toBeVisible(); - // Select key in browser panel and create index await vectorSearchPage.createIndexForm.selectKey(`${TEST_INDEX_PREFIX}key1`); await expect(vectorSearchPage.createIndexForm.content).toBeVisible(); - // Create index and navigate to query page, verify toast await vectorSearchPage.createIndexForm.createIndexButton.click(); await expect(vectorSearchPage.queryPageWrapper).toBeVisible(); await expect(vectorSearchPage.indexCreatedToast).toBeVisible(); @@ -142,11 +138,12 @@ test.describe('Vector Search > Create Index from List Page - No Hash/JSON Keys', await apiHelper.deleteDatabase(database.id); }); - test('should disable "Use existing data" when no hash or JSON keys exist', async ({ + test('should open existing data flow into manual creation with the browser collapsed', async ({ vectorSearchPage, apiHelper, + page, }) => { - // FLUSHDB leaves no hash/JSON keys → "Use existing data" should be disabled + // FLUSHDB leaves no hash/JSON keys; seed one index so the list page appears await apiHelper.sendCommand(database.id, 'FLUSHDB'); await apiHelper.createIndex(database.id, emptyIndex.indexName, emptyIndex.prefix, emptyIndex.schema); @@ -154,15 +151,25 @@ test.describe('Vector Search > Create Index from List Page - No Hash/JSON Keys', .poll(() => apiHelper.getIndexes(database.id).then((indexes) => indexes.includes(emptyIndex.indexName))) .toBe(true); - // Navigate to list page await vectorSearchPage.goto(database.id); await expect(vectorSearchPage.listWrapper).toBeVisible(); - // Open create index menu → "Use existing data" should be disabled + // Skip onboarding (after navigation so localStorage targets the app origin) + await page.evaluate(() => { + localStorage.setItem('vectorSearchSelectKeyOnboarding', 'true'); + localStorage.setItem('vectorSearchCreateIndexOnboarding', 'true'); + }); + await vectorSearchPage.indexList.createIndexButton.click(); const existingDataItem = vectorSearchPage.indexList.getCreateIndexMenuItem('existing-data'); await expect(existingDataItem).toBeVisible(); - await expect(existingDataItem).toHaveAttribute('data-disabled', ''); + await expect(existingDataItem).toBeEnabled(); + await existingDataItem.click(); + + // With no data to browse, the key browser is collapsed and the manual empty state shows + await expect(vectorSearchPage.createIndexWrapper).toBeVisible(); + await expect(vectorSearchPage.createIndexForm.emptyState).toBeVisible(); + await expect(vectorSearchPage.createIndexForm.browserPanel).toBeHidden(); }); }); From b5c91eda476667e5d001d1d89f1888dbde73a8d5 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Mon, 20 Jul 2026 17:38:44 +0300 Subject: [PATCH 056/166] feat(i18n): migrate autodiscover-azure to i18n (RI-8273) (#6240) - migrate autodiscover-azure to i18n - migrate Azure sign-in dialog to i18n Ref: RI-8273 --- .../AzureSignInDialog.tsx | 36 ++--- redisinsight/ui/src/i18n/locales/bg.json | 110 ++++++++++++++ redisinsight/ui/src/i18n/locales/en.json | 110 ++++++++++++++ .../AzureDatabases.constants.tsx | 21 +-- .../AzureDatabases/AzureDatabases.tsx | 49 +++--- .../azure-databases/AzureDatabasesPage.tsx | 21 ++- .../AzureManualConnectionForm.tsx | 52 ++++--- .../AzureManualConnectionPage.tsx | 20 +-- .../AzureSubscriptions.constants.tsx | 17 ++- .../AzureSubscriptions/AzureSubscriptions.tsx | 31 ++-- .../AzureSubscriptionsPage.tsx | 4 +- .../components/DescriptionsTooltip.tsx | 9 +- .../src/pages/autodiscover-azure/constants.ts | 143 ++++++++++++++---- 13 files changed, 479 insertions(+), 144 deletions(-) diff --git a/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx index d759e8215e..441497bb27 100644 --- a/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx +++ b/redisinsight/ui/src/components/azure-sign-in-dialog/AzureSignInDialog.tsx @@ -11,6 +11,7 @@ import { PrimaryButton, SecondaryButton, } from 'uiSrc/components/base/forms/buttons' +import { useTranslation } from 'uiSrc/i18n' import { AzureSignInDialogProps } from './AzureSignInDialog.types' import * as S from './AzureSignInDialog.styles' @@ -22,25 +23,13 @@ const TEST_ID = 'azure-sign-in-dialog' const AZURE_TENANT_ID_REGEX = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,})$/i -const TENANT_ID_ERROR = 'Enter a valid tenant GUID or domain.' - -const TENANT_ID_HINT = - 'Only needed if your resources and your account are in different tenants.' - -// Explains the cross-tenant case: authenticate against the tenant that OWNS the -// resources, not the user's home tenant. -const TENANT_ID_INFO = - "Leave blank to use your account's default (home) tenant. " + - 'If your Azure Managed Redis resources are in a different tenant than your ' + - 'account, enter the tenant that owns the resources (you need guest access ' + - 'to it) — not your own home tenant.' - export const AzureSignInDialog = ({ isOpen, loading, onClose, onSignIn, }: AzureSignInDialogProps) => { + const { t } = useTranslation() const [tenantId, setTenantId] = useState('') useEffect(() => { @@ -71,7 +60,7 @@ export const AzureSignInDialog = ({ - Connect to Azure Managed Redis + {t('autodiscover.azure.signIn.title')} @@ -79,26 +68,29 @@ export const AzureSignInDialog = ({
- Sign in with your Microsoft account to discover and add Azure - Managed Redis databases. + {t('autodiscover.azure.signIn.description')} - {isTenantInvalid ? TENANT_ID_ERROR : TENANT_ID_HINT} + {isTenantInvalid + ? t('autodiscover.azure.signIn.tenantError') + : t('autodiscover.azure.signIn.tenantHint')} @@ -111,7 +103,7 @@ export const AzureSignInDialog = ({ onClick={onClose} data-testid={`${TEST_ID}-cancel`} > - Cancel + {t('autodiscover.azure.button.cancel')} - Sign in with Microsoft + {t('autodiscover.azure.signIn.signInButton')} diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 2955ef3724..b92e8d1100 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -298,6 +298,116 @@ "api.error.code.12404.title": "Ресурсът не е намерен", "api.error.code.12409.title": "Конфликт", "api.error.code.12500.title": "Сървърна грешка", + "autodiscover.azure.button.addDatabase": "Добавяне на база данни", + "autodiscover.azure.button.cancel": "Отказ", + "autodiscover.azure.button.manualConnection": "Ръчно свързване", + "autodiscover.azure.column.databaseName": "Име на база данни", + "autodiscover.azure.column.number": "#", + "autodiscover.azure.column.region": "Регион", + "autodiscover.azure.column.state": "Състояние", + "autodiscover.azure.column.status": "Статус", + "autodiscover.azure.column.subscriptionId": "ID на абонамент", + "autodiscover.azure.column.subscriptionName": "Име на абонамент", + "autodiscover.azure.column.type": "Тип", + "autodiscover.azure.databaseType.enterprise.description": "Azure Cache for Redis Enterprise със специализирана инфраструктура, по-висока производителност и поддръжка на Redis модули.", + "autodiscover.azure.databaseType.enterprise.label": "Enterprise", + "autodiscover.azure.databaseType.standard.description": "Azure Cache for Redis с нива Basic, Standard или Premium. Подходящо за повечето сценарии на кеширане.", + "autodiscover.azure.databaseType.standard.label": "Standard", + "autodiscover.azure.databases.addButtonEmpty": "Добавяне на бази данни", + "autodiscover.azure.databases.addButton_one": "Добавяне ({{count}}) база данни", + "autodiscover.azure.databases.addButton_other": "Добавяне ({{count}}) бази данни", + "autodiscover.azure.databases.addFailedDefault": "Неуспешно добавяне на база данни", + "autodiscover.azure.databases.addFailedTitle_one": "Неуспешно добавяне на {{count}} база данни", + "autodiscover.azure.databases.addFailedTitle_other": "Неуспешно добавяне на {{count}} бази данни", + "autodiscover.azure.databases.addedMultiple": "{{count}} бази данни", + "autodiscover.azure.databases.auth": "Удостоверяване:", + "autodiscover.azure.databases.authAccessKey": "Ключ за достъп", + "autodiscover.azure.databases.authEntraId": "Microsoft Entra ID (Препоръчително)", + "autodiscover.azure.databases.backButton": "Абонаменти", + "autodiscover.azure.databases.defaultDatabaseName": "База данни", + "autodiscover.azure.databases.empty": "Не са намерени Redis бази данни в този абонамент.", + "autodiscover.azure.databases.maxSelection": "Максимум {{max}} бази данни могат да бъдат добавени наведнъж.", + "autodiscover.azure.databases.pageTitle": "Azure бази данни", + "autodiscover.azure.databases.refreshAria": "Обновяване на бази данни", + "autodiscover.azure.databases.subscription": "Абонамент:", + "autodiscover.azure.databases.title": "Azure Redis бази данни", + "autodiscover.azure.databases.unknownDatabase": "база данни", + "autodiscover.azure.manual.aliasLabel": "Псевдоним на база данни", + "autodiscover.azure.manual.aliasPlaceholder": "Въведете псевдоним на база данни", + "autodiscover.azure.manual.aliasRequired": "Псевдонимът на базата данни е задължителен", + "autodiscover.azure.manual.backButton": "Бази данни", + "autodiscover.azure.manual.entraCredentialsInfo": "Удостоверяването ще използва вашите идентификационни данни за Azure Entra ID", + "autodiscover.azure.manual.hostLabel": "Хост", + "autodiscover.azure.manual.hostPlaceholder": "Въведете име на хост / IP адрес / частна крайна точка", + "autodiscover.azure.manual.hostRequired": "Хостът е задължителен", + "autodiscover.azure.manual.pageTitle": "Ръчно свързване с Azure", + "autodiscover.azure.manual.portLabel": "Порт", + "autodiscover.azure.manual.portPlaceholder": "Въведете порт", + "autodiscover.azure.manual.portRequired": "Портът е задължителен", + "autodiscover.azure.manual.serverNameLabel": "Име на сървър", + "autodiscover.azure.manual.serverNameRequired": "Името на сървъра е задължително, когато SNI е активиран", + "autodiscover.azure.manual.sniInfo": "Активирайте SNI, когато се свързвате чрез Private Link с помощта на IP адрес. Въведете оригиналното Redis име на хост като Име на сървър.", + "autodiscover.azure.manual.timeoutLabel": "Таймаут (с)", + "autodiscover.azure.manual.timeoutPlaceholder": "Въведете таймаут (в секунди)", + "autodiscover.azure.manual.title": "Ръчно свързване с Azure", + "autodiscover.azure.manual.tlsAlwaysEnabled": "TLS е винаги активиран за връзки с Azure Cache for Redis.", + "autodiscover.azure.manual.tlsSettings": "TLS настройки", + "autodiscover.azure.manual.useSni": "Използване на SNI", + "autodiscover.azure.manual.usernameLabel": "Потребителско име", + "autodiscover.azure.manual.usernamePlaceholder": "Въведете потребителско име", + "autodiscover.azure.manual.verifyServerCert": "Проверка на сертификата на сървъра", + "autodiscover.azure.manual.verifyServerCertInfo": "Препоръчително за продукция. Проверява дали сертификатът на сървъра съответства на името на хоста.", + "autodiscover.azure.provisioningState.configuringAad.description": "Удостоверяването с Entra ID (Azure AD) се конфигурира.", + "autodiscover.azure.provisioningState.configuringAad.label": "ConfiguringAAD", + "autodiscover.azure.provisioningState.creating.description": "Базата данни се създава и все още не е налична.", + "autodiscover.azure.provisioningState.creating.label": "Creating", + "autodiscover.azure.provisioningState.deleting.description": "Базата данни се изтрива.", + "autodiscover.azure.provisioningState.deleting.label": "Deleting", + "autodiscover.azure.provisioningState.exporting.description": "Данните се експортират от базата данни.", + "autodiscover.azure.provisioningState.exporting.label": "Exporting", + "autodiscover.azure.provisioningState.failed.description": "Осигуряването е неуспешно. Базата данни не може да се използва.", + "autodiscover.azure.provisioningState.failed.label": "Failed", + "autodiscover.azure.provisioningState.importing.description": "Данните се импортират в базата данни.", + "autodiscover.azure.provisioningState.importing.label": "Importing", + "autodiscover.azure.provisioningState.linking.description": "Базата данни се свързва за гео-репликация.", + "autodiscover.azure.provisioningState.linking.label": "Linking", + "autodiscover.azure.provisioningState.provisioning.description": "Базата данни се осигурява.", + "autodiscover.azure.provisioningState.provisioning.label": "Provisioning", + "autodiscover.azure.provisioningState.recovering.description": "Базата данни се възстановява след неуспех.", + "autodiscover.azure.provisioningState.recovering.label": "Recovering", + "autodiscover.azure.provisioningState.scaling.description": "Базата данни се мащабира.", + "autodiscover.azure.provisioningState.scaling.label": "Scaling", + "autodiscover.azure.provisioningState.succeeded.description": "Базата данни е напълно осигурена и готова за използване.", + "autodiscover.azure.provisioningState.succeeded.label": "Succeeded", + "autodiscover.azure.provisioningState.unlinking.description": "Базата данни се разкача от гео-репликация.", + "autodiscover.azure.provisioningState.unlinking.label": "Unlinking", + "autodiscover.azure.provisioningState.updating.description": "Конфигурацията на базата данни се актуализира.", + "autodiscover.azure.provisioningState.updating.label": "Updating", + "autodiscover.azure.signIn.description": "Влезте с вашия Microsoft акаунт, за да откриете и добавите Azure Managed Redis бази данни.", + "autodiscover.azure.signIn.signInButton": "Вход с Microsoft", + "autodiscover.azure.signIn.tenantError": "Въведете валиден GUID или домейн на наемател.", + "autodiscover.azure.signIn.tenantHint": "Необходимо е само ако вашите ресурси и вашият акаунт са в различни наематели.", + "autodiscover.azure.signIn.tenantInfo": "Оставете празно, за да използвате наемателя по подразбиране (домашния) на вашия акаунт. Ако вашите Azure Managed Redis ресурси са в различен наемател от вашия акаунт, въведете наемателя, който притежава ресурсите (нужен ви е гостуващ достъп до него) — а не вашия собствен домашен наемател.", + "autodiscover.azure.signIn.tenantLabel": "ID на наемател (по избор)", + "autodiscover.azure.signIn.tenantPlaceholder": "your-tenant.onmicrosoft.com или GUID", + "autodiscover.azure.signIn.title": "Свързване с Azure Managed Redis", + "autodiscover.azure.subscriptionState.deleted.description": "Абонаментът е изтрит и не може да бъде възстановен.", + "autodiscover.azure.subscriptionState.deleted.label": "Deleted", + "autodiscover.azure.subscriptionState.disabled.description": "Абонаментът е спрян. Ресурсите не са достъпни, докато абонаментът не бъде повторно активиран.", + "autodiscover.azure.subscriptionState.disabled.label": "Disabled", + "autodiscover.azure.subscriptionState.enabled.description": "Абонаментът е активен и напълно функционален.", + "autodiscover.azure.subscriptionState.enabled.label": "Enabled", + "autodiscover.azure.subscriptionState.pastDue.description": "Плащането е просрочено. Услугите може да са ограничени.", + "autodiscover.azure.subscriptionState.pastDue.label": "PastDue", + "autodiscover.azure.subscriptionState.warned.description": "Абонаментът има проблеми с плащането, но все още е операционен по време на гратисен период.", + "autodiscover.azure.subscriptionState.warned.label": "Warned", + "autodiscover.azure.subscriptions.empty": "Не са намерени Azure абонаменти за този акаунт.", + "autodiscover.azure.subscriptions.refreshAria": "Обновяване на абонаменти", + "autodiscover.azure.subscriptions.showDatabases": "Показване на бази данни", + "autodiscover.azure.subscriptions.signedInAs": "Влезли сте като", + "autodiscover.azure.subscriptions.switchAccount": "Смяна на акаунт или тенант", + "autodiscover.azure.subscriptions.tenant": "Тенант", + "autodiscover.azure.subscriptions.title": "Azure абонаменти", "browser.array.delete.bulk.aria": "Изтриване на избраните елементи", "browser.array.delete.bulk.button": "Премахни", "browser.array.delete.bulk.message": "Избраните елементи ({{count}}) ще бъдат премахнати за постоянно от масива.", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 8a70fea529..d6a9bae7f0 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -298,6 +298,116 @@ "api.error.code.12404.title": "Resource not found", "api.error.code.12409.title": "Conflict", "api.error.code.12500.title": "Server error", + "autodiscover.azure.button.addDatabase": "Add Database", + "autodiscover.azure.button.cancel": "Cancel", + "autodiscover.azure.button.manualConnection": "Manual Connection", + "autodiscover.azure.column.databaseName": "Database Name", + "autodiscover.azure.column.number": "#", + "autodiscover.azure.column.region": "Region", + "autodiscover.azure.column.state": "State", + "autodiscover.azure.column.status": "Status", + "autodiscover.azure.column.subscriptionId": "Subscription ID", + "autodiscover.azure.column.subscriptionName": "Subscription Name", + "autodiscover.azure.column.type": "Type", + "autodiscover.azure.databaseType.enterprise.description": "Azure Cache for Redis Enterprise with dedicated infrastructure, higher performance, and Redis modules support.", + "autodiscover.azure.databaseType.enterprise.label": "Enterprise", + "autodiscover.azure.databaseType.standard.description": "Azure Cache for Redis with Basic, Standard, or Premium tiers. Suitable for most caching scenarios.", + "autodiscover.azure.databaseType.standard.label": "Standard", + "autodiscover.azure.databases.addButtonEmpty": "Add Databases", + "autodiscover.azure.databases.addButton_one": "Add ({{count}}) Database", + "autodiscover.azure.databases.addButton_other": "Add ({{count}}) Databases", + "autodiscover.azure.databases.addFailedDefault": "Failed to add database", + "autodiscover.azure.databases.addFailedTitle_one": "Failed to add {{count}} database", + "autodiscover.azure.databases.addFailedTitle_other": "Failed to add {{count}} databases", + "autodiscover.azure.databases.addedMultiple": "{{count}} databases", + "autodiscover.azure.databases.auth": "Auth:", + "autodiscover.azure.databases.authAccessKey": "Access Key", + "autodiscover.azure.databases.authEntraId": "Microsoft Entra ID (Recommended)", + "autodiscover.azure.databases.backButton": "Subscriptions", + "autodiscover.azure.databases.defaultDatabaseName": "Database", + "autodiscover.azure.databases.empty": "No Redis databases found in this subscription.", + "autodiscover.azure.databases.maxSelection": "Maximum of {{max}} databases can be added at a time.", + "autodiscover.azure.databases.pageTitle": "Azure Databases", + "autodiscover.azure.databases.refreshAria": "Refresh databases", + "autodiscover.azure.databases.subscription": "Subscription:", + "autodiscover.azure.databases.title": "Azure Redis Databases", + "autodiscover.azure.databases.unknownDatabase": "database", + "autodiscover.azure.manual.aliasLabel": "Database alias", + "autodiscover.azure.manual.aliasPlaceholder": "Enter Database Alias", + "autodiscover.azure.manual.aliasRequired": "Database alias is required", + "autodiscover.azure.manual.backButton": "Databases", + "autodiscover.azure.manual.entraCredentialsInfo": "Authentication will use your Azure Entra ID credentials", + "autodiscover.azure.manual.hostLabel": "Host", + "autodiscover.azure.manual.hostPlaceholder": "Enter Hostname / IP address / Private Endpoint", + "autodiscover.azure.manual.hostRequired": "Host is required", + "autodiscover.azure.manual.pageTitle": "Azure Manual Connection", + "autodiscover.azure.manual.portLabel": "Port", + "autodiscover.azure.manual.portPlaceholder": "Enter Port", + "autodiscover.azure.manual.portRequired": "Port is required", + "autodiscover.azure.manual.serverNameLabel": "Server Name", + "autodiscover.azure.manual.serverNameRequired": "Server Name is required when SNI is enabled", + "autodiscover.azure.manual.sniInfo": "Enable SNI when connecting via Private Link using an IP address. Enter the original Redis hostname as the Server Name.", + "autodiscover.azure.manual.timeoutLabel": "Timeout (s)", + "autodiscover.azure.manual.timeoutPlaceholder": "Enter Timeout (in seconds)", + "autodiscover.azure.manual.title": "Manual Azure Connection", + "autodiscover.azure.manual.tlsAlwaysEnabled": "TLS is always enabled for Azure Cache for Redis connections.", + "autodiscover.azure.manual.tlsSettings": "TLS Settings", + "autodiscover.azure.manual.useSni": "Use SNI", + "autodiscover.azure.manual.usernameLabel": "Username", + "autodiscover.azure.manual.usernamePlaceholder": "Enter Username", + "autodiscover.azure.manual.verifyServerCert": "Verify server certificate", + "autodiscover.azure.manual.verifyServerCertInfo": "Recommended for production. Validates that the server certificate matches the hostname.", + "autodiscover.azure.provisioningState.configuringAad.description": "Entra ID (Azure AD) authentication is being configured.", + "autodiscover.azure.provisioningState.configuringAad.label": "ConfiguringAAD", + "autodiscover.azure.provisioningState.creating.description": "Database is being created and is not yet available.", + "autodiscover.azure.provisioningState.creating.label": "Creating", + "autodiscover.azure.provisioningState.deleting.description": "Database is being deleted.", + "autodiscover.azure.provisioningState.deleting.label": "Deleting", + "autodiscover.azure.provisioningState.exporting.description": "Data is being exported from the database.", + "autodiscover.azure.provisioningState.exporting.label": "Exporting", + "autodiscover.azure.provisioningState.failed.description": "Provisioning failed. The database is not usable.", + "autodiscover.azure.provisioningState.failed.label": "Failed", + "autodiscover.azure.provisioningState.importing.description": "Data is being imported into the database.", + "autodiscover.azure.provisioningState.importing.label": "Importing", + "autodiscover.azure.provisioningState.linking.description": "Database is being linked for geo-replication.", + "autodiscover.azure.provisioningState.linking.label": "Linking", + "autodiscover.azure.provisioningState.provisioning.description": "Database is being provisioned.", + "autodiscover.azure.provisioningState.provisioning.label": "Provisioning", + "autodiscover.azure.provisioningState.recovering.description": "Database is recovering from a failure.", + "autodiscover.azure.provisioningState.recovering.label": "Recovering", + "autodiscover.azure.provisioningState.scaling.description": "Database is being scaled.", + "autodiscover.azure.provisioningState.scaling.label": "Scaling", + "autodiscover.azure.provisioningState.succeeded.description": "Database is fully provisioned and ready to use.", + "autodiscover.azure.provisioningState.succeeded.label": "Succeeded", + "autodiscover.azure.provisioningState.unlinking.description": "Database is being unlinked from geo-replication.", + "autodiscover.azure.provisioningState.unlinking.label": "Unlinking", + "autodiscover.azure.provisioningState.updating.description": "Database configuration is being updated.", + "autodiscover.azure.provisioningState.updating.label": "Updating", + "autodiscover.azure.signIn.description": "Sign in with your Microsoft account to discover and add Azure Managed Redis databases.", + "autodiscover.azure.signIn.signInButton": "Sign in with Microsoft", + "autodiscover.azure.signIn.tenantError": "Enter a valid tenant GUID or domain.", + "autodiscover.azure.signIn.tenantHint": "Only needed if your resources and your account are in different tenants.", + "autodiscover.azure.signIn.tenantInfo": "Leave blank to use your account's default (home) tenant. If your Azure Managed Redis resources are in a different tenant than your account, enter the tenant that owns the resources (you need guest access to it) — not your own home tenant.", + "autodiscover.azure.signIn.tenantLabel": "Tenant ID (optional)", + "autodiscover.azure.signIn.tenantPlaceholder": "your-tenant.onmicrosoft.com or GUID", + "autodiscover.azure.signIn.title": "Connect to Azure Managed Redis", + "autodiscover.azure.subscriptionState.deleted.description": "Subscription has been deleted and cannot be recovered.", + "autodiscover.azure.subscriptionState.deleted.label": "Deleted", + "autodiscover.azure.subscriptionState.disabled.description": "Subscription is suspended. Resources are not accessible until the subscription is re-enabled.", + "autodiscover.azure.subscriptionState.disabled.label": "Disabled", + "autodiscover.azure.subscriptionState.enabled.description": "Subscription is active and fully functional.", + "autodiscover.azure.subscriptionState.enabled.label": "Enabled", + "autodiscover.azure.subscriptionState.pastDue.description": "Payment is overdue. Services may be limited.", + "autodiscover.azure.subscriptionState.pastDue.label": "PastDue", + "autodiscover.azure.subscriptionState.warned.description": "Subscription has payment issues but is still operational during a grace period.", + "autodiscover.azure.subscriptionState.warned.label": "Warned", + "autodiscover.azure.subscriptions.empty": "No Azure subscriptions found for this account.", + "autodiscover.azure.subscriptions.refreshAria": "Refresh subscriptions", + "autodiscover.azure.subscriptions.showDatabases": "Show Databases", + "autodiscover.azure.subscriptions.signedInAs": "Signed in as", + "autodiscover.azure.subscriptions.switchAccount": "Switch account or tenant", + "autodiscover.azure.subscriptions.tenant": "Tenant", + "autodiscover.azure.subscriptions.title": "Azure Subscriptions", "browser.array.delete.bulk.aria": "Delete selected elements", "browser.array.delete.bulk.button": "Remove", "browser.array.delete.bulk.message": "{{count}} selected element(s) will be permanently removed from the array.", diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.constants.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.constants.tsx index 051112fc3e..bbffd4f8b1 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.constants.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.constants.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { TFunction } from 'i18next' import { type ColumnDef, Table } from 'uiSrc/components/base/layout/table' import { AzureRedisDatabase } from 'uiSrc/slices/interfaces' @@ -6,13 +7,15 @@ import { Text } from 'uiSrc/components/base/text' import { ColumnHeader } from 'uiSrc/components/column-header' import { DescriptionsTooltip } from 'uiSrc/pages/autodiscover-azure/components' import { - AZURE_DATABASE_TYPE_DESCRIPTIONS, - AZURE_PROVISIONING_STATE_DESCRIPTIONS, + getAzureDatabaseTypeDescriptions, + getAzureProvisioningStateDescriptions, } from 'uiSrc/pages/autodiscover-azure/constants' export const MAX_DATABASES_SELECTION = 10 -export const AZURE_DATABASES_COLUMNS: ColumnDef[] = [ +export const getAzureDatabasesColumns = ( + t: TFunction, +): ColumnDef[] => [ { id: 'row-selection', maxSize: 20, @@ -34,7 +37,7 @@ export const AZURE_DATABASES_COLUMNS: ColumnDef[] = [ }, { id: 'name', - header: 'Database Name', + header: t('autodiscover.azure.column.databaseName'), accessorKey: 'name', enableSorting: true, cell: ({ getValue }) => {getValue() as string}, @@ -46,10 +49,10 @@ export const AZURE_DATABASES_COLUMNS: ColumnDef[] = [ isHeaderCustom: true, header: () => ( } /> @@ -62,7 +65,7 @@ export const AZURE_DATABASES_COLUMNS: ColumnDef[] = [ }, { id: 'location', - header: 'Region', + header: t('autodiscover.azure.column.region'), accessorKey: 'location', enableSorting: true, cell: ({ getValue }) => {getValue() as string}, @@ -74,10 +77,10 @@ export const AZURE_DATABASES_COLUMNS: ColumnDef[] = [ isHeaderCustom: true, header: () => ( } /> diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.tsx index d763da1d70..f8cba932de 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabases/AzureDatabases.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'uiSrc/i18n' import { Spacer } from 'uiSrc/components/base/layout' import { AutodiscoveryPageTemplate } from 'uiSrc/templates' import { @@ -31,7 +32,7 @@ import { } from 'uiSrc/components/base/forms/radio-group/RadioGroup' import { - AZURE_DATABASES_COLUMNS, + getAzureDatabasesColumns, MAX_DATABASES_SELECTION, } from './AzureDatabases.constants' @@ -66,6 +67,8 @@ const AzureDatabases = ({ onRefresh, onManualConnection, }: Props) => { + const { t } = useTranslation() + const columns = useMemo(() => getAzureDatabasesColumns(t), [t]) const [items, setItems] = useState(databases) useEffect(() => { @@ -141,14 +144,14 @@ const AzureDatabases = ({
- Subscription:{' '} + {t('autodiscover.azure.databases.subscription')}{' '} {subscriptionName} @@ -157,11 +160,11 @@ const AzureDatabases = ({ icon={RefreshIcon} onClick={onRefresh} disabled={loading} - aria-label="Refresh databases" + aria-label={t('autodiscover.azure.databases.refreshAria')} data-testid="btn-refresh-databases" /> | - Auth: + {t('autodiscover.azure.databases.auth')} onAuthTypeChange(value as AzureAuthType)} @@ -175,7 +178,7 @@ const AzureDatabases = ({ > - Microsoft Entra ID (Recommended) + {t('autodiscover.azure.databases.authEntraId')} @@ -185,7 +188,9 @@ const AzureDatabases = ({ data-testid="auth-type-access-key" > - Access Key + + {t('autodiscover.azure.databases.authAccessKey')} + @@ -202,7 +207,7 @@ const AzureDatabases = ({ onRowClick={handleRowClick} getRowId={(row) => row.id} getRowCanSelect={canSelectRow} - columns={AZURE_DATABASES_COLUMNS} + columns={columns} data={items} defaultSorting={[{ id: 'name', desc: false }]} paginationEnabled={items.length > 10} @@ -214,9 +219,7 @@ const AzureDatabases = ({ ) : ( ) } @@ -230,19 +233,22 @@ const AzureDatabases = ({ {isMaxSelected ? ( - Maximum of {MAX_DATABASES_SELECTION} databases can be added at a - time. + {t('autodiscover.azure.databases.maxSelection', { + max: MAX_DATABASES_SELECTION, + })} ) : (
)} - Cancel + + {t('autodiscover.azure.button.cancel')} + - Manual Connection + {t('autodiscover.azure.button.manualConnection')} - Add{' '} {selectedDatabases.length > 0 - ? `(${selectedDatabases.length})` - : ''}{' '} - Database - {selectedDatabases.length !== 1 ? 's' : ''} + ? t('autodiscover.azure.databases.addButton', { + count: selectedDatabases.length, + }) + : t('autodiscover.azure.databases.addButtonEmpty')} diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx index d061c6c7c3..9763fbe065 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-databases/AzureDatabasesPage.tsx @@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { Pages } from 'uiSrc/constants' import { setTitle } from 'uiSrc/utils' +import i18n, { useTranslation } from 'uiSrc/i18n' import { Text } from 'uiSrc/components/base/text' import { fetchInstancesAction } from 'uiSrc/slices/instances/instances' import { addMessageNotification } from 'uiSrc/slices/app/notifications' @@ -37,8 +38,10 @@ const groupErrorsByMessage = ( ): Record => failedResults.reduce>((acc, r) => { const db = selectedDatabases.find((db) => db.id === r.id) - const dbName = db?.name || 'database' - const errorMessage = r.message || 'Failed to add database' + const dbName = + db?.name || i18n.t('autodiscover.azure.databases.unknownDatabase') + const errorMessage = + r.message || i18n.t('autodiscover.azure.databases.addFailedDefault') if (!acc[errorMessage]) { acc[errorMessage] = [] @@ -71,7 +74,9 @@ const showErrorToast = ( errorMessages.DEFAULT( <>{errorList}, () => {}, - `Failed to add ${failedResults.length} database${failedResults.length > 1 ? 's' : ''}`, + i18n.t('autodiscover.azure.databases.addFailedTitle', { + count: failedResults.length, + }), ), { variant: riToast.Variant.Danger, @@ -81,6 +86,7 @@ const showErrorToast = ( } const AzureDatabasesPage = () => { + const { t } = useTranslation() const history = useHistory() const dispatch = useAppDispatch() const account = useAppSelector(azureAuthAccountSelector) @@ -109,7 +115,7 @@ const AzureDatabasesPage = () => { return } - setTitle('Azure Databases') + setTitle(t('autodiscover.azure.databases.pageTitle')) // Only fetch if not already loaded if (!loaded.databases) { @@ -146,8 +152,11 @@ const AzureDatabasesPage = () => { addMessageNotification( successMessages.ADDED_NEW_INSTANCE( successResults.length > 1 - ? `${successResults.length} databases` - : successDb?.name || 'Database', + ? t('autodiscover.azure.databases.addedMultiple', { + count: successResults.length, + }) + : successDb?.name || + t('autodiscover.azure.databases.defaultDatabaseName'), ), ), ) diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-manual-connection/AzureManualConnectionForm.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-manual-connection/AzureManualConnectionForm.tsx index 055da2557e..fd7e2e5ab2 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-manual-connection/AzureManualConnectionForm.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-manual-connection/AzureManualConnectionForm.tsx @@ -14,6 +14,7 @@ import { validateField, } from 'uiSrc/utils' import { Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' export interface AzureManualConnectionFormValues { host: string @@ -32,6 +33,7 @@ export interface Props { const AzureManualConnectionForm = (props: Props) => { const { formik } = props + const { t } = useTranslation() return ( @@ -39,12 +41,15 @@ const AzureManualConnectionForm = (props: Props) => { {/* Database alias */} - + { {/* Host and Port */} - + { formik.setFieldValue('host', validateField(value.trim())) @@ -74,13 +82,16 @@ const AzureManualConnectionForm = (props: Props) => { - + formik.setFieldValue('port', value)} value={Number(formik.values.port)} min={0} @@ -96,17 +107,17 @@ const AzureManualConnectionForm = (props: Props) => { - Authentication will use your Azure Entra ID credentials + {t('autodiscover.azure.manual.entraCredentialsInfo')} - + { {/* Timeout */} - + formik.setFieldValue('timeout', value)} value={Number(formik.values.timeout)} min={1} @@ -145,14 +156,14 @@ const AzureManualConnectionForm = (props: Props) => { - TLS Settings + {t('autodiscover.azure.manual.tlsSettings')} - TLS is always enabled for Azure Cache for Redis connections. + {t('autodiscover.azure.manual.tlsAlwaysEnabled')} @@ -164,7 +175,7 @@ const AzureManualConnectionForm = (props: Props) => { id="verifyServerCert" name="verifyServerCert" labelSize="M" - label="Verify server certificate" + label={t('autodiscover.azure.manual.verifyServerCert')} checked={!!formik.values.verifyServerCert} onChange={formik.handleChange} data-testid="verify-server-cert" @@ -174,8 +185,7 @@ const AzureManualConnectionForm = (props: Props) => { - Recommended for production. Validates that the server - certificate matches the hostname. + {t('autodiscover.azure.manual.verifyServerCertInfo')} @@ -187,7 +197,7 @@ const AzureManualConnectionForm = (props: Props) => { id="sni" name="sni" labelSize="M" - label="Use SNI" + label={t('autodiscover.azure.manual.useSni')} checked={!!formik.values.sni} onChange={(e: ChangeEvent) => { // Pre-fill servername with host value when enabling SNI @@ -203,15 +213,17 @@ const AzureManualConnectionForm = (props: Props) => { - Enable SNI when connecting via Private Link using an IP address. - Enter the original Redis hostname as the Server Name. + {t('autodiscover.azure.manual.sniInfo')} {formik.values.sni && ( - + = {} if (!values.host) { - errs.host = 'Host is required' + errs.host = i18n.t('autodiscover.azure.manual.hostRequired') } if (!values.port) { - errs.port = 'Port is required' + errs.port = i18n.t('autodiscover.azure.manual.portRequired') } if (!values.name) { - errs.name = 'Database alias is required' + errs.name = i18n.t('autodiscover.azure.manual.aliasRequired') } if (values.sni && !values.servername) { - errs.servername = 'Server Name is required when SNI is enabled' + errs.servername = i18n.t('autodiscover.azure.manual.serverNameRequired') } return errs } const AzureManualConnectionPage = () => { + const { t } = useTranslation() const history = useHistory() const dispatch = useAppDispatch() const account = useAppSelector(azureAuthAccountSelector) @@ -82,7 +84,7 @@ const AzureManualConnectionPage = () => { // Send telemetry only once on initial page load (skip if not authenticated) useEffect(() => { if (!account) return - setTitle('Azure Manual Connection') + setTitle(i18n.t('autodiscover.azure.manual.pageTitle')) sendEventTelemetry({ event: TelemetryEvent.AZURE_MANUAL_CONNECTION_OPENED, }) @@ -175,9 +177,9 @@ const AzureManualConnectionPage = () => {
@@ -190,7 +192,7 @@ const AzureManualConnectionPage = () => { - Cancel + {t('autodiscover.azure.button.cancel')} { loading={loading} onClick={() => formik.handleSubmit()} > - Add Database + {t('autodiscover.azure.button.addDatabase')} diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.constants.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.constants.tsx index c95024744f..9296190663 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.constants.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.constants.tsx @@ -1,19 +1,22 @@ import React from 'react' +import { TFunction } from 'i18next' import { type ColumnDef, Table } from 'uiSrc/components/base/layout/table' import { AzureSubscription } from 'uiSrc/slices/interfaces' import { Text } from 'uiSrc/components/base/text' import { ColumnHeader } from 'uiSrc/components/column-header' import { DescriptionsTooltip } from 'uiSrc/pages/autodiscover-azure/components' -import { AZURE_SUBSCRIPTION_STATE_DESCRIPTIONS } from 'uiSrc/pages/autodiscover-azure/constants' +import { getAzureSubscriptionStateDescriptions } from 'uiSrc/pages/autodiscover-azure/constants' -export const AZURE_SUBSCRIPTIONS_COLUMNS: ColumnDef[] = [ +export const getAzureSubscriptionsColumns = ( + t: TFunction, +): ColumnDef[] => [ { id: 'row-selection', maxSize: 15, size: 15, isHeaderCustom: true, - header: '#', + header: t('autodiscover.azure.column.number'), cell: ({ row }) => ( [] = [ }, { id: 'displayName', - header: 'Subscription Name', + header: t('autodiscover.azure.column.subscriptionName'), accessorKey: 'displayName', enableSorting: true, cell: ({ getValue }) => {getValue() as string}, }, { id: 'subscriptionId', - header: 'Subscription ID', + header: t('autodiscover.azure.column.subscriptionId'), accessorKey: 'subscriptionId', enableSorting: true, cell: ({ getValue }) => {getValue() as string}, @@ -42,10 +45,10 @@ export const AZURE_SUBSCRIPTIONS_COLUMNS: ColumnDef[] = [ isHeaderCustom: true, header: () => ( } /> diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx index f481201eb5..54e1101c95 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptions/AzureSubscriptions.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useAppSelector } from 'uiSrc/slices/hooks' +import { useTranslation } from 'uiSrc/i18n' import { Spacer } from 'uiSrc/components/base/layout' import { AutodiscoveryPageTemplate } from 'uiSrc/templates' import { @@ -30,7 +31,7 @@ import { import { Loader } from 'uiSrc/components/base/display' import { RefreshIcon } from 'uiSrc/components/base/icons' -import { AZURE_SUBSCRIPTIONS_COLUMNS } from './AzureSubscriptions.constants' +import { getAzureSubscriptionsColumns } from './AzureSubscriptions.constants' export interface Props { subscriptions: AzureSubscription[] @@ -55,6 +56,8 @@ const AzureSubscriptions = ({ onRefresh, onManualConnection, }: Props) => { + const { t } = useTranslation() + const columns = useMemo(() => getAzureSubscriptionsColumns(t), [t]) const account = useAppSelector(azureAuthAccountSelector) const tenant = useAppSelector(azureAuthTenantSelector) const [items, setItems] = useState(subscriptions) @@ -117,21 +120,21 @@ const AzureSubscriptions = ({
- Signed in as{' '} + {t('autodiscover.azure.subscriptions.signedInAs')}{' '} {account.username} {tenant && ( - Tenant{' '} + {t('autodiscover.azure.subscriptions.tenant')}{' '} {tenant} @@ -142,13 +145,13 @@ const AzureSubscriptions = ({ onClick={onSwitchAccount} data-testid="btn-switch-account" > - Switch account or tenant + {t('autodiscover.azure.subscriptions.switchAccount')} @@ -163,7 +166,7 @@ const AzureSubscriptions = ({ onRowSelectionChange={handleSelectionChange} onRowClick={handleRowClick} getRowId={(row) => row.subscriptionId} - columns={AZURE_SUBSCRIPTIONS_COLUMNS} + columns={columns} data={items} defaultSorting={[{ id: 'displayName', desc: false }]} paginationEnabled={items.length > 10} @@ -175,9 +178,7 @@ const AzureSubscriptions = ({ ) : ( ) } @@ -190,19 +191,21 @@ const AzureSubscriptions = ({
- Cancel + + {t('autodiscover.azure.button.cancel')} + - Manual Connection + {t('autodiscover.azure.button.manualConnection')} - Show Databases + {t('autodiscover.azure.subscriptions.showDatabases')} diff --git a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx index b10c749266..a3d43dc4ba 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/azure-subscriptions/AzureSubscriptionsPage.tsx @@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from 'uiSrc/slices/hooks' import { Pages } from 'uiSrc/constants' import { setTitle } from 'uiSrc/utils' +import { useTranslation } from 'uiSrc/i18n' import { useAzureAuth } from 'uiSrc/components/hooks/useAzureAuth' import { AzureSignInDialog } from 'uiSrc/components/azure-sign-in-dialog' import { azureAuthTenantSelector } from 'uiSrc/slices/oauth/azure' @@ -18,6 +19,7 @@ import { import AzureSubscriptions from './AzureSubscriptions/AzureSubscriptions' const AzureSubscriptionsPage = () => { + const { t } = useTranslation() const history = useHistory() const dispatch = useAppDispatch() const { initiateLogin, loading: azureLoading, account } = useAzureAuth() @@ -33,7 +35,7 @@ const AzureSubscriptionsPage = () => { return } - setTitle('Azure Subscriptions') + setTitle(t('autodiscover.azure.subscriptions.title')) if (!loaded.subscriptions) { dispatch(fetchSubscriptionsAzure(account.id, tenant ?? undefined)) diff --git a/redisinsight/ui/src/pages/autodiscover-azure/components/DescriptionsTooltip.tsx b/redisinsight/ui/src/pages/autodiscover-azure/components/DescriptionsTooltip.tsx index 68a3e40a45..8154fef64b 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/components/DescriptionsTooltip.tsx +++ b/redisinsight/ui/src/pages/autodiscover-azure/components/DescriptionsTooltip.tsx @@ -1,19 +1,20 @@ import React from 'react' import { Col } from 'uiSrc/components/base/layout/flex' import { Text } from 'uiSrc/components/base/text' +import { StateDescription } from 'uiSrc/pages/autodiscover-azure/constants' export interface DescriptionsTooltipProps { - descriptions: Record + descriptions: StateDescription[] } export const DescriptionsTooltip = ({ descriptions, }: DescriptionsTooltipProps) => (
- {Object.entries(descriptions).map(([key, description]) => ( - + {descriptions.map(({ label, description }) => ( + - {key}: + {label}: {' '} {description} diff --git a/redisinsight/ui/src/pages/autodiscover-azure/constants.ts b/redisinsight/ui/src/pages/autodiscover-azure/constants.ts index d314240625..60d3a2cd58 100644 --- a/redisinsight/ui/src/pages/autodiscover-azure/constants.ts +++ b/redisinsight/ui/src/pages/autodiscover-azure/constants.ts @@ -1,44 +1,127 @@ +import { TFunction } from 'i18next' + +export interface StateDescription { + label: string + description: string +} + /** * Azure subscription state descriptions. * @see https://learn.microsoft.com/en-us/rest/api/resources/subscriptions/list#subscriptionstate */ -export const AZURE_SUBSCRIPTION_STATE_DESCRIPTIONS: Record = { - Enabled: 'Subscription is active and fully functional.', - Warned: - 'Subscription has payment issues but is still operational during a grace period.', - PastDue: 'Payment is overdue. Services may be limited.', - Disabled: - 'Subscription is suspended. Resources are not accessible until the subscription is re-enabled.', - Deleted: 'Subscription has been deleted and cannot be recovered.', -} +export const getAzureSubscriptionStateDescriptions = ( + t: TFunction, +): StateDescription[] => [ + { + label: t('autodiscover.azure.subscriptionState.enabled.label'), + description: t('autodiscover.azure.subscriptionState.enabled.description'), + }, + { + label: t('autodiscover.azure.subscriptionState.warned.label'), + description: t('autodiscover.azure.subscriptionState.warned.description'), + }, + { + label: t('autodiscover.azure.subscriptionState.pastDue.label'), + description: t('autodiscover.azure.subscriptionState.pastDue.description'), + }, + { + label: t('autodiscover.azure.subscriptionState.disabled.label'), + description: t('autodiscover.azure.subscriptionState.disabled.description'), + }, + { + label: t('autodiscover.azure.subscriptionState.deleted.label'), + description: t('autodiscover.azure.subscriptionState.deleted.description'), + }, +] /** * Azure database type descriptions. * @see https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-overview */ -export const AZURE_DATABASE_TYPE_DESCRIPTIONS: Record = { - Standard: - 'Azure Cache for Redis with Basic, Standard, or Premium tiers. Suitable for most caching scenarios.', - Enterprise: - 'Azure Cache for Redis Enterprise with dedicated infrastructure, higher performance, and Redis modules support.', -} +export const getAzureDatabaseTypeDescriptions = ( + t: TFunction, +): StateDescription[] => [ + { + label: t('autodiscover.azure.databaseType.standard.label'), + description: t('autodiscover.azure.databaseType.standard.description'), + }, + { + label: t('autodiscover.azure.databaseType.enterprise.label'), + description: t('autodiscover.azure.databaseType.enterprise.description'), + }, +] /** * Azure database provisioning state descriptions. * @see https://learn.microsoft.com/en-us/rest/api/redis/redis/get#provisioningstate */ -export const AZURE_PROVISIONING_STATE_DESCRIPTIONS: Record = { - Succeeded: 'Database is fully provisioned and ready to use.', - Creating: 'Database is being created and is not yet available.', - Updating: 'Database configuration is being updated.', - Deleting: 'Database is being deleted.', - Failed: 'Provisioning failed. The database is not usable.', - Linking: 'Database is being linked for geo-replication.', - Unlinking: 'Database is being unlinked from geo-replication.', - Recovering: 'Database is recovering from a failure.', - Provisioning: 'Database is being provisioned.', - Scaling: 'Database is being scaled.', - ConfiguringAAD: 'Entra ID (Azure AD) authentication is being configured.', - Importing: 'Data is being imported into the database.', - Exporting: 'Data is being exported from the database.', -} +export const getAzureProvisioningStateDescriptions = ( + t: TFunction, +): StateDescription[] => [ + { + label: t('autodiscover.azure.provisioningState.succeeded.label'), + description: t( + 'autodiscover.azure.provisioningState.succeeded.description', + ), + }, + { + label: t('autodiscover.azure.provisioningState.creating.label'), + description: t('autodiscover.azure.provisioningState.creating.description'), + }, + { + label: t('autodiscover.azure.provisioningState.updating.label'), + description: t('autodiscover.azure.provisioningState.updating.description'), + }, + { + label: t('autodiscover.azure.provisioningState.deleting.label'), + description: t('autodiscover.azure.provisioningState.deleting.description'), + }, + { + label: t('autodiscover.azure.provisioningState.failed.label'), + description: t('autodiscover.azure.provisioningState.failed.description'), + }, + { + label: t('autodiscover.azure.provisioningState.linking.label'), + description: t('autodiscover.azure.provisioningState.linking.description'), + }, + { + label: t('autodiscover.azure.provisioningState.unlinking.label'), + description: t( + 'autodiscover.azure.provisioningState.unlinking.description', + ), + }, + { + label: t('autodiscover.azure.provisioningState.recovering.label'), + description: t( + 'autodiscover.azure.provisioningState.recovering.description', + ), + }, + { + label: t('autodiscover.azure.provisioningState.provisioning.label'), + description: t( + 'autodiscover.azure.provisioningState.provisioning.description', + ), + }, + { + label: t('autodiscover.azure.provisioningState.scaling.label'), + description: t('autodiscover.azure.provisioningState.scaling.description'), + }, + { + label: t('autodiscover.azure.provisioningState.configuringAad.label'), + description: t( + 'autodiscover.azure.provisioningState.configuringAad.description', + ), + }, + { + label: t('autodiscover.azure.provisioningState.importing.label'), + description: t( + 'autodiscover.azure.provisioningState.importing.description', + ), + }, + { + label: t('autodiscover.azure.provisioningState.exporting.label'), + description: t( + 'autodiscover.azure.provisioningState.exporting.description', + ), + }, +] From c5ec0a2ce53883b3c4d3085ed445e524b4a05d70 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Tue, 21 Jul 2026 09:34:20 +0300 Subject: [PATCH 057/166] feat(i18n): migrate autodiscover-cloud to i18n (RI-8273) (#6241) Route the Redis Cloud autodiscovery flow (subscriptions, databases, and result screens) through i18next. - Deleted the AutoDiscoverCloudTitles enum; column factories now resolve headers via the i18n singleton (i18n.t('autodiscover.cloud.column.*')), matching the redis-cluster convention. Cells reuse the column keys where a tooltip mirrors a header. - Components use useTranslation; the loading/notFound/noResults message consts moved into render so they resolve at render time. The database-list subtitle and the Summary success/fail lines use count-plurals; setTitle in the config hooks uses the i18n singleton. - Shared validationErrors.* constants are left as-is (cross-feature). - Added autodiscover.cloud.* keys to en.json and bg.json (Bulgarian filled). --- redisinsight/ui/src/i18n/locales/bg.json | 48 +++++++++++++ redisinsight/ui/src/i18n/locales/en.json | 48 +++++++++++++ .../column-definitions/columns/database.tsx | 8 +-- .../columns/databaseResult.tsx | 8 +-- .../column-definitions/columns/endpoint.tsx | 8 +-- .../columns/endpointResult.tsx | 8 +-- .../column-definitions/columns/id.tsx | 8 +-- .../columns/messageResult.tsx | 8 +-- .../column-definitions/columns/modules.tsx | 8 +-- .../columns/modulesResult.tsx | 8 +-- .../columns/numberOfDbs.tsx | 8 +-- .../column-definitions/columns/options.tsx | 8 +-- .../columns/optionsResult.tsx | 8 +-- .../column-definitions/columns/provider.tsx | 8 +-- .../column-definitions/columns/region.tsx | 8 +-- .../column-definitions/columns/status.tsx | 8 +-- .../column-definitions/columns/statusDb.tsx | 8 +-- .../columns/statusDbResult.tsx | 8 +-- .../columns/subscription.tsx | 8 +-- .../columns/subscriptionDb.tsx | 8 +-- .../columns/subscriptionDbResult.tsx | 8 +-- .../columns/subscriptionId.tsx | 8 +-- .../columns/subscriptionIdResult.tsx | 8 +-- .../columns/subscriptionType.tsx | 8 +-- .../columns/subscriptionTypeResult.tsx | 8 +-- .../column-definitions/columns/type.tsx | 8 +-- .../components/AlertCell/AlertCell.tsx | 6 +- .../components/DatabaseCell/DatabaseCell.tsx | 4 +- .../components/EndpointCell/EndpointCell.tsx | 7 +- .../MessageResultCell/MessageResultCell.tsx | 7 +- .../SubscriptionCell/SubscriptionCell.tsx | 4 +- .../components/AlertStatusContent.tsx | 45 ++++++------ .../autodiscover-cloud/constants/constants.ts | 16 ----- .../RedisCloudDatabasesResult.tsx | 12 ++-- .../components/SummaryText/SummaryText.tsx | 42 +++++++---- .../hooks/useCloudDatabasesResultConfig.ts | 3 +- .../RedisCloudDatabases.tsx | 32 ++++----- .../hooks/useCloudDatabasesConfig.ts | 3 +- .../RedisCloudSubscriptions.tsx | 13 ++-- .../components/Account/Account.tsx | 69 +++++++++++-------- .../components/CancelButton/CancelButton.tsx | 68 +++++++++--------- .../components/SubmitButton/SubmitButton.tsx | 49 +++++++------ .../components/SummaryText/SummaryText.tsx | 48 +++++++------ .../hooks/useCloudSubscriptionConfig.ts | 3 +- 44 files changed, 407 insertions(+), 312 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index b92e8d1100..135b1d9033 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -408,6 +408,54 @@ "autodiscover.azure.subscriptions.switchAccount": "Смяна на акаунт или тенант", "autodiscover.azure.subscriptions.tenant": "Тенант", "autodiscover.azure.subscriptions.title": "Azure абонаменти", + "autodiscover.cloud.account.accountId": "ID на акаунт:", + "autodiscover.cloud.account.name": "Име:", + "autodiscover.cloud.account.ownerEmail": "Имейл на собственик:", + "autodiscover.cloud.account.ownerName": "Име на собственик:", + "autodiscover.cloud.alert.aria": "предупреждение за абонамент", + "autodiscover.cloud.alert.errorFetching": "Грешка при извличане на детайли за абонамента", + "autodiscover.cloud.alert.noDatabases": "Абонаментът няма никакви бази данни", + "autodiscover.cloud.alert.statusNotActive": "Статусът на абонамента не е Активен", + "autodiscover.cloud.alert.title": "Този абонамент не е наличен по една от следните причини:", + "autodiscover.cloud.cancel.button": "Отказ", + "autodiscover.cloud.cancel.confirm": "Промените ви не са запазени. Искате ли да продължите към списъка с бази данни?", + "autodiscover.cloud.cancel.proceed": "Продължи", + "autodiscover.cloud.cell.copyEndpointAria": "Копиране на публичната крайна точка", + "autodiscover.cloud.cell.error": "Грешка", + "autodiscover.cloud.column.capabilities": "Възможности", + "autodiscover.cloud.column.database": "База данни", + "autodiscover.cloud.column.endpoint": "Крайна точка", + "autodiscover.cloud.column.id": "ID", + "autodiscover.cloud.column.numberOfDatabases": "# бази данни", + "autodiscover.cloud.column.options": "Опции", + "autodiscover.cloud.column.provider": "Облачен доставчик", + "autodiscover.cloud.column.region": "Регион", + "autodiscover.cloud.column.result": "Резултат", + "autodiscover.cloud.column.status": "Статус", + "autodiscover.cloud.column.subscription": "Абонамент", + "autodiscover.cloud.column.subscriptionId": "ID на абонамент", + "autodiscover.cloud.column.type": "Тип", + "autodiscover.cloud.databases.addSelected": "Добавяне на избраните бази данни", + "autodiscover.cloud.databases.noResults": "Вашият Redis Enterprise Cloud няма налични бази данни", + "autodiscover.cloud.databases.subtitle_one": "Това е база данни във вашия Redis Cloud. Изберете базата данни, която искате да добавите.", + "autodiscover.cloud.databases.subtitle_other": "Това са бази данни във вашия Redis Cloud. Изберете базите данни, които искате да добавите.", + "autodiscover.cloud.databases.title": "Redis Cloud бази данни", + "autodiscover.cloud.loading": "зареждане...", + "autodiscover.cloud.notFound": "Не е намерено", + "autodiscover.cloud.result.title": "Добавени Redis Enterprise бази данни", + "autodiscover.cloud.result.viewDatabases": "Преглед на бази данни", + "autodiscover.cloud.subscriptions.noResults": "Вашият Redis Cloud няма налични абонаменти.", + "autodiscover.cloud.subscriptions.showDatabases": "Показване на бази данни", + "autodiscover.cloud.subscriptions.title": "Redis Cloud абонаменти", + "autodiscover.cloud.summary.databasesFail_one": "Неуспешно добавяне на {{count}} база данни", + "autodiscover.cloud.summary.databasesFail_other": "Неуспешно добавяне на {{count}} бази данни", + "autodiscover.cloud.summary.databasesSuccess_one": "Успешно добавена {{count}} база данни", + "autodiscover.cloud.summary.databasesSuccess_other": "Успешно добавени {{count}} бази данни", + "autodiscover.cloud.summary.prefix": "Резюме: ", + "autodiscover.cloud.summary.subscriptionsFail_one": "Неуспешно откриване на бази данни в {{count}} абонамент", + "autodiscover.cloud.summary.subscriptionsFail_other": "Неуспешно откриване на бази данни в {{count}} абонамента", + "autodiscover.cloud.summary.subscriptionsSuccess_one": "Успешно открити бази данни в {{count}} абонамент", + "autodiscover.cloud.summary.subscriptionsSuccess_other": "Успешно открити бази данни в {{count}} абонамента", "browser.array.delete.bulk.aria": "Изтриване на избраните елементи", "browser.array.delete.bulk.button": "Премахни", "browser.array.delete.bulk.message": "Избраните елементи ({{count}}) ще бъдат премахнати за постоянно от масива.", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index d6a9bae7f0..4ff37e9bc1 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -408,6 +408,54 @@ "autodiscover.azure.subscriptions.switchAccount": "Switch account or tenant", "autodiscover.azure.subscriptions.tenant": "Tenant", "autodiscover.azure.subscriptions.title": "Azure Subscriptions", + "autodiscover.cloud.account.accountId": "Account ID:", + "autodiscover.cloud.account.name": "Name:", + "autodiscover.cloud.account.ownerEmail": "Owner Email:", + "autodiscover.cloud.account.ownerName": "Owner Name:", + "autodiscover.cloud.alert.aria": "subscription alert", + "autodiscover.cloud.alert.errorFetching": "Error fetching subscription details", + "autodiscover.cloud.alert.noDatabases": "Subscription does not have any databases", + "autodiscover.cloud.alert.statusNotActive": "Subscription status is not Active", + "autodiscover.cloud.alert.title": "This subscription is not available for one of the following reasons:", + "autodiscover.cloud.cancel.button": "Cancel", + "autodiscover.cloud.cancel.confirm": "Your changes have not been saved. Do you want to proceed to the list of databases?", + "autodiscover.cloud.cancel.proceed": "Proceed", + "autodiscover.cloud.cell.copyEndpointAria": "Copy public endpoint", + "autodiscover.cloud.cell.error": "Error", + "autodiscover.cloud.column.capabilities": "Capabilities", + "autodiscover.cloud.column.database": "Database", + "autodiscover.cloud.column.endpoint": "Endpoint", + "autodiscover.cloud.column.id": "Id", + "autodiscover.cloud.column.numberOfDatabases": "# databases", + "autodiscover.cloud.column.options": "Options", + "autodiscover.cloud.column.provider": "Cloud provider", + "autodiscover.cloud.column.region": "Region", + "autodiscover.cloud.column.result": "Result", + "autodiscover.cloud.column.status": "Status", + "autodiscover.cloud.column.subscription": "Subscription", + "autodiscover.cloud.column.subscriptionId": "Subscription id", + "autodiscover.cloud.column.type": "Type", + "autodiscover.cloud.databases.addSelected": "Add selected Databases", + "autodiscover.cloud.databases.noResults": "Your Redis Enterprise Cloud has no databases available", + "autodiscover.cloud.databases.subtitle_one": "This is a database in your Redis Cloud. Select the database that you want to add.", + "autodiscover.cloud.databases.subtitle_other": "These are databases in your Redis Cloud. Select the databases that you want to add.", + "autodiscover.cloud.databases.title": "Redis Cloud Databases", + "autodiscover.cloud.loading": "loading...", + "autodiscover.cloud.notFound": "Not found", + "autodiscover.cloud.result.title": "Redis Enterprise Databases Added", + "autodiscover.cloud.result.viewDatabases": "View Databases", + "autodiscover.cloud.subscriptions.noResults": "Your Redis Cloud has no subscriptions available.", + "autodiscover.cloud.subscriptions.showDatabases": "Show databases", + "autodiscover.cloud.subscriptions.title": "Redis Cloud Subscriptions", + "autodiscover.cloud.summary.databasesFail_one": "Failed to add {{count}} database", + "autodiscover.cloud.summary.databasesFail_other": "Failed to add {{count}} databases", + "autodiscover.cloud.summary.databasesSuccess_one": "Successfully added {{count}} database", + "autodiscover.cloud.summary.databasesSuccess_other": "Successfully added {{count}} databases", + "autodiscover.cloud.summary.prefix": "Summary: ", + "autodiscover.cloud.summary.subscriptionsFail_one": "Failed to discover databases in {{count}} subscription", + "autodiscover.cloud.summary.subscriptionsFail_other": "Failed to discover databases in {{count}} subscriptions", + "autodiscover.cloud.summary.subscriptionsSuccess_one": "Successfully discovered databases in {{count}} subscription", + "autodiscover.cloud.summary.subscriptionsSuccess_other": "Successfully discovered databases in {{count}} subscriptions", "browser.array.delete.bulk.aria": "Delete selected elements", "browser.array.delete.bulk.button": "Remove", "browser.array.delete.bulk.message": "{{count}} selected element(s) will be permanently removed from the array.", diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/database.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/database.tsx index 72ef9c1b4e..13c2ed402e 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/database.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/database.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { DatabaseCell } from '../components/DatabaseCell/DatabaseCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const databaseColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Database, + header: i18n.t('autodiscover.cloud.column.database'), id: AutoDiscoverCloudIds.Name, accessorKey: AutoDiscoverCloudIds.Name, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/databaseResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/databaseResult.tsx index 43abf52e74..756058951d 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/databaseResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/databaseResult.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { DatabaseCell } from '../components/DatabaseCell/DatabaseCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const databaseResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Database, + header: i18n.t('autodiscover.cloud.column.database'), id: AutoDiscoverCloudIds.Name, accessorKey: AutoDiscoverCloudIds.Name, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpoint.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpoint.tsx index 29ba60271c..0989efc287 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpoint.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpoint.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { EndpointCell } from '../components/EndpointCell/EndpointCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const endpointColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Endpoint, + header: i18n.t('autodiscover.cloud.column.endpoint'), id: AutoDiscoverCloudIds.PublicEndpoint, accessorKey: AutoDiscoverCloudIds.PublicEndpoint, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpointResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpointResult.tsx index e58dd9f9de..bf338dc146 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpointResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/endpointResult.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { EndpointCell } from '../components/EndpointCell/EndpointCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const endpointResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Endpoint, + header: i18n.t('autodiscover.cloud.column.endpoint'), id: AutoDiscoverCloudIds.PublicEndpoint, accessorKey: AutoDiscoverCloudIds.PublicEndpoint, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/id.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/id.tsx index 7fe8ba7624..40065beada 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/id.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/id.tsx @@ -1,19 +1,17 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type RedisCloudSubscription } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const idColumn = (): ColumnDef => { return { id: AutoDiscoverCloudIds.Id, accessorKey: AutoDiscoverCloudIds.Id, - header: AutoDiscoverCloudTitles.Id, + header: i18n.t('autodiscover.cloud.column.id'), enableSorting: true, size: 80, cell: ({ diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/messageResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/messageResult.tsx index fa4aed5c01..f96b11ae05 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/messageResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/messageResult.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { MessageResultCell } from '../components/MessageResultCell/MessageResultCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const messageResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Result, + header: i18n.t('autodiscover.cloud.column.result'), id: AutoDiscoverCloudIds.MessageAdded, accessorKey: AutoDiscoverCloudIds.MessageAdded, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modules.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modules.tsx index e92740a981..dc1609e339 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modules.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modules.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { DatabaseListModules } from 'uiSrc/components' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const modulesColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Capabilities, + header: i18n.t('autodiscover.cloud.column.capabilities'), id: AutoDiscoverCloudIds.Modules, accessorKey: AutoDiscoverCloudIds.Modules, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modulesResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modulesResult.tsx index 2202142021..9c65ee06a1 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modulesResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/modulesResult.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { DatabaseListModules } from 'uiSrc/components' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const modulesResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Capabilities, + header: i18n.t('autodiscover.cloud.column.capabilities'), id: AutoDiscoverCloudIds.Modules, accessorKey: AutoDiscoverCloudIds.Modules, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/numberOfDbs.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/numberOfDbs.tsx index b99e47448b..de96685290 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/numberOfDbs.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/numberOfDbs.tsx @@ -1,20 +1,18 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type RedisCloudSubscription } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' import { isNumber } from 'lodash' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const numberOfDbsColumn = (): ColumnDef => { return { id: AutoDiscoverCloudIds.NumberOfDatabases, accessorKey: AutoDiscoverCloudIds.NumberOfDatabases, - header: AutoDiscoverCloudTitles.NumberOfDatabases, + header: i18n.t('autodiscover.cloud.column.numberOfDatabases'), enableSorting: true, cell: ({ row: { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/options.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/options.tsx index c3d2a59472..7556902f93 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/options.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/options.tsx @@ -1,20 +1,18 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { DatabaseListOptions } from 'uiSrc/components' import { parseInstanceOptionsCloud } from 'uiSrc/utils' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const optionsColumn = ( instances: InstanceRedisCloud[], ): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Options, + header: i18n.t('autodiscover.cloud.column.options'), id: AutoDiscoverCloudIds.Options, accessorKey: AutoDiscoverCloudIds.Options, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/optionsResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/optionsResult.tsx index 62117a764d..c7fab07024 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/optionsResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/optionsResult.tsx @@ -1,18 +1,16 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { DatabaseListOptions } from 'uiSrc/components' import { parseInstanceOptionsCloud } from 'uiSrc/utils' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const optionsResultColumn = ( instancesForOptions: InstanceRedisCloud[], ): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Options, + header: i18n.t('autodiscover.cloud.column.options'), id: AutoDiscoverCloudIds.Options, accessorKey: AutoDiscoverCloudIds.Options, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/provider.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/provider.tsx index 182b781d74..387509cc91 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/provider.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/provider.tsx @@ -1,19 +1,17 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type RedisCloudSubscription } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const providerColumn = (): ColumnDef => { return { id: AutoDiscoverCloudIds.Provider, accessorKey: AutoDiscoverCloudIds.Provider, - header: AutoDiscoverCloudTitles.Provider, + header: i18n.t('autodiscover.cloud.column.provider'), enableSorting: true, cell: ({ row: { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/region.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/region.tsx index a2baae3d9d..5bb8943ad1 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/region.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/region.tsx @@ -1,19 +1,17 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type RedisCloudSubscription } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const regionColumn = (): ColumnDef => { return { id: AutoDiscoverCloudIds.Region, accessorKey: AutoDiscoverCloudIds.Region, - header: AutoDiscoverCloudTitles.Region, + header: i18n.t('autodiscover.cloud.column.region'), enableSorting: true, cell: ({ row: { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/status.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/status.tsx index 693c55fc7e..a8ea54811e 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/status.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/status.tsx @@ -1,4 +1,5 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type RedisCloudSubscription, @@ -6,16 +7,13 @@ import { } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const statusColumn = (): ColumnDef => { return { id: AutoDiscoverCloudIds.Status, accessorKey: AutoDiscoverCloudIds.Status, - header: AutoDiscoverCloudTitles.Status, + header: i18n.t('autodiscover.cloud.column.status'), enableSorting: true, cell: ({ row: { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDb.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDb.tsx index f772b3a188..f12691dc45 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDb.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDb.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { StatusColumnText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const statusDbColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Status, + header: i18n.t('autodiscover.cloud.column.status'), id: AutoDiscoverCloudIds.Status, accessorKey: AutoDiscoverCloudIds.Status, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDbResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDbResult.tsx index cb90945feb..b737819ee7 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDbResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/statusDbResult.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const statusDbResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Status, + header: i18n.t('autodiscover.cloud.column.status'), id: AutoDiscoverCloudIds.Status, accessorKey: AutoDiscoverCloudIds.Status, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscription.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscription.tsx index 67fc02717e..0aa62dfd96 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscription.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscription.tsx @@ -1,19 +1,17 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type RedisCloudSubscription } from 'uiSrc/slices/interfaces' import { SubscriptionCell } from '../components/SubscriptionCell/SubscriptionCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const subscriptionColumn = (): ColumnDef => { return { id: AutoDiscoverCloudIds.Name, accessorKey: AutoDiscoverCloudIds.Name, - header: AutoDiscoverCloudTitles.Subscription, + header: i18n.t('autodiscover.cloud.column.subscription'), enableSorting: true, cell: ({ row: { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDb.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDb.tsx index b56b6ab6e0..898164e8d4 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDb.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDb.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { SubscriptionCell } from '../components/SubscriptionCell/SubscriptionCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const subscriptionDbColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Subscription, + header: i18n.t('autodiscover.cloud.column.subscription'), id: AutoDiscoverCloudIds.SubscriptionName, accessorKey: AutoDiscoverCloudIds.SubscriptionName, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDbResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDbResult.tsx index f47abc69c6..13a0c7836f 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDbResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionDbResult.tsx @@ -1,17 +1,15 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { SubscriptionCell } from '../components/SubscriptionCell/SubscriptionCell' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const subscriptionDbResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Subscription, + header: i18n.t('autodiscover.cloud.column.subscription'), id: AutoDiscoverCloudIds.SubscriptionName, accessorKey: AutoDiscoverCloudIds.SubscriptionName, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionId.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionId.tsx index ebce482a60..91757df441 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionId.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionId.tsx @@ -1,15 +1,13 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const subscriptionIdColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.SubscriptionId, + header: i18n.t('autodiscover.cloud.column.subscriptionId'), id: AutoDiscoverCloudIds.SubscriptionId, accessorKey: AutoDiscoverCloudIds.SubscriptionId, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionIdResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionIdResult.tsx index 7416b5dfa9..97e4d6a22a 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionIdResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionIdResult.tsx @@ -1,13 +1,11 @@ import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { type InstanceRedisCloud } from 'uiSrc/slices/interfaces' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import i18n from 'uiSrc/i18n' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const subscriptionIdResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.SubscriptionId, + header: i18n.t('autodiscover.cloud.column.subscriptionId'), id: AutoDiscoverCloudIds.SubscriptionId, accessorKey: AutoDiscoverCloudIds.SubscriptionId, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionType.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionType.tsx index 2f64ae12a0..cc9b19c848 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionType.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionType.tsx @@ -1,4 +1,5 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { @@ -7,14 +8,11 @@ import { } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const subscriptionTypeColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Type, + header: i18n.t('autodiscover.cloud.column.type'), id: AutoDiscoverCloudIds.SubscriptionType, accessorKey: AutoDiscoverCloudIds.SubscriptionType, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionTypeResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionTypeResult.tsx index 855e34f4f7..5f9de3af34 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionTypeResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/subscriptionTypeResult.tsx @@ -3,15 +3,13 @@ import { type InstanceRedisCloud, RedisCloudSubscriptionTypeText, } from 'uiSrc/slices/interfaces' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import i18n from 'uiSrc/i18n' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const subscriptionTypeResultColumn = (): ColumnDef => { return { - header: AutoDiscoverCloudTitles.Type, + header: i18n.t('autodiscover.cloud.column.type'), id: AutoDiscoverCloudIds.SubscriptionType, accessorKey: AutoDiscoverCloudIds.SubscriptionType, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/type.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/type.tsx index 91ea21f098..2f302987cf 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/type.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/columns/type.tsx @@ -1,4 +1,5 @@ import React from 'react' +import i18n from 'uiSrc/i18n' import { type ColumnDef } from 'uiSrc/components/base/layout/table' import { @@ -7,16 +8,13 @@ import { } from 'uiSrc/slices/interfaces' import { CellText } from 'uiSrc/components/auto-discover' -import { - AutoDiscoverCloudIds, - AutoDiscoverCloudTitles, -} from 'uiSrc/pages/autodiscover-cloud/constants/constants' +import { AutoDiscoverCloudIds } from 'uiSrc/pages/autodiscover-cloud/constants/constants' export const typeColumn = (): ColumnDef => { return { id: AutoDiscoverCloudIds.Type, accessorKey: AutoDiscoverCloudIds.Type, - header: AutoDiscoverCloudTitles.Type, + header: i18n.t('autodiscover.cloud.column.type'), enableSorting: true, cell: ({ row: { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/AlertCell/AlertCell.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/AlertCell/AlertCell.tsx index 0dbc9a6788..e0e92a990e 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/AlertCell/AlertCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/AlertCell/AlertCell.tsx @@ -5,11 +5,13 @@ import { RedisCloudSubscriptionStatus } from 'uiSrc/slices/interfaces' import { RiIcon } from 'uiSrc/components/base/icons' import { CellText } from 'uiSrc/components/auto-discover' import { AlertStatusContent } from 'uiSrc/pages/autodiscover-cloud/components/AlertStatusContent' +import { useTranslation } from 'uiSrc/i18n' import styles from 'uiSrc/pages/autodiscover-cloud/redis-cloud-subscriptions/styles.module.scss' import { AlertCellProps } from './AlertCell.types' export const AlertCell = ({ status, numberOfDatabases }: AlertCellProps) => { + const { t } = useTranslation() const isUnavailable = status !== RedisCloudSubscriptionStatus.Active || numberOfDatabases === 0 @@ -18,7 +20,7 @@ export const AlertCell = ({ status, numberOfDatabases }: AlertCellProps) => { - This subscription is not available for one of the following reasons: + {t('autodiscover.cloud.alert.title')} } content={} @@ -29,7 +31,7 @@ export const AlertCell = ({ status, numberOfDatabases }: AlertCellProps) => { type="ToastDangerIcon" color="danger500" size="m" - aria-label="subscription alert" + aria-label={t('autodiscover.cloud.alert.aria')} /> ) diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/DatabaseCell/DatabaseCell.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/DatabaseCell/DatabaseCell.tsx index 22a97fd60f..606a02dfa4 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/DatabaseCell/DatabaseCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/DatabaseCell/DatabaseCell.tsx @@ -3,11 +3,13 @@ import React from 'react' import { formatLongName, replaceSpaces } from 'uiSrc/utils' import { RiTooltip } from 'uiSrc/components' import { CellText } from 'uiSrc/components/auto-discover' +import { useTranslation } from 'uiSrc/i18n' import styles from 'uiSrc/pages/autodiscover-cloud/redis-cloud-databases/styles.module.scss' import { DatabaseCellProps } from './DatabaseCell.types' export const DatabaseCell = ({ name, className }: DatabaseCellProps) => { + const { t } = useTranslation() const cellContent = replaceSpaces(name.substring(0, 200)) return ( @@ -18,7 +20,7 @@ export const DatabaseCell = ({ name, className }: DatabaseCellProps) => { > { + const { t } = useTranslation() + if (!publicEndpoint) { return - } @@ -20,7 +23,7 @@ export const EndpointCell = ({ publicEndpoint }: EndpointCellProps) => { {publicEndpoint} @@ -28,7 +31,7 @@ export const EndpointCell = ({ publicEndpoint }: EndpointCellProps) => { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/MessageResultCell/MessageResultCell.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/MessageResultCell/MessageResultCell.tsx index 0373c3cc45..70a6c49e11 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/MessageResultCell/MessageResultCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/MessageResultCell/MessageResultCell.tsx @@ -6,6 +6,7 @@ import { RiTooltip } from 'uiSrc/components' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { ColorText } from 'uiSrc/components/base/text' import { RiIcon } from 'uiSrc/components/base/icons/RiIcon' +import { useTranslation } from 'uiSrc/i18n' import { MessageResultCellProps } from './MessageResultCell.types' @@ -13,6 +14,8 @@ export const MessageResultCell = ({ statusAdded, messageAdded = '', }: MessageResultCellProps) => { + const { t } = useTranslation() + if (!statusAdded) { return - } @@ -24,7 +27,7 @@ export const MessageResultCell = ({ return ( @@ -35,7 +38,7 @@ export const MessageResultCell = ({ - Error + {t('autodiscover.cloud.cell.error')} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/SubscriptionCell/SubscriptionCell.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/SubscriptionCell/SubscriptionCell.tsx index 65fb023131..5f20cc4c64 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/SubscriptionCell/SubscriptionCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/column-definitions/components/SubscriptionCell/SubscriptionCell.tsx @@ -3,6 +3,7 @@ import React from 'react' import { formatLongName, replaceSpaces } from 'uiSrc/utils' import { RiTooltip } from 'uiSrc/components' import { CellText } from 'uiSrc/components/auto-discover' +import { useTranslation } from 'uiSrc/i18n' import styles from 'uiSrc/pages/autodiscover-cloud/redis-cloud-databases/styles.module.scss' import { SubscriptionCellProps } from './SubscriptionCell.types' @@ -11,13 +12,14 @@ export const SubscriptionCell = ({ name, className, }: SubscriptionCellProps) => { + const { t } = useTranslation() const cellContent = replaceSpaces(name.substring(0, 200)) return (
( - - } - /> - } - /> - } - /> - -) +export const AlertStatusContent = () => { + const { t } = useTranslation() + + return ( + + } + /> + } + /> + } + /> + + ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/constants/constants.ts b/redisinsight/ui/src/pages/autodiscover-cloud/constants/constants.ts index 606993c40e..faf00681aa 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/constants/constants.ts +++ b/redisinsight/ui/src/pages/autodiscover-cloud/constants/constants.ts @@ -15,19 +15,3 @@ export enum AutoDiscoverCloudIds { SubscriptionType = 'subscriptionType', Type = 'type', } - -export enum AutoDiscoverCloudTitles { - Id = 'Id', - Database = 'Database', - Endpoint = 'Endpoint', - Result = 'Result', - Capabilities = 'Capabilities', - NumberOfDatabases = '# databases', - Options = 'Options', - Provider = 'Cloud provider', - Region = 'Region', - Status = 'Status', - Subscription = 'Subscription', - SubscriptionId = 'Subscription id', - Type = 'Type', -} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResult.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResult.tsx index ed390c78a9..15335549b9 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResult.tsx @@ -17,6 +17,7 @@ import { Footer, Header, } from 'uiSrc/components/auto-discover' +import { useTranslation } from 'uiSrc/i18n' import { SummaryText } from './components' export interface Props { @@ -26,15 +27,16 @@ export interface Props { onBack: () => void } -const loadingMsg = 'loading...' -const notFoundMsg = 'Not found' - const RedisCloudDatabaseListResult = ({ instances, columns, onBack, onView, }: Props) => { + const { t } = useTranslation() + const loadingMsg = t('autodiscover.cloud.loading') + const notFoundMsg = t('autodiscover.cloud.notFound') + const [items, setItems] = useState([]) const [message, setMessage] = useState(loadingMsg) @@ -70,7 +72,7 @@ const RedisCloudDatabaseListResult = ({
@@ -111,7 +113,7 @@ const RedisCloudDatabaseListResult = ({ data-testid="btn-view-databases" disabled={items.length === 0} > - View Databases + {t('autodiscover.cloud.result.viewDatabases')} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/components/SummaryText/SummaryText.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/components/SummaryText/SummaryText.tsx index 9de7c87825..e76cd2c4e0 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/components/SummaryText/SummaryText.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/components/SummaryText/SummaryText.tsx @@ -1,22 +1,36 @@ import React from 'react' import { ColorText, Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import type { SummaryTextProps } from './SummaryText.types' export const SummaryText = ({ countSuccessAdded, countFailAdded, -}: SummaryTextProps) => ( - - Summary: {' '} - {countSuccessAdded ? ( - - Successfully added {countSuccessAdded} database(s) - {countFailAdded ? '. ' : '.'} - - ) : null} - {countFailAdded ? ( - Failed to add {countFailAdded} database(s). - ) : null} - -) +}: SummaryTextProps) => { + const { t } = useTranslation() + + return ( + + + {t('autodiscover.cloud.summary.prefix')} + {' '} + {countSuccessAdded ? ( + + {t('autodiscover.cloud.summary.databasesSuccess', { + count: countSuccessAdded, + })} + {countFailAdded ? '. ' : '.'} + + ) : null} + {countFailAdded ? ( + + {t('autodiscover.cloud.summary.databasesFail', { + count: countFailAdded, + })} + . + + ) : null} + + ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/hooks/useCloudDatabasesResultConfig.ts b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/hooks/useCloudDatabasesResultConfig.ts index 7cef62aea6..f2b7287050 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/hooks/useCloudDatabasesResultConfig.ts +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/hooks/useCloudDatabasesResultConfig.ts @@ -10,6 +10,7 @@ import { } from 'uiSrc/slices/instances/cloud' import { LoadedCloud } from 'uiSrc/slices/interfaces' import { setTitle } from 'uiSrc/utils' +import i18n from 'uiSrc/i18n' import { colFactory } from '../utils/colFactory' import { UseCloudDatabasesResultConfigReturn } from './useCloudDatabasesResultConfig.types' @@ -25,7 +26,7 @@ export const useCloudDatabasesResultConfig = if (!instances.length) { history.push(Pages.home) } - setTitle('Redis Enterprise Databases Added') + setTitle(i18n.t('autodiscover.cloud.result.title')) }, [instances.length, history]) const handleClose = useCallback(() => { diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/RedisCloudDatabases/RedisCloudDatabases.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/RedisCloudDatabases/RedisCloudDatabases.tsx index b5de1cd00a..61214e3277 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/RedisCloudDatabases/RedisCloudDatabases.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/RedisCloudDatabases/RedisCloudDatabases.tsx @@ -14,6 +14,7 @@ import { } from 'uiSrc/components/base/forms/buttons' import { RiPopover, RiTooltip } from 'uiSrc/components/base' import { Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import { ColumnDef, RowSelectionState, @@ -48,11 +49,6 @@ interface IPopoverProps { isPopoverOpen: boolean } -const loadingMsg = 'loading...' -const notFoundMsg = 'Not found' -const noResultsMessage = - 'Your Redis Enterprise Cloud has no databases available' - const RedisCloudDatabasesPage = ({ columns, selection, @@ -63,6 +59,11 @@ const RedisCloudDatabasesPage = ({ onBack, onSubmit, }: Props) => { + const { t } = useTranslation() + const loadingMsg = t('autodiscover.cloud.loading') + const notFoundMsg = t('autodiscover.cloud.notFound') + const noResultsMessage = t('autodiscover.cloud.databases.noResults') + const [items, setItems] = useState([]) const [message, setMessage] = useState(loadingMsg) const [isPopoverOpen, setIsPopoverOpen] = useState(false) @@ -125,14 +126,11 @@ const RedisCloudDatabasesPage = ({ className="btn-cancel" data-testid="btn-cancel" > - Cancel + {t('autodiscover.cloud.cancel.button')} } > - - Your changes have not been saved. Do you want to proceed to - the list of databases? - + {t('autodiscover.cloud.cancel.confirm')}
- Proceed + {t('autodiscover.cloud.cancel.proceed')}
@@ -165,7 +163,7 @@ const RedisCloudDatabasesPage = ({ icon={isDisabled ? InfoIcon : undefined} data-testid="btn-add-databases" > - Add selected Databases + {t('autodiscover.cloud.databases.addSelected')} ) @@ -174,14 +172,12 @@ const RedisCloudDatabasesPage = ({
1 ? 'databases ' : 'database '} - in your Redis Cloud. Select the - ${items.length > 1 ? ' databases ' : ' database '} that you want to - add.`} + subTitle={t('autodiscover.cloud.databases.subtitle', { + count: items.length, + })} /> diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/hooks/useCloudDatabasesConfig.ts b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/hooks/useCloudDatabasesConfig.ts index eab072d941..248cec15ba 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/hooks/useCloudDatabasesConfig.ts +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases/hooks/useCloudDatabasesConfig.ts @@ -11,6 +11,7 @@ import { } from 'uiSrc/slices/instances/cloud' import { oauthCloudUserSelector } from 'uiSrc/slices/oauth/cloud' import { setTitle } from 'uiSrc/utils' +import i18n from 'uiSrc/i18n' import { Pages } from 'uiSrc/constants' import { InstanceRedisCloud, @@ -59,7 +60,7 @@ export const useCloudDatabasesConfig = (): UseCloudDatabasesConfigReturn => { if (instances === null) { history.push(Pages.home) } - setTitle('Redis Cloud Databases') + setTitle(i18n.t('autodiscover.cloud.databases.title')) dispatch(resetLoadedRedisCloud(LoadedCloud.Instances)) }, [instances]) diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx index c6e240eb0d..55613bcb18 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx @@ -25,6 +25,7 @@ import { Footer, Header, } from 'uiSrc/components/auto-discover' +import { useTranslation } from 'uiSrc/i18n' import { canSelectRow } from '../utils/canSelectRow' import { Account, CancelButton, SubmitButton, SummaryText } from '../components' @@ -45,10 +46,6 @@ export interface Props { onSelectionChange: (state: RowSelectionState) => void } -const loadingMsg = 'loading...' -const notFoundMsg = 'Not found' -const noResultsMessage = 'Your Redis Cloud has no subscriptions available.' - const RedisCloudSubscriptions = ({ subscriptions, selection, @@ -60,7 +57,11 @@ const RedisCloudSubscriptions = ({ onSubmit, onSelectionChange, }: Props) => { - // const subscriptions = []; + const { t } = useTranslation() + const loadingMsg = t('autodiscover.cloud.loading') + const notFoundMsg = t('autodiscover.cloud.notFound') + const noResultsMessage = t('autodiscover.cloud.subscriptions.noResults') + const [items, setItems] = useState(subscriptions || []) const [message, setMessage] = useState(loadingMsg) const [isPopoverOpen, setIsPopoverOpen] = useState(false) @@ -119,7 +120,7 @@ const RedisCloudSubscriptions = ({
diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/Account/Account.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/Account/Account.tsx index fe02cb95a9..ada83117aa 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/Account/Account.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/Account/Account.tsx @@ -1,6 +1,7 @@ import React from 'react' import { LoadingContent } from 'uiSrc/components/base/layout' import { ColorText } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import * as S from './Account.style' import { type AccountProps, type AccountValueProps } from './Account.types' @@ -23,31 +24,43 @@ const AccountValue = ({ value, ...rest }: AccountValueProps) => { export const Account = ({ account: { accountId, accountName, ownerEmail, ownerName }, -}: AccountProps) => ( - - {accountId && ( - - Account ID: - - - )} - {accountName && ( - - Name: - - - )} - {ownerName && ( - - Owner Name: - - - )} - {ownerEmail && ( - - Owner Email: - - - )} - -) +}: AccountProps) => { + const { t } = useTranslation() + + return ( + + {accountId && ( + + + {t('autodiscover.cloud.account.accountId')} + + + + )} + {accountName && ( + + + {t('autodiscover.cloud.account.name')} + + + + )} + {ownerName && ( + + + {t('autodiscover.cloud.account.ownerName')} + + + + )} + {ownerEmail && ( + + + {t('autodiscover.cloud.account.ownerEmail')} + + + + )} + + ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/CancelButton/CancelButton.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/CancelButton/CancelButton.tsx index 7de59591a0..49970e2da0 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/CancelButton/CancelButton.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/CancelButton/CancelButton.tsx @@ -5,6 +5,7 @@ import { } from 'uiSrc/components/base/forms/buttons' import { Text } from 'uiSrc/components/base/text' import { RiPopover } from 'uiSrc/components/base' +import { useTranslation } from 'uiSrc/i18n' import styles from '../../styles.module.scss' import { type CancelButtonProps } from './CancelButton.types' @@ -14,36 +15,37 @@ export const CancelButton = ({ onClose, onShowPopover, onClosePopover, -}: CancelButtonProps) => ( - - Cancel - - } - > - - Your changes have not been saved. Do you want to proceed to the - list of databases? - -
-
- - Proceed - -
-
-) +}: CancelButtonProps) => { + const { t } = useTranslation() + + return ( + + {t('autodiscover.cloud.cancel.button')} + + } + > + {t('autodiscover.cloud.cancel.confirm')} +
+
+ + {t('autodiscover.cloud.cancel.proceed')} + +
+
+ ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SubmitButton/SubmitButton.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SubmitButton/SubmitButton.tsx index 5643764d8a..24ff76a1a9 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SubmitButton/SubmitButton.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SubmitButton/SubmitButton.tsx @@ -2,6 +2,7 @@ import React from 'react' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { RiTooltip } from 'uiSrc/components/base' import validationErrors from 'uiSrc/constants/validationErrors' +import { useTranslation } from 'uiSrc/i18n' import { type SubmitButtonProps } from './SubmitButton.types' @@ -9,25 +10,31 @@ export const SubmitButton = ({ isDisabled, loading, onClick, -}: SubmitButtonProps) => ( - {validationErrors.NO_SUBSCRIPTIONS_CLOUD} : null - } - > - { + const { t } = useTranslation() + + return ( + {validationErrors.NO_SUBSCRIPTIONS_CLOUD} + ) : null + } > - Show databases - - -) + + {t('autodiscover.cloud.subscriptions.showDatabases')} + + + ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SummaryText/SummaryText.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SummaryText/SummaryText.tsx index aff143ca98..14d4660213 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SummaryText/SummaryText.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/components/SummaryText/SummaryText.tsx @@ -1,29 +1,37 @@ import React from 'react' import { ColorText, Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import { type SummaryTextProps } from './SummaryText.types' export const SummaryText = ({ countStatusActive, countStatusFailed, -}: SummaryTextProps) => ( - - Summary: - {countStatusActive ? ( - - Successfully discovered database(s) in {countStatusActive} -   - {countStatusActive > 1 ? 'subscriptions' : 'subscription'} - .  - - ) : null} +}: SummaryTextProps) => { + const { t } = useTranslation() - {countStatusFailed ? ( - - Failed to discover database(s) in {countStatusFailed} -   - {countStatusFailed > 1 ? 'subscriptions.' : ' subscription.'} - - ) : null} - -) + return ( + + + {t('autodiscover.cloud.summary.prefix')} + + {countStatusActive ? ( + + {t('autodiscover.cloud.summary.subscriptionsSuccess', { + count: countStatusActive, + })} + .  + + ) : null} + + {countStatusFailed ? ( + + {t('autodiscover.cloud.summary.subscriptionsFail', { + count: countStatusFailed, + })} + . + + ) : null} + + ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/hooks/useCloudSubscriptionConfig.ts b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/hooks/useCloudSubscriptionConfig.ts index cd0862fe14..a466106bf3 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/hooks/useCloudSubscriptionConfig.ts +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-subscriptions/hooks/useCloudSubscriptionConfig.ts @@ -18,6 +18,7 @@ import { } from 'uiSrc/slices/instances/cloud' import { oauthCloudUserSelector } from 'uiSrc/slices/oauth/cloud' import { Maybe, setTitle } from 'uiSrc/utils' +import i18n from 'uiSrc/i18n' import { Pages } from 'uiSrc/constants' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' @@ -48,7 +49,7 @@ export const useCloudSubscriptionConfig = if (subscriptions === null) { history.push(Pages.home) } else { - setTitle('Redis Cloud Subscriptions') + setTitle(i18n.t('autodiscover.cloud.subscriptions.title')) } }, []) From ed4e4ac52a1b63dc90cff75b93bd4e92c5c9e6b9 Mon Sep 17 00:00:00 2001 From: Krum Tyukenov Date: Tue, 21 Jul 2026 10:59:07 +0300 Subject: [PATCH 058/166] feat(api): enable whatsNew for everyone (#6247) --- redisinsight/api/config/features-config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index d4b7fac67d..29ee4ef560 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 8, + "version": 9, "features": { "dev-language": { "flag": true, @@ -158,7 +158,7 @@ }, "whatsNew": { "flag": true, - "perc": [[0, 50]] + "perc": [[0, 100]] }, "valueDecoder": { "flag": false, From 4db705fd42f19bde9529445640ecf4dc48d1a135 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Tue, 21 Jul 2026 12:27:02 +0300 Subject: [PATCH 059/166] feat(i18n): migrate autodiscover-sentinel to i18n (RI-8273) (#6242) Route the Redis Sentinel autodiscovery flow (primary-groups discovery and result screens) through i18next. - Deleted the SentinelDatabaseTitles enum; column factories resolve headers via the i18n singleton (i18n.t('autodiscover.sentinel.column.*')), matching the cluster/cloud convention. - Components use useTranslation; the loading/notFound/noMasters message consts moved into render. The primary-groups subtitle uses for its
; the Summary success/fail lines use count-plurals; setTitle in the config hooks uses the i18n singleton. - Input/result cells (alias, username, password, address, db index, result) migrated, incl. the "Default"/"not assigned" fallbacks and copy aria-labels. - Shared validationErrors.* constants left as-is (cross-feature). - Added autodiscover.sentinel.* keys to en.json and bg.json (Bulgarian filled). --- redisinsight/ui/src/i18n/locales/bg.json | 36 ++++++++++ redisinsight/ui/src/i18n/locales/en.json | 36 ++++++++++ .../constants/constants.ts | 11 --- .../SentinelDatabasesResult.tsx | 12 ++-- .../components/Summary.tsx | 43 ++++++------ .../column-definitions/columns/address.tsx | 8 +-- .../column-definitions/columns/alias.tsx | 8 +-- .../column-definitions/columns/db.tsx | 8 +-- .../columns/numberOfReplicas.ts | 8 +-- .../column-definitions/columns/password.tsx | 8 +-- .../columns/primaryGroup.tsx | 8 +-- .../column-definitions/columns/result.tsx | 8 +-- .../column-definitions/columns/username.tsx | 8 +-- .../AddErrorButton/AddErrorButton.tsx | 10 ++- .../components/AddressCell/AddressCell.tsx | 9 ++- .../components/AliasCell/AliasCell.tsx | 5 +- .../components/DbCell/DbCell.tsx | 11 ++- .../components/PasswordCell/PasswordCell.tsx | 11 ++- .../components/ResultCell/ResultCell.tsx | 11 ++- .../components/UsernameCell/UsernameCell.tsx | 11 ++- .../useSentinelDatabasesResultConfig.tsx | 3 +- .../SentinelDatabases/SentinelDatabases.tsx | 19 +++--- .../components/CancelButton/CancelButton.tsx | 68 ++++++++++--------- .../components/SubmitButton/SubmitButton.tsx | 6 +- .../column-definitions/columns/address.tsx | 8 +-- .../column-definitions/columns/alias.tsx | 8 +-- .../column-definitions/columns/dbIndex.tsx | 8 +-- .../columns/numberOfReplicas.ts | 8 +-- .../column-definitions/columns/password.tsx | 8 +-- .../columns/primaryGroup.tsx | 8 +-- .../column-definitions/columns/username.tsx | 8 +-- .../components/AddressCell/AddressCell.tsx | 5 +- .../components/AliasCell/AliasCell.tsx | 29 ++++---- .../components/DbIndexCell/DbIndexCell.tsx | 47 +++++++------ .../components/PasswordCell/PasswordCell.tsx | 27 +++++--- .../components/UsernameCell/UsernameCell.tsx | 27 +++++--- .../useSentinelDatabasesConfig.tsx | 3 +- 37 files changed, 337 insertions(+), 223 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 135b1d9033..51ede4f281 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -456,6 +456,42 @@ "autodiscover.cloud.summary.subscriptionsFail_other": "Неуспешно откриване на бази данни в {{count}} абонамента", "autodiscover.cloud.summary.subscriptionsSuccess_one": "Успешно открити бази данни в {{count}} абонамент", "autodiscover.cloud.summary.subscriptionsSuccess_other": "Успешно открити бази данни в {{count}} абонамента", + "autodiscover.sentinel.aliasRequiredContent": "Псевдоним на база данни", + "autodiscover.sentinel.button.addPrimaryGroup": "Добавяне на първична група", + "autodiscover.sentinel.cancel.button": "Отказ", + "autodiscover.sentinel.cancel.confirm": "Промените ви не са запазени. Искате ли да продължите към списъка с бази данни?", + "autodiscover.sentinel.cancel.proceed": "Продължи", + "autodiscover.sentinel.cell.aliasPlaceholder": "Въведете псевдоним на база данни", + "autodiscover.sentinel.cell.aliasResultPlaceholder": "База данни", + "autodiscover.sentinel.cell.copyAddressAria": "Копиране на адреса", + "autodiscover.sentinel.cell.copyPublicEndpointAria": "Копиране на публичната крайна точка", + "autodiscover.sentinel.cell.dbIndexTooltip": "Изберете логическата база данни на Redis, с която да работите в Browser и Workbench.", + "autodiscover.sentinel.cell.error": "Грешка", + "autodiscover.sentinel.cell.indexPlaceholder": "Въведете индекс", + "autodiscover.sentinel.cell.notAssigned": "не е зададено", + "autodiscover.sentinel.cell.passwordPlaceholder": "Въведете парола", + "autodiscover.sentinel.cell.usernameDefault": "По подразбиране", + "autodiscover.sentinel.cell.usernamePlaceholder": "Въведете потребителско име", + "autodiscover.sentinel.column.address": "Адрес", + "autodiscover.sentinel.column.alias": "Псевдоним на база данни*", + "autodiscover.sentinel.column.databaseIndex": "Индекс на база данни", + "autodiscover.sentinel.column.numberOfReplicas": "# реплики", + "autodiscover.sentinel.column.password": "Парола", + "autodiscover.sentinel.column.primaryGroup": "Първична група", + "autodiscover.sentinel.column.result": "Резултат", + "autodiscover.sentinel.column.username": "Потребителско име", + "autodiscover.sentinel.databases.noMasters": "Вашият Redis Sentinel няма налични първични групи.", + "autodiscover.sentinel.databases.subtitle": "Открита е инстанция на Redis Sentinel. Ето списък с първичните групи, които вашата Sentinel инстанция управлява.
Изберете първичните групи, които искате да добавите:", + "autodiscover.sentinel.databases.title": "Автоматично откриване на първични групи на Redis Sentinel", + "autodiscover.sentinel.loading": "зареждане...", + "autodiscover.sentinel.notFound": "Не е намерено.", + "autodiscover.sentinel.result.pageTitle": "Добавени първични групи на Redis Sentinel", + "autodiscover.sentinel.result.viewDatabases": "Преглед на бази данни", + "autodiscover.sentinel.summary.fail_one": "Неуспешно добавяне на {{count}} първична група", + "autodiscover.sentinel.summary.fail_other": "Неуспешно добавяне на {{count}} първични групи", + "autodiscover.sentinel.summary.prefix": "Резюме: ", + "autodiscover.sentinel.summary.success_one": "Успешно добавена {{count}} първична група", + "autodiscover.sentinel.summary.success_other": "Успешно добавени {{count}} първични групи", "browser.array.delete.bulk.aria": "Изтриване на избраните елементи", "browser.array.delete.bulk.button": "Премахни", "browser.array.delete.bulk.message": "Избраните елементи ({{count}}) ще бъдат премахнати за постоянно от масива.", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 4ff37e9bc1..f6f80d0344 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -456,6 +456,42 @@ "autodiscover.cloud.summary.subscriptionsFail_other": "Failed to discover databases in {{count}} subscriptions", "autodiscover.cloud.summary.subscriptionsSuccess_one": "Successfully discovered databases in {{count}} subscription", "autodiscover.cloud.summary.subscriptionsSuccess_other": "Successfully discovered databases in {{count}} subscriptions", + "autodiscover.sentinel.aliasRequiredContent": "Database Alias", + "autodiscover.sentinel.button.addPrimaryGroup": "Add Primary Group", + "autodiscover.sentinel.cancel.button": "Cancel", + "autodiscover.sentinel.cancel.confirm": "Your changes have not been saved. Do you want to proceed to the list of databases?", + "autodiscover.sentinel.cancel.proceed": "Proceed", + "autodiscover.sentinel.cell.aliasPlaceholder": "Enter Database Alias", + "autodiscover.sentinel.cell.aliasResultPlaceholder": "Database", + "autodiscover.sentinel.cell.copyAddressAria": "Copy address", + "autodiscover.sentinel.cell.copyPublicEndpointAria": "Copy public endpoint", + "autodiscover.sentinel.cell.dbIndexTooltip": "Select the Redis logical database to work with in Browser and Workbench.", + "autodiscover.sentinel.cell.error": "Error", + "autodiscover.sentinel.cell.indexPlaceholder": "Enter Index", + "autodiscover.sentinel.cell.notAssigned": "not assigned", + "autodiscover.sentinel.cell.passwordPlaceholder": "Enter Password", + "autodiscover.sentinel.cell.usernameDefault": "Default", + "autodiscover.sentinel.cell.usernamePlaceholder": "Enter Username", + "autodiscover.sentinel.column.address": "Address", + "autodiscover.sentinel.column.alias": "Database alias*", + "autodiscover.sentinel.column.databaseIndex": "Database index", + "autodiscover.sentinel.column.numberOfReplicas": "# of replicas", + "autodiscover.sentinel.column.password": "Password", + "autodiscover.sentinel.column.primaryGroup": "Primary group", + "autodiscover.sentinel.column.result": "Result", + "autodiscover.sentinel.column.username": "Username", + "autodiscover.sentinel.databases.noMasters": "Your Redis Sentinel has no primary groups available.", + "autodiscover.sentinel.databases.subtitle": "Redis Sentinel instance found. Here is a list of primary groups your Sentinel instance is managing.
Select the primary group(s) you want to add:", + "autodiscover.sentinel.databases.title": "Auto-Discover Redis Sentinel Primary Groups", + "autodiscover.sentinel.loading": "loading...", + "autodiscover.sentinel.notFound": "Not found.", + "autodiscover.sentinel.result.pageTitle": "Redis Sentinel Primary Groups Added", + "autodiscover.sentinel.result.viewDatabases": "View Databases", + "autodiscover.sentinel.summary.fail_one": "Failed to add {{count}} primary group", + "autodiscover.sentinel.summary.fail_other": "Failed to add {{count}} primary groups", + "autodiscover.sentinel.summary.prefix": "Summary: ", + "autodiscover.sentinel.summary.success_one": "Successfully added {{count}} primary group", + "autodiscover.sentinel.summary.success_other": "Successfully added {{count}} primary groups", "browser.array.delete.bulk.aria": "Delete selected elements", "browser.array.delete.bulk.button": "Remove", "browser.array.delete.bulk.message": "{{count}} selected element(s) will be permanently removed from the array.", diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/constants/constants.ts b/redisinsight/ui/src/pages/autodiscover-sentinel/constants/constants.ts index 92a92ebf6d..d211c71751 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/constants/constants.ts +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/constants/constants.ts @@ -1,14 +1,3 @@ -export enum SentinelDatabaseTitles { - Address = 'Address', - Alias = 'Database alias*', - Username = 'Username', - DatabaseIndex = 'Database index', - NumberOfReplicas = '# of replicas', - Password = 'Password', - PrimaryGroup = 'Primary group', - Result = 'Result', -} - export enum SentinelDatabaseIds { Message = 'message', Address = 'host', diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/SentinelDatabasesResult.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/SentinelDatabasesResult.tsx index 0a72bf61c9..3e68828752 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/SentinelDatabasesResult.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/SentinelDatabasesResult.tsx @@ -20,6 +20,7 @@ import { } from 'uiSrc/components/auto-discover' import { Spacer } from 'uiSrc/components/base/layout' import { Header } from 'uiSrc/components/auto-discover/Header' +import { useTranslation } from 'uiSrc/i18n' import { SummaryText } from './components/Summary' export interface Props { @@ -30,9 +31,6 @@ export interface Props { onViewDatabases: () => void } -const loadingMsg = 'loading...' -const notFoundMsg = 'Not found.' - const SentinelDatabasesResult = ({ columns, onBack, @@ -40,6 +38,10 @@ const SentinelDatabasesResult = ({ countSuccessAdded, masters, }: Props) => { + const { t } = useTranslation() + const loadingMsg = t('autodiscover.sentinel.loading') + const notFoundMsg = t('autodiscover.sentinel.notFound') + const [items, setItems] = useState(masters) const [message, setMessage] = useState(loadingMsg) @@ -82,7 +84,7 @@ const SentinelDatabasesResult = ({
@@ -130,7 +132,7 @@ const SentinelDatabasesResult = ({ onClick={handleViewDatabases} data-testid="btn-view-databases" > - View Databases + {t('autodiscover.sentinel.result.viewDatabases')} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/components/Summary.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/components/Summary.tsx index 359041ae3a..531c4735b0 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/components/Summary.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/SentinelDatabasesResult/components/Summary.tsx @@ -1,27 +1,32 @@ import React from 'react' import { ColorText, Text } from 'uiSrc/components/base/text' +import { useTranslation } from 'uiSrc/i18n' import { type SummaryTextProps } from './SummaryTextProps.types' export const SummaryText = ({ countSuccessAdded, countFailAdded, -}: SummaryTextProps) => ( - - - Summary:  - - {countSuccessAdded ? ( - - Successfully added {countSuccessAdded} - {' primary group(s)'} - {countFailAdded ? '; ' : ' '} - - ) : null} - {countFailAdded ? ( - - Failed to add {countFailAdded} - {' primary group(s)'} +}: SummaryTextProps) => { + const { t } = useTranslation() + + return ( + + + {t('autodiscover.sentinel.summary.prefix')} - ) : null} - -) + {countSuccessAdded ? ( + + {t('autodiscover.sentinel.summary.success', { + count: countSuccessAdded, + })} + {countFailAdded ? '; ' : ' '} + + ) : null} + {countFailAdded ? ( + + {t('autodiscover.sentinel.summary.fail', { count: countFailAdded })} + + ) : null} + + ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/address.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/address.tsx index 4ab0b1d0ab..bcae3e4529 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/address.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/address.tsx @@ -2,15 +2,13 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { AddressCell } from '../components' export const addressColumn = (): ColumnDef => { return { - header: SentinelDatabaseTitles.Address, + header: i18n.t('autodiscover.sentinel.column.address'), id: SentinelDatabaseIds.Address, accessorKey: SentinelDatabaseIds.Address, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/alias.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/alias.tsx index a3b17e81c9..4f714fb804 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/alias.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/alias.tsx @@ -5,10 +5,8 @@ import type { ModifiedSentinelMaster, AddRedisDatabaseStatus, } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { AliasCell } from '../components' export const aliasColumn = ( @@ -19,7 +17,7 @@ export const aliasColumn = ( ) => boolean, ): ColumnDef => { return { - header: SentinelDatabaseTitles.Alias, + header: i18n.t('autodiscover.sentinel.column.alias'), id: SentinelDatabaseIds.Alias, accessorKey: SentinelDatabaseIds.Alias, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/db.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/db.tsx index 0ec88858be..2968762d8b 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/db.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/db.tsx @@ -2,17 +2,15 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { DbCell } from '../components' export const dbColumn = ( handleChangedInput: (name: string, value: string) => void, ): ColumnDef => { return { - header: SentinelDatabaseTitles.DatabaseIndex, + header: i18n.t('autodiscover.sentinel.column.databaseIndex'), id: SentinelDatabaseIds.DatabaseIndex, accessorKey: SentinelDatabaseIds.DatabaseIndex, size: 140, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/numberOfReplicas.ts b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/numberOfReplicas.ts index 1a7f2ac647..5f33361d10 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/numberOfReplicas.ts +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/numberOfReplicas.ts @@ -1,13 +1,11 @@ import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' export const numberOfReplicasColumn = (): ColumnDef => { return { - header: SentinelDatabaseTitles.NumberOfReplicas, + header: i18n.t('autodiscover.sentinel.column.numberOfReplicas'), id: SentinelDatabaseIds.NumberOfReplicas, accessorKey: SentinelDatabaseIds.NumberOfReplicas, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/password.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/password.tsx index da8416f6b7..70e7c74b9f 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/password.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/password.tsx @@ -5,10 +5,8 @@ import type { ModifiedSentinelMaster, AddRedisDatabaseStatus, } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { PasswordCell } from '../components' export const passwordColumn = ( @@ -20,7 +18,7 @@ export const passwordColumn = ( ) => boolean, ): ColumnDef => { return { - header: SentinelDatabaseTitles.Password, + header: i18n.t('autodiscover.sentinel.column.password'), id: SentinelDatabaseIds.Password, accessorKey: SentinelDatabaseIds.Password, cell: ({ diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/primaryGroup.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/primaryGroup.tsx index 8e1ac71756..3a63a608cc 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/primaryGroup.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/primaryGroup.tsx @@ -2,15 +2,13 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { PrimaryGroupCell } from '../components' export const primaryGroupColumn = (): ColumnDef => { return { - header: SentinelDatabaseTitles.PrimaryGroup, + header: i18n.t('autodiscover.sentinel.column.primaryGroup'), id: SentinelDatabaseIds.PrimaryGroup, accessorKey: SentinelDatabaseIds.PrimaryGroup, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/result.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/result.tsx index 243a19c47d..53b93a0def 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/result.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/result.tsx @@ -2,10 +2,8 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { ResultCell } from '../components' export const resultColumn = ( @@ -13,7 +11,7 @@ export const resultColumn = ( onAddInstance?: (name: string) => void, ): ColumnDef => { return { - header: SentinelDatabaseTitles.Result, + header: i18n.t('autodiscover.sentinel.column.result'), id: SentinelDatabaseIds.Message, accessorKey: SentinelDatabaseIds.Message, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/username.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/username.tsx index 35965d1ccc..cc44f7a386 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/username.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/columns/username.tsx @@ -5,10 +5,8 @@ import type { ModifiedSentinelMaster, AddRedisDatabaseStatus, } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { UsernameCell } from '../components' export const usernameColumn = ( @@ -20,7 +18,7 @@ export const usernameColumn = ( ) => boolean, ): ColumnDef => { return { - header: SentinelDatabaseTitles.Username, + header: i18n.t('autodiscover.sentinel.column.username'), id: SentinelDatabaseIds.Username, accessorKey: SentinelDatabaseIds.Username, cell: ({ diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddErrorButton/AddErrorButton.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddErrorButton/AddErrorButton.tsx index e1aab4ee33..955387f88f 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddErrorButton/AddErrorButton.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddErrorButton/AddErrorButton.tsx @@ -6,6 +6,7 @@ import { ApiEncryptionErrors } from 'uiSrc/constants/apiErrors' import validationErrors from 'uiSrc/constants/validationErrors' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { InfoIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import type { AddErrorButtonProps } from './AddErrorButton.types' @@ -16,6 +17,7 @@ export const AddErrorButton = ({ loading = false, onAddInstance = () => {}, }: AddErrorButtonProps) => { + const { t } = useTranslation() const isDisabled = !alias if ( typeof error === 'object' && @@ -32,7 +34,11 @@ export const AddErrorButton = ({ Database Alias : null} + content={ + isDisabled ? ( + {t('autodiscover.sentinel.aliasRequiredContent')} + ) : null + } > onAddInstance(name)} icon={isDisabled ? InfoIcon : undefined} > - Add Primary Group + {t('autodiscover.sentinel.button.addPrimaryGroup')} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddressCell/AddressCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddressCell/AddressCell.tsx index a11959f82d..030c5651ff 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddressCell/AddressCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AddressCell/AddressCell.tsx @@ -4,10 +4,13 @@ import { CopyPublicEndpointText, CopyBtnWrapper, } from 'uiSrc/components/auto-discover' +import { useTranslation } from 'uiSrc/i18n' import type { AddressCellProps } from './AddressCell.types' export const AddressCell = ({ host = '', port = '' }: AddressCellProps) => { + const { t } = useTranslation() + if (!host || !port) { return null } @@ -18,7 +21,11 @@ export const AddressCell = ({ host = '', port = '' }: AddressCellProps) => { {text} - + ) } diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AliasCell/AliasCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AliasCell/AliasCell.tsx index cb0da36e65..45b7e860a3 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AliasCell/AliasCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/AliasCell/AliasCell.tsx @@ -2,6 +2,7 @@ import React from 'react' import { CellText } from 'uiSrc/components/auto-discover' import { InputFieldSentinel } from 'uiSrc/components' import { SentinelInputFieldType } from 'uiSrc/components/input-field-sentinel/InputFieldSentinel' +import { useTranslation } from 'uiSrc/i18n' import type { AliasCellProps } from './AliasCell.types' @@ -14,6 +15,8 @@ export const AliasCell = ({ handleChangedInput, errorNotAuth, }: AliasCellProps) => { + const { t } = useTranslation() + if (errorNotAuth(error, status)) { return {alias} } @@ -21,7 +24,7 @@ export const AliasCell = ({ { + const { t } = useTranslation() + if (status === AddRedisDatabaseStatus.Success) { - return db !== undefined ? {db} : not assigned + return db !== undefined ? ( + {db} + ) : ( + {t('autodiscover.sentinel.cell.notAssigned')} + ) } const isDBInvalid = typeof error === 'object' && @@ -30,7 +37,7 @@ export const DbCell = ({ value={`${db}` || '0'} name={`db-${id}`} isInvalid={isDBInvalid} - placeholder="Enter Index" + placeholder={t('autodiscover.sentinel.cell.indexPlaceholder')} inputType={SentinelInputFieldType.Number} onChangedInput={handleChangedInput} /> diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/PasswordCell/PasswordCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/PasswordCell/PasswordCell.tsx index 548b3b8009..a30606905b 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/PasswordCell/PasswordCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/PasswordCell/PasswordCell.tsx @@ -2,6 +2,7 @@ import React from 'react' import { InputFieldSentinel } from 'uiSrc/components' import { SentinelInputFieldType } from 'uiSrc/components/input-field-sentinel/InputFieldSentinel' import { AddRedisDatabaseStatus } from 'uiSrc/slices/interfaces' +import { useTranslation } from 'uiSrc/i18n' import type { PasswordCellProps } from './PasswordCell.types' @@ -15,11 +16,17 @@ export const PasswordCell = ({ isInvalid, errorNotAuth, }: PasswordCellProps) => { + const { t } = useTranslation() + if ( errorNotAuth(error, status) || status === AddRedisDatabaseStatus.Success ) { - return password ? ************ : not assigned + return password ? ( + ************ + ) : ( + {t('autodiscover.sentinel.cell.notAssigned')} + ) } return (
@@ -27,7 +34,7 @@ export const PasswordCell = ({ isInvalid={isInvalid} value={password} name={`password-${id}`} - placeholder="Enter Password" + placeholder={t('autodiscover.sentinel.cell.passwordPlaceholder')} disabled={loading} inputType={SentinelInputFieldType.Password} onChangedInput={handleChangedInput} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/ResultCell/ResultCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/ResultCell/ResultCell.tsx index 49cb5a01ed..7c915dc6bf 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/ResultCell/ResultCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/ResultCell/ResultCell.tsx @@ -8,6 +8,7 @@ import { RiTooltip } from 'uiSrc/components' import { ColorText } from 'uiSrc/components/base/text' import { Spacer } from 'uiSrc/components/base/layout' import { RiIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import { AddErrorButton } from '../AddErrorButton/AddErrorButton' import type { ResultCellProps } from './ResultCell.types' @@ -22,6 +23,8 @@ export const ResultCell = ({ addActions, onAddInstance, }: ResultCellProps) => { + const { t } = useTranslation() + return ( {message} )} {!loading && status !== AddRedisDatabaseStatus.Success && ( - + - Error + {t('autodiscover.sentinel.cell.error')} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/UsernameCell/UsernameCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/UsernameCell/UsernameCell.tsx index d55050dc31..3d6bc33bde 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/UsernameCell/UsernameCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/components/column-definitions/components/UsernameCell/UsernameCell.tsx @@ -2,6 +2,7 @@ import React from 'react' import { InputFieldSentinel } from 'uiSrc/components' import { SentinelInputFieldType } from 'uiSrc/components/input-field-sentinel/InputFieldSentinel' import { AddRedisDatabaseStatus } from 'uiSrc/slices/interfaces' +import { useTranslation } from 'uiSrc/i18n' import type { UsernameCellProps } from './UsernameCell.types' @@ -15,11 +16,17 @@ export const UsernameCell = ({ isInvalid, errorNotAuth, }: UsernameCellProps) => { + const { t } = useTranslation() + if ( errorNotAuth(error, status) || status === AddRedisDatabaseStatus.Success ) { - return username ? {username} : Default + return username ? ( + {username} + ) : ( + {t('autodiscover.sentinel.cell.usernameDefault')} + ) } return (
@@ -28,7 +35,7 @@ export const UsernameCell = ({ isInvalid={isInvalid} value={username} name={`username-${id}`} - placeholder="Enter Username" + placeholder={t('autodiscover.sentinel.cell.usernamePlaceholder')} disabled={loading} inputType={SentinelInputFieldType.Text} onChangedInput={handleChangedInput} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/useSentinelDatabasesResultConfig.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/useSentinelDatabasesResultConfig.tsx index c1924d5004..0d219705e4 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/useSentinelDatabasesResultConfig.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases-result/useSentinelDatabasesResultConfig.tsx @@ -16,6 +16,7 @@ import { ModifiedSentinelMaster, } from 'uiSrc/slices/interfaces' import { removeEmpty, setTitle } from 'uiSrc/utils' +import i18n from 'uiSrc/i18n' import { pick } from 'lodash' import { ColumnDef } from 'uiSrc/components/base/layout/table' import { @@ -97,7 +98,7 @@ export const useSentinelDatabasesResultConfig = () => { history.push(Pages.home) return } - setTitle('Redis Sentinel Primary Groups Added') + setTitle(i18n.t('autodiscover.sentinel.result.pageTitle')) setIsInvalid(true) setItems(masters) diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/SentinelDatabases.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/SentinelDatabases.tsx index 0997b2d696..5038efd6e1 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/SentinelDatabases.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/SentinelDatabases.tsx @@ -20,6 +20,7 @@ import { Header, } from 'uiSrc/components/auto-discover' import { Text } from 'uiSrc/components/base/text' +import { Trans, useTranslation } from 'uiSrc/i18n' import { getRowId } from '../../useSentinelDatabasesConfig' import { CancelButton, SubmitButton, NoMastersMessage } from './components' @@ -34,10 +35,6 @@ export interface Props { onSubmit: (databases: ModifiedSentinelMaster[]) => void } -const loadingMsg = 'loading...' -const notMastersMsg = 'Your Redis Sentinel has no primary groups available.' -const notFoundMsg = 'Not found.' - const SentinelDatabases = ({ columns, onSelectionChange, @@ -47,6 +44,11 @@ const SentinelDatabases = ({ masters, selection, }: Props) => { + const { t } = useTranslation() + const loadingMsg = t('autodiscover.sentinel.loading') + const notMastersMsg = t('autodiscover.sentinel.databases.noMasters') + const notFoundMsg = t('autodiscover.sentinel.notFound') + const [items, setItems] = useState(masters) const [message, setMessage] = useState(loadingMsg) @@ -105,15 +107,16 @@ const SentinelDatabases = ({
0 && ( - Redis Sentinel instance found. Here is a list of primary groups - your Sentinel instance is managing.
- Select the primary group(s) you want to add: + }} + />
) } diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/CancelButton/CancelButton.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/CancelButton/CancelButton.tsx index 5d2cbe43f7..42d6c52939 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/CancelButton/CancelButton.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/CancelButton/CancelButton.tsx @@ -5,6 +5,7 @@ import { } from 'uiSrc/components/base/forms/buttons' import { Text } from 'uiSrc/components/base/text' import { RiPopover } from 'uiSrc/components/base' +import { useTranslation } from 'uiSrc/i18n' import { type CancelButtonProps } from './CancelButton.types' import styles from './styles.module.scss' @@ -14,36 +15,37 @@ export const CancelButton = ({ onClose, onShowPopover, onClosePopover, -}: CancelButtonProps) => ( - - Cancel - - } - > - - Your changes have not been saved. Do you want to proceed to the - list of databases? - -
-
- - Proceed - -
-
-) +}: CancelButtonProps) => { + const { t } = useTranslation() + + return ( + + {t('autodiscover.sentinel.cancel.button')} + + } + > + {t('autodiscover.sentinel.cancel.confirm')} +
+
+ + {t('autodiscover.sentinel.cancel.proceed')} + +
+
+ ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/SubmitButton/SubmitButton.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/SubmitButton/SubmitButton.tsx index a81b20d084..08e87e6b74 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/SubmitButton/SubmitButton.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/SentinelDatabases/components/SubmitButton/SubmitButton.tsx @@ -3,6 +3,7 @@ import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { RiIcon } from 'uiSrc/components/base/icons' import { RiTooltip } from 'uiSrc/components/base' import validationErrors from 'uiSrc/constants/validationErrors' +import { useTranslation } from 'uiSrc/i18n' import { type SubmitButtonProps } from './SubmitButton.types' @@ -24,6 +25,7 @@ export const SubmitButton = ({ onClick, isDisabled, }: SubmitButtonProps) => { + const { t } = useTranslation() let title: string | null = null let content: string | null = null const emptyAliases = selection.filter(({ alias }) => !alias) @@ -35,7 +37,7 @@ export const SubmitButton = ({ if (emptyAliases.length !== 0) { title = validationErrors.REQUIRED_TITLE(emptyAliases.length) - content = 'Database Alias' + content = t('autodiscover.sentinel.aliasRequiredContent') } return ( @@ -51,7 +53,7 @@ export const SubmitButton = ({ } data-testid="btn-add-primary-group" > - Add Primary Group + {t('autodiscover.sentinel.button.addPrimaryGroup')} ) } diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/address.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/address.tsx index 7677da0aa4..e25d4773d7 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/address.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/address.tsx @@ -2,16 +2,14 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { AddressCell } from '../components' export const addressColumn = (): ColumnDef => { return { - header: SentinelDatabaseTitles.Address, + header: i18n.t('autodiscover.sentinel.column.address'), id: SentinelDatabaseIds.Address, accessorKey: SentinelDatabaseIds.Address, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/alias.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/alias.tsx index 53945db9ff..977f993291 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/alias.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/alias.tsx @@ -3,16 +3,14 @@ import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' import { AliasCell } from '../components' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' export const aliasColumn = ( handleChangedInput: (name: string, value: string) => void, ): ColumnDef => { return { - header: SentinelDatabaseTitles.Alias, + header: i18n.t('autodiscover.sentinel.column.alias'), id: SentinelDatabaseIds.Alias, accessorKey: SentinelDatabaseIds.Alias, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/dbIndex.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/dbIndex.tsx index 344e9f0c5b..4d4e5eef81 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/dbIndex.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/dbIndex.tsx @@ -3,16 +3,14 @@ import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' import { DbIndexCell } from '../components' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' export const dbIndexColumn = ( handleChangedInput: (name: string, value: string) => void, ): ColumnDef => { return { - header: SentinelDatabaseTitles.DatabaseIndex, + header: i18n.t('autodiscover.sentinel.column.databaseIndex'), id: SentinelDatabaseIds.DatabaseIndex, accessorKey: SentinelDatabaseIds.DatabaseIndex, size: 140, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/numberOfReplicas.ts b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/numberOfReplicas.ts index 9837bd82dd..544186a725 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/numberOfReplicas.ts +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/numberOfReplicas.ts @@ -1,14 +1,12 @@ import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' export const numberOfReplicasColumn = (): ColumnDef => { return { - header: SentinelDatabaseTitles.NumberOfReplicas, + header: i18n.t('autodiscover.sentinel.column.numberOfReplicas'), id: SentinelDatabaseIds.NumberOfReplicas, accessorKey: SentinelDatabaseIds.NumberOfReplicas, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/password.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/password.tsx index adbdd33ba0..3e4a094ae0 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/password.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/password.tsx @@ -2,10 +2,8 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { PasswordCell } from '../components' @@ -13,7 +11,7 @@ export const passwordColumn = ( handleChangedInput: (name: string, value: string) => void, ): ColumnDef => { return { - header: SentinelDatabaseTitles.Password, + header: i18n.t('autodiscover.sentinel.column.password'), id: SentinelDatabaseIds.Password, accessorKey: SentinelDatabaseIds.Password, cell: ({ diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/primaryGroup.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/primaryGroup.tsx index 3998a347f4..774c58e924 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/primaryGroup.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/primaryGroup.tsx @@ -3,15 +3,13 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { PrimaryGroupCell } from '../components' export const primaryGroupColumn = (): ColumnDef => { return { - header: SentinelDatabaseTitles.PrimaryGroup, + header: i18n.t('autodiscover.sentinel.column.primaryGroup'), id: SentinelDatabaseIds.PrimaryGroup, accessorKey: SentinelDatabaseIds.PrimaryGroup, enableSorting: true, diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/username.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/username.tsx index b12b298aa0..e0690c5a04 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/username.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/columns/username.tsx @@ -2,10 +2,8 @@ import React from 'react' import type { ColumnDef } from 'uiSrc/components/base/layout/table' import type { ModifiedSentinelMaster } from 'uiSrc/slices/interfaces' -import { - SentinelDatabaseIds, - SentinelDatabaseTitles, -} from 'uiSrc/pages/autodiscover-sentinel/constants/constants' +import i18n from 'uiSrc/i18n' +import { SentinelDatabaseIds } from 'uiSrc/pages/autodiscover-sentinel/constants/constants' import { UsernameCell } from '../components' @@ -13,7 +11,7 @@ export const usernameColumn = ( handleChangedInput: (name: string, value: string) => void, ): ColumnDef => { return { - header: SentinelDatabaseTitles.Username, + header: i18n.t('autodiscover.sentinel.column.username'), id: SentinelDatabaseIds.Username, accessorKey: SentinelDatabaseIds.Username, cell: ({ diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AddressCell/AddressCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AddressCell/AddressCell.tsx index c64f5c69ca..c17e0de6a8 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AddressCell/AddressCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AddressCell/AddressCell.tsx @@ -4,10 +4,13 @@ import { CopyPublicEndpointText, CopyBtnWrapper, } from 'uiSrc/components/auto-discover' +import { useTranslation } from 'uiSrc/i18n' import type { AddressCellProps } from './AddressCell.types' export const AddressCell = ({ host, port }: AddressCellProps) => { + const { t } = useTranslation() + if (!host || !port) { return null } @@ -18,7 +21,7 @@ export const AddressCell = ({ host, port }: AddressCellProps) => { {text} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AliasCell/AliasCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AliasCell/AliasCell.tsx index b12bbf87e0..2554c429eb 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AliasCell/AliasCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/AliasCell/AliasCell.tsx @@ -1,6 +1,7 @@ import React from 'react' import { InputFieldSentinel } from 'uiSrc/components' import { SentinelInputFieldType } from 'uiSrc/components/input-field-sentinel/InputFieldSentinel' +import { useTranslation } from 'uiSrc/i18n' import type { AliasCellProps } from './AliasCell.types' @@ -9,15 +10,19 @@ export const AliasCell = ({ alias, name, handleChangedInput, -}: AliasCellProps) => ( -
- -
-) +}: AliasCellProps) => { + const { t } = useTranslation() + + return ( +
+ +
+ ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/DbIndexCell/DbIndexCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/DbIndexCell/DbIndexCell.tsx index cea8395bf4..e65e8071dd 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/DbIndexCell/DbIndexCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/DbIndexCell/DbIndexCell.tsx @@ -2,6 +2,7 @@ import React from 'react' import { InputFieldSentinel, RiTooltip } from 'uiSrc/components' import { SentinelInputFieldType } from 'uiSrc/components/input-field-sentinel/InputFieldSentinel' import { RiIcon } from 'uiSrc/components/base/icons' +import { useTranslation } from 'uiSrc/i18n' import type { DbIndexCellProps } from './DbIndexCell.types' @@ -9,24 +10,28 @@ export const DbIndexCell = ({ db = 0, id, handleChangedInput, -}: DbIndexCellProps) => ( -
- - - - } - /> -
-) +}: DbIndexCellProps) => { + const { t } = useTranslation() + + return ( +
+ + + + } + /> +
+ ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/PasswordCell/PasswordCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/PasswordCell/PasswordCell.tsx index 075a0b05b0..0464586bf4 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/PasswordCell/PasswordCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/PasswordCell/PasswordCell.tsx @@ -1,6 +1,7 @@ import React from 'react' import { InputFieldSentinel } from 'uiSrc/components' import { SentinelInputFieldType } from 'uiSrc/components/input-field-sentinel/InputFieldSentinel' +import { useTranslation } from 'uiSrc/i18n' import type { PasswordCellProps } from './PasswordCell.types' @@ -8,14 +9,18 @@ export const PasswordCell = ({ password, id, handleChangedInput, -}: PasswordCellProps) => ( -
- -
-) +}: PasswordCellProps) => { + const { t } = useTranslation() + + return ( +
+ +
+ ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/UsernameCell/UsernameCell.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/UsernameCell/UsernameCell.tsx index 7798132197..44c5d8e2e5 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/UsernameCell/UsernameCell.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/components/column-definitions/components/UsernameCell/UsernameCell.tsx @@ -1,6 +1,7 @@ import React from 'react' import { InputFieldSentinel } from 'uiSrc/components' import { SentinelInputFieldType } from 'uiSrc/components/input-field-sentinel/InputFieldSentinel' +import { useTranslation } from 'uiSrc/i18n' import type { UsernameCellProps } from './UsernameCell.types' @@ -8,14 +9,18 @@ export const UsernameCell = ({ username, id, handleChangedInput, -}: UsernameCellProps) => ( -
- -
-) +}: UsernameCellProps) => { + const { t } = useTranslation() + + return ( +
+ +
+ ) +} diff --git a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/useSentinelDatabasesConfig.tsx b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/useSentinelDatabasesConfig.tsx index 4d40f0dfeb..07638b2b15 100644 --- a/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/useSentinelDatabasesConfig.tsx +++ b/redisinsight/ui/src/pages/autodiscover-sentinel/sentinel-databases/useSentinelDatabasesConfig.tsx @@ -14,6 +14,7 @@ import { import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { Pages } from 'uiSrc/constants' import { setTitle } from 'uiSrc/utils' +import i18n from 'uiSrc/i18n' import { CreateSentinelDatabaseDto } from 'apiClient' import { ColumnDef, @@ -82,7 +83,7 @@ export const useSentinelDatabasesConfig = () => { } }, [masters.length]) - useEffect(() => setTitle('Auto-Discover Redis Sentinel Primary Groups'), []) + useEffect(() => setTitle(i18n.t('autodiscover.sentinel.databases.title')), []) const handleClose = useCallback(() => { sendCancelEvent() dispatch(resetDataSentinel()) From 723eb3ca31c42b3e2e060194b288c98c0683f111 Mon Sep 17 00:00:00 2001 From: Valentin Kirilov Date: Tue, 21 Jul 2026 12:27:15 +0300 Subject: [PATCH 060/166] feat(i18n): migrate add-database & connection forms to i18n (RI-8273) (#6238) Route the add-database screen, its connectivity options, and the shared connection-URL info tooltip through i18next. - add-database-screen: buttons, divider, cloud section, connection-URL label, and the
error message (via ); option titles stored as i18n keys (ParseKeys) on the config, resolved with t() at render. - database-panel-dialog: modal title. - host-info-tooltip-content: converted to a t()-accepting factory so its text resolves at render time (the info icon's visibility check reads the rendered text) and follows runtime language changes; updated its three callers (connection-url, cluster-connection form, manual DatabaseForm). - Added addDatabase.* and shared common.connectionInfo.* keys to en.json and bg.json (Bulgarian filled). --- redisinsight/ui/src/i18n/locales/bg.json | 19 ++++++++ redisinsight/ui/src/i18n/locales/en.json | 19 ++++++++ .../add-database-screen/AddDatabaseScreen.tsx | 29 ++++++------ .../connection-url/ConnectionUrl.tsx | 45 ++++++++++--------- .../ConnectivityOptions.tsx | 23 +++++++--- .../add-database-screen/constants.tsx | 12 ++--- .../hooks/useConnectivityOptions.spec.ts | 2 +- .../ClusterConnectionForm.tsx | 15 ++++--- .../DatabasePanelDialog.tsx | 6 ++- .../home/components/form/DatabaseForm.tsx | 15 ++++--- .../HostInfoTooltipContent.tsx | 9 ++-- 11 files changed, 130 insertions(+), 64 deletions(-) diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index 51ede4f281..21003ff264 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -1,4 +1,21 @@ { + "addDatabase.button.addDatabase": "Добавяне на база данни", + "addDatabase.button.cancel": "Отказ", + "addDatabase.button.connectionSettings": "Настройки на връзката", + "addDatabase.button.testConnection": "Тест на връзката", + "addDatabase.cloud.addDatabases": "Добавяне на бази данни", + "addDatabase.cloud.freeBadge": "БЕЗПЛАТНО", + "addDatabase.cloud.newDatabase": "Създай си нова база данни", + "addDatabase.cloud.title": "Започнете с акаунт в Redis Cloud", + "addDatabase.connectionUrl.error": "Предоставеният формат на URL адреса за връзка не се поддържа.
Опитайте да промените настройките на връзката за свързване.", + "addDatabase.connectionUrl.label": "URL адрес за връзка", + "addDatabase.divider.or": "Или", + "addDatabase.modal.title": "Добавяне на база данни", + "addDatabase.moreOptions.title": "Още опции за свързване", + "addDatabase.option.azure": "Azure Managed Redis", + "addDatabase.option.import": "Импортиране от файл", + "addDatabase.option.sentinel": "Redis Sentinel", + "addDatabase.option.software": "Redis Software", "analytics.clusterDetails.graphics.keys": "Ключове", "analytics.clusterDetails.graphics.memory": "Памет", "analytics.clusterDetails.header.defaultUsername": "По подразбиране", @@ -529,6 +546,8 @@ "cluster.summary.label": "Обобщение: ", "cluster.summary.success_one": "Успешно добавена {{count}} база данни", "cluster.summary.success_other": "Успешно добавени {{count}} бази данни", + "common.connectionInfo.autofill": "Поставянето на URL адрес за връзка автоматично попълва детайлите на базата данни.", + "common.connectionInfo.supportedUrls": "Поддържат се следните URL адреси за връзка:", "common.fullScreen.enter": "Цял екран", "common.fullScreen.exit": "Изход от цял екран", "common.fullScreen.openAria": "Отвори на цял екран", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index f6f80d0344..80d780c5e7 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -1,4 +1,21 @@ { + "addDatabase.button.addDatabase": "Add database", + "addDatabase.button.cancel": "Cancel", + "addDatabase.button.connectionSettings": "Connection settings", + "addDatabase.button.testConnection": "Test connection", + "addDatabase.cloud.addDatabases": "Add databases", + "addDatabase.cloud.freeBadge": "FREE", + "addDatabase.cloud.newDatabase": "New database", + "addDatabase.cloud.title": "Get started with Redis Cloud account", + "addDatabase.connectionUrl.error": "The connection URL format provided is not supported.
Try adding a database using a connection form.", + "addDatabase.connectionUrl.label": "Connection URL", + "addDatabase.divider.or": "Or", + "addDatabase.modal.title": "Add database", + "addDatabase.moreOptions.title": "More connectivity options", + "addDatabase.option.azure": "Azure Managed Redis", + "addDatabase.option.import": "Import from file", + "addDatabase.option.sentinel": "Redis Sentinel", + "addDatabase.option.software": "Redis Software", "analytics.clusterDetails.graphics.keys": "Keys", "analytics.clusterDetails.graphics.memory": "Memory", "analytics.clusterDetails.header.defaultUsername": "Default", @@ -529,6 +546,8 @@ "cluster.summary.label": "Summary: ", "cluster.summary.success_one": "Successfully added {{count}} database", "cluster.summary.success_other": "Successfully added {{count}} databases", + "common.connectionInfo.autofill": "Pasting a connection URL auto fills the database details.", + "common.connectionInfo.supportedUrls": "The following connection URLs are supported:", "common.fullScreen.enter": "Full Screen", "common.fullScreen.exit": "Exit Full Screen", "common.fullScreen.openAria": "Open full screen", diff --git a/redisinsight/ui/src/pages/home/components/add-database-screen/AddDatabaseScreen.tsx b/redisinsight/ui/src/pages/home/components/add-database-screen/AddDatabaseScreen.tsx index fcdffcf4ca..5ee20d8926 100644 --- a/redisinsight/ui/src/pages/home/components/add-database-screen/AddDatabaseScreen.tsx +++ b/redisinsight/ui/src/pages/home/components/add-database-screen/AddDatabaseScreen.tsx @@ -22,6 +22,7 @@ import { } from 'uiSrc/components/base/forms/buttons' import { InfoIcon } from 'uiSrc/components/base/icons' import { RiTooltip } from 'uiSrc/components' +import { Trans, useTranslation } from 'uiSrc/i18n' import ConnectivityOptions from './components/connectivity-options' import ConnectionUrl from './components/connection-url' import { Values } from './constants' @@ -51,17 +52,17 @@ const getPayload = (connectionUrl: string, returnOnError = false) => { } } -const ConnectionUrlError = ( - <> - The connection URL format provided is not supported. -
- Try adding a database using a connection form. - -) - const AddDatabaseScreen = (props: Props) => { + const { t } = useTranslation() const { onSelectOption, onClose } = props const [isInvalid, setIsInvalid] = useState(false) + + const connectionUrlError = ( + }} + /> + ) const { loadingChanging: isLoading } = useAppSelector(instancesSelector) const dispatch = useAppDispatch() @@ -120,7 +121,7 @@ const AddDatabaseScreen = (props: Props) => { {ConnectionUrlError} : null} + content={isInvalid ? {connectionUrlError} : null} > { loading={isLoading} data-testid="btn-test-connection" > - Test connection + {t('addDatabase.button.testConnection')} @@ -142,14 +143,14 @@ const AddDatabaseScreen = (props: Props) => { onClick={() => handleProceedForm(AddDbType.manual)} data-testid="btn-connection-settings" > - Connection settings + {t('addDatabase.button.connectionSettings')} {ConnectionUrlError} : null} + content={isInvalid ? {connectionUrlError} : null} > { icon={isInvalid ? InfoIcon : undefined} data-testid="btn-submit" > - Add database + {t('addDatabase.button.addDatabase')} @@ -167,7 +168,7 @@ const AddDatabaseScreen = (props: Props) => { - Or + {t('addDatabase.divider.or')} ) => void } -const connectionUrlInfo: RiInfoIconProps = { - content: HostInfoTooltipContent({ includeAutofillInfo: false }), - placement: 'right', - maxWidth: '100%', -} +const ConnectionUrl = ({ value, onChange }: Props) => { + const { t } = useTranslation() + + const connectionUrlInfo: RiInfoIconProps = { + content: HostInfoTooltipContent({ includeAutofillInfo: false, t }), + placement: 'right', + maxWidth: '100%', + } -const ConnectionUrl = ({ value, onChange }: Props) => ( - Connection URL} - infoIconProps={connectionUrlInfo} - > -