Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6a92b5a
feat: show streaming query stats in Info tab
DaryaVorontsova Nov 10, 2025
ace6893
Merge branch 'main' into feat/streaming-query-info
DaryaVorontsova Nov 10, 2025
9ce4a5c
Add error display
DaryaVorontsova Nov 10, 2025
ccbc5b2
Fix i18n
DaryaVorontsova Nov 10, 2025
c6912ad
Merge branch 'main' into feat/streaming-query-info
DaryaVorontsova Nov 11, 2025
ab27dde
Fix modal
DaryaVorontsova Nov 11, 2025
4fb7854
Fix error displaying
DaryaVorontsova Nov 11, 2025
97f455c
Merge branch 'main' into feat/streaming-query-info
DaryaVorontsova Nov 11, 2025
fca7538
Fix styles
DaryaVorontsova Nov 12, 2025
93a7f11
Fix gap
DaryaVorontsova Nov 12, 2025
c5117d4
Merge branch 'main' into feat/streaming-query-info
DaryaVorontsova Nov 12, 2025
ad41dd7
Fix issues bug
DaryaVorontsova Nov 12, 2025
9aadde9
rollback package-lock.json to base branch state
DaryaVorontsova Nov 12, 2025
82c0102
Merge branch 'main' into feat/streaming-query-info
DaryaVorontsova Nov 12, 2025
66e7175
Fix bugs
DaryaVorontsova Nov 13, 2025
88915a2
Fix bugs
DaryaVorontsova Nov 13, 2025
f654979
Merge branch 'main' into feat/streaming-query-info
DaryaVorontsova Nov 13, 2025
f0d64cf
Fix error displaying
DaryaVorontsova Nov 11, 2025
aa621f2
Fix issues bug
DaryaVorontsova Nov 12, 2025
46b85da
rollback package-lock.json to base branch state
DaryaVorontsova Nov 12, 2025
48967af
Fix bugs
DaryaVorontsova Nov 13, 2025
a06dcca
Fix query text displaying
DaryaVorontsova Nov 13, 2025
62a0382
Fix i18n
DaryaVorontsova Nov 13, 2025
37517ac
Fix i18n
DaryaVorontsova Nov 13, 2025
8536622
Fix bugs
DaryaVorontsova Nov 13, 2025
06121ac
Fix i18n
DaryaVorontsova Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/containers/Tenant/Diagnostics/Overview/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {ViewInfo} from '../../Info/View/View';

import {AsyncReplicationInfo} from './AsyncReplicationInfo';
import {ChangefeedInfo} from './ChangefeedInfo';
import {StreamingQueryInfo} from './StreamingQueryInfo';
import {TableInfo} from './TableInfo';
import {TopicInfo} from './TopicInfo';
import {TransferInfo} from './TransferInfo';
Expand Down Expand Up @@ -77,7 +78,9 @@ function Overview({type, path, database, databaseFullPath}: OverviewProps) {
data={data}
/>
),
[EPathType.EPathTypeStreamingQuery]: undefined,
[EPathType.EPathTypeStreamingQuery]: () => (
<StreamingQueryInfo data={data} path={path} database={database} />
),
};

return (type && pathTypeToComponent[type]?.()) || <TableInfo data={data} type={type} />;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import React from 'react';

import NiceModal from '@ebay/nice-modal-react';
import {Flex, Label} from '@gravity-ui/uikit';

import {CONFIRMATION_DIALOG} from '../../../../../components/ConfirmationDialog/ConfirmationDialog';
import {YDBSyntaxHighlighter} from '../../../../../components/SyntaxHighlighter/YDBSyntaxHighlighter';
import {YDBDefinitionList} from '../../../../../components/YDBDefinitionList/YDBDefinitionList';
import type {YDBDefinitionListItem} from '../../../../../components/YDBDefinitionList/YDBDefinitionList';
import {streamingQueriesApi} from '../../../../../store/reducers/streamingQuery/streamingQuery';
import type {ErrorResponse} from '../../../../../types/api/query';
import type {TEvDescribeSchemeResult} from '../../../../../types/api/schema';
import type {IQueryResult} from '../../../../../types/store/query';
import {getStringifiedData} from '../../../../../utils/dataFormatters/dataFormatters';
import {Issues, ResultIssues} from '../../../Query/Issues/Issues';
import {getEntityName} from '../../../utils';

import i18n from './i18n';

interface StreamingQueryProps {
data?: TEvDescribeSchemeResult;
database: string;
path: string;
}

/** Displays overview for StreamingQuery EPathType */
export function StreamingQueryInfo({data, database, path}: StreamingQueryProps) {
const entityName = getEntityName(data?.PathDescription);

if (!data) {
return (
<div className="error">
{i18n('noData')} {entityName}
</div>
);
}

const {data: sysData} = streamingQueriesApi.useGetStreamingQueryInfoQuery(
{database, path},
{skip: !database || !path},
);

const items = prepareStreamingQueryItems(sysData);

return (
<Flex direction="column" gap="4">
<YDBDefinitionList title={entityName} items={items} />
</Flex>
);
}

const STATE_THEME_MAP: Record<string, React.ComponentProps<typeof Label>['theme']> = {
CREATING: 'info',
CREATED: 'normal',
STARTING: 'info',
RUNNING: 'success',
STOPPING: 'info',
STOPPED: 'normal',
COMPLETED: 'success',
SUSPENDED: 'warning',
FAILED: 'danger',
};

function renderStateLabel(state?: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you use render function here, not a component?

if (!state) {
return null;
}

const theme = STATE_THEME_MAP[state] ?? 'normal';

return <Label theme={theme}>{state}</Label>;
}

function prepareStreamingQueryItems(sysData?: IQueryResult): YDBDefinitionListItem[] {
if (!sysData) {
return [];
}

const info: YDBDefinitionListItem[] = [];
const state = getStringifiedData(sysData.resultSets?.[0]?.result?.[0]?.State);

const queryText = getStringifiedData(sysData.resultSets?.[0]?.result?.[0]?.Text);
const normalizedQueryText = normalizeQueryText(queryText);

const errorRaw = sysData.resultSets?.[0]?.result?.[0]?.Error;

let errorData: ErrorResponse | string | undefined;
if (typeof errorRaw === 'string') {
try {
errorData = JSON.parse(errorRaw) as ErrorResponse;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after parsing, let's check that parsed error is of type ErrorResponse (it seems type guard is needed)

} catch {
errorData = errorRaw;
}
} else if (errorRaw) {
errorData = errorRaw as ErrorResponse;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here. Let's try not to use type casts, if it is possible.

}

info.push({
name: i18n('state.label'),
content: renderStateLabel(state),
});

if (errorData && Object.keys(errorData).length > 0) {
const issues = typeof errorData === 'string' ? undefined : errorData.issues;

info.push({
name: i18n('state.error'),
content: (
<ResultIssues
data={errorData}
titlePreviewMode="multi"
detailsMode="modal"
onOpenDetails={() =>
NiceModal.show(CONFIRMATION_DIALOG, {
caption: i18n('state.error'),
children: <Issues issues={issues ?? []} />,
})
}
/>
),
});
}

info.push({
name: i18n('text.label'),
copyText: normalizedQueryText,
content: normalizedQueryText ? (
<YDBSyntaxHighlighter language="yql" text={normalizedQueryText} />
) : null,
});

return info;
}

function normalizeQueryText(text?: string) {
if (!text) {
return text;
}

let normalized = text.replace(/^\s*\n+/, '');
normalized = normalized.replace(/\n+\s*$/, '');

return normalized;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"noData": "No data for entity:",
"state.label": "State",
"state.error": "Error",
"text.label": "Text"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {registerKeysets} from '../../../../../../utils/i18n';

import en from './en.json';

const COMPONENT = 'ydb-diagnostics-streaming-query-info';

export default registerKeysets(COMPONENT, {en});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './StreamingQueryInfo';
17 changes: 17 additions & 0 deletions src/containers/Tenant/Query/Issues/Issues.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
.kv-result-issues {
padding: 0 10px;

&__error-list,
&__error-list-item {
display: flex;
flex-direction: column;
gap: 10px;
}

&__error-list-item {
gap: 4px;
}

&__error-message {
position: sticky;
z-index: 2;
Expand All @@ -13,6 +24,12 @@
padding: 10px 0;

background-color: var(--g-color-base-background);

&_column {
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
}

&__error-message-text {
Expand Down
98 changes: 74 additions & 24 deletions src/containers/Tenant/Query/Issues/Issues.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,48 +26,98 @@ const blockIssue = cn('kv-issue');
interface ResultIssuesProps {
data: ErrorResponse | string;
hideSeverity?: boolean;

// 'inline': Expands/collapses the full issues tree right below the preview.
// 'modal': Opens the full issues tree in an extra modal on "Show details".
detailsMode?: 'inline' | 'modal';
onOpenDetails?: () => void;

// 'single': Show a single top line: "<Severity> Error + root error.message".
// 'multi': If there is no root error.message, preview can show all first-level issues.
// Otherwise falls back to 'single'.
titlePreviewMode?: 'single' | 'multi';
}

export function ResultIssues({data, hideSeverity}: ResultIssuesProps) {
export function ResultIssues({
data,
hideSeverity,
detailsMode = 'inline',
onOpenDetails,
titlePreviewMode = 'single',
}: ResultIssuesProps) {
const [showIssues, setShowIssues] = React.useState(false);

const issues = typeof data === 'string' ? undefined : data?.issues;
const hasIssues = Array.isArray(issues) && issues.length > 0;

const renderTitle = () => {
let content;
if (typeof data === 'string') {
content = data;
} else {
const severity = getSeverity(data?.error?.severity);
content = (
const rootHasMessage = typeof data === 'string' ? undefined : data?.error?.message;

const isMultiApplicable = titlePreviewMode === 'multi' && hasIssues && !rootHasMessage;

const renderSinglePreviewLine = (severity: SEVERITY, message?: string) => (
<React.Fragment>
{hideSeverity ? null : (
<React.Fragment>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe Fragment is not needed here if we add gap for error-message css-class?

{hideSeverity ? null : (
<React.Fragment>
<IssueSeverity severity={severity} />{' '}
</React.Fragment>
)}
<span className={blockWrapper('error-message-text')}>
{data?.error?.message}
</span>
<IssueSeverity severity={severity} />{' '}
</React.Fragment>
)}
<span className={blockWrapper('error-message-text')}>{message}</span>
</React.Fragment>
);

const renderTitle = () => {
if (typeof data === 'string') {
return data;
}

if (isMultiApplicable) {
return (
<div className={blockWrapper('error-list')}>
{issues.map((issue, idx) => {
const severity = getSeverity(issue.severity);
return (
<div className={blockWrapper('error-list-item')} key={idx}>
{renderSinglePreviewLine(severity, issue.message)}
</div>
);
})}
</div>
);
}

const severity = getSeverity(data?.error?.severity);
return renderSinglePreviewLine(severity, data?.error?.message ?? '');
};

const renderDetailsControl = () => {
if (!hasIssues) {
return null;
}

if (detailsMode === 'modal') {
return (
<Button view="normal" onClick={() => onOpenDetails?.()}>
Show details
</Button>
);
}

return content;
return (
<Button view="normal" onClick={() => setShowIssues(!showIssues)}>
{showIssues ? 'Hide details' : 'Show details'}
</Button>
);
};

return (
<div className={blockWrapper()}>
<div className={blockWrapper('error-message')}>
<div className={blockWrapper('error-message', {column: isMultiApplicable})}>
{renderTitle()}
{hasIssues && (
<Button view="normal" onClick={() => setShowIssues(!showIssues)}>
{showIssues ? 'Hide details' : 'Show details'}
</Button>
)}
{renderDetailsControl()}
</div>
{hasIssues && showIssues && <Issues hideSeverity={hideSeverity} issues={issues} />}
{detailsMode === 'inline' && hasIssues && showIssues && (
<Issues hideSeverity={hideSeverity} issues={issues} />
)}
</div>
);
}
Expand Down
49 changes: 49 additions & 0 deletions src/store/reducers/streamingQuery/streamingQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {QUERY_TECHNICAL_MARK} from '../../../utils/constants';
import {isQueryErrorResponse, parseQueryAPIResponse} from '../../../utils/query';
import {api} from '../api';

function getStreamingQueryInfoSQL(path: string) {
const safePath = path.replace(/'/g, "''");
return `${QUERY_TECHNICAL_MARK}
SELECT
Status AS State,
Issues AS Error,
Text
FROM \`.sys/streaming_queries\`
WHERE Path = '${safePath}'
LIMIT 1`;
}

export const streamingQueriesApi = api.injectEndpoints({
endpoints: (build) => ({
getStreamingQueryInfo: build.query({
queryFn: async ({database, path}: {database: string; path: string}, {signal}) => {
try {
const response = await window.api.viewer.sendQuery(
{
query: getStreamingQueryInfoSQL(path),
database,
action: 'execute-scan',
internal_call: true,
},
{signal, withRetries: true},
);

if (isQueryErrorResponse(response)) {
return {error: response};
}

const data = parseQueryAPIResponse(response);
return {data};
} catch (error) {
return {error};
}
},
forceRefetch() {
return true;
},
providesTags: ['All'],
}),
}),
overrideExisting: 'throw',
});
Loading