diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 7c25c65f77..197c9c9cb0 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 5.1, "features": { "dev-language": { "flag": true, @@ -155,6 +155,10 @@ "prodMode": { "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 2fb0827b6e..904cfdb641 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -39,6 +39,7 @@ export enum KnownFeatures { VectorSet = 'vectorSet', DevArray = 'dev-array', ProdMode = 'prodMode', + ValueDecoder = 'valueDecoder', DevLanguage = 'dev-language', } diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index 3fbce7f867..d4b4e93758 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -95,6 +95,10 @@ export const knownFeatures: Record = { name: KnownFeatures.ProdMode, storage: FeatureStorage.Database, }, + [KnownFeatures.ValueDecoder]: { + name: KnownFeatures.ValueDecoder, + storage: FeatureStorage.Database, + }, [KnownFeatures.DevLanguage]: { name: KnownFeatures.DevLanguage, 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 e4f920121c..a5678d2fc1 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 @@ -114,6 +114,10 @@ export class FeatureFlagProvider { KnownFeatures.ProdMode, new CommonFlagStrategy(this.featuresConfigService, this.settingsService), ); + this.strategies.set( + KnownFeatures.ValueDecoder, + new CommonFlagStrategy(this.featuresConfigService, this.settingsService), + ); this.strategies.set( KnownFeatures.DevLanguage, new SwitchableFlagStrategy( diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index de186aea4b..cbf33750d8 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -17,5 +17,6 @@ export enum FeatureFlags { azureEntraId = 'azureEntraId', devBrowser = 'dev-browser', prodMode = 'prodMode', + valueDecoder = 'valueDecoder', devLanguage = 'dev-language', } diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index 4ff6233c27..4d18a8cfae 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -45,6 +45,7 @@ enum BrowserStorageItem { vectorSearchQueryOnboarding = 'vectorSearchQueryOnboarding', vectorSearchSelectKeyOnboarding = 'vectorSearchSelectKeyOnboarding', vectorSearchCreateIndexOnboarding = 'vectorSearchCreateIndexOnboarding', + valueDecoderRules = 'valueDecoderRules_', wbTsResultPreferences = 'wbTsResultPreferences_', prodModeCtaActioned = 'prodModeCtaActioned', } 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..3ab5ba3fe5 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/value-decoder/ConfigValueDecoderButton.tsx @@ -0,0 +1,30 @@ +import React, { useCallback } from 'react' + +import { EmptyButton } from 'uiSrc/components/base/forms/buttons' +import { RiTooltip } from 'uiSrc/components' + +import { useValueDecoder } from './ValueDecoderProvider' +import { VALUE_DECODER_TEST_ID } from './constants' + +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 452f03c9e1..6e447d9d6a 100644 --- a/redisinsight/ui/src/slices/app/features.ts +++ b/redisinsight/ui/src/slices/app/features.ts @@ -80,6 +80,9 @@ export const initialState: StateAppFeatures = { [FeatureFlags.prodMode]: { flag: false, }, + [FeatureFlags.valueDecoder]: { + flag: false, + }, [FeatureFlags.devLanguage]: { flag: false, }, @@ -219,6 +222,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 => {