diff --git a/src/components/EnableFullscreenButton/EnableFullscreenButton.tsx b/src/components/EnableFullscreenButton/EnableFullscreenButton.tsx index 7f13fe581..8e3f46288 100644 --- a/src/components/EnableFullscreenButton/EnableFullscreenButton.tsx +++ b/src/components/EnableFullscreenButton/EnableFullscreenButton.tsx @@ -1,4 +1,5 @@ import {SquareDashed} from '@gravity-ui/icons'; +import type {ButtonView} from '@gravity-ui/uikit'; import {Button, Icon} from '@gravity-ui/uikit'; import {enableFullscreen} from '../../store/reducers/fullscreen'; @@ -6,20 +7,16 @@ import {useTypedDispatch} from '../../utils/hooks'; interface EnableFullscreenButtonProps { disabled?: boolean; + view?: ButtonView; } -function EnableFullscreenButton({disabled}: EnableFullscreenButtonProps) { +function EnableFullscreenButton({disabled, view = 'flat-secondary'}: EnableFullscreenButtonProps) { const dispatch = useTypedDispatch(); const onEnableFullscreen = () => { dispatch(enableFullscreen()); }; return ( - ); diff --git a/src/components/EntityStatus/EntityStatus.tsx b/src/components/EntityStatus/EntityStatus.tsx index b4ac4b71c..5e72b2bb5 100644 --- a/src/components/EntityStatus/EntityStatus.tsx +++ b/src/components/EntityStatus/EntityStatus.tsx @@ -16,6 +16,7 @@ const b = cn('entity-status'); interface EntityStatusProps { status?: EFlag; name?: string; + renderName?: (name?: string) => React.ReactNode; label?: string; path?: string; iconPath?: string; @@ -34,9 +35,14 @@ interface EntityStatusProps { className?: string; } +function defaultRenderName(name?: string) { + return name ?? ''; +} + export function EntityStatus({ status = EFlag.Grey, name = '', + renderName = defaultRenderName, label, path, iconPath, @@ -75,14 +81,14 @@ export function EntityStatus({ if (externalLink) { return ( - {name} + {renderName(name)} ); } return ( - {name} + {renderName(name)} ); } diff --git a/src/components/JsonViewer/JsonViewer.scss b/src/components/JsonViewer/JsonViewer.scss index 1286148b1..9b3edc81d 100644 --- a/src/components/JsonViewer/JsonViewer.scss +++ b/src/components/JsonViewer/JsonViewer.scss @@ -4,6 +4,8 @@ --data-table-row-height: 20px; --toolbar-background-color: var(--g-color-base-background); + width: max-content; + &__toolbar { position: sticky; z-index: 2; diff --git a/src/components/JsonViewer/JsonViewer.tsx b/src/components/JsonViewer/JsonViewer.tsx index fbb5c411e..8112820b0 100644 --- a/src/components/JsonViewer/JsonViewer.tsx +++ b/src/components/JsonViewer/JsonViewer.tsx @@ -33,6 +33,8 @@ interface JsonViewerCommonProps { tableSettings?: DT100.Settings; search?: boolean; collapsedInitially?: boolean; + maxValueWidth?: number; + toolbarClassName?: string; } interface JsonViewerProps extends JsonViewerCommonProps { @@ -60,7 +62,7 @@ const SETTINGS: DT100.Settings = { displayIndices: false, dynamicRender: true, sortable: false, - dynamicRenderMinSize: 100, + dynamicRenderMinSize: 50, }; function getCollapsedState(value: UnipikaValue) { @@ -114,6 +116,8 @@ function JsonViewerComponent({ search = true, extraTools, collapsedInitially, + maxValueWidth = 100, + toolbarClassName, }: JsonViewerComponentProps) { const [caseSensitiveSearch, setCaseSensitiveSearch] = useSetting( CASE_SENSITIVE_JSON_SEARCH, @@ -162,6 +166,7 @@ function JsonViewerComponent({ filter={filter} showFullText={onShowFullText} index={index} + maxValueWidth={maxValueWidth} /> ); }; @@ -295,7 +300,7 @@ function JsonViewerComponent({ const renderToolbar = () => { return ( - + + + + + ); + }; + + const truncated = safeParseNumber(size) > MESSAGE_SIZE_LIMIT; + + return ( + } + renderToolbar={renderToolbar} + className={b('message')} + > + {messageContent} + + ); +} + +interface MessageTitleProps { + truncated?: boolean; +} + +function MessageTitle({truncated}: MessageTitleProps) { + return ( + + {i18n('label_message')} + {truncated && ( + + {' '} + + [ + {i18n('label_truncated', { + size: bytesToMB(MESSAGE_SIZE_LIMIT), + })} + ] + + + )} + + ); +} diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessageGeneralInfo.tsx b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessageGeneralInfo.tsx new file mode 100644 index 000000000..ebf478476 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessageGeneralInfo.tsx @@ -0,0 +1,65 @@ +import {DefinitionList, Flex} from '@gravity-ui/uikit'; + +import type {TopicMessage} from '../../../../../../types/api/topic'; +import type {ValueOf} from '../../../../../../types/common'; +import {formatTimestamp} from '../../../../../../utils/dataFormatters/dataFormatters'; +import {TOPIC_DATA_COLUMNS_IDS} from '../../utils/types'; +import {b} from '../shared'; + +import {fields} from './fields'; + +const dataGroups: { + name: ValueOf; + copy?: (row: TopicMessage) => string | undefined; +}[][] = [ + [ + {name: TOPIC_DATA_COLUMNS_IDS.PARTITION}, + {name: TOPIC_DATA_COLUMNS_IDS.OFFSET}, + {name: TOPIC_DATA_COLUMNS_IDS.SIZE}, + ], + [ + { + name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_CREATE, + copy: (row) => formatTimestamp(row.CreateTimestamp), + }, + { + name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_WRITE, + copy: (row) => formatTimestamp(row.WriteTimestamp), + }, + {name: TOPIC_DATA_COLUMNS_IDS.TS_DIFF}, + ], + [ + {name: TOPIC_DATA_COLUMNS_IDS.ORIGINAL_SIZE}, + {name: TOPIC_DATA_COLUMNS_IDS.CODEC}, + {name: TOPIC_DATA_COLUMNS_IDS.PRODUCERID, copy: (row) => row.ProducerId}, + {name: TOPIC_DATA_COLUMNS_IDS.SEQNO, copy: (row) => row.SeqNo}, + ], +]; + +interface TopicMessageGeneralInfoProps { + messageData: TopicMessage; +} + +export function TopicMessageGeneralInfo({messageData}: TopicMessageGeneralInfoProps) { + return ( + + {dataGroups.map((group, index) => ( + + {group.map((item) => { + const column = fields.find((f) => f.name === item.name); + const copyText = item.copy?.(messageData); + return ( + + {column?.render?.({row: messageData})} + + ); + })} + + ))} + + ); +} diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessageMetadata.tsx b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessageMetadata.tsx new file mode 100644 index 000000000..4511a1b64 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/TopicMessageMetadata.tsx @@ -0,0 +1,25 @@ +import {DefinitionList} from '@gravity-ui/uikit'; + +import type {TopicMessageMetadataItem} from '../../../../../../types/api/topic'; +import i18n from '../../i18n'; +import {b} from '../shared'; + +import {TopicDataSection} from './TopicDataSection'; + +interface TopicMessageMetadataProps { + data: TopicMessageMetadataItem[]; +} + +export function TopicMessageMetadata({data}: TopicMessageMetadataProps) { + return ( + + + {data.map((item) => ( + + {item.Value} + + ))} + + + ); +} diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/fields.tsx b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/fields.tsx new file mode 100644 index 000000000..aeca92893 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/components/fields.tsx @@ -0,0 +1,90 @@ +import {isNil} from 'lodash'; + +import type {TopicMessageEnhanced} from '../../../../../../types/api/topic'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../../../utils/constants'; +import { + TopicDataTimestamp, + codecColumn, + messageColumn, + metadataColumn, + originalSizeColumn, + sizeColumn, + tsDiffColumn, +} from '../../columns/columns'; +import {useTopicDataQueryParams} from '../../useTopicDataQueryParams'; +import {TOPIC_DATA_COLUMNS_TITLES} from '../../utils/constants'; +import {TOPIC_DATA_COLUMNS_IDS} from '../../utils/types'; + +function valueOrPlaceholder( + value: string | number | undefined, + placeholder = EMPTY_DATA_PLACEHOLDER, +) { + return isNil(value) ? placeholder : value; +} + +type TopicMessageDetailsField = { + name: string; + header?: React.ReactNode; + render: (props: {row: TopicMessageEnhanced}) => React.ReactNode; +}; + +const partitionColumn: TopicMessageDetailsField = { + name: TOPIC_DATA_COLUMNS_IDS.PARTITION, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.PARTITION], + render: () => { + return ; + }, +}; +const offsetColumn: TopicMessageDetailsField = { + name: TOPIC_DATA_COLUMNS_IDS.OFFSET, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.OFFSET], + render: ({row}) => { + return valueOrPlaceholder(row.Offset); + }, +}; + +const timestampCreateColumn: TopicMessageDetailsField = { + name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_CREATE, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_CREATE], + render: ({row}) => , +}; +const timestampWriteColumn: TopicMessageDetailsField = { + name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_WRITE, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_WRITE], + render: ({row}) => , +}; + +const producerIdColumn: TopicMessageDetailsField = { + name: TOPIC_DATA_COLUMNS_IDS.PRODUCERID, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.PRODUCERID], + render: ({row}) => { + return valueOrPlaceholder(row.ProducerId); + }, +}; +const seqNoColumn: TopicMessageDetailsField = { + name: TOPIC_DATA_COLUMNS_IDS.SEQNO, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.SEQNO], + render: ({row}) => { + return valueOrPlaceholder(row.SeqNo); + }, +}; + +function PartitionId() { + const {selectedPartition} = useTopicDataQueryParams(); + return selectedPartition; +} + +export const fields: TopicMessageDetailsField[] = [ + partitionColumn, + offsetColumn, + timestampCreateColumn, + timestampWriteColumn, + tsDiffColumn, + metadataColumn, + messageColumn, + sizeColumn, + originalSizeColumn, + codecColumn, + producerIdColumn, + seqNoColumn, +]; diff --git a/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/shared.ts b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/shared.ts new file mode 100644 index 000000000..b62ad0f8d --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TopicData/TopicMessageDetails/shared.ts @@ -0,0 +1,5 @@ +import {cn} from '../../../../../utils/cn'; + +export const b = cn('ydb-diagnostics-message-details'); + +export const MESSAGE_SIZE_LIMIT = 10_000_000; diff --git a/src/containers/Tenant/Diagnostics/TopicData/__test__/getData.test.ts b/src/containers/Tenant/Diagnostics/TopicData/__test__/getData.test.ts index 93e02a04b..b75d59e6a 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/__test__/getData.test.ts +++ b/src/containers/Tenant/Diagnostics/TopicData/__test__/getData.test.ts @@ -3,7 +3,7 @@ import {prepareResponse} from '../getData'; import {TOPIC_DATA_FETCH_LIMIT} from '../utils/constants'; describe('prepareResponse', () => { - test('should handle case with some removed messages', () => { + test('should handle case with some notLoaded messages', () => { const response: TopicDataResponse = { StartOffset: '105', EndOffset: '120', @@ -24,11 +24,11 @@ describe('prepareResponse', () => { expect(result.messages[5]).toEqual({Offset: '105'}); expect(result.messages[6]).toEqual({Offset: '106'}); expect(result.messages[7]).toEqual({Offset: '107'}); - expect(result.messages[8]).toEqual({Offset: 108, removed: true}); - expect(result.messages[19]).toEqual({Offset: 119, removed: true}); + expect(result.messages[8]).toEqual({Offset: 108, notLoaded: true}); + expect(result.messages[19]).toEqual({Offset: 119, notLoaded: true}); }); - test('should handle case with more removed messages than the limit', () => { + test('should handle case with more notLoaded messages than the limit', () => { const response: TopicDataResponse = { StartOffset: '150', EndOffset: '170', @@ -77,9 +77,9 @@ describe('prepareResponse', () => { expect(result.end).toBe(120); // Should have placeholders for all offsets in range expect(result.messages.length).toBe(TOPIC_DATA_FETCH_LIMIT); - // All should be marked as removed - expect(result.messages[0]).toEqual({Offset: 100, removed: true}); - expect(result.messages[19]).toEqual({Offset: 119, removed: true}); + // All should be marked as notLoaded + expect(result.messages[0]).toEqual({Offset: 100, notLoaded: true}); + expect(result.messages[19]).toEqual({Offset: 119, notLoaded: true}); }); test('should handle case with more messages than the limit', () => { @@ -109,7 +109,7 @@ describe('prepareResponse', () => { expect(result.messages[2]).toEqual({Offset: '102'}); }); - test('should handle case with both removed and actual messages within limit', () => { + test('should handle case with both notLoaded and actual messages within limit', () => { const response: TopicDataResponse = { StartOffset: '110', EndOffset: '130', @@ -159,13 +159,13 @@ describe('prepareResponse', () => { expect(result.messages[5]).toEqual({Offset: '105'}); expect(result.messages[9]).toEqual({Offset: '109'}); - // Check removed messages (gaps) - expect(result.messages[1]).toEqual({Offset: 101, removed: true}); - expect(result.messages[3]).toEqual({Offset: 103, removed: true}); - expect(result.messages[4]).toEqual({Offset: 104, removed: true}); - expect(result.messages[6]).toEqual({Offset: 106, removed: true}); - expect(result.messages[7]).toEqual({Offset: 107, removed: true}); - expect(result.messages[8]).toEqual({Offset: 108, removed: true}); + // Check notLoaded messages (gaps) + expect(result.messages[1]).toEqual({Offset: 101, notLoaded: true}); + expect(result.messages[3]).toEqual({Offset: 103, notLoaded: true}); + expect(result.messages[4]).toEqual({Offset: 104, notLoaded: true}); + expect(result.messages[6]).toEqual({Offset: 106, notLoaded: true}); + expect(result.messages[7]).toEqual({Offset: 107, notLoaded: true}); + expect(result.messages[8]).toEqual({Offset: 108, notLoaded: true}); }); test('should handle case with offset greater than EndOffset', () => { @@ -211,9 +211,9 @@ describe('prepareResponse', () => { expect(result.end).toBe(110); // Should have placeholders for all offsets in range expect(result.messages.length).toBe(10); - // All should be marked as removed + // All should be marked as notLoaded for (let i = 0; i < 10; i++) { - expect(result.messages[i]).toEqual({Offset: 100 + i, removed: true}); + expect(result.messages[i]).toEqual({Offset: 100 + i, notLoaded: true}); } }); @@ -234,7 +234,7 @@ describe('prepareResponse', () => { expect(result.messages[0]).toEqual({Offset: '0'}); expect(result.messages[1]).toEqual({Offset: '1'}); expect(result.messages[2]).toEqual({Offset: '2'}); - expect(result.messages[3]).toEqual({Offset: 3, removed: true}); - expect(result.messages[9]).toEqual({Offset: 9, removed: true}); + expect(result.messages[3]).toEqual({Offset: 3, notLoaded: true}); + expect(result.messages[9]).toEqual({Offset: 9, notLoaded: true}); }); }); diff --git a/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss b/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss index 5be8951b6..8e890ad19 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss +++ b/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss @@ -1,8 +1,6 @@ -.ydb-diagnostics-topic-data-columns { - &__timestamp-ms { - color: var(--g-color-text-secondary); - } +@use '../../../../../styles/mixins.scss'; +.ydb-diagnostics-topic-data-columns { &__ts-diff_danger { color: var(--g-color-text-danger); } @@ -11,7 +9,24 @@ color: var(--g-color-text-info); } + &__offset { + display: inline-flex; + align-items: center; + gap: var(--g-spacing-1); + .g-help-mark, + .g-help-mark__button { + display: flex; + } + } + &__offset { + width: 100%; + height: 100%; + } + &__offset_link { + @extend .link; + } &__offset_removed { + cursor: not-allowed; text-decoration: line-through; } @@ -19,4 +34,10 @@ &__truncated { font-style: italic; } + &__help { + white-space: pre-wrap; + } + &__help-popover { + display: flex; + } } diff --git a/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx b/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx index f2230f669..02cfcdcd5 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx +++ b/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx @@ -1,12 +1,15 @@ import React from 'react'; +import {TriangleExclamation} from '@gravity-ui/icons'; import DataTable from '@gravity-ui/react-data-table'; -import {Text} from '@gravity-ui/uikit'; +import {ActionTooltip, Icon, Popover, Text} from '@gravity-ui/uikit'; import {isNil} from 'lodash'; +import {Link} from 'react-router-dom'; import {EntityStatus} from '../../../../../components/EntityStatus/EntityStatus'; import {MultilineTableHeader} from '../../../../../components/MultilineTableHeader/MultilineTableHeader'; import type {Column} from '../../../../../components/PaginatedTable'; +import {TENANT_DIAGNOSTICS_TABS_IDS} from '../../../../../store/reducers/tenant/constants'; import {TOPIC_MESSAGE_SIZE_LIMIT} from '../../../../../store/reducers/topic'; import type {TopicMessageEnhanced} from '../../../../../types/api/topic'; import {cn} from '../../../../../utils/cn'; @@ -14,7 +17,9 @@ import {EMPTY_DATA_PLACEHOLDER} from '../../../../../utils/constants'; import {formatBytes, formatTimestamp} from '../../../../../utils/dataFormatters/dataFormatters'; import {formatToMs} from '../../../../../utils/timeParsers'; import {safeParseNumber} from '../../../../../utils/utils'; +import {useDiagnosticsPageLinkGetter} from '../../DiagnosticsPages'; import i18n from '../i18n'; +import {useTopicDataQueryParams} from '../useTopicDataQueryParams'; import {TOPIC_DATA_COLUMNS_TITLES, codecNumberToName} from '../utils/constants'; import type {TopicDataColumnId} from '../utils/types'; import {TOPIC_DATA_COLUMNS_IDS} from '../utils/types'; @@ -23,160 +28,204 @@ import './Columns.scss'; const b = cn('ydb-diagnostics-topic-data-columns'); +export const offsetColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.OFFSET, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.OFFSET], + align: DataTable.LEFT, + render: ({row}) => { + const {Offset: offset, removed, notLoaded} = row; + + return ; + }, + width: 100, +}; + +export const timestampCreateColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_CREATE, + header: ( + + ), + align: DataTable.LEFT, + render: ({row: {CreateTimestamp}}) => + CreateTimestamp ? ( + } + name={formatTimestamp(CreateTimestamp)} + hasClipboardButton + /> + ) : ( + EMPTY_DATA_PLACEHOLDER + ), + width: 220, +}; + +export const timestampWriteColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_WRITE, + header: ( + + ), + align: DataTable.LEFT, + render: ({row: {WriteTimestamp}}) => + WriteTimestamp ? ( + } + name={formatTimestamp(WriteTimestamp)} + hasClipboardButton + /> + ) : ( + EMPTY_DATA_PLACEHOLDER + ), + width: 220, +}; + +export const tsDiffColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.TS_DIFF, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.TS_DIFF], + align: DataTable.RIGHT, + render: ({row}) => { + if (isNil(row.TimestampDiff)) { + return EMPTY_DATA_PLACEHOLDER; + } + const numericValue = safeParseNumber(row.TimestampDiff); + return ( + = 100_000})}> + {formatToMs(numericValue)} + + ); + }, + width: 90, + note: i18n('context_ts-diff'), +}; + +export const metadataColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.METADATA, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.METADATA], + align: DataTable.LEFT, + render: ({row: {MessageMetadata}}) => { + if (!MessageMetadata) { + return EMPTY_DATA_PLACEHOLDER; + } + const prepared = MessageMetadata.map(({Key = '', Value = ''}) => `${Key}: ${Value}`); + return prepared.join(', '); + }, + width: 200, +}; + +export const messageColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.MESSAGE, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.MESSAGE], + align: DataTable.LEFT, + render: ({row: {Message, OriginalSize}}) => { + if (isNil(Message)) { + return EMPTY_DATA_PLACEHOLDER; + } + let encryptedMessage; + let invalid = false; + try { + encryptedMessage = atob(Message); + } catch { + encryptedMessage = i18n('description_failed-decode'); + invalid = true; + } + + const truncated = safeParseNumber(OriginalSize) > TOPIC_MESSAGE_SIZE_LIMIT; + return ( + + {encryptedMessage} + {truncated && ( + + {' '} + {i18n('description_truncated')} + + )} + + ); + }, + width: 500, +}; + +export const sizeColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.SIZE, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.SIZE], + align: DataTable.RIGHT, + render: ({row}) => formatBytes(row.StorageSize), + width: 100, +}; + +export const originalSizeColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.ORIGINAL_SIZE, + header: ( + + ), + align: DataTable.RIGHT, + render: ({row}) => formatBytes(row.OriginalSize), + width: 100, +}; + +export const codecColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.CODEC, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.CODEC], + align: DataTable.RIGHT, + render: ({row: {Codec}}) => { + if (isNil(Codec)) { + return EMPTY_DATA_PLACEHOLDER; + } + return codecNumberToName[Codec] ?? Codec; + }, + width: 70, +}; + +export const producerIdColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.PRODUCERID, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.PRODUCERID], + align: DataTable.LEFT, + render: ({row}) => + row.ProducerId ? ( + + ) : ( + EMPTY_DATA_PLACEHOLDER + ), + width: 100, +}; + +export const seqNoColumn: Column = { + name: TOPIC_DATA_COLUMNS_IDS.SEQNO, + header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.SEQNO], + align: DataTable.RIGHT, + render: ({row}) => + row.SeqNo ? ( + + ) : ( + EMPTY_DATA_PLACEHOLDER + ), + width: 100, +}; + export function getAllColumns() { const columns: Column[] = [ - { - name: TOPIC_DATA_COLUMNS_IDS.OFFSET, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.OFFSET], - align: DataTable.LEFT, - render: ({row}) => { - const {Offset, removed} = row; - return ( - - {valueOrPlaceholder(Offset)} - - ); - }, - width: 100, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_CREATE, - header: ( - - ), - align: DataTable.LEFT, - render: ({row}) => , - width: 220, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_WRITE, - header: ( - - ), - align: DataTable.LEFT, - render: ({row}) => , - width: 220, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.TS_DIFF, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.TS_DIFF], - align: DataTable.RIGHT, - render: ({row}) => { - const numericValue = safeParseNumber(row.TimestampDiff); - return ( - = 100_000})}> - {formatToMs(numericValue)} - - ); - }, - width: 90, - note: i18n('context_ts-diff'), - }, - { - name: TOPIC_DATA_COLUMNS_IDS.METADATA, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.METADATA], - align: DataTable.LEFT, - render: ({row: {MessageMetadata}}) => { - if (!MessageMetadata) { - return EMPTY_DATA_PLACEHOLDER; - } - const prepared = MessageMetadata.map( - ({Key = '', Value = ''}) => `${Key}: ${Value}`, - ); - return prepared.join(', '); - }, - width: 200, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.MESSAGE, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.MESSAGE], - align: DataTable.LEFT, - render: ({row: {Message, OriginalSize}}) => { - if (isNil(Message)) { - return EMPTY_DATA_PLACEHOLDER; - } - let encryptedMessage; - let invalid = false; - try { - encryptedMessage = atob(Message); - } catch { - encryptedMessage = i18n('description_failed-decode'); - invalid = true; - } - - const truncated = safeParseNumber(OriginalSize) > TOPIC_MESSAGE_SIZE_LIMIT; - return ( - - {encryptedMessage} - {truncated && ( - - {' '} - {i18n('description_truncated')} - - )} - - ); - }, - width: 500, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.SIZE, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.SIZE], - align: DataTable.RIGHT, - render: ({row}) => formatBytes(row.StorageSize), - width: 100, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.ORIGINAL_SIZE, - header: ( - - ), - align: DataTable.RIGHT, - render: ({row}) => formatBytes(row.OriginalSize), - width: 100, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.CODEC, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.CODEC], - align: DataTable.RIGHT, - render: ({row: {Codec}}) => { - if (isNil(Codec)) { - return EMPTY_DATA_PLACEHOLDER; - } - return codecNumberToName[Codec] ?? Codec; - }, - width: 70, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.PRODUCERID, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.PRODUCERID], - align: DataTable.LEFT, - render: ({row}) => ( - - ), - width: 100, - }, - { - name: TOPIC_DATA_COLUMNS_IDS.SEQNO, - header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.SEQNO], - align: DataTable.RIGHT, - render: ({row}) => valueOrPlaceholder(row.SeqNo), - width: 70, - }, + offsetColumn, + timestampCreateColumn, + timestampWriteColumn, + tsDiffColumn, + metadataColumn, + messageColumn, + sizeColumn, + originalSizeColumn, + codecColumn, + producerIdColumn, + seqNoColumn, ]; return columns; } @@ -193,7 +242,7 @@ export const REQUIRED_TOPIC_DATA_COLUMNS: TopicDataColumnId[] = ['offset']; interface TopicDataTimestampProps { timestamp?: string; } -function TopicDataTimestamp({timestamp}: TopicDataTimestampProps) { +export function TopicDataTimestamp({timestamp}: TopicDataTimestampProps) { if (!timestamp) { return EMPTY_DATA_PLACEHOLDER; } @@ -201,16 +250,69 @@ function TopicDataTimestamp({timestamp}: TopicDataTimestampProps) { const splitted = formatted.split('.'); const ms = splitted.pop(); return ( - + {splitted.join('.')} - .{ms} - + + .{ms} + + ); } -function valueOrPlaceholder( - value: string | number | undefined, - placeholder = EMPTY_DATA_PLACEHOLDER, -) { - return isNil(value) ? placeholder : value; +interface PartitionIdProps { + offset?: string | number; + removed?: boolean; + notLoaded?: boolean; +} + +function Offset({offset, removed, notLoaded}: PartitionIdProps) { + const getDiagnosticsPageLink = useDiagnosticsPageLinkGetter(); + const {handleActiveOffsetChange} = useTopicDataQueryParams(); + + if (isNil(offset)) { + return EMPTY_DATA_PLACEHOLDER; + } + + if (removed) { + return ( + + + {offset} + + + ); + } + + const offsetLink = getDiagnosticsPageLink(TENANT_DIAGNOSTICS_TABS_IDS.topicData, { + activeOffset: String(offset), + }); + + const handleClick: React.MouseEventHandler = (e) => { + //if allow to navigate link, the table will be rerendered + e.stopPropagation(); + e.preventDefault(); + const stringOffset = String(offset); + + handleActiveOffsetChange(stringOffset); + }; + + return ( + + + {offset} + + {notLoaded && ( + {i18n('description_not-loaded-message')} + } + className={b('help-popover')} + > + + + + + )} + + ); } diff --git a/src/containers/Tenant/Diagnostics/TopicData/getData.ts b/src/containers/Tenant/Diagnostics/TopicData/getData.ts index 36ee21b3b..58a2a3cdc 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/getData.ts +++ b/src/containers/Tenant/Diagnostics/TopicData/getData.ts @@ -5,7 +5,6 @@ import {TOPIC_MESSAGE_SIZE_LIMIT} from '../../../../store/reducers/topic'; import type { TopicDataRequest, TopicDataResponse, - TopicMessage, TopicMessageEnhanced, } from '../../../../types/api/topic'; import {safeParseNumber} from '../../../../utils/utils'; @@ -16,8 +15,7 @@ import type {TopicDataFilters} from './utils/types'; const emptyData = {data: [], total: 0, found: 0}; interface GetTopicDataProps { - setStartOffset: (offset: number) => void; - setEndOffset: (offset: number) => void; + setBoundOffsets: (props: {startOffset: number; endOffset: number}) => void; baseOffset?: number; } @@ -49,7 +47,7 @@ export function prepareResponse(response: TopicDataResponse, offset: number) { } else { normalizedMessages.push({ Offset: currentOffset, - removed: true, + notLoaded: true, }); } j++; @@ -57,12 +55,8 @@ export function prepareResponse(response: TopicDataResponse, offset: number) { return {start, end, messages: normalizedMessages}; } -export const generateTopicDataGetter = ({ - setStartOffset, - setEndOffset, - baseOffset = 0, -}: GetTopicDataProps) => { - const getTopicData: FetchData = async ({ +export const generateTopicDataGetter = ({setBoundOffsets, baseOffset = 0}: GetTopicDataProps) => { + const getTopicData: FetchData = async ({ limit, offset: tableOffset, filters, @@ -93,8 +87,7 @@ export const generateTopicDataGetter = ({ const {start, end, messages} = prepareResponse(response, normalizedOffset); //need to update start and end offsets every time data is fetched to show fresh data in parent component - setStartOffset(start); - setEndOffset(end); + setBoundOffsets({startOffset: start, endOffset: end}); const quantity = end - baseOffset; diff --git a/src/containers/Tenant/Diagnostics/TopicData/i18n/en.json b/src/containers/Tenant/Diagnostics/TopicData/i18n/en.json index ecbc71682..c147c4605 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/i18n/en.json +++ b/src/containers/Tenant/Diagnostics/TopicData/i18n/en.json @@ -1,8 +1,9 @@ { "label_offset": "Offset", + "label_partition": "Partition ID", "label_timestamp-create": "Timestamp Create", "label_timestamp-write": "Timestamp Write", - "label_ts_diff": "TS Diff", + "label_ts_diff": "Write Lag", "label_key": "Key", "label_metadata": "Metadata", "label_message": "Message", @@ -24,5 +25,12 @@ "action_scroll-selected": "Scroll to selected offset", "action_scroll-up": "Scroll to the start", "description_failed-decode": "Failed to decode message", - "description_truncated": "[truncated]" + "description_truncated": "[truncated]", + "context_message-not-found": "Message with offset {{offset}} not found", + "context_get-data-error": "Failed to get message", + "label_download": "Save message to file", + "label_truncated": "Truncated {{size}}", + "description_not-loaded-message": "Message was not fetched in table due to problems with big messages.\nYou can view it in side panel by clicking on Offset", + "description_removed-message": "Message was deleted due to retention", + "description_last-messages": "Only last 50 000 messages from any partition are displayed" } diff --git a/src/containers/Tenant/Diagnostics/TopicData/useTopicDataQueryParams.ts b/src/containers/Tenant/Diagnostics/TopicData/useTopicDataQueryParams.ts index 108c9d421..6ae8e777a 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/useTopicDataQueryParams.ts +++ b/src/containers/Tenant/Diagnostics/TopicData/useTopicDataQueryParams.ts @@ -6,13 +6,16 @@ import type {TopicDataFilterValue} from './utils/types'; import {TopicDataFilterValueParam} from './utils/types'; export function useTopicDataQueryParams() { - const [{selectedPartition, selectedOffset, startTimestamp, topicDataFilter}, setQueryParams] = - useQueryParams({ - selectedPartition: StringParam, - selectedOffset: StringParam, - startTimestamp: NumberParam, - topicDataFilter: TopicDataFilterValueParam, - }); + const [ + {selectedPartition, selectedOffset, startTimestamp, topicDataFilter, activeOffset}, + setQueryParams, + ] = useQueryParams({ + selectedPartition: StringParam, + selectedOffset: StringParam, + startTimestamp: NumberParam, + topicDataFilter: TopicDataFilterValueParam, + activeOffset: StringParam, + }); const handleSelectedPartitionChange = React.useCallback( (value?: string) => { @@ -28,6 +31,13 @@ export function useTopicDataQueryParams() { [setQueryParams], ); + const handleActiveOffsetChange = React.useCallback( + (value?: string) => { + setQueryParams({activeOffset: value}, 'replaceIn'); + }, + [setQueryParams], + ); + const handleStartTimestampChange = React.useCallback( (value?: number) => { setQueryParams({startTimestamp: value}, 'replaceIn'); @@ -47,9 +57,11 @@ export function useTopicDataQueryParams() { selectedOffset, startTimestamp, topicDataFilter, + activeOffset, handleSelectedPartitionChange, handleSelectedOffsetChange, handleStartTimestampChange, handleTopicDataFilterChange, + handleActiveOffsetChange, }; } diff --git a/src/containers/Tenant/Diagnostics/TopicData/utils/constants.ts b/src/containers/Tenant/Diagnostics/TopicData/utils/constants.ts index 4f8873880..b2334bcf4 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/utils/constants.ts +++ b/src/containers/Tenant/Diagnostics/TopicData/utils/constants.ts @@ -9,6 +9,9 @@ export const TOPIC_DATA_COLUMNS_TITLES: Record = { get offset() { return i18n('label_offset'); }, + get partition() { + return i18n('label_partition'); + }, get timestampCreate() { return i18n('label_timestamp-create'); }, diff --git a/src/containers/Tenant/Diagnostics/TopicData/utils/types.ts b/src/containers/Tenant/Diagnostics/TopicData/utils/types.ts index 436696a54..79e782373 100644 --- a/src/containers/Tenant/Diagnostics/TopicData/utils/types.ts +++ b/src/containers/Tenant/Diagnostics/TopicData/utils/types.ts @@ -4,6 +4,7 @@ import type {ValueOf} from '../../../../../types/common'; import i18n from '../i18n'; export const TOPIC_DATA_COLUMNS_IDS = { + PARTITION: 'partition', OFFSET: 'offset', TIMESTAMP_CREATE: 'timestampCreate', TIMESTAMP_WRITE: 'timestampWrite', diff --git a/src/containers/Tenant/TenantPages.tsx b/src/containers/Tenant/TenantPages.tsx index 909c557f4..81bd20850 100644 --- a/src/containers/Tenant/TenantPages.tsx +++ b/src/containers/Tenant/TenantPages.tsx @@ -13,6 +13,7 @@ type AdditionalQueryParams = { name?: string; backend?: string; selectedPartition?: string; + activeOffset?: string; }; export type TenantQuery = TenantQueryParams | AdditionalQueryParams; diff --git a/src/store/reducers/capabilities/hooks.ts b/src/store/reducers/capabilities/hooks.ts index 2280ff9e9..335c0a910 100644 --- a/src/store/reducers/capabilities/hooks.ts +++ b/src/store/reducers/capabilities/hooks.ts @@ -79,7 +79,7 @@ export const useStreamingAvailable = () => { }; export const useTopicDataAvailable = () => { - return useGetFeatureVersion('/viewer/topic_data') >= 1; + return useGetFeatureVersion('/viewer/topic_data') >= 2; }; const useGetSecuritySetting = (feature: SecuritySetting) => { diff --git a/src/store/reducers/partitions/types.ts b/src/store/reducers/partitions/types.ts index 3e131c6af..829ea1304 100644 --- a/src/store/reducers/partitions/types.ts +++ b/src/store/reducers/partitions/types.ts @@ -2,7 +2,7 @@ import type {ProcessSpeedStats} from '../../../utils/bytesParsers'; // Fields that could be undefined corresponds to partitions without consumers export interface PreparedPartitionData { - partitionId: string; + partitionId: string | number; storeSize: string; writeSpeed: ProcessSpeedStats; diff --git a/src/store/reducers/topic.ts b/src/store/reducers/topic.ts index 44e8862d1..3f64f058a 100644 --- a/src/store/reducers/topic.ts +++ b/src/store/reducers/topic.ts @@ -8,7 +8,7 @@ import type {RootState} from '../defaultStore'; import {api} from './api'; -export const TOPIC_MESSAGE_SIZE_LIMIT = 1000; +export const TOPIC_MESSAGE_SIZE_LIMIT = 100; export const topicApi = api.injectEndpoints({ endpoints: (build) => ({ diff --git a/src/types/api/topic.ts b/src/types/api/topic.ts index f36b764dd..bc4d2e4c5 100644 --- a/src/types/api/topic.ts +++ b/src/types/api/topic.ts @@ -285,6 +285,7 @@ export interface TopicMessage { export interface TopicMessageEnhanced extends TopicMessage { removed?: boolean; + notLoaded?: boolean; } export interface TopicMessageMetadataItem { diff --git a/src/utils/downloadFile.ts b/src/utils/downloadFile.ts index f07840c7f..a80b2ad9b 100644 --- a/src/utils/downloadFile.ts +++ b/src/utils/downloadFile.ts @@ -7,11 +7,16 @@ export function downloadFile(url: string, filename: string) { document.body.removeChild(link); } -export const createAndDownloadJsonFile = (data: unknown, fileName: string) => { - const blob = new Blob([JSON.stringify(data, null, 2)], { - type: 'application/json', +export const createAndDownloadFile = (data: string, fileName: string, type?: string) => { + const blob = new Blob([data], { + type, }); const url = URL.createObjectURL(blob); - downloadFile(url, `${fileName}.json`); + downloadFile(url, fileName); URL.revokeObjectURL(url); }; + +export const createAndDownloadJsonFile = (data: unknown, fileName: string) => { + const preparedData = JSON.stringify(data, null, 2); + createAndDownloadFile(preparedData, `${fileName}.json`, 'application/json'); +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index d23546451..03d6089d5 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -31,7 +31,7 @@ export function bytesToSize(bytes: number) { return val.toPrecision(3) + sizes[i]; } -function bytesToMB(bytes?: number | string) { +export function bytesToMB(bytes?: number | string) { const bytesNumber = Number(bytes); if (isNaN(bytesNumber)) { return '';