diff --git a/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.scss b/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.scss index 278cdcb139..7f3e35102f 100644 --- a/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.scss +++ b/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.scss @@ -25,4 +25,9 @@ top: 23px; right: 13px; } + + // Delete padding for all query text on Diagnostics -> Info tab + pre { + padding: 0; + } } diff --git a/src/containers/Tenant/Diagnostics/Overview/Overview.tsx b/src/containers/Tenant/Diagnostics/Overview/Overview.tsx index 4e4e17aa5f..01766a9d85 100644 --- a/src/containers/Tenant/Diagnostics/Overview/Overview.tsx +++ b/src/containers/Tenant/Diagnostics/Overview/Overview.tsx @@ -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'; @@ -77,7 +78,9 @@ function Overview({type, path, database, databaseFullPath}: OverviewProps) { data={data} /> ), - [EPathType.EPathTypeStreamingQuery]: undefined, + [EPathType.EPathTypeStreamingQuery]: () => ( + + ), }; return (type && pathTypeToComponent[type]?.()) || ; diff --git a/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/StreamingQueryInfo.tsx b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/StreamingQueryInfo.tsx new file mode 100644 index 0000000000..f4b4ba685d --- /dev/null +++ b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/StreamingQueryInfo.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import {Label} from '@gravity-ui/uikit'; + +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, + stripIndentByFirstLine, + trimOuterEmptyLines, +} from '../../../../../utils/dataFormatters/dataFormatters'; +import {isErrorResponse} from '../../../../../utils/query'; +import {ResultIssuesModal} 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 ( +
+ {i18n('alert_no-data')} {entityName} +
+ ); + } + + const {data: sysData} = streamingQueriesApi.useGetStreamingQueryInfoQuery( + {database, path}, + {skip: !database || !path}, + ); + + const items = prepareStreamingQueryItems(sysData); + + return ; +} + +const STATE_THEME_MAP: Record['theme']> = { + CREATING: 'info', + CREATED: 'normal', + STARTING: 'info', + RUNNING: 'success', + STOPPING: 'info', + STOPPED: 'normal', + COMPLETED: 'success', + SUSPENDED: 'warning', + FAILED: 'danger', +}; + +function StateLabel({state}: {state?: string}) { + if (!state) { + return null; + } + + const theme = STATE_THEME_MAP[state] ?? 'normal'; + + return ; +} + +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); + let normalizedQueryText = trimOuterEmptyLines(queryText); + normalizedQueryText = stripIndentByFirstLine(normalizedQueryText); + + const errorRaw = sysData.resultSets?.[0]?.result?.[0]?.Error; + + // We use custom error check, because error type can be non-standard + const errorData = parseErrorData(errorRaw); + + info.push({ + name: i18n('field_query-state'), + content: , + }); + + if (errorData) { + info.push({ + name: i18n('field_query-error'), + content: , + }); + } + + info.push({ + name: i18n('field_query-text'), + copyText: normalizedQueryText, + content: normalizedQueryText ? ( + + ) : null, + }); + + return info; +} + +function parseErrorData(raw: unknown): ErrorResponse | string | undefined { + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return isErrorResponse(parsed) ? parsed : undefined; + } catch { + return raw; + } + } + + if (isErrorResponse(raw)) { + return raw; + } + + return undefined; +} diff --git a/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/i18n/en.json b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/i18n/en.json new file mode 100644 index 0000000000..4fac301e24 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/i18n/en.json @@ -0,0 +1,6 @@ +{ + "alert_no-data": "No data for entity:", + "field_query-state": "State", + "field_query-error": "Error", + "field_query-text": "Text" +} diff --git a/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/i18n/index.ts b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/i18n/index.ts new file mode 100644 index 0000000000..6ba1497686 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/i18n/index.ts @@ -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}); diff --git a/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/index.ts b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/index.ts new file mode 100644 index 0000000000..20a5cec023 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/Overview/StreamingQueryInfo/index.ts @@ -0,0 +1 @@ +export * from './StreamingQueryInfo'; diff --git a/src/containers/Tenant/Query/Issues/Issues.scss b/src/containers/Tenant/Query/Issues/Issues.scss index ca0db88644..fea4797976 100644 --- a/src/containers/Tenant/Query/Issues/Issues.scss +++ b/src/containers/Tenant/Query/Issues/Issues.scss @@ -1,6 +1,4 @@ .kv-result-issues { - padding: 0 10px; - &__error-message { position: sticky; z-index: 2; @@ -10,8 +8,6 @@ display: flex; align-items: center; - padding: 10px 0; - background-color: var(--g-color-base-background); } diff --git a/src/containers/Tenant/Query/Issues/Issues.tsx b/src/containers/Tenant/Query/Issues/Issues.tsx index 5a99d416c1..5917c01481 100644 --- a/src/containers/Tenant/Query/Issues/Issues.tsx +++ b/src/containers/Tenant/Query/Issues/Issues.tsx @@ -7,13 +7,16 @@ import { TriangleExclamationFill, } from '@gravity-ui/icons'; import type {IconData} from '@gravity-ui/uikit'; -import {ArrowToggle, Button, Icon, Link} from '@gravity-ui/uikit'; +import {ArrowToggle, Button, Flex, Icon, Link} from '@gravity-ui/uikit'; import ShortyString from '../../../../components/ShortyString/ShortyString'; import type {ErrorResponse, IssueMessage} from '../../../../types/api/query'; import {cn} from '../../../../utils/cn'; import {isNumeric} from '../../../../utils/utils'; +import {IssuesDialog} from './IssuesDialog'; +import {getIssuePosition, getMostSevere, hasRootIssues, normalizeRoots} from './helpers'; +import i18n from './i18n'; import type {SEVERITY} from './models'; import {getSeverity} from './models'; @@ -28,46 +31,125 @@ interface ResultIssuesProps { hideSeverity?: boolean; } +function ErrorStringMessage({message}: {message: string}) { + return
{message}
; +} + export function ResultIssues({data, hideSeverity}: 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 = ( - - {hideSeverity ? null : ( - - {' '} + const roots = normalizeRoots(data); + + const [expanded, setExpanded] = React.useState>({}); + + const onToggleInline = (idx: number) => setExpanded((p) => ({...p, [idx]: !p[idx]})); + + if (typeof data === 'string') { + return ; + } + + return ( +
+ + {roots.map((root, idx) => { + const hasIssues = hasRootIssues(root.issues); + + return ( + + onToggleInline(idx)} + /> + {expanded[idx] && hasIssues && ( + + )} - )} - - {data?.error?.message} - - - ); - } + ); + })} + +
+ ); +} + +export function ResultIssuesModal({data, hideSeverity}: ResultIssuesProps) { + const roots = normalizeRoots(data); + + const [open, setOpen] = React.useState(false); + const [currentIssues, setCurrentIssues] = React.useState(null); - return content; + const openDialog = (issues?: IssueMessage[]) => { + setCurrentIssues(issues ?? []); + setOpen(true); }; + const closeDialog = () => setOpen(false); + + if (typeof data === 'string') { + return ; + } return ( -
-
- {renderTitle()} - {hasIssues && ( - - )} + +
+ + {roots.map((root, idx) => { + const hasIssues = hasRootIssues(root.issues); + + return ( + openDialog(root.issues)} + /> + ); + })} +
- {hasIssues && showIssues && } + + +
+ ); +} + +interface ErrorPreviewItemProps { + severity: SEVERITY; + message?: string; + hideSeverity?: boolean; + hasIssues?: boolean; + expanded?: boolean; + onClick: () => void; +} + +export function ErrorPreviewItem({ + severity, + message, + hideSeverity, + hasIssues, + expanded, + onClick, +}: ErrorPreviewItemProps) { + const buttonLabel = expanded ? i18n('action_hide-details') : i18n('action_show-details'); + + return ( +
+ {hideSeverity ? null : } + {message} + + {hasIssues && ( + + )}
); } @@ -77,10 +159,7 @@ interface IssuesProps { hideSeverity?: boolean; } export function Issues({issues, hideSeverity}: IssuesProps) { - const mostSevereIssue = issues?.reduce((result, issue) => { - const severity = issue.severity ?? 10; - return Math.min(result, severity); - }, 10); + const mostSevereIssue = getMostSevere(issues); return (
{issues?.map((issue, index) => ( @@ -88,7 +167,7 @@ export function Issues({issues, hideSeverity}: IssuesProps) { key={index} hideSeverity={hideSeverity} issue={issue} - expanded={issue === mostSevereIssue} + expanded={issue.severity === mostSevereIssue} /> ))}
@@ -109,7 +188,7 @@ function Issue({ const severity = getSeverity(issue.severity); const issues = issue.issues; - const hasIssues = Array.isArray(issues) && issues.length > 0; + const hasIssues = hasRootIssues(issues); const arrowDirection = isExpand ? 'bottom' : 'right'; @@ -133,7 +212,9 @@ function Issue({ {hideSeverity ? null : } {issue.issue_code ? ( - Code: {issue.issue_code} + + {i18n('field_code')}: {issue.issue_code} + ) : null}
{hasIssues && isExpand && ( @@ -158,12 +239,15 @@ function IssueText({issue}: IssueTextProps) { return ( {position && ( - + {position} )}
- +
); @@ -217,14 +301,3 @@ function IssueSeverity({severity}: {severity: SEVERITY}) {
); } - -function getIssuePosition(issue: IssueMessage): string { - const {position} = issue; - if (typeof position !== 'object' || position === null || !isNumeric(position.row)) { - return ''; - } - - const {row, column} = position; - - return isNumeric(column) ? `${row}:${column}` : `line ${row}`; -} diff --git a/src/containers/Tenant/Query/Issues/IssuesDialog.tsx b/src/containers/Tenant/Query/Issues/IssuesDialog.tsx new file mode 100644 index 0000000000..1c94b9affe --- /dev/null +++ b/src/containers/Tenant/Query/Issues/IssuesDialog.tsx @@ -0,0 +1,35 @@ +import {Dialog} from '@gravity-ui/uikit'; + +import type {IssueMessage} from '../../../../types/api/query'; + +import {Issues} from './Issues'; + +interface IssuesDialogProps { + open: boolean; + issues: IssueMessage[]; + hideSeverity?: boolean; + onClose: () => void; + textButtonCancel?: string; + size?: 's' | 'm' | 'l'; + caption?: string; +} + +export function IssuesDialog({ + open, + issues, + hideSeverity, + onClose, + textButtonCancel = 'Close', + size = 'm', + caption, +}: IssuesDialogProps) { + return ( + + + + + + + + ); +} diff --git a/src/containers/Tenant/Query/Issues/helpers.ts b/src/containers/Tenant/Query/Issues/helpers.ts new file mode 100644 index 0000000000..c225a9ad78 --- /dev/null +++ b/src/containers/Tenant/Query/Issues/helpers.ts @@ -0,0 +1,45 @@ +import type {ErrorResponse, IssueMessage} from '../../../../types/api/query'; +import {isNumeric} from '../../../../utils/utils'; + +export function hasRootIssues(issues?: IssueMessage[]): issues is IssueMessage[] { + return Array.isArray(issues) && issues.length > 0; +} + +export function normalizeRoots(data: ErrorResponse | string): IssueMessage[] { + if (typeof data === 'string') { + return []; + } + + if (data?.error?.message) { + return [ + { + message: data.error.message, + severity: data.error.severity, + position: data.error.position, + end_position: data.error.end_position, + issue_code: data.error.issue_code, + issues: Array.isArray(data.issues) ? data.issues : [], + }, + ]; + } + + return Array.isArray(data.issues) ? data.issues : []; +} + +export function getIssuePosition(issue: IssueMessage): string { + const {position} = issue; + if (typeof position !== 'object' || position === null || !isNumeric(position.row)) { + return ''; + } + + const {row, column} = position; + + return isNumeric(column) ? `${row}:${column}` : `line ${row}`; +} + +export function getMostSevere(issues?: IssueMessage[] | null) { + return issues?.reduce((result, issue) => { + const severity = issue.severity ?? 10; + return Math.min(result, severity); + }, 10); +} diff --git a/src/containers/Tenant/Query/Issues/i18n/en.json b/src/containers/Tenant/Query/Issues/i18n/en.json new file mode 100644 index 0000000000..2c1a620d78 --- /dev/null +++ b/src/containers/Tenant/Query/Issues/i18n/en.json @@ -0,0 +1,9 @@ +{ + "action_show-details": "Show details", + "action_hide-details": "Hide details", + "action_close": "Close", + "action_show-full-message": "Show full message", + + "field_code": "Code", + "field_position": "Position" +} diff --git a/src/containers/Tenant/Query/Issues/i18n/index.ts b/src/containers/Tenant/Query/Issues/i18n/index.ts new file mode 100644 index 0000000000..14b87ff223 --- /dev/null +++ b/src/containers/Tenant/Query/Issues/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'issues'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss index 86c532db7c..e905320ac5 100644 --- a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss +++ b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.scss @@ -1,6 +1,5 @@ .ydb-query-result-error { &__message { - padding-top: var(--g-spacing-4); - padding-left: var(--g-spacing-4); + padding: var(--g-spacing-4) var(--g-spacing-4) 0 var(--g-spacing-4); } } diff --git a/src/containers/Tenant/utils/schemaQueryTemplates.ts b/src/containers/Tenant/utils/schemaQueryTemplates.ts index c28a664ec8..682d5b3e39 100644 --- a/src/containers/Tenant/utils/schemaQueryTemplates.ts +++ b/src/containers/Tenant/utils/schemaQueryTemplates.ts @@ -1,5 +1,9 @@ import type {IQueryResult} from '../../../types/store/query'; -import {getStringifiedData} from '../../../utils/dataFormatters/dataFormatters'; +import { + getStringifiedData, + stripIndentByFirstLine, + trimOuterEmptyLines, +} from '../../../utils/dataFormatters/dataFormatters'; import type {SchemaData} from '../Schema/SchemaViewer/types'; export interface SchemaQueryParams { @@ -19,13 +23,6 @@ function toLF(str: string) { return str.replace(/\r\n?/g, '\n'); } -function stripAllIndent(str: string) { - return str - .split('\n') - .map((line) => line.trim()) - .join('\n'); -} - function indentBlock(str: string, pad = ' ') { return str.replace(/^/gm, pad); } @@ -347,8 +344,8 @@ export const alterStreamingQueryText = (params?: SchemaQueryParams) => { const sysData = params?.streamingQueryData; const rawQueryText = getStringifiedData(sysData?.resultSets?.[0]?.result?.[0]?.Text); let queryText = toLF(rawQueryText); - queryText = queryText.trim(); - queryText = stripAllIndent(queryText); + queryText = trimOuterEmptyLines(queryText); + queryText = stripIndentByFirstLine(queryText); queryText = normalizeParameter(queryText); const bodyQueryText = queryText diff --git a/src/utils/dataFormatters/dataFormatters.ts b/src/utils/dataFormatters/dataFormatters.ts index 616b085086..2ee9adfd3f 100644 --- a/src/utils/dataFormatters/dataFormatters.ts +++ b/src/utils/dataFormatters/dataFormatters.ts @@ -241,3 +241,45 @@ export function getStringifiedData(value: unknown) { return value.toString(); } } + +// Delete outer empty lines, saving first line spaces +export function trimOuterEmptyLines(str: string) { + const lines = str.split('\n'); + + let start = 0; + while (start < lines.length && lines[start].trim() === '') { + start++; + } + + let end = lines.length - 1; + while (end >= start && lines[end].trim() === '') { + end--; + } + + return lines.slice(start, end + 1).join('\n'); +} + +// Remove from each line exactly as many leading spaces +// as from the first non-empty line +export function stripIndentByFirstLine(str: string) { + const lines = str.split('\n'); + if (lines.length === 0) { + return str; + } + + const firstIdx = lines.findIndex((l) => l.trim() !== ''); + if (firstIdx === -1) { + return str; + } + + const firstLine = lines[firstIdx]; + const match = firstLine.match(/^ +/); + const leadSpaces = match ? match[0].length : 0; + if (leadSpaces === 0) { + return str; + } + + const re = new RegExp(`^ {0,${leadSpaces}}`); + const res = lines.map((line) => line.replace(re, '')); + return res.join('\n'); +} diff --git a/src/utils/query.ts b/src/utils/query.ts index 82ed602203..24173add71 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -231,6 +231,10 @@ export function isQueryErrorResponse(data: unknown): data is ErrorResponse { return Boolean(data && typeof data === 'object' && 'error' in data && 'issues' in data); } +export function isErrorResponse(data: unknown): data is ErrorResponse { + return Boolean(data && typeof data === 'object' && 'issues' in data); +} + // Although schema is set in request, if schema is not supported default schema for the version will be used // So we should additionally parse response export function parseQueryAPIResponse(