Skip to content

Commit 70a7882

Browse files
Merge pull request #3665 from RedisInsight/fe/feature/RI-5965-add-a-timestamp-formatter
RI-5965 Added a new timestamp formatter
2 parents 09f2545 + bfd2e57 commit 70a7882

File tree

10 files changed

+225
-5
lines changed

10 files changed

+225
-5
lines changed

redisinsight/ui/src/constants/keys.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,11 @@ export enum KeyValueFormat {
148148
Pickle = 'Pickle',
149149
Vector32Bit = 'Vector 32-bit',
150150
Vector64Bit = 'Vector 64-bit',
151+
DateTime = 'DateTime',
151152
}
152153

154+
export const DATETIME_FORMATTER_DEFAULT = 'HH:mm:ss.SSS d MMM yyyy'
155+
153156
export enum KeyValueCompressor {
154157
GZIP = 'GZIP',
155158
ZSTD = 'ZSTD',

redisinsight/ui/src/pages/browser/modules/key-details-header/components/key-details-header-formatter/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export const KEY_VALUE_FORMATTER_OPTIONS = [
4949
text: 'Vector 64-bit',
5050
value: KeyValueFormat.Vector64Bit,
5151
},
52+
{
53+
text: 'Timestamp to DateTime',
54+
value: KeyValueFormat.DateTime,
55+
}
5256
]
5357

5458
export const KEY_VALUE_JSON_FORMATTER_OPTIONS = []

redisinsight/ui/src/utils/formatters/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,17 @@ export const bufferFormatRangeItems = (
1111

1212
return newItems
1313
}
14+
15+
export const convertTimestampToMilliseconds = (value: string): number => {
16+
// seconds, microseconds, nanoseconds to milliseconds
17+
switch (parseInt(value, 10).toString().length) {
18+
case 10:
19+
return +value * 1000
20+
case 16:
21+
return +value / 1000
22+
case 19:
23+
return +value / 1000000
24+
default:
25+
return +value
26+
}
27+
}

redisinsight/ui/src/utils/formatters/valueFormatters.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { serialize, unserialize } from 'php-serialize'
66
import { getData } from 'rawproto'
77
import { Parser } from 'pickleparser'
88
import JSONBigInt from 'json-bigint'
9+
import { format as formatDateFns } from 'date-fns'
910

1011
import JSONViewer from 'uiSrc/components/json-viewer/JSONViewer'
11-
import { KeyValueFormat } from 'uiSrc/constants'
12+
import { DATETIME_FORMATTER_DEFAULT, KeyValueFormat } from 'uiSrc/constants'
1213
import { RedisResponseBuffer } from 'uiSrc/slices/interfaces'
1314
import {
1415
anyToBuffer,
@@ -23,6 +24,8 @@ import {
2324
Maybe,
2425
bufferToFloat64Array,
2526
bufferToFloat32Array,
27+
checkTimestamp,
28+
convertTimestampToMilliseconds,
2629
} from 'uiSrc/utils'
2730
import { reSerializeJSON } from 'uiSrc/utils/formatters/json'
2831

@@ -149,6 +152,20 @@ const formattingBuffer = (
149152
return { value: bufferToUTF8(reply), isValid: false }
150153
}
151154
}
155+
case KeyValueFormat.DateTime: {
156+
const value = bufferToUnicode(reply)?.trim()
157+
try {
158+
if (checkTimestamp(value)) {
159+
// formatting to DateTime only from timestamp(the number of milliseconds since January 1, 1970, UTC).
160+
// if seconds - add milliseconds (since JS Date works only with milliseconds)
161+
const timestamp = convertTimestampToMilliseconds(value)
162+
return { value: formatDateFns(timestamp, DATETIME_FORMATTER_DEFAULT), isValid: true }
163+
}
164+
} catch (e) {
165+
// if error return default
166+
}
167+
return { value, isValid: false }
168+
}
152169
default: return { value: bufferToUnicode(reply), isValid: true }
153170
}
154171
}

redisinsight/ui/src/utils/tests/formatters/valueFormatters.spec.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { format } from 'date-fns'
12
import { encode } from 'msgpackr'
23
import { serialize } from 'php-serialize'
3-
import { KeyValueFormat } from 'uiSrc/constants'
4-
import { anyToBuffer, bufferToSerializedFormat, stringToBuffer, stringToSerializedBufferFormat } from 'uiSrc/utils'
4+
import { DATETIME_FORMATTER_DEFAULT, KeyValueFormat } from 'uiSrc/constants'
5+
import { anyToBuffer, bufferToSerializedFormat, formattingBuffer, stringToBuffer, stringToSerializedBufferFormat } from 'uiSrc/utils'
56

67
describe('bufferToSerializedFormat', () => {
78
describe(KeyValueFormat.JSON, () => {
@@ -174,3 +175,43 @@ describe('stringToSerializedBufferFormat', () => {
174175
})
175176
})
176177
})
178+
179+
describe('formattingBuffer', () => {
180+
describe(KeyValueFormat.DateTime, () => {
181+
describe('should properly format timestamp number', () => {
182+
// Since we formatting with local timezome, we cannot hardcode the expected string result
183+
const expected = new Date(1722593319805)
184+
const testValues = [new Uint8Array([49, 55, 50, 50, 53, 57, 51, 51, 49, 57, 56, 48, 53])].map((v) => ({
185+
input: anyToBuffer(v),
186+
expected: { value: format(expected, DATETIME_FORMATTER_DEFAULT), isValid: true },
187+
}))
188+
189+
test.each(testValues)('test %j', ({ input, expected }) => {
190+
expect(formattingBuffer(input, KeyValueFormat.DateTime)).toEqual(expected)
191+
})
192+
})
193+
194+
describe('should left iso strings and other strings as they are', () => {
195+
const testValues = [
196+
{
197+
input: anyToBuffer(new Uint8Array(
198+
[65, 110, 121, 32, 83, 116, 114, 105, 110, 103]
199+
)),
200+
expected: { value: 'Any String', isValid: false },
201+
},
202+
{
203+
input: anyToBuffer(new Uint8Array([
204+
50, 48, 50, 52, 45, 48, 56, 45,
205+
48, 50, 84, 48, 48, 58, 48, 48,
206+
58, 48, 48, 46, 48, 48, 48, 90
207+
])),
208+
expected: { value: '2024-08-02T00:00:00.000Z', isValid: false }
209+
}
210+
]
211+
212+
test.each(testValues)('test %j', ({ input, expected }) => {
213+
expect(formattingBuffer(input, KeyValueFormat.DateTime)).toEqual(expected)
214+
})
215+
})
216+
})
217+
})

redisinsight/ui/src/utils/tests/validations.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
validateConsumerGroupId,
1717
validateNumber,
1818
validateTimeoutNumber,
19+
checkTimestamp,
20+
checkConvertToDate,
1921
} from 'uiSrc/utils'
2022

2123
const text1 = '123 123 123'
@@ -32,6 +34,39 @@ const text11 = '3.3.1'
3234
const text12 = '-3-2'
3335
const text13 = '5'
3436

37+
const checkTimestampTests = [
38+
{ input: '1234567891', expected: true },
39+
{ input: '1234567891234', expected: true },
40+
{ input: '1234567891234567', expected: true },
41+
{ input: '1234567891234567891', expected: true },
42+
{ input: '1234567891.2', expected: true },
43+
// it should be valid timestamp (for date < 1970)
44+
{ input: '-123456789', expected: true },
45+
{ input: '', expected: false },
46+
{ input: '-', expected: false },
47+
{ input: '0', expected: false },
48+
{ input: '1', expected: false },
49+
{ input: '123', expected: false },
50+
{ input: '12345678911', expected: false },
51+
{ input: '12345678912345', expected: false },
52+
{ input: '12345678912345678', expected: false },
53+
{ input: '1234567891.2.2', expected: false },
54+
{ input: '1234567891asd', expected: false },
55+
{ input: 'inf', expected: false },
56+
{ input: '-inf', expected: false },
57+
{ input: '1234567891:12', expected: false },
58+
{ input: '1234567891a12', expected: false },
59+
]
60+
61+
const checkConvertToDateTests = [
62+
...checkTimestampTests,
63+
{ input: '2024-08-02T00:00:00.000Z', expected: true },
64+
{ input: '10-10-2020', expected: true },
65+
{ input: '10/10/2020', expected: true },
66+
{ input: '10/10/2020invalid', expected: false },
67+
{ input: 'invalid', expected: false },
68+
]
69+
3570
describe('Validations utils', () => {
3671
describe('validateField', () => {
3772
it('validateField should return text without empty spaces', () => {
@@ -275,4 +310,16 @@ describe('Validations utils', () => {
275310
expect(result).toBe(expected)
276311
})
277312
})
313+
314+
describe('checkTimestamp', () => {
315+
test.each(checkTimestampTests)('%j', ({ input, expected }) => {
316+
expect(checkTimestamp(input)).toEqual(expected)
317+
})
318+
})
319+
320+
describe('checkConvertToDate', () => {
321+
test.each(checkConvertToDateTests)('%j', ({ input, expected }) => {
322+
expect(checkConvertToDate(input)).toEqual(expected)
323+
})
324+
})
278325
})

redisinsight/ui/src/utils/validations.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { floor } from 'lodash'
2+
import { isValid } from 'date-fns'
23

34
export const MAX_TTL_NUMBER = 2_147_483_647
45
export const MAX_PORT_NUMBER = 65_535
@@ -126,3 +127,41 @@ export const getApproximatePercentage = (total?: number, part: number = 0): stri
126127
const percent = (total ? part / total : 1) * 100
127128
return `${getApproximateNumber(percent)}%`
128129
}
130+
131+
export const IS_NUMBER_REGEX = /^-?\d*(\.\d+)?$/
132+
export const IS_TIMESTAMP = /^(\d{10}|\d{13}|\d{16}|\d{19})$/
133+
export const IS_NEGATIVE_TIMESTAMP = /^-(\d{9}|\d{12}|\d{15}|\d{18})$/
134+
export const IS_INTEGER_NUMBER_REGEX = /^\d+$/
135+
136+
const detailedTimestampCheck = (value: string) => {
137+
try {
138+
// test integer to be of 10, 13, 16 or 19 digits
139+
const integerPart = parseInt(value, 10).toString()
140+
141+
if (IS_TIMESTAMP.test(integerPart) || IS_NEGATIVE_TIMESTAMP.test(integerPart)) {
142+
if (integerPart.length === value.length) {
143+
return true
144+
}
145+
// check part after dot separator (checking floating numbers)
146+
const subPart = value.replace(integerPart, '')
147+
return IS_INTEGER_NUMBER_REGEX.test(subPart.substring(1, subPart.length))
148+
}
149+
return false
150+
} catch (err) {
151+
// ignore errors
152+
return false
153+
}
154+
}
155+
156+
// checks stringified number to may be a timestamp
157+
export const checkTimestamp = (value: string): boolean => IS_NUMBER_REGEX.test(value) && detailedTimestampCheck(value)
158+
159+
// checks any string to may be converted to date
160+
export const checkConvertToDate = (value: string): boolean => {
161+
// if string is not number-like, try to convert it to date
162+
if (!IS_NUMBER_REGEX.test(value)) {
163+
return isValid(new Date(value))
164+
}
165+
166+
return checkTimestamp(value)
167+
}

tests/e2e/test-data/formatters-data.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Vector32BitFormatter,
1212
Vector64BitFormatter
1313
} from './formatters';
14+
import { DataTimeFormatter } from './formatters/DataTime';
1415

1516
interface IFormatter {
1617
format: string,
@@ -36,7 +37,8 @@ export const formatters: IFormatter[] = [
3637
BinaryFormatter,
3738
PickleFormatter,
3839
Vector32BitFormatter,
39-
Vector64BitFormatter
40+
Vector64BitFormatter,
41+
DataTimeFormatter
4042
];
4143

4244
export const binaryFormattersSet: IFormatter[] = [
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const DataTimeFormatter = {
2+
format: 'Timestamp to DateTime',
3+
fromText: '1633072800',
4+
fromTextEdit: '-179064000000',
5+
formattedText: '09:20:00.000 1 Oct 2021',
6+
formattedTextEdit: '13:00:00.000 29 Apr 1964'
7+
};

tests/e2e/tests/web/critical-path/browser/formatters.e2e.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
formattersHighlightedSet,
1212
formattersWithTooltipSet,
1313
fromBinaryFormattersSet,
14-
notEditableFormattersSet
14+
notEditableFormattersSet,
15+
formatters
1516
} from '../../../../test-data/formatters-data';
1617
import { phpData } from '../../../../test-data/formatters';
1718

@@ -233,3 +234,48 @@ notEditableFormattersSet.forEach(formatter => {
233234
}
234235
});
235236
});
237+
test('Verify that user can format timestamp value', async t => {
238+
const formatterName = 'Timestamp to DateTime';
239+
await browserPage.openKeyDetailsByKeyName(keysData[0].keyName);
240+
//Add fields to the hash key
241+
await browserPage.selectFormatter('Unicode');
242+
const formatter = formatters.find(f => f.format === formatterName);
243+
if (!formatter) {
244+
throw new Error('Formatter not found');
245+
}
246+
// add key in sec
247+
const hashSec = {
248+
field: 'fromTextSec',
249+
value: formatter.fromText!
250+
};
251+
// add key in msec
252+
const hashMsec = {
253+
field: 'fromTextMsec',
254+
value: `${formatter.fromText!}000`
255+
};
256+
// add key with minus
257+
const hashMinusSec = {
258+
field: 'fromTextEdit',
259+
value: formatter.fromTextEdit!
260+
};
261+
//Search the added field
262+
await browserPage.addFieldToHash(
263+
hashSec.field, hashSec.value
264+
);
265+
await browserPage.addFieldToHash(
266+
hashMsec.field, hashMsec.value
267+
);
268+
await browserPage.addFieldToHash(
269+
hashMinusSec.field, hashMinusSec.value
270+
);
271+
272+
await browserPage.searchByTheValueInKeyDetails(hashSec.field);
273+
await browserPage.selectFormatter('DateTime');
274+
await t.expect(await browserPage.getHashKeyValue()).eql(formatter.formattedText!, `Value is not formatted as DateTime ${formatter.fromText}`);
275+
276+
await browserPage.searchByTheValueInKeyDetails(hashMsec.field);
277+
await t.expect(await browserPage.getHashKeyValue()).eql(formatter.formattedText!, `Value is not formatted as DateTime ${formatter.fromTextEdit}`);
278+
279+
await browserPage.searchByTheValueInKeyDetails(hashMinusSec.field);
280+
await t.expect(await browserPage.getHashKeyValue()).eql(formatter.formattedTextEdit!, `Value is not formatted as DateTime ${formatter.fromTextEdit}`);
281+
});

0 commit comments

Comments
 (0)