Skip to content

Commit d6ddd80

Browse files
committed
feat(FR-2300): add diagnostics result export (#5977)
Resolves #5966 (FR-2300) ## Summary - Add CSV export functionality to the Diagnostics page via a Dropdown "more" menu (MoreOutlined + Dropdown pattern) - Export all diagnostics results as a timestamped CSV file with proper escaping (commas, quotes, newlines) - Add `onResultsChange` callback prop to all diagnostics section components (`CspDiagnosticsSection`, `EndpointDiagnosticsSection`, `StorageProxyDiagnosticsSection`, `WebServerConfigDiagnosticsSection`) to collect results from each section - Aggregate results from all sections in `DiagnosticsPage` using a ref, with stable per-section `useCallback` handlers - Use `useFetchKey` hook from `backend.ai-ui` for triggering refetch instead of `key`-based re-render - Remove all `useMemo` usage from diagnostics hooks (`useCspDiagnostics`, `useEndpointDiagnostics`, `useWebServerConfigDiagnostics`) — replaced by React Compiler `'use memo'` directive - Add `ExportCSV` and `NoResultsToExport` i18n keys to `en.json` - Add `ShowOnlyFailedItems` i18n key with correct translations across 20 languages ## Changed Files - `react/src/pages/DiagnosticsPage.tsx` — CSV export, Dropdown menu, `useFetchKey`, stable callbacks - `react/src/components/CspDiagnosticsSection.tsx` — `onResultsChange` prop, `fetchKey` prop - `react/src/components/EndpointDiagnosticsSection.tsx` — `onResultsChange` prop, `fetchKey` prop - `react/src/components/StorageProxyDiagnosticsSection.tsx` — `onResultsChange` prop, `fetchKey` prop - `react/src/components/WebServerConfigDiagnosticsSection.tsx` — `onResultsChange` prop, `fetchKey` prop - `react/src/hooks/useCspDiagnostics.ts` — remove `useMemo`, use `'use memo'` directive - `react/src/hooks/useEndpointDiagnostics.ts` — remove `useMemo`, use `'use memo'` directive - `react/src/hooks/useWebServerConfigDiagnostics.ts` — remove `useMemo`, use `'use memo'` directive - `react/src/hooks/useStorageProxyDiagnostics.ts` — `fetchKey` prop - `resources/i18n/*.json` — `ExportCSV`, `NoResultsToExport`, `ShowOnlyFailedItems` keys
1 parent 9bf9e18 commit d6ddd80

31 files changed

Lines changed: 32230 additions & 372 deletions

pnpm-lock.03-05-feat_fr-2213_add_minimize_maximize_and_fullscreen_mode_to_baimodal.yaml

Lines changed: 31692 additions & 0 deletions
Large diffs are not rendered by default.

react/src/components/CspDiagnosticsSection.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,36 @@
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
55
import { useCspDiagnostics } from '../hooks/useCspDiagnostics';
6+
import { DiagnosticResult } from '../types/diagnostics';
67
import DiagnosticResultList from './DiagnosticResultList';
78
import { useEffect } from 'react';
89

910
interface CspDiagnosticsSectionProps {
1011
hidePassed?: boolean;
12+
fetchKey?: string;
1113
onHasIssues?: (hasIssues: boolean) => void;
14+
onResultsChange?: (results: DiagnosticResult[]) => void;
1215
}
1316

1417
const CspDiagnosticsSection: React.FC<CspDiagnosticsSectionProps> = ({
1518
hidePassed = false,
19+
fetchKey,
1620
onHasIssues,
21+
onResultsChange,
1722
}) => {
1823
'use memo';
1924

20-
const results = useCspDiagnostics();
25+
const results = useCspDiagnostics(fetchKey);
2126
const hasIssues = results.some((r) => r.severity !== 'passed');
2227

2328
useEffect(() => {
2429
onHasIssues?.(hasIssues);
2530
}, [hasIssues, onHasIssues]);
2631

32+
useEffect(() => {
33+
onResultsChange?.(results);
34+
}, [results, onResultsChange]);
35+
2736
return <DiagnosticResultList results={results} hidePassed={hidePassed} />;
2837
};
2938

react/src/components/EndpointDiagnosticsSection.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,26 @@
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
55
import { useEndpointDiagnostics } from '../hooks/useEndpointDiagnostics';
6+
import { DiagnosticResult } from '../types/diagnostics';
67
import DiagnosticResultList from './DiagnosticResultList';
78
import { useEffect } from 'react';
89

910
interface EndpointDiagnosticsSectionProps {
1011
hidePassed?: boolean;
12+
fetchKey?: string;
1113
onHasIssues?: (hasIssues: boolean) => void;
14+
onResultsChange?: (results: DiagnosticResult[]) => void;
1215
}
1316

1417
const EndpointDiagnosticsSection: React.FC<EndpointDiagnosticsSectionProps> = ({
1518
hidePassed = false,
19+
fetchKey,
1620
onHasIssues,
21+
onResultsChange,
1722
}) => {
1823
'use memo';
1924

20-
const { results, isLoading } = useEndpointDiagnostics();
25+
const { results, isLoading } = useEndpointDiagnostics(fetchKey);
2126
const hasIssues = results.some((r) => r.severity !== 'passed');
2227

2328
useEffect(() => {
@@ -26,6 +31,10 @@ const EndpointDiagnosticsSection: React.FC<EndpointDiagnosticsSectionProps> = ({
2631
}
2732
}, [hasIssues, isLoading, onHasIssues]);
2833

34+
useEffect(() => {
35+
onResultsChange?.(results);
36+
}, [results, onResultsChange]);
37+
2938
return (
3039
<DiagnosticResultList
3140
results={results}

react/src/components/StorageProxyDiagnosticsSection.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,33 @@
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
55
import { useStorageProxyDiagnostics } from '../hooks/useStorageProxyDiagnostics';
6+
import { DiagnosticResult } from '../types/diagnostics';
67
import DiagnosticResultList from './DiagnosticResultList';
78
import { useEffect } from 'react';
89

910
interface StorageProxyDiagnosticsSectionProps {
1011
hidePassed?: boolean;
12+
fetchKey?: string;
1113
onHasIssues?: (hasIssues: boolean) => void;
14+
onResultsChange?: (results: DiagnosticResult[]) => void;
1215
}
1316

1417
const StorageProxyDiagnosticsSection: React.FC<
1518
StorageProxyDiagnosticsSectionProps
16-
> = ({ hidePassed = false, onHasIssues }) => {
19+
> = ({ hidePassed = false, fetchKey, onHasIssues, onResultsChange }) => {
1720
'use memo';
1821

19-
const results = useStorageProxyDiagnostics();
22+
const results = useStorageProxyDiagnostics(fetchKey);
2023
const hasIssues = results.some((r) => r.severity !== 'passed');
2124

2225
useEffect(() => {
2326
onHasIssues?.(hasIssues);
2427
}, [hasIssues, onHasIssues]);
2528

29+
useEffect(() => {
30+
onResultsChange?.(results);
31+
}, [results, onResultsChange]);
32+
2633
return <DiagnosticResultList results={results} hidePassed={hidePassed} />;
2734
};
2835

react/src/components/WebServerConfigDiagnosticsSection.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,33 @@
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
55
import { useWebServerConfigDiagnostics } from '../hooks/useWebServerConfigDiagnostics';
6+
import { DiagnosticResult } from '../types/diagnostics';
67
import DiagnosticResultList from './DiagnosticResultList';
78
import { useEffect } from 'react';
89

910
interface WebServerConfigDiagnosticsSectionProps {
1011
hidePassed?: boolean;
12+
fetchKey?: string;
1113
onHasIssues?: (hasIssues: boolean) => void;
14+
onResultsChange?: (results: DiagnosticResult[]) => void;
1215
}
1316

1417
const WebServerConfigDiagnosticsSection: React.FC<
1518
WebServerConfigDiagnosticsSectionProps
16-
> = ({ hidePassed = false, onHasIssues }) => {
19+
> = ({ hidePassed = false, fetchKey, onHasIssues, onResultsChange }) => {
1720
'use memo';
1821

19-
const results = useWebServerConfigDiagnostics();
22+
const results = useWebServerConfigDiagnostics(fetchKey);
2023
const hasIssues = results.some((r) => r.severity !== 'passed');
2124

2225
useEffect(() => {
2326
onHasIssues?.(hasIssues);
2427
}, [hasIssues, onHasIssues]);
2528

29+
useEffect(() => {
30+
onResultsChange?.(results);
31+
}, [results, onResultsChange]);
32+
2633
return <DiagnosticResultList results={results} hidePassed={hidePassed} />;
2734
};
2835

react/src/hooks/useCspDiagnostics.ts

Lines changed: 82 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -13,123 +13,116 @@ import {
1313
} from '../diagnostics/rules/cspRules';
1414
import type { DiagnosticResult } from '../types/diagnostics';
1515
import { useProxyUrl } from './useWebUIConfig';
16-
import { useMemo } from 'react';
1716

1817
/**
1918
* Hook that checks CSP directives: connect-src, script-src, style-src.
2019
* Synchronous — no suspense needed.
2120
*/
22-
export function useCspDiagnostics(): DiagnosticResult[] {
21+
export function useCspDiagnostics(_fetchKey?: string): DiagnosticResult[] {
2322
'use memo';
2423

2524
const baiClient = useSuspendedBackendaiClient();
2625
const proxyUrl = useProxyUrl();
2726

28-
return useMemo(() => {
29-
const cspMeta = document.querySelector(
30-
'meta[http-equiv="Content-Security-Policy"]',
31-
);
32-
const cspContent = cspMeta?.getAttribute('content') ?? null;
33-
const apiEndpoint: string = baiClient?._config?.endpoint ?? '';
27+
const cspMeta = document.querySelector(
28+
'meta[http-equiv="Content-Security-Policy"]',
29+
);
30+
const cspContent = cspMeta?.getAttribute('content') ?? null;
31+
const apiEndpoint: string = baiClient?._config?.endpoint ?? '';
3432

35-
const results: DiagnosticResult[] = [];
33+
const results: DiagnosticResult[] = [];
3634

37-
if (!cspContent) {
35+
if (!cspContent) {
36+
results.push({
37+
id: 'csp-not-set',
38+
severity: 'passed',
39+
category: 'csp',
40+
titleKey: 'diagnostics.CspNotSet',
41+
descriptionKey: 'diagnostics.CspNotSetDesc',
42+
});
43+
return results;
44+
}
45+
46+
const pageOrigin = globalThis.location?.origin;
47+
48+
// Check connect-src for API endpoint (skip placeholders)
49+
if (!isPlaceholder(apiEndpoint)) {
50+
const apiCheck = checkCspConnectSrc(cspContent, apiEndpoint, pageOrigin);
51+
if (apiCheck) {
52+
results.push(apiCheck);
53+
} else if (apiEndpoint) {
3854
results.push({
39-
id: 'csp-not-set',
55+
id: 'csp-connect-src-api-passed',
4056
severity: 'passed',
4157
category: 'csp',
42-
titleKey: 'diagnostics.CspNotSet',
43-
descriptionKey: 'diagnostics.CspNotSetDesc',
58+
titleKey: 'diagnostics.CspApiEndpointAllowed',
59+
descriptionKey: 'diagnostics.CspApiEndpointAllowedDesc',
60+
interpolationValues: { endpoint: apiEndpoint },
4461
});
45-
return results;
4662
}
63+
}
4764

48-
const pageOrigin = globalThis.location?.origin;
49-
50-
// Check connect-src for API endpoint (skip placeholders)
51-
if (!isPlaceholder(apiEndpoint)) {
52-
const apiCheck = checkCspConnectSrc(cspContent, apiEndpoint, pageOrigin);
53-
if (apiCheck) {
54-
results.push(apiCheck);
55-
} else if (apiEndpoint) {
56-
results.push({
57-
id: 'csp-connect-src-api-passed',
58-
severity: 'passed',
59-
category: 'csp',
60-
titleKey: 'diagnostics.CspApiEndpointAllowed',
61-
descriptionKey: 'diagnostics.CspApiEndpointAllowedDesc',
62-
interpolationValues: { endpoint: apiEndpoint },
63-
});
64-
}
65-
}
66-
67-
// Check connect-src for WebSocket proxy (skip placeholders)
68-
if (!isPlaceholder(proxyUrl)) {
69-
const wsCheck = checkCspWsConnectSrc(cspContent, proxyUrl, pageOrigin);
70-
if (wsCheck) {
71-
results.push(wsCheck);
72-
} else if (proxyUrl) {
73-
results.push({
74-
id: 'csp-connect-src-ws-passed',
75-
severity: 'passed',
76-
category: 'csp',
77-
titleKey: 'diagnostics.CspWsProxyAllowed',
78-
descriptionKey: 'diagnostics.CspWsProxyAllowedDesc',
79-
interpolationValues: { proxyUrl },
80-
});
81-
}
82-
}
83-
84-
// Check script-src allows loading app scripts
85-
const scriptCheck = checkCspScriptSrc(cspContent);
86-
if (scriptCheck) {
87-
results.push(scriptCheck);
88-
} else {
65+
// Check connect-src for WebSocket proxy (skip placeholders)
66+
if (!isPlaceholder(proxyUrl)) {
67+
const wsCheck = checkCspWsConnectSrc(cspContent, proxyUrl, pageOrigin);
68+
if (wsCheck) {
69+
results.push(wsCheck);
70+
} else if (proxyUrl) {
8971
results.push({
90-
id: 'csp-script-src-passed',
72+
id: 'csp-connect-src-ws-passed',
9173
severity: 'passed',
9274
category: 'csp',
93-
titleKey: 'diagnostics.CspScriptSrcPassed',
94-
descriptionKey: 'diagnostics.CspScriptSrcPassedDesc',
75+
titleKey: 'diagnostics.CspWsProxyAllowed',
76+
descriptionKey: 'diagnostics.CspWsProxyAllowedDesc',
77+
interpolationValues: { proxyUrl },
9578
});
9679
}
80+
}
81+
82+
// Check script-src allows loading app scripts
83+
const scriptCheck = checkCspScriptSrc(cspContent);
84+
if (scriptCheck) {
85+
results.push(scriptCheck);
86+
} else {
87+
results.push({
88+
id: 'csp-script-src-passed',
89+
severity: 'passed',
90+
category: 'csp',
91+
titleKey: 'diagnostics.CspScriptSrcPassed',
92+
descriptionKey: 'diagnostics.CspScriptSrcPassedDesc',
93+
});
94+
}
95+
96+
// Check style-src allows inline styles (required by antd)
97+
const styleCheck = checkCspStyleSrc(cspContent);
98+
if (styleCheck) {
99+
results.push(styleCheck);
100+
} else {
101+
results.push({
102+
id: 'csp-style-src-passed',
103+
severity: 'passed',
104+
category: 'csp',
105+
titleKey: 'diagnostics.CspStyleSrcPassed',
106+
descriptionKey: 'diagnostics.CspStyleSrcPassedDesc',
107+
});
108+
}
97109

98-
// Check style-src allows inline styles (required by antd)
99-
const styleCheck = checkCspStyleSrc(cspContent);
100-
if (styleCheck) {
101-
results.push(styleCheck);
102-
} else {
110+
// Check frame-src allows API endpoint for iframe embedding (skip placeholders)
111+
if (!isPlaceholder(apiEndpoint)) {
112+
const frameSrcCheck = checkCspFrameSrc(cspContent, apiEndpoint, pageOrigin);
113+
if (frameSrcCheck) {
114+
results.push(frameSrcCheck);
115+
} else if (apiEndpoint) {
103116
results.push({
104-
id: 'csp-style-src-passed',
117+
id: 'csp-frame-src-passed',
105118
severity: 'passed',
106119
category: 'csp',
107-
titleKey: 'diagnostics.CspStyleSrcPassed',
108-
descriptionKey: 'diagnostics.CspStyleSrcPassedDesc',
120+
titleKey: 'diagnostics.CspFrameSrcPassed',
121+
descriptionKey: 'diagnostics.CspFrameSrcPassedDesc',
122+
interpolationValues: { endpoint: apiEndpoint },
109123
});
110124
}
125+
}
111126

112-
// Check frame-src allows API endpoint for iframe embedding (skip placeholders)
113-
if (!isPlaceholder(apiEndpoint)) {
114-
const frameSrcCheck = checkCspFrameSrc(
115-
cspContent,
116-
apiEndpoint,
117-
pageOrigin,
118-
);
119-
if (frameSrcCheck) {
120-
results.push(frameSrcCheck);
121-
} else if (apiEndpoint) {
122-
results.push({
123-
id: 'csp-frame-src-passed',
124-
severity: 'passed',
125-
category: 'csp',
126-
titleKey: 'diagnostics.CspFrameSrcPassed',
127-
descriptionKey: 'diagnostics.CspFrameSrcPassedDesc',
128-
interpolationValues: { endpoint: apiEndpoint },
129-
});
130-
}
131-
}
132-
133-
return results;
134-
}, [baiClient?._config?.endpoint, proxyUrl]);
127+
return results;
135128
}

0 commit comments

Comments
 (0)