Skip to content

Commit 638b87d

Browse files
committed
fix(FR-2870): catch storage host fetch errors with session launcher boundary
1 parent 67278e6 commit 638b87d

6 files changed

Lines changed: 127 additions & 13 deletions

File tree

packages/backend.ai-ui/src/hooks/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,5 +166,8 @@ export {
166166
} from './useBAILogger';
167167
export type { LoggerPlugin, LogContext, BAILogger } from './useBAILogger';
168168
export { useEventNotStable } from './useEventNotStable';
169-
export { useProjectResourceGroups } from './useProjectResourceGroups';
169+
export {
170+
useProjectResourceGroups,
171+
StorageHostFetchError,
172+
} from './useProjectResourceGroups';
170173
export type { ScalingGroupItem } from './useProjectResourceGroups';

packages/backend.ai-ui/src/hooks/useProjectResourceGroups.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ export interface ScalingGroupItem {
66
name: string;
77
}
88

9+
/**
10+
* Thrown when the vfolder host info fetch (`/folders/_/hosts`) inside
11+
* `useProjectResourceGroups` fails. Tagging the failure lets callers wrap
12+
* the hook with a dedicated error boundary that can distinguish this
13+
* specific case from unrelated render errors and surface a targeted
14+
* message (and discriminate it from the parallel scaling-groups fetch
15+
* failure, which is re-thrown as-is so an outer boundary handles it).
16+
*/
17+
export class StorageHostFetchError extends Error {
18+
readonly originalError: unknown;
19+
constructor(originalError: unknown) {
20+
super(
21+
originalError instanceof Error
22+
? originalError.message
23+
: 'Failed to fetch storage host information.',
24+
);
25+
this.name = 'StorageHostFetchError';
26+
this.originalError = originalError;
27+
}
28+
}
29+
930
interface VolumeInfo {
1031
backend: string;
1132
capabilities: string[];
@@ -60,15 +81,21 @@ export const useProjectResourceGroups = (
6081

6182
const { data } = useSuspenseTanQuery<ProjectResourceGroupsQueryResult>({
6283
queryKey: ['ResourceGroupSelectQuery', projectName],
63-
queryFn: () => {
84+
queryFn: async () => {
6485
// Short-circuit when there is no project context yet — avoids hitting
6586
// `/scaling-groups?group=` and `/folders/_/hosts` with an unscoped query.
6687
if (!projectName) {
67-
return Promise.resolve(null);
88+
return null;
6889
}
6990
const search = new URLSearchParams();
7091
search.set('group', projectName);
71-
return Promise.all([
92+
// Run both fetches concurrently but discriminate failures: a host-info
93+
// failure is tagged with `StorageHostFetchError` so a dedicated boundary
94+
// can surface it, while a scaling-groups failure is re-thrown as-is and
95+
// bubbles up to the generic error boundary. Host-info failure takes
96+
// precedence when both fail because SFTP filtering depends on it and
97+
// the result is otherwise unusable.
98+
const [scalingGroupsResult, hostsResult] = await Promise.allSettled([
7299
baiRequestWithPromise({
73100
method: 'GET',
74101
url: `/scaling-groups?${search.toString()}`,
@@ -78,6 +105,16 @@ export const useProjectResourceGroups = (
78105
url: `/folders/_/hosts`,
79106
}),
80107
]);
108+
if (hostsResult.status === 'rejected') {
109+
throw new StorageHostFetchError(hostsResult.reason);
110+
}
111+
if (scalingGroupsResult.status === 'rejected') {
112+
throw scalingGroupsResult.reason;
113+
}
114+
return [
115+
scalingGroupsResult.value,
116+
hostsResult.value,
117+
] as unknown as NonNullable<ProjectResourceGroupsQueryResult>;
81118
},
82119
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
83120
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
@license
3+
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
4+
*/
5+
import { LeftOutlined } from '@ant-design/icons';
6+
import { Button, Result } from 'antd';
7+
import { BAIFlex, StorageHostFetchError } from 'backend.ai-ui';
8+
import React from 'react';
9+
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
10+
import { useTranslation } from 'react-i18next';
11+
12+
interface StorageHostFetchErrorBoundaryProps {
13+
children: React.ReactNode;
14+
style?: React.CSSProperties;
15+
}
16+
17+
const Fallback: React.FC<FallbackProps & { style?: React.CSSProperties }> = ({
18+
error,
19+
style,
20+
}) => {
21+
'use memo';
22+
const { t } = useTranslation();
23+
if (!(error instanceof StorageHostFetchError)) {
24+
// Re-throw non-storage errors so the outer BAIErrorBoundary handles them.
25+
throw error;
26+
}
27+
return (
28+
<BAIFlex
29+
style={{ margin: 'auto', ...style }}
30+
justify="center"
31+
align="center"
32+
>
33+
<Result
34+
status="warning"
35+
title={t('errorBoundary.StorageHostFetchFailedTitle')}
36+
extra={
37+
<BAIFlex direction="column" gap="md">
38+
<Button
39+
type="primary"
40+
icon={<LeftOutlined />}
41+
onClick={() => {
42+
globalThis.history.back();
43+
}}
44+
>
45+
{t('button.GoBackToPreviousPage')}
46+
</Button>
47+
</BAIFlex>
48+
}
49+
/>
50+
</BAIFlex>
51+
);
52+
};
53+
54+
const StorageHostFetchErrorBoundary: React.FC<
55+
StorageHostFetchErrorBoundaryProps
56+
> = ({ children, style }) => {
57+
'use memo';
58+
return (
59+
<ErrorBoundary
60+
fallbackRender={(props) => <Fallback {...props} style={style} />}
61+
>
62+
{children}
63+
</ErrorBoundary>
64+
);
65+
};
66+
67+
export default StorageHostFetchErrorBoundary;

react/src/routes.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import LocationStateBreadCrumb from './components/LocationStateBreadCrumb';
1313
import LoginView from './components/LoginView';
1414
import MainLayout from './components/MainLayout/MainLayout';
1515
import { STokenLoginBoundary } from './components/STokenLoginBoundary';
16+
import StorageHostFetchErrorBoundary from './components/StorageHostFetchErrorBoundary';
1617
import WebUINavigate from './components/WebUINavigate';
1718
import { persistPostLoginState } from './helper/loginSessionAuth';
1819
import { useSuspendedBackendaiClient } from './hooks';
@@ -258,15 +259,17 @@ export const mainLayoutChildRoutes: RouteObject[] = [
258259
style={{ paddingBottom: token.paddingContentVerticalLG }}
259260
>
260261
<LocationStateBreadCrumb />
261-
<Suspense
262-
fallback={
263-
<BAIFlex direction="column" style={{ maxWidth: 700 }}>
264-
<Skeleton active />
265-
</BAIFlex>
266-
}
267-
>
268-
<SessionLauncherPage />
269-
</Suspense>
262+
<StorageHostFetchErrorBoundary>
263+
<Suspense
264+
fallback={
265+
<BAIFlex direction="column" style={{ maxWidth: 700 }}>
266+
<Skeleton active />
267+
</BAIFlex>
268+
}
269+
>
270+
<SessionLauncherPage />
271+
</Suspense>
272+
</StorageHostFetchErrorBoundary>
270273
</BAIFlex>
271274
);
272275
},

resources/i18n/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@
379379
"Finish": "Finish",
380380
"ForceTerminate": "Force Terminate",
381381
"Generate": "Generate",
382+
"GoBackToPreviousPage": "Go back to the previous page",
382383
"GoBackToStartPage": "Go back to the {{title}} page",
383384
"Info": "Info",
384385
"More": "More",
@@ -1376,6 +1377,7 @@
13761377
"ReloadPage": "Reload the page",
13771378
"ResetErrorBoundary": "Reset ErrorBoundary",
13781379
"StackTrace": "Stack Trace:",
1380+
"StorageHostFetchFailedTitle": "Failed to fetch storage host information.",
13791381
"Title": "An error has occurred."
13801382
},
13811383
"explorer": {

resources/i18n/ko.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@
362362
"Finish": "완료",
363363
"ForceTerminate": "강제 종료",
364364
"Generate": "생성하기",
365+
"GoBackToPreviousPage": "이전 페이지로 이동",
365366
"GoBackToStartPage": "{{title}} 페이지로 돌아가기",
366367
"Info": "정보",
367368
"More": "더보기",
@@ -1358,6 +1359,7 @@
13581359
"ReloadPage": "페이지 새로고침",
13591360
"ResetErrorBoundary": "ErrorBoundary 초기화",
13601361
"StackTrace": "스택 트레이스:",
1362+
"StorageHostFetchFailedTitle": "스토리지 호스트 정보를 가져오는데 실패했습니다.",
13611363
"Title": "에러가 발생했습니다."
13621364
},
13631365
"explorer": {

0 commit comments

Comments
 (0)