Repository: ydb-platform/ydb-embedded-ui
Pull request #2292: feat(Preview): add preview for topics
Author: login: Raubzeug
Created: 2025-05-16T11:21:51Z | Merged: 2025-05-21T15:44:49Z

Closes #1943
[Stand](https://nda.ya.ru/t/BU-ibsXL7EWxUq)

## CI Results

  ### Test Status: <span style="color: orange;">⚠️ FLAKY</span>
  📊 [Full Report](https://ydb-platform.github.io/ydb-embedded-ui/2292/)

  | Total | Passed | Failed | Flaky | Skipped |
  |:-----:|:------:|:------:|:-----:|:-------:|
  | 318 | 317 | 0 | 1 | 0 |

  😟 No changes in tests. 😕

  ### Bundle Size: 🔺
  Current: 83.58 MB | Main: 83.57 MB
  Diff: +0.02 MB (0.02%)

  ⚠️ Bundle size increased. Please review.

  <details>
  <summary>ℹ️ CI Information</summary>

  - Test recordings for failed tests are available in the full report.
  - Bundle size is measured for the entire 'dist' directory.
  - 📊 indicates links to detailed reports.
  - 🔺 indicates increase, 🔽 decrease, and ✅ no change in bundle size.
  </details>

Linked issue #1943: Topic Data Preview (by "eye" in navigation panel)
1. Add "eye" icon on hover on a topic in left navigation panel.
2. By clicking on this icon system shows standard Query panel.
3. Bottom preview contains table with topic data, columns: PartitionID, Offset, Timestamp Create, Timestamp Write, Key, Metadata, Message, Size. 
4. It's better to hide Query Editor panel for topics as we don't have a possibility to write YQL queries for getting topics data. 

Diff:
diff --git a/package-lock.json b/package-lock.json
index 21a3f35c92..3cb600db84 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -57,7 +57,7 @@
         "use-query-params": "^2.2.1",
         "uuid": "^10.0.0",
         "web-vitals": "^1.1.2",
-        "ydb-ui-components": "^4.6.0",
+        "ydb-ui-components": "^4.7.0",
         "zod": "^3.24.1"
       },
       "devDependencies": {
@@ -29316,9 +29316,9 @@
       }
     },
     "node_modules/ydb-ui-components": {
-      "version": "4.6.0",
-      "resolved": "https://registry.npmjs.org/ydb-ui-components/-/ydb-ui-components-4.6.0.tgz",
-      "integrity": "sha512-9gkD6KBda3Jo/zt8SpTgrmUckXP6gophDiL4E41CKjORCmi5umT5C7D/D98awzaB+ZltVL0L5ySdnZKizfD9Yw==",
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/ydb-ui-components/-/ydb-ui-components-4.7.0.tgz",
+      "integrity": "sha512-KalZ1LyX5heI+ZGEsxu3hg9iHUGsvqPyGXli4E4CzN8rUbrvDCrpmDUj1xmn+qFyLtrhQvUUKIX23+zDNoZw1A==",
       "license": "MIT",
       "dependencies": {
         "@bem-react/classname": "^1.6.0",
diff --git a/package.json b/package.json
index ec3ab6ca6b..6a56d793e9 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
     "use-query-params": "^2.2.1",
     "uuid": "^10.0.0",
     "web-vitals": "^1.1.2",
-    "ydb-ui-components": "^4.6.0",
+    "ydb-ui-components": "^4.7.0",
     "zod": "^3.24.1"
   },
   "scripts": {
diff --git a/src/components/EntityStatus/EntityStatus.tsx b/src/components/EntityStatus/EntityStatus.tsx
index 5e72b2bb50..6db1f3a789 100644
--- a/src/components/EntityStatus/EntityStatus.tsx
+++ b/src/components/EntityStatus/EntityStatus.tsx
@@ -92,7 +92,7 @@ export function EntityStatus({
                 </InternalLink>
             );
         }
-        return name && <span className={b('name')}>{name}</span>;
+        return name && <span className={b('name')}>{renderName(name)}</span>;
     };
     return (
         <div className={b(null, className)}>
diff --git a/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss b/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss
index 8e890ad195..33013c8a6c 100644
--- a/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss
+++ b/src/containers/Tenant/Diagnostics/TopicData/columns/Columns.scss
@@ -1,24 +1,11 @@
 @use '../../../../../styles/mixins.scss';
 
 .ydb-diagnostics-topic-data-columns {
-    &__ts-diff_danger {
-        color: var(--g-color-text-danger);
-    }
-    &__message_clickable {
-        cursor: pointer;
-
-        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%;
     }
diff --git a/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx b/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx
index 02cfcdcd5f..35e4aeaac5 100644
--- a/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx
+++ b/src/containers/Tenant/Diagnostics/TopicData/columns/columns.tsx
@@ -2,6 +2,7 @@ import React from 'react';
 
 import {TriangleExclamation} from '@gravity-ui/icons';
 import DataTable from '@gravity-ui/react-data-table';
+import type {TextProps} from '@gravity-ui/uikit';
 import {ActionTooltip, Icon, Popover, Text} from '@gravity-ui/uikit';
 import {isNil} from 'lodash';
 import {Link} from 'react-router-dom';
@@ -88,18 +89,8 @@ export const tsDiffColumn: Column<TopicMessageEnhanced> = {
     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 (
-            <span className={b('ts-diff', {danger: numericValue >= 100_000})}>
-                {formatToMs(numericValue)}
-            </span>
-        );
-    },
-    width: 90,
+    render: ({row: {TimestampDiff}}) => <TopicDataTsDiff value={TimestampDiff} />,
+    width: 110,
     note: i18n('context_ts-diff'),
 };
 
@@ -121,36 +112,9 @@ export const messageColumn: Column<TopicMessageEnhanced> = {
     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 (
-            <Text
-                variant="body-2"
-                color={invalid ? 'secondary' : 'primary'}
-                className={b('message', {invalid})}
-            >
-                {encryptedMessage}
-                {truncated && (
-                    <Text color="secondary" className={b('truncated')}>
-                        {' '}
-                        {i18n('description_truncated')}
-                    </Text>
-                )}
-            </Text>
-        );
-    },
+    render: ({row: {Message, OriginalSize}}) => (
+        <TopicDataMessage message={Message} size={OriginalSize} />
+    ),
     width: 500,
 };
 
@@ -158,7 +122,7 @@ export const sizeColumn: Column<TopicMessageEnhanced> = {
     name: TOPIC_DATA_COLUMNS_IDS.SIZE,
     header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.SIZE],
     align: DataTable.RIGHT,
-    render: ({row}) => formatBytes(row.StorageSize),
+    render: ({row: {StorageSize}}) => <TopicDataSize size={StorageSize} />,
     width: 100,
 };
 
@@ -316,3 +280,68 @@ function Offset({offset, removed, notLoaded}: PartitionIdProps) {
         </Link>
     );
 }
+interface TopicDataTsDiffProps {
+    value?: string;
+    baseColor?: TextProps['color'];
+    variant?: TextProps['variant'];
+}
+
+export function TopicDataTsDiff({
+    value,
+    baseColor = 'primary',
+    variant = 'body-2',
+}: TopicDataTsDiffProps) {
+    if (isNil(value)) {
+        return EMPTY_DATA_PLACEHOLDER;
+    }
+    const numericValue = safeParseNumber(value);
+    return (
+        <Text variant={variant} color={numericValue >= 100_000 ? 'danger' : baseColor}>
+            {formatToMs(numericValue)}
+        </Text>
+    );
+}
+
+interface TopicDataMessageProps {
+    message?: string;
+    size?: number;
+}
+
+export function TopicDataMessage({message, size}: TopicDataMessageProps) {
+    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(size) > TOPIC_MESSAGE_SIZE_LIMIT;
+    return (
+        <Text
+            variant="body-2"
+            color={invalid ? 'secondary' : 'primary'}
+            className={b('message', {invalid})}
+        >
+            {encryptedMessage}
+            {truncated && (
+                <Text color="secondary" className={b('truncated')}>
+                    {' '}
+                    {i18n('description_truncated')}
+                </Text>
+            )}
+        </Text>
+    );
+}
+
+interface TopicDataSizeProps {
+    size?: number;
+}
+
+export function TopicDataSize({size}: TopicDataSizeProps) {
+    return formatBytes(size);
+}
diff --git a/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx b/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx
index 6bde55a1db..c12669e3ed 100644
--- a/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx
+++ b/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx
@@ -32,7 +32,15 @@ function ObjectGeneral(props: ObjectGeneralProps) {
             props;
         switch (tenantPage) {
             case TENANT_PAGES_IDS.query: {
-                return <Query tenantName={tenantName} path={path} theme={theme} type={type} />;
+                return (
+                    <Query
+                        tenantName={tenantName}
+                        path={path}
+                        theme={theme}
+                        type={type}
+                        subType={subType}
+                    />
+                );
             }
             default: {
                 return (
diff --git a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx
index 3d5b9541e9..cd5ed5b0cb 100644
--- a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx
+++ b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx
@@ -6,7 +6,10 @@ import React from 'react';
 import {NavigationTree} from 'ydb-ui-components';
 
 import {getConnectToDBDialog} from '../../../../components/ConnectToDB/ConnectToDBDialog';
-import {useCreateDirectoryFeatureAvailable} from '../../../../store/reducers/capabilities/hooks';
+import {
+    useCreateDirectoryFeatureAvailable,
+    useTopicDataAvailable,
+} from '../../../../store/reducers/capabilities/hooks';
 import {selectIsDirty, selectUserInput} from '../../../../store/reducers/query/query';
 import {schemaApi} from '../../../../store/reducers/schema/schema';
 import {tableSchemaDataApi} from '../../../../store/reducers/tableSchemaData';
@@ -21,6 +24,7 @@ import {
     nodeTableTypeToPathType,
 } from '../../utils/schema';
 import {getActions} from '../../utils/schemaActions';
+import type {DropdownItem, TreeNodeMeta} from '../../utils/types';
 import {CreateDirectoryDialog} from '../CreateDirectoryDialog/CreateDirectoryDialog';
 import {useDispatchTreeKey, useTreeKey} from '../UpdateTreeContext';
 import {isDomain} from '../transformPath';
@@ -44,6 +48,8 @@ export function SchemaTree(props: SchemaTreeProps) {
         {currentData: actionsSchemaData, isFetching: isActionsDataFetching},
     ] = tableSchemaDataApi.useLazyGetTableSchemaDataQuery();
 
+    const isTopicPreviewAvailable = useTopicDataAvailable();
+
     const [createDirectoryOpen, setCreateDirectoryOpen] = React.useState(false);
     const [parentPath, setParentPath] = React.useState('');
     const setSchemaTreeKey = useDispatchTreeKey();
@@ -91,6 +97,7 @@ export function SchemaTree(props: SchemaTreeProps) {
                 // FIXME: should only be explicitly set to true for tables with indexes
                 // at the moment of writing there is no property to determine this, fix later
                 expandable: !isChildless,
+                meta: {subType: PathSubType},
             };
         });
 
@@ -153,7 +160,7 @@ export function SchemaTree(props: SchemaTreeProps) {
                 parentPath={parentPath}
                 onSuccess={handleSuccessSubmit}
             />
-            <NavigationTree
+            <NavigationTree<DropdownItem, TreeNodeMeta>
                 key={schemaTreeKey}
                 rootState={{
                     path: rootPath,
@@ -171,9 +178,14 @@ export function SchemaTree(props: SchemaTreeProps) {
 
                     return [];
                 }}
-                renderAdditionalNodeElements={getSchemaControls(dispatch, {
-                    setActivePath: onActivePathUpdate,
-                })}
+                renderAdditionalNodeElements={getSchemaControls(
+                    dispatch,
+                    {
+                        setActivePath: onActivePathUpdate,
+                    },
+                    undefined,
+                    isTopicPreviewAvailable,
+                )}
                 activePath={currentPath}
                 onActivePathUpdate={onActivePathUpdate}
                 cache={false}
diff --git a/src/containers/Tenant/Query/Preview/Preview.scss b/src/containers/Tenant/Query/Preview/Preview.scss
index a151d5f2d1..fb533ac254 100644
--- a/src/containers/Tenant/Query/Preview/Preview.scss
+++ b/src/containers/Tenant/Query/Preview/Preview.scss
@@ -1,6 +1,6 @@
 @use '../../../../styles/mixins.scss';
 
-.kv-preview {
+.ydb-preview {
     height: 100%;
     @include mixins.flex-container();
     @include mixins.query-data-table();
@@ -8,23 +8,15 @@
         position: sticky;
         top: 0;
 
-        display: flex;
         flex-shrink: 0;
-        justify-content: space-between;
-        align-items: center;
 
         height: 53px;
-        padding: 0 20px;
+        padding: 0 var(--g-spacing-6);
 
         border-bottom: 1px solid var(--g-color-line-generic);
         background-color: var(--g-color-base-background);
     }
 
-    &__title {
-        display: flex;
-        gap: var(--g-spacing-1);
-    }
-
     &__table-name {
         margin-left: var(--g-spacing-1);
 
@@ -36,21 +28,18 @@
         gap: var(--g-spacing-1);
     }
     &__message-container {
-        padding: 15px 20px;
-    }
-
-    &__loader-container {
-        display: flex;
-        justify-content: center;
-        align-items: center;
-
-        height: 100%;
+        padding: var(--g-spacing-3) 0;
     }
 
     &__result {
         overflow: auto;
 
         width: 100%;
-        padding-left: 10px;
+        padding-left: var(--g-spacing-5);
+    }
+
+    &__partition-info {
+        height: 36px;
+        padding-left: var(--g-spacing-1);
     }
 }
diff --git a/src/containers/Tenant/Query/Preview/Preview.tsx b/src/containers/Tenant/Query/Preview/Preview.tsx
index 61a8f5bd00..952de9f4d3 100644
--- a/src/containers/Tenant/Query/Preview/Preview.tsx
+++ b/src/containers/Tenant/Query/Preview/Preview.tsx
@@ -1,106 +1,33 @@
-import {Xmark} from '@gravity-ui/icons';
-import {Button, Icon, Loader, Text} from '@gravity-ui/uikit';
-
-import EnableFullscreenButton from '../../../../components/EnableFullscreenButton/EnableFullscreenButton';
-import Fullscreen from '../../../../components/Fullscreen/Fullscreen';
-import {QueryResultTable} from '../../../../components/QueryResultTable';
-import {previewApi} from '../../../../store/reducers/preview';
-import {setShowPreview} from '../../../../store/reducers/schema/schema';
-import type {EPathType} from '../../../../types/api/schema';
-import {cn} from '../../../../utils/cn';
-import {useTypedDispatch} from '../../../../utils/hooks';
-import {parseQueryErrorToString} from '../../../../utils/query';
-import {isExternalTableType, isTableType} from '../../utils/schema';
+import {useTopicDataAvailable} from '../../../../store/reducers/capabilities/hooks';
+import {EPathSubType, EPathType} from '../../../../types/api/schema';
+import {isTableType} from '../../utils/schema';
 import i18n from '../i18n';
 
-import './Preview.scss';
-
-const b = cn('kv-preview');
-
-interface PreviewProps {
-    database: string;
-    path: string;
-    type: EPathType | undefined;
-}
-
-export const Preview = ({database, path, type}: PreviewProps) => {
-    const dispatch = useTypedDispatch();
-
-    const isPreviewAvailable = isTableType(type);
+import {Preview} from './components/PreviewView';
+import {TablePreview} from './components/TablePreview';
+import {TopicPreview} from './components/TopicPreview';
+import {b} from './shared';
+import type {PreviewContainerProps} from './types';
 
-    const query = `select * from \`${path}\` limit 101`;
-    const {currentData, isFetching, error} = previewApi.useSendQueryQuery(
-        {
-            database,
-            query,
-            action: isExternalTableType(type) ? 'execute-query' : 'execute-scan',
-            limitRows: 100,
-        },
-        {
-            skip: !isPreviewAvailable,
-            refetchOnMountOrArgChange: true,
-        },
-    );
-    const loading = isFetching && currentData === undefined;
-    const data = currentData?.resultSets?.[0] ?? {};
-
-    const handleClosePreview = () => {
-        dispatch(setShowPreview(false));
-    };
+import './Preview.scss';
 
-    const renderHeader = () => {
-        return (
-            <div className={b('header')}>
-                <div className={b('title')}>
-                    {i18n('preview.title')}
-                    <Text color="secondary" variant="body-2">
-                        {data.truncated ? `${i18n('preview.truncated')} ` : ''}(
-                        {data.result?.length ?? 0})
-                    </Text>
-                    <div className={b('table-name')}>{path}</div>
-                </div>
-                <div className={b('controls-left')}>
-                    <EnableFullscreenButton disabled={Boolean(error)} />
-                    <Button
-                        view="flat-secondary"
-                        onClick={handleClosePreview}
-                        title={i18n('preview.close')}
-                    >
-                        <Icon data={Xmark} size={18} />
-                    </Button>
-                </div>
-            </div>
-        );
-    };
+export function PreviewContainer(props: PreviewContainerProps) {
+    const {type, subType} = props;
+    const isTable = isTableType(type);
+    const isTopic = type === EPathType.EPathTypePersQueueGroup;
+    const isCdcTopic = subType === EPathSubType.EPathSubTypeStreamImpl;
+    const isTopicPreviewAvailable = useTopicDataAvailable();
 
-    if (loading) {
-        return (
-            <div className={b('loader-container')}>
-                <Loader size="m" />
-            </div>
-        );
+    if (isTable) {
+        return <TablePreview {...props} />;
     }
 
-    let message;
-
-    if (!isPreviewAvailable) {
-        message = <div className={b('message-container')}>{i18n('preview.not-available')}</div>;
-    } else if (error) {
-        message = (
-            <div className={b('message-container', 'error')}>{parseQueryErrorToString(error)}</div>
-        );
+    if (isTopic && !isCdcTopic && isTopicPreviewAvailable) {
+        return <TopicPreview {...props} />;
     }
 
-    const content = message ?? (
-        <div className={b('result')}>
-            <QueryResultTable data={data.result} columns={data.columns} />
-        </div>
+    const renderContent = () => (
+        <div className={b('message-container')}>{i18n('preview.not-available')}</div>
     );
-
-    return (
-        <div className={b()}>
-            {renderHeader()}
-            <Fullscreen>{content}</Fullscreen>
-        </div>
-    );
-};
+    return <Preview {...props} renderResult={renderContent} />;
+}
diff --git a/src/containers/Tenant/Query/Preview/components/PreviewView.tsx b/src/containers/Tenant/Query/Preview/components/PreviewView.tsx
new file mode 100644
index 0000000000..3e64b8a4cd
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/components/PreviewView.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+
+import {Xmark} from '@gravity-ui/icons';
+import {Button, Flex, Icon, Text} from '@gravity-ui/uikit';
+
+import EnableFullscreenButton from '../../../../../components/EnableFullscreenButton/EnableFullscreenButton';
+import Fullscreen from '../../../../../components/Fullscreen/Fullscreen';
+import {LoaderWrapper} from '../../../../../components/LoaderWrapper/LoaderWrapper';
+import {setShowPreview} from '../../../../../store/reducers/schema/schema';
+import {useTypedDispatch} from '../../../../../utils/hooks';
+import {parseQueryErrorToString} from '../../../../../utils/query';
+import i18n from '../../i18n';
+import {b} from '../shared';
+
+interface PreviewProps {
+    path: string;
+    quantity?: number;
+    truncated?: boolean;
+    renderResult?: () => React.ReactNode;
+    loading?: boolean;
+    error?: unknown;
+}
+
+export function Preview({
+    path,
+    quantity = 0,
+    truncated,
+    renderResult,
+    loading,
+    error,
+}: PreviewProps) {
+    const dispatch = useTypedDispatch();
+
+    const handleClosePreview = () => {
+        dispatch(setShowPreview(false));
+    };
+
+    const renderHeader = () => {
+        return (
+            <Flex justifyContent="space-between" alignItems="center" className={b('header')}>
+                <Flex gap={1}>
+                    {i18n('preview.title')}
+                    <Text color="secondary" variant="body-2">
+                        {truncated ? `${i18n('preview.truncated')} ` : ''}({quantity})
+                    </Text>
+                    <div className={b('table-name')}>{path}</div>
+                </Flex>
+                <div className={b('controls-left')}>
+                    <EnableFullscreenButton disabled={Boolean(error)} />
+                    <Button
+                        view="flat-secondary"
+                        onClick={handleClosePreview}
+                        title={i18n('preview.close')}
+                    >
+                        <Icon data={Xmark} size={18} />
+                    </Button>
+                </div>
+            </Flex>
+        );
+    };
+
+    const renderContent = () => {
+        if (error) {
+            return (
+                <div className={b('message-container', 'error')}>
+                    {parseQueryErrorToString(error)}
+                </div>
+            );
+        }
+        return renderResult?.();
+    };
+
+    return (
+        <LoaderWrapper loading={loading}>
+            <div className={b()}>
+                {renderHeader()}
+                <Fullscreen>
+                    <div className={b('result')}>{renderContent()}</div>
+                </Fullscreen>
+            </div>
+        </LoaderWrapper>
+    );
+}
diff --git a/src/containers/Tenant/Query/Preview/components/TablePreview.tsx b/src/containers/Tenant/Query/Preview/components/TablePreview.tsx
new file mode 100644
index 0000000000..c8df79abaf
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/components/TablePreview.tsx
@@ -0,0 +1,40 @@
+import {QueryResultTable} from '../../../../../components/QueryResultTable';
+import {previewApi} from '../../../../../store/reducers/preview';
+import {isExternalTableType} from '../../../utils/schema';
+import type {PreviewContainerProps} from '../types';
+
+import {Preview} from './PreviewView';
+
+const TABLE_PREVIEW_LIMIT = 100;
+
+export function TablePreview({database, path, type}: PreviewContainerProps) {
+    const query = `select * from \`${path}\` limit 101`;
+    const {currentData, isFetching, error} = previewApi.useSendQueryQuery(
+        {
+            database,
+            query,
+            action: isExternalTableType(type) ? 'execute-query' : 'execute-scan',
+            limitRows: TABLE_PREVIEW_LIMIT,
+        },
+        {
+            refetchOnMountOrArgChange: true,
+        },
+    );
+    const loading = isFetching && currentData === undefined;
+    const data = currentData?.resultSets?.[0] ?? {};
+
+    const renderResult = () => {
+        return <QueryResultTable data={data.result} columns={data.columns} />;
+    };
+
+    return (
+        <Preview
+            path={path}
+            renderResult={renderResult}
+            loading={loading}
+            error={error}
+            truncated={data.truncated}
+            quantity={data.result?.length}
+        />
+    );
+}
diff --git a/src/containers/Tenant/Query/Preview/components/TopicPreview.tsx b/src/containers/Tenant/Query/Preview/components/TopicPreview.tsx
new file mode 100644
index 0000000000..c159de9b5c
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/components/TopicPreview.tsx
@@ -0,0 +1,97 @@
+import React from 'react';
+
+import {Flex, Text} from '@gravity-ui/uikit';
+import {skipToken} from '@reduxjs/toolkit/query';
+import {isNil} from 'lodash';
+
+import {partitionsApi} from '../../../../../store/reducers/partitions/partitions';
+import {topicApi} from '../../../../../store/reducers/topic';
+import type {TopicDataRequest} from '../../../../../types/api/topic';
+import {safeParseNumber} from '../../../../../utils/utils';
+import i18n from '../i18n';
+import {b} from '../shared';
+import type {PreviewContainerProps} from '../types';
+
+import {Preview} from './PreviewView';
+import {TopicPreviewTable} from './TopicPreviewTable';
+
+const TOPIC_PREVIEW_LIMIT = 100;
+
+export function TopicPreview({database, path}: PreviewContainerProps) {
+    const {
+        data: partitions,
+        isLoading: partitionsLoading,
+        error: partitionsError,
+    } = partitionsApi.useGetPartitionsQuery({path, database});
+
+    const firstPartition = React.useMemo(() => {
+        return partitions?.[0];
+    }, [partitions]);
+
+    const queryParams = React.useMemo(() => {
+        const firstPartitionId = firstPartition?.partitionId;
+        if (!firstPartition || isNil(firstPartitionId)) {
+            return skipToken;
+        }
+        const params: TopicDataRequest = {
+            database,
+            path,
+            partition: String(firstPartitionId),
+            limit: TOPIC_PREVIEW_LIMIT,
+            offset: safeParseNumber(firstPartition.endOffset) - TOPIC_PREVIEW_LIMIT,
+            message_size_limit: 100,
+        };
+
+        return params;
+    }, [database, path, firstPartition]);
+
+    const {currentData, error, isFetching} = topicApi.useGetTopicDataQuery(queryParams);
+
+    const loading = partitionsLoading || (isFetching && currentData === undefined);
+
+    const offsetsRange = React.useMemo(() => {
+        if (!firstPartition) {
+            return undefined;
+        }
+        return {
+            start: safeParseNumber(firstPartition.startOffset),
+            end: safeParseNumber(firstPartition.endOffset) - 1,
+        };
+    }, [firstPartition]);
+
+    const renderResult = React.useCallback(() => {
+        return (
+            <Flex direction="column">
+                <Flex className={b('partition-info')} alignItems="center" gap={2}>
+                    {!isNil(firstPartition?.partitionId) && (
+                        <Text variant="body-2">
+                            {i18n('label_partition-id', {id: firstPartition.partitionId})}
+                        </Text>
+                    )}
+                    {offsetsRange && (
+                        <Text variant="body-2" color="secondary">
+                            ({i18n('label_offsets-range', offsetsRange)})
+                        </Text>
+                    )}
+                </Flex>
+                {currentData && (
+                    <TopicPreviewTable messages={currentData.Messages?.toReversed() ?? []} />
+                )}
+            </Flex>
+        );
+    }, [offsetsRange, firstPartition, currentData]);
+
+    const offsetsQuantity =
+        safeParseNumber(currentData?.EndOffset) - safeParseNumber(currentData?.StartOffset);
+
+    return (
+        <Preview
+            path={path}
+            renderResult={renderResult}
+            loading={loading}
+            error={partitionsError || error}
+            truncated={offsetsQuantity > TOPIC_PREVIEW_LIMIT}
+            quantity={currentData?.Messages?.length ?? 0}
+        />
+    );
+}
diff --git a/src/containers/Tenant/Query/Preview/components/TopicPreviewTable.tsx b/src/containers/Tenant/Query/Preview/components/TopicPreviewTable.tsx
new file mode 100644
index 0000000000..358d555a37
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/components/TopicPreviewTable.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+
+import type {Column} from '@gravity-ui/react-data-table';
+import {isNil} from 'lodash';
+
+import {ResizeableDataTable} from '../../../../../components/ResizeableDataTable/ResizeableDataTable';
+import type {TopicMessage} from '../../../../../types/api/topic';
+import {DEFAULT_TABLE_SETTINGS, EMPTY_DATA_PLACEHOLDER} from '../../../../../utils/constants';
+import {
+    TopicDataMessage,
+    TopicDataSize,
+    TopicDataTimestamp,
+    TopicDataTsDiff,
+} from '../../../Diagnostics/TopicData/columns/columns';
+import {TOPIC_DATA_COLUMNS_TITLES} from '../../../Diagnostics/TopicData/utils/constants';
+import {TOPIC_DATA_COLUMNS_IDS} from '../../../Diagnostics/TopicData/utils/types';
+import {b} from '../shared';
+
+const columns: Column<TopicMessage>[] = [
+    {
+        name: TOPIC_DATA_COLUMNS_IDS.OFFSET,
+        header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.OFFSET],
+        render: ({row: {Offset}}) => (isNil(Offset) ? EMPTY_DATA_PLACEHOLDER : Offset),
+        sortable: false,
+    },
+    {
+        name: TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_CREATE,
+        header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.TIMESTAMP_CREATE],
+        render: ({row: {CreateTimestamp, TimestampDiff}}) => (
+            <React.Fragment>
+                <TopicDataTimestamp timestamp={CreateTimestamp} />
+                <br />
+                <TopicDataTsDiff value={TimestampDiff} baseColor="secondary" variant="body-1" />
+            </React.Fragment>
+        ),
+        width: 200,
+        sortable: false,
+    },
+    {
+        name: TOPIC_DATA_COLUMNS_IDS.MESSAGE,
+        header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.MESSAGE],
+        render: ({row: {Message, OriginalSize}}) => (
+            <TopicDataMessage message={Message} size={OriginalSize} />
+        ),
+        width: 500,
+        sortable: false,
+    },
+    {
+        name: TOPIC_DATA_COLUMNS_IDS.SIZE,
+        header: TOPIC_DATA_COLUMNS_TITLES[TOPIC_DATA_COLUMNS_IDS.SIZE],
+        render: ({row: {StorageSize}}) => <TopicDataSize size={StorageSize} />,
+        align: 'right',
+        width: 100,
+        sortable: false,
+    },
+];
+
+interface TopicPreviewTableProps {
+    messages: TopicMessage[];
+}
+
+export function TopicPreviewTable({messages}: TopicPreviewTableProps) {
+    const getRowIndex = (row: TopicMessage, index: number) => row.Offset ?? index;
+    return (
+        <ResizeableDataTable
+            data={messages}
+            columns={columns}
+            settings={DEFAULT_TABLE_SETTINGS}
+            rowKey={getRowIndex}
+            wrapperClassName={b('table-wrapper')}
+            emptyDataMessage="No data"
+        />
+    );
+}
diff --git a/src/containers/Tenant/Query/Preview/i18n/en.json b/src/containers/Tenant/Query/Preview/i18n/en.json
new file mode 100644
index 0000000000..f705686b6c
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/i18n/en.json
@@ -0,0 +1,4 @@
+{
+  "label_partition-id": "Partition ID: {{id}}",
+  "label_offsets-range": "offsets: {{start}} - {{end}}"
+}
diff --git a/src/containers/Tenant/Query/Preview/i18n/index.ts b/src/containers/Tenant/Query/Preview/i18n/index.ts
new file mode 100644
index 0000000000..4f39cee9bb
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/i18n/index.ts
@@ -0,0 +1,7 @@
+import {registerKeysets} from '../../../../../utils/i18n';
+
+import en from './en.json';
+
+const COMPONENT = 'ydb-preview';
+
+export default registerKeysets(COMPONENT, {en});
diff --git a/src/containers/Tenant/Query/Preview/shared.ts b/src/containers/Tenant/Query/Preview/shared.ts
new file mode 100644
index 0000000000..83de532aed
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/shared.ts
@@ -0,0 +1,3 @@
+import {cn} from '../../../../utils/cn';
+
+export const b = cn('ydb-preview');
diff --git a/src/containers/Tenant/Query/Preview/types.ts b/src/containers/Tenant/Query/Preview/types.ts
new file mode 100644
index 0000000000..b79c95811a
--- /dev/null
+++ b/src/containers/Tenant/Query/Preview/types.ts
@@ -0,0 +1,8 @@
+import type {EPathSubType, EPathType} from '../../../../types/api/schema';
+
+export interface PreviewContainerProps {
+    database: string;
+    path: string;
+    type?: EPathType;
+    subType?: EPathSubType;
+}
diff --git a/src/containers/Tenant/Query/Query.tsx b/src/containers/Tenant/Query/Query.tsx
index 549fa31f6d..5f1bacef96 100644
--- a/src/containers/Tenant/Query/Query.tsx
+++ b/src/containers/Tenant/Query/Query.tsx
@@ -4,7 +4,7 @@ import {Helmet} from 'react-helmet-async';
 
 import {changeUserInput} from '../../../store/reducers/query/query';
 import {TENANT_QUERY_TABS_ID} from '../../../store/reducers/tenant/constants';
-import type {EPathType} from '../../../types/api/schema';
+import type {EPathSubType, EPathType} from '../../../types/api/schema';
 import {cn} from '../../../utils/cn';
 import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks';
 
@@ -22,6 +22,7 @@ interface QueryProps {
     tenantName: string;
     path: string;
     type?: EPathType;
+    subType?: EPathSubType;
 }
 
 export const Query = (props: QueryProps) => {
diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx
index 9a81715319..a6ba65b071 100644
--- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx
+++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx
@@ -22,7 +22,7 @@ import {
 import type {QueryResult} from '../../../../store/reducers/query/types';
 import {setQueryAction} from '../../../../store/reducers/queryActions/queryActions';
 import {selectShowPreview, setShowPreview} from '../../../../store/reducers/schema/schema';
-import type {EPathType} from '../../../../types/api/schema';
+import type {EPathSubType, EPathType} from '../../../../types/api/schema';
 import type {QueryAction} from '../../../../types/store/query';
 import {cn} from '../../../../utils/cn';
 import {
@@ -46,7 +46,7 @@ import {
     PaneVisibilityActionTypes,
     paneVisibilityToggleReducerCreator,
 } from '../../utils/paneVisibilityToggleHelpers';
-import {Preview} from '../Preview/Preview';
+import {PreviewContainer} from '../Preview/Preview';
 import {QueryEditorControls} from '../QueryEditorControls/QueryEditorControls';
 import {QueryResultViewer} from '../QueryResult/QueryResultViewer';
 import {QuerySettingsDialog} from '../QuerySettingsDialog/QuerySettingsDialog';
@@ -70,11 +70,12 @@ interface QueryEditorProps {
     changeUserInput: (arg: {input: string}) => void;
     theme: string;
     type?: EPathType;
+    subType?: EPathSubType;
 }
 
 export default function QueryEditor(props: QueryEditorProps) {
     const dispatch = useTypedDispatch();
-    const {tenantName, path, type, theme, changeUserInput} = props;
+    const {tenantName, path, type, theme, changeUserInput, subType} = props;
     const savedPath = useTypedSelector(selectTenantPath);
     const result = useTypedSelector(selectResult);
     const historyQueries = useTypedSelector(selectQueriesHistory);
@@ -270,6 +271,7 @@ export default function QueryEditor(props: QueryEditorProps) {
                         onExpandResultHandler={onExpandResultHandler}
                         onCollapseResultHandler={onCollapseResultHandler}
                         type={type}
+                        subType={subType}
                         theme={theme}
                         key={result?.queryId}
                         result={result}
@@ -291,6 +293,7 @@ interface ResultProps {
     onExpandResultHandler: VoidFunction;
     onCollapseResultHandler: VoidFunction;
     type?: EPathType;
+    subType?: EPathSubType;
     theme: string;
     result?: QueryResult;
     tenantName: string;
@@ -304,6 +307,7 @@ function Result({
     onExpandResultHandler,
     onCollapseResultHandler,
     type,
+    subType,
     theme,
     result,
     tenantName,
@@ -313,7 +317,7 @@ function Result({
     tableSettings,
 }: ResultProps) {
     if (showPreview) {
-        return <Preview database={tenantName} path={path} type={type} />;
+        return <PreviewContainer database={tenantName} path={path} type={type} subType={subType} />;
     }
 
     if (result) {
diff --git a/src/containers/Tenant/utils/controls.tsx b/src/containers/Tenant/utils/controls.tsx
index 5e4f2a6566..740fa9ec0a 100644
--- a/src/containers/Tenant/utils/controls.tsx
+++ b/src/containers/Tenant/utils/controls.tsx
@@ -1,13 +1,16 @@
 import type {ButtonSize} from '@gravity-ui/uikit';
 import {Button, Icon} from '@gravity-ui/uikit';
-import type {NavigationTreeNodeType, NavigationTreeProps} from 'ydb-ui-components';
+import type {NavigationTreeNodeType} from 'ydb-ui-components';
 
 import {api} from '../../../store/reducers/api';
 import {setShowPreview} from '../../../store/reducers/schema/schema';
 import {TENANT_PAGES_IDS, TENANT_QUERY_TABS_ID} from '../../../store/reducers/tenant/constants';
 import {setQueryTab, setTenantPage} from '../../../store/reducers/tenant/tenant';
+import {EPathSubType} from '../../../types/api/schema';
 import i18n from '../i18n';
 
+import type {YdbNavigationTreeProps} from './types';
+
 import EyeIcon from '@gravity-ui/icons/svgs/eye.svg';
 
 interface ControlsAdditionalEffects {
@@ -32,7 +35,7 @@ const bindActions = (
     };
 };
 
-type Controls = ReturnType<Required<NavigationTreeProps>['renderAdditionalNodeElements']>;
+type Controls = ReturnType<Required<YdbNavigationTreeProps>['renderAdditionalNodeElements']>;
 
 type SummaryType = 'preview';
 
@@ -54,11 +57,14 @@ export const getSchemaControls =
         dispatch: React.Dispatch<any>,
         additionalEffects: ControlsAdditionalEffects,
         size?: ButtonSize,
-    ) =>
-    (path: string, type: NavigationTreeNodeType) => {
+        isTopicPreviewAvailable?: boolean,
+    ): YdbNavigationTreeProps['renderAdditionalNodeElements'] =>
+    (path, type, meta) => {
         const options = bindActions(path, dispatch, additionalEffects);
         const openPreview = getPreviewControl(options, size);
 
+        const isCdcTopic = meta?.subType === EPathSubType.EPathSubTypeStreamImpl;
+
         const nodeTypeToControls: Record<NavigationTreeNodeType, Controls> = {
             async_replication: undefined,
             transfer: undefined,
@@ -71,7 +77,7 @@ export const getSchemaControls =
             column_table: openPreview,
 
             index_table: undefined,
-            topic: undefined,
+            topic: isTopicPreviewAvailable && !isCdcTopic ? openPreview : undefined,
             stream: undefined,
 
             index: undefined,
diff --git a/src/containers/Tenant/utils/schemaActions.tsx b/src/containers/Tenant/utils/schemaActions.tsx
index 7d9128bcd9..02a1cfb40e 100644
--- a/src/containers/Tenant/utils/schemaActions.tsx
+++ b/src/containers/Tenant/utils/schemaActions.tsx
@@ -1,7 +1,7 @@
 import {Copy, PlugConnection} from '@gravity-ui/icons';
 import {Flex, Spin} from '@gravity-ui/uikit';
 import copy from 'copy-to-clipboard';
-import type {NavigationTreeNodeType, NavigationTreeProps} from 'ydb-ui-components';
+import type {NavigationTreeNodeType} from 'ydb-ui-components';
 
 import type {SnippetParams} from '../../../components/ConnectToDB/types';
 import type {AppDispatch} from '../../../store';
@@ -39,6 +39,7 @@ import {
     selectQueryTemplate,
     upsertQueryTemplate,
 } from './schemaQueryTemplates';
+import type {YdbNavigationTreeProps} from './types';
 
 interface ActionsAdditionalParams {
     setActivePath: (path: string) => void;
@@ -139,7 +140,7 @@ const bindActions = (
     };
 };
 
-type ActionsSet = ReturnType<Required<NavigationTreeProps>['getActions']>;
+type ActionsSet = ReturnType<Required<YdbNavigationTreeProps>['getActions']>;
 
 interface ActionConfig {
     text: string;
diff --git a/src/containers/Tenant/utils/types.ts b/src/containers/Tenant/utils/types.ts
new file mode 100644
index 0000000000..0676495d7c
--- /dev/null
+++ b/src/containers/Tenant/utils/types.ts
@@ -0,0 +1,13 @@
+import type {NavigationTreeProps} from 'ydb-ui-components';
+
+export type DropdownItem = {
+    text: string;
+    action: () => void;
+    iconEnd?: React.ReactNode;
+};
+
+export type TreeNodeMeta = {
+    subType?: string;
+};
+
+export type YdbNavigationTreeProps = NavigationTreeProps<DropdownItem, TreeNodeMeta | undefined>;
diff --git a/tests/suites/tenant/queryEditor/models/ResultTable.ts b/tests/suites/tenant/queryEditor/models/ResultTable.ts
index dfd36a5941..b945dc2887 100644
--- a/tests/suites/tenant/queryEditor/models/ResultTable.ts
+++ b/tests/suites/tenant/queryEditor/models/ResultTable.ts
@@ -29,7 +29,7 @@ export class ResultTable {
 
     constructor(selector: Locator) {
         this.table = selector.locator('.ydb-query-result-sets-viewer__result');
-        this.preview = selector.locator('.kv-preview__result');
+        this.preview = selector.locator('.ydb-preview__result');
         this.resultHead = selector.locator('.ydb-query-result-sets-viewer__head');
         this.resultTitle = selector.locator('.ydb-query-result-sets-viewer__title');
         this.resultWrapper = selector.locator('.ydb-query-result-sets-viewer__result-wrapper');


Reviews:
user: login: artemmufazalov
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-19T18:01:12Z
user: login: artemmufazalov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T18:11:40Z
user: login: artemmufazalov
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-21T12:53:05Z
user: login: artemmufazalov
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-21T15:11:05Z
user: login: Raubzeug
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T15:32:59Z
user: login: artemmufazalov
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-21T15:38:30Z

Review comments:
diff_hunk: @@ -1,106 +1,31 @@
-import {Xmark} from '@gravity-ui/icons';
-import {Button, Icon, Loader, Text} from '@gravity-ui/uikit';
-
-import EnableFullscreenButton from '../../../../components/EnableFullscreenButton/EnableFullscreenButton';
-import Fullscreen from '../../../../components/Fullscreen/Fullscreen';
-import {QueryResultTable} from '../../../../components/QueryResultTable';
-import {previewApi} from '../../../../store/reducers/preview';
-import {setShowPreview} from '../../../../store/reducers/schema/schema';
-import type {EPathType} from '../../../../types/api/schema';
-import {cn} from '../../../../utils/cn';
-import {useTypedDispatch} from '../../../../utils/hooks';
-import {parseQueryErrorToString} from '../../../../utils/query';
-import {isExternalTableType, isTableType} from '../../utils/schema';
+import {useTopicDataAvailable} from '../../../../store/reducers/capabilities/hooks';
+import {EPathType} from '../../../../types/api/schema';
+import {isTableType} from '../../utils/schema';
 import i18n from '../i18n';
 
-import './Preview.scss';
-
-const b = cn('kv-preview');
-
-interface PreviewProps {
-    database: string;
-    path: string;
-    type: EPathType | undefined;
-}
-
-export const Preview = ({database, path, type}: PreviewProps) => {
-    const dispatch = useTypedDispatch();
+import {Preview} from './components/PreviewView';
+import {TablePreview} from './components/TablePreview';
+import {TopicPreview} from './components/TopicPreview';
+import {b} from './shared';
+import type {PreviewContainerProps} from './types';
 
-    const isPreviewAvailable = isTableType(type);
-
-    const query = `select * from \`${path}\` limit 101`;
-    const {currentData, isFetching, error} = previewApi.useSendQueryQuery(
-        {
-            database,
-            query,
-            action: isExternalTableType(type) ? 'execute-query' : 'execute-scan',
-            limitRows: 100,
-        },
-        {
-            skip: !isPreviewAvailable,
-            refetchOnMountOrArgChange: true,
-        },
-    );
-    const loading = isFetching && currentData === undefined;
-    const data = currentData?.resultSets?.[0] ?? {};
-
-    const handleClosePreview = () => {
-        dispatch(setShowPreview(false));
-    };
+import './Preview.scss';
 
-    const renderHeader = () => {
-        return (
-            <div className={b('header')}>
-                <div className={b('title')}>
-                    {i18n('preview.title')}
-                    <Text color="secondary" variant="body-2">
-                        {data.truncated ? `${i18n('preview.truncated')} ` : ''}(
-                        {data.result?.length ?? 0})
-                    </Text>
-                    <div className={b('table-name')}>{path}</div>
-                </div>
-                <div className={b('controls-left')}>
-                    <EnableFullscreenButton disabled={Boolean(error)} />
-                    <Button
-                        view="flat-secondary"
-                        onClick={handleClosePreview}
-                        title={i18n('preview.close')}
-                    >
-                        <Icon data={Xmark} size={18} />
-                    </Button>
-                </div>
-            </div>
-        );
-    };
+export function PreviewContainer(props: PreviewContainerProps) {
+    const {type} = props;
+    const isTable = isTableType(type);
+    const isTopic = type === EPathType.EPathTypePersQueueGroup;
+    const isTopicPreviewAvailable = useTopicDataAvailable();
 
-    if (loading) {
-        return (
-            <div className={b('loader-container')}>
-                <Loader size="m" />
-            </div>
-        );
+    if (isTable) {
+        return <TablePreview {...props} />;
     }
-
-    let message;
-
-    if (!isPreviewAvailable) {
-        message = <div className={b('message-container')}>{i18n('preview.not-available')}</div>;
-    } else if (error) {
-        message = (
-            <div className={b('message-container', 'error')}>{parseQueryErrorToString(error)}</div>
-        );
+    if (isTopic && isTopicPreviewAvailable) {
+        return <TopicPreview {...props} />;
     }
 
-    const content = message ?? (
-        <div className={b('result')}>
-            <QueryResultTable data={data.result} columns={data.columns} />
-        </div>
+    const renderContent = () => (
+        <div className={b('message-container')}>{i18n('preview.not-available')}</div>
path: src/containers/Tenant/Query/Preview/Preview.tsx
user: login: artemmufazalov
body: Preview not available has redundant left padding

<img width="462" alt="Screenshot 2025-05-19 at 20 55 29" src="https://github.com/user-attachments/assets/a1045327-1bb0-4a3d-b899-ae0f16b8ccdf" />

created_at: 2025-05-19T17:56:56Z
author_association: MEMBER
diff_hunk: @@ -1,106 +1,31 @@
-import {Xmark} from '@gravity-ui/icons';
-import {Button, Icon, Loader, Text} from '@gravity-ui/uikit';
-
-import EnableFullscreenButton from '../../../../components/EnableFullscreenButton/EnableFullscreenButton';
-import Fullscreen from '../../../../components/Fullscreen/Fullscreen';
-import {QueryResultTable} from '../../../../components/QueryResultTable';
-import {previewApi} from '../../../../store/reducers/preview';
-import {setShowPreview} from '../../../../store/reducers/schema/schema';
-import type {EPathType} from '../../../../types/api/schema';
-import {cn} from '../../../../utils/cn';
-import {useTypedDispatch} from '../../../../utils/hooks';
-import {parseQueryErrorToString} from '../../../../utils/query';
-import {isExternalTableType, isTableType} from '../../utils/schema';
+import {useTopicDataAvailable} from '../../../../store/reducers/capabilities/hooks';
+import {EPathType} from '../../../../types/api/schema';
+import {isTableType} from '../../utils/schema';
 import i18n from '../i18n';
 
-import './Preview.scss';
-
-const b = cn('kv-preview');
-
-interface PreviewProps {
-    database: string;
-    path: string;
-    type: EPathType | undefined;
-}
-
-export const Preview = ({database, path, type}: PreviewProps) => {
-    const dispatch = useTypedDispatch();
+import {Preview} from './components/PreviewView';
+import {TablePreview} from './components/TablePreview';
+import {TopicPreview} from './components/TopicPreview';
+import {b} from './shared';
+import type {PreviewContainerProps} from './types';
 
-    const isPreviewAvailable = isTableType(type);
-
-    const query = `select * from \`${path}\` limit 101`;
-    const {currentData, isFetching, error} = previewApi.useSendQueryQuery(
-        {
-            database,
-            query,
-            action: isExternalTableType(type) ? 'execute-query' : 'execute-scan',
-            limitRows: 100,
-        },
-        {
-            skip: !isPreviewAvailable,
-            refetchOnMountOrArgChange: true,
-        },
-    );
-    const loading = isFetching && currentData === undefined;
-    const data = currentData?.resultSets?.[0] ?? {};
-
-    const handleClosePreview = () => {
-        dispatch(setShowPreview(false));
-    };
+import './Preview.scss';
 
-    const renderHeader = () => {
-        return (
-            <div className={b('header')}>
-                <div className={b('title')}>
-                    {i18n('preview.title')}
-                    <Text color="secondary" variant="body-2">
-                        {data.truncated ? `${i18n('preview.truncated')} ` : ''}(
-                        {data.result?.length ?? 0})
-                    </Text>
-                    <div className={b('table-name')}>{path}</div>
-                </div>
-                <div className={b('controls-left')}>
-                    <EnableFullscreenButton disabled={Boolean(error)} />
-                    <Button
-                        view="flat-secondary"
-                        onClick={handleClosePreview}
-                        title={i18n('preview.close')}
-                    >
-                        <Icon data={Xmark} size={18} />
-                    </Button>
-                </div>
-            </div>
-        );
-    };
+export function PreviewContainer(props: PreviewContainerProps) {
+    const {type} = props;
+    const isTable = isTableType(type);
+    const isTopic = type === EPathType.EPathTypePersQueueGroup;
path: src/containers/Tenant/Query/Preview/Preview.tsx
user: login: artemmufazalov
body: Please, rebase you PR and check whether it works for topics inside CDC. If it doesn't work, create an issue and disable preview for them
created_at: 2025-05-19T17:58:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,86 @@
+import React from 'react';
+
+import {Xmark} from '@gravity-ui/icons';
+import {Button, Flex, Icon, Text} from '@gravity-ui/uikit';
+
+import EnableFullscreenButton from '../../../../../components/EnableFullscreenButton/EnableFullscreenButton';
+import Fullscreen from '../../../../../components/Fullscreen/Fullscreen';
+import {LoaderWrapper} from '../../../../../components/LoaderWrapper/LoaderWrapper';
+import {setShowPreview} from '../../../../../store/reducers/schema/schema';
+import {useTypedDispatch} from '../../../../../utils/hooks';
+import {parseQueryErrorToString} from '../../../../../utils/query';
+import i18n from '../../i18n';
+import {b} from '../shared';
+
+interface PreviewProps {
+    path: string;
+    quantity?: number;
+    truncated?: boolean;
+    renderResult?: () => React.ReactNode;
+    loading?: boolean;
+    error?: unknown;
+}
+
+export function Preview({
+    path,
+    quantity = 0,
+    truncated,
+    renderResult,
+    loading,
+    error,
+}: PreviewProps) {
+    const dispatch = useTypedDispatch();
+
+    const handleClosePreview = () => {
+        dispatch(setShowPreview(false));
+    };
+
+    const renderHeader = () => {
+        return (
+            <Flex justifyContent="space-between" alignItems="center" className={b('header')}>
+                <Flex gap={1}>
+                    {i18n('preview.title')}
+                    <Text color="secondary" variant="body-2">
+                        {truncated ? `${i18n('preview.truncated')} ` : ''}({quantity})
+                    </Text>
+                    <div className={b('table-name')}>{path}</div>
+                </Flex>
+                <div className={b('controls-left')}>
+                    <EnableFullscreenButton disabled={Boolean(error)} />
+                    <Button
+                        view="flat-secondary"
+                        onClick={handleClosePreview}
+                        title={i18n('preview.close')}
+                    >
+                        <Icon data={Xmark} size={18} />
+                    </Button>
+                </div>
+            </Flex>
+        );
+    };
+
+    const renderContent = () => {
+        let message;
+
+        if (error) {
+            message = (
+                <div className={b('message-container', 'error')}>
+                    {parseQueryErrorToString(error)}
+                </div>
+            );
+        }
+        if (message) {
+            return message;
+        }
path: src/containers/Tenant/Query/Preview/components/PreviewView.tsx
user: login: artemmufazalov
body: ```
if (error){
    return <.../>
}
```
created_at: 2025-05-19T18:01:09Z
author_association: MEMBER
diff_hunk: @@ -1,106 +1,31 @@
-import {Xmark} from '@gravity-ui/icons';
-import {Button, Icon, Loader, Text} from '@gravity-ui/uikit';
-
-import EnableFullscreenButton from '../../../../components/EnableFullscreenButton/EnableFullscreenButton';
-import Fullscreen from '../../../../components/Fullscreen/Fullscreen';
-import {QueryResultTable} from '../../../../components/QueryResultTable';
-import {previewApi} from '../../../../store/reducers/preview';
-import {setShowPreview} from '../../../../store/reducers/schema/schema';
-import type {EPathType} from '../../../../types/api/schema';
-import {cn} from '../../../../utils/cn';
-import {useTypedDispatch} from '../../../../utils/hooks';
-import {parseQueryErrorToString} from '../../../../utils/query';
-import {isExternalTableType, isTableType} from '../../utils/schema';
+import {useTopicDataAvailable} from '../../../../store/reducers/capabilities/hooks';
+import {EPathType} from '../../../../types/api/schema';
+import {isTableType} from '../../utils/schema';
 import i18n from '../i18n';
 
-import './Preview.scss';
-
-const b = cn('kv-preview');
-
-interface PreviewProps {
-    database: string;
-    path: string;
-    type: EPathType | undefined;
-}
-
-export const Preview = ({database, path, type}: PreviewProps) => {
-    const dispatch = useTypedDispatch();
+import {Preview} from './components/PreviewView';
+import {TablePreview} from './components/TablePreview';
+import {TopicPreview} from './components/TopicPreview';
+import {b} from './shared';
+import type {PreviewContainerProps} from './types';
 
-    const isPreviewAvailable = isTableType(type);
-
-    const query = `select * from \`${path}\` limit 101`;
-    const {currentData, isFetching, error} = previewApi.useSendQueryQuery(
-        {
-            database,
-            query,
-            action: isExternalTableType(type) ? 'execute-query' : 'execute-scan',
-            limitRows: 100,
-        },
-        {
-            skip: !isPreviewAvailable,
-            refetchOnMountOrArgChange: true,
-        },
-    );
-    const loading = isFetching && currentData === undefined;
-    const data = currentData?.resultSets?.[0] ?? {};
-
-    const handleClosePreview = () => {
-        dispatch(setShowPreview(false));
-    };
+import './Preview.scss';
 
-    const renderHeader = () => {
-        return (
-            <div className={b('header')}>
-                <div className={b('title')}>
-                    {i18n('preview.title')}
-                    <Text color="secondary" variant="body-2">
-                        {data.truncated ? `${i18n('preview.truncated')} ` : ''}(
-                        {data.result?.length ?? 0})
-                    </Text>
-                    <div className={b('table-name')}>{path}</div>
-                </div>
-                <div className={b('controls-left')}>
-                    <EnableFullscreenButton disabled={Boolean(error)} />
-                    <Button
-                        view="flat-secondary"
-                        onClick={handleClosePreview}
-                        title={i18n('preview.close')}
-                    >
-                        <Icon data={Xmark} size={18} />
-                    </Button>
-                </div>
-            </div>
-        );
-    };
+export function PreviewContainer(props: PreviewContainerProps) {
+    const {type} = props;
+    const isTable = isTableType(type);
+    const isTopic = type === EPathType.EPathTypePersQueueGroup;
path: src/containers/Tenant/Query/Preview/Preview.tsx
user: login: artemmufazalov
body: I mean `EPathSubType.EPathSubTypeStreamImpl`, I restored them here: https://github.com/ydb-platform/ydb-embedded-ui/pull/2281
created_at: 2025-05-19T18:11:40Z
author_association: MEMBER
diff_hunk: @@ -71,7 +74,7 @@ export const getSchemaControls =
             column_table: openPreview,
 
             index_table: undefined,
-            topic: undefined,
+            topic: isTopicPreviewAvailable && !meta?.subType ? openPreview : undefined,
path: src/containers/Tenant/utils/controls.tsx
user: login: artemmufazalov
body: You should also turn off preview in preview table component. Currently there are not preview button for topic, but if I open preview and then try to navigate from table to cdc impl topic, I will receive an error

<img width="1390" alt="Screenshot 2025-05-21 at 15 50 47" src="https://github.com/user-attachments/assets/e81b64bb-d734-4663-9009-9c30d45163c2" />

created_at: 2025-05-21T12:52:08Z
author_association: MEMBER
diff_hunk: @@ -12,15 +12,17 @@ import type {PreviewContainerProps} from './types';
 import './Preview.scss';
 
 export function PreviewContainer(props: PreviewContainerProps) {
-    const {type} = props;
+    const {type, subType} = props;
     const isTable = isTableType(type);
     const isTopic = type === EPathType.EPathTypePersQueueGroup;
     const isTopicPreviewAvailable = useTopicDataAvailable();
 
     if (isTable) {
         return <TablePreview {...props} />;
     }
-    if (isTopic && isTopicPreviewAvailable) {
+
+    // preview is not available for topics inside CDC (has subtype)
+    if (isTopic && !subType && isTopicPreviewAvailable) {
path: src/containers/Tenant/Query/Preview/Preview.tsx
user: login: artemmufazalov
body: You turned off preview for every topic.

Every element has subtype, in most cases it is `EPathSubTypeEmpty`, so you should check `EPathSubTypeStreamImpl` explicitly
created_at: 2025-05-21T15:11:01Z
author_association: MEMBER
diff_hunk: @@ -12,15 +12,17 @@ import type {PreviewContainerProps} from './types';
 import './Preview.scss';
 
 export function PreviewContainer(props: PreviewContainerProps) {
-    const {type} = props;
+    const {type, subType} = props;
     const isTable = isTableType(type);
     const isTopic = type === EPathType.EPathTypePersQueueGroup;
     const isTopicPreviewAvailable = useTopicDataAvailable();
 
     if (isTable) {
         return <TablePreview {...props} />;
     }
-    if (isTopic && isTopicPreviewAvailable) {
+
+    // preview is not available for topics inside CDC (has subtype)
+    if (isTopic && !subType && isTopicPreviewAvailable) {
path: src/containers/Tenant/Query/Preview/Preview.tsx
user: login: Raubzeug
body: Thanks! Fixed
created_at: 2025-05-21T15:32:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: Raubzeug
created_at: 2025-05-16T15:02:12Z
body: @artemmufazalov @astandrik design review passed.
author_association: CONTRIBUTOR
user: login: Raubzeug
created_at: 2025-05-21T14:45:13Z
body: @artemmufazalov fixed, stand redeployed
author_association: CONTRIBUTOR