Skip to content

Commit 2be0f0b

Browse files
authored
feat: show query trace results (#1248)
1 parent 3d780aa commit 2be0f0b

File tree

22 files changed

+326
-11
lines changed

22 files changed

+326
-11
lines changed

src/components/InfoViewerSkeleton/InfoViewerSkeleton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ interface InfoViewerSkeletonProps {
2323
}
2424

2525
export const InfoViewerSkeleton = ({rows = 8, className, delay = 600}: InfoViewerSkeletonProps) => {
26-
const show = useDelayed(delay);
26+
const [show] = useDelayed(delay);
2727
let skeletons: React.ReactNode = (
2828
<React.Fragment>
2929
<SkeletonLabel />

src/components/LinkWithIcon/LinkWithIcon.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ interface ExternalLinkWithIconProps {
1414
title: string;
1515
url: string;
1616
external?: boolean;
17+
className?: string;
1718
}
1819

19-
export const LinkWithIcon = ({title, url, external = true}: ExternalLinkWithIconProps) => {
20+
export const LinkWithIcon = ({
21+
title,
22+
url,
23+
external = true,
24+
className,
25+
}: ExternalLinkWithIconProps) => {
2026
const linkContent = (
2127
<React.Fragment>
2228
{title}
@@ -27,14 +33,14 @@ export const LinkWithIcon = ({title, url, external = true}: ExternalLinkWithIcon
2733

2834
if (external) {
2935
return (
30-
<Link href={url} target="_blank" className={b()}>
36+
<Link href={url} target="_blank" className={b(null, className)}>
3137
{linkContent}
3238
</Link>
3339
);
3440
}
3541

3642
return (
37-
<InternalLink to={url} className={b()}>
43+
<InternalLink to={url} className={b(null, className)}>
3844
{linkContent}
3945
</InternalLink>
4046
);

src/components/Loader/Loader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface LoaderProps {
1515
}
1616

1717
export const Loader = ({size = 'm', delay = 600, className}: LoaderProps) => {
18-
const show = useDelayed(delay);
18+
const [show] = useDelayed(delay);
1919
if (!show) {
2020
return null;
2121
}

src/components/Skeleton/Skeleton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ interface SkeletonProps {
88
}
99

1010
export const Skeleton = ({delay = 600, className}: SkeletonProps) => {
11-
const show = useDelayed(delay);
11+
const [show] = useDelayed(delay);
1212
if (!show) {
1313
return null;
1414
}

src/containers/Tenant/Query/ExecuteResult/ExecuteResult.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,10 @@
8484
@include query-buttons-animations();
8585
}
8686
}
87+
88+
&__trace-link {
89+
&_loading {
90+
color: var(--g-color-text-secondary);
91+
}
92+
}
8793
}

src/containers/Tenant/Query/ExecuteResult/ExecuteResult.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {cn} from '../../../../utils/cn';
2323
import {getStringifiedData} from '../../../../utils/dataFormatters/dataFormatters';
2424
import {useTypedDispatch} from '../../../../utils/hooks';
2525
import {parseQueryError} from '../../../../utils/query';
26+
import {ClusterModeGuard} from '../../../ClusterModeGuard';
2627
import {PaneVisibilityToggleButtons} from '../../utils/paneVisibilityToggleHelpers';
2728
import {SimplifiedPlan} from '../ExplainResult/components/SimplifiedPlan/SimplifiedPlan';
2829
import {ResultIssues} from '../Issues/Issues';
@@ -31,6 +32,7 @@ import {QuerySettingsBanner} from '../QuerySettingsBanner/QuerySettingsBanner';
3132
import {getPreparedResult} from '../utils/getPreparedResult';
3233
import {isQueryCancelledError} from '../utils/isQueryCancelledError';
3334

35+
import {TraceButton} from './TraceButton';
3436
import i18n from './i18n';
3537
import {getPlan} from './utils';
3638

@@ -243,7 +245,6 @@ export function ExecuteResult({
243245
<div className={b('controls')}>
244246
<div className={b('controls-right')}>
245247
<QueryExecutionStatus error={error} loading={loading} />
246-
247248
{!error && !loading && (
248249
<React.Fragment>
249250
{stats?.DurationUs !== undefined && (
@@ -274,6 +275,9 @@ export function ExecuteResult({
274275
</Button>
275276
</React.Fragment>
276277
) : null}
278+
<ClusterModeGuard mode="multi">
279+
<TraceButton traceId={data?.traceId} />
280+
</ClusterModeGuard>
277281
</div>
278282
<div className={b('controls-left')}>
279283
{renderClipboardButton()}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
3+
import {Button} from '@gravity-ui/uikit';
4+
import {StringParam, useQueryParams} from 'use-query-params';
5+
6+
import {LinkWithIcon} from '../../../../components/LinkWithIcon/LinkWithIcon';
7+
import {clusterApi} from '../../../../store/reducers/cluster/cluster';
8+
import {traceApi} from '../../../../store/reducers/trace';
9+
import type {TClusterInfo} from '../../../../types/api/cluster';
10+
import {cn} from '../../../../utils/cn';
11+
import {SECOND_IN_MS} from '../../../../utils/constants';
12+
import {useDelayed} from '../../../../utils/hooks/useDelayed';
13+
import {replaceParams} from '../utils/replaceParams';
14+
15+
import i18n from './i18n';
16+
17+
const b = cn('ydb-query-execute-result');
18+
19+
const TIME_BEFORE_CHECK = 15 * SECOND_IN_MS;
20+
21+
interface TraceUrlButtonProps {
22+
traceId?: string;
23+
}
24+
25+
function hasValidTraceCheckUrl(cluster?: TClusterInfo): cluster is {TraceCheck: {url: string}} {
26+
return Boolean(cluster && cluster.TraceCheck && typeof cluster.TraceCheck.url === 'string');
27+
}
28+
29+
function hasValidTraceViewUrl(cluster?: TClusterInfo): cluster is {TraceView: {url: string}} {
30+
return Boolean(cluster && cluster.TraceView && typeof cluster.TraceView.url === 'string');
31+
}
32+
33+
export function TraceButton({traceId}: TraceUrlButtonProps) {
34+
const [queryParams] = useQueryParams({clusterName: StringParam});
35+
36+
const {data: {clusterData: cluster} = {}} = clusterApi.useGetClusterInfoQuery(
37+
queryParams.clusterName ?? undefined,
38+
{skip: !traceId},
39+
);
40+
41+
const hasTraceCheck = Boolean(hasValidTraceCheckUrl(cluster) && traceId);
42+
43+
const checkTraceUrl =
44+
traceId && hasValidTraceCheckUrl(cluster)
45+
? replaceParams(cluster.TraceCheck.url, {traceId})
46+
: '';
47+
48+
// We won't get any trace data at first 15 seconds for sure
49+
const [readyToFetch, resetDelay] = useDelayed(TIME_BEFORE_CHECK);
50+
51+
React.useEffect(() => {
52+
resetDelay();
53+
}, [traceId, resetDelay]);
54+
55+
const {currentData: traceData, error: traceError} = traceApi.useCheckTraceQuery(
56+
{url: checkTraceUrl},
57+
{skip: !hasTraceCheck || !readyToFetch},
58+
);
59+
60+
const traceUrl =
61+
hasValidTraceViewUrl(cluster) && traceId
62+
? replaceParams(cluster.TraceView.url, {traceId})
63+
: '';
64+
65+
if (!traceUrl) {
66+
return null;
67+
}
68+
69+
return (
70+
<Button view="flat-secondary" loading={!traceData && !traceError}>
71+
<LinkWithIcon
72+
className={b('trace-link', {loading: !traceData && !traceError})}
73+
title={i18n('trace')}
74+
url={traceUrl}
75+
external
76+
/>
77+
</Button>
78+
);
79+
}

src/containers/Tenant/Query/ExecuteResult/i18n/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"action.schema": "Schema",
66
"action.stop": "Stop",
77
"action.explain-plan": "Explain Plan",
8-
"action.copy": "Copy {{activeSection}}"
8+
"action.copy": "Copy {{activeSection}}",
9+
"trace": "Trace"
910
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {replaceParams} from '../replaceParams'; // Adjust the import path as needed
2+
3+
describe('replaceParams for example.com requests', () => {
4+
test('replaces single parameter in URL path', () => {
5+
const template = 'https://example.com/users/${userId}';
6+
const params = {userId: '12345'};
7+
expect(replaceParams(template, params)).toBe('https://example.com/users/12345');
8+
});
9+
10+
test('replaces multiple parameters in URL path and query', () => {
11+
const template = 'https://example.com/api/${version}/search?q=${query}&limit=${limit}';
12+
const params = {version: 'v1', query: 'nodejs', limit: '10'};
13+
expect(replaceParams(template, params)).toBe(
14+
'https://example.com/api/v1/search?q=nodejs&limit=10',
15+
);
16+
});
17+
18+
test('handles missing parameters in complex URL', () => {
19+
const template = 'https://example.com/${resource}/${id}?filter=${filter}&sort=${sort}';
20+
const params = {resource: 'products', id: '987', sort: 'desc'};
21+
expect(replaceParams(template, params)).toBe(
22+
'https://example.com/products/987?filter=${filter}&sort=desc',
23+
);
24+
});
25+
26+
test('handles empty params object', () => {
27+
const template = 'https://example.com/static/${path}/${file}';
28+
const params = {};
29+
expect(replaceParams(template, params)).toBe('https://example.com/static/${path}/${file}');
30+
});
31+
32+
test('handles template with no parameters', () => {
33+
const template = 'https://example.com/about';
34+
const params = {unused: 'parameter'};
35+
expect(replaceParams(template, params)).toBe('https://example.com/about');
36+
});
37+
38+
test('handles empty template string', () => {
39+
const template = '';
40+
const params = {key: 'value'};
41+
expect(replaceParams(template, params)).toBe('');
42+
});
43+
44+
test('handles complex nested parameters in URL structure', () => {
45+
const template =
46+
'https://example.com/${service}/${version}/${resource}?id=${id}&type=${type}';
47+
const params = {
48+
service: 'api',
49+
version: 'v2',
50+
resource: 'users',
51+
id: '12345',
52+
type: 'admin',
53+
};
54+
expect(replaceParams(template, params)).toBe(
55+
'https://example.com/api/v2/users?id=12345&type=admin',
56+
);
57+
});
58+
59+
test('handles URL-encoded parameters', () => {
60+
const template = 'https://example.com/search?q=${query}';
61+
const params = {query: encodeURIComponent('Hello World!')};
62+
expect(replaceParams(template, params)).toBe('https://example.com/search?q=Hello%20World!');
63+
});
64+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Takes a template string and an object of parameters, and replaces placeholders in the template with corresponding values
2+
// from the parameters object.
3+
export function replaceParams(template: string, params: Record<string, string>) {
4+
return template.replace(/\${(\w+)}/g, (_, key) => params[key] || _);
5+
}

0 commit comments

Comments
 (0)