Skip to content

Commit 23e6048

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

25 files changed

Lines changed: 163 additions & 13 deletions

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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
@license
3+
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
4+
*/
5+
import { Button, Result } from 'antd';
6+
import { BAIFlex, StorageHostFetchError } from 'backend.ai-ui';
7+
import React from 'react';
8+
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
9+
import { useTranslation } from 'react-i18next';
10+
11+
interface StorageHostFetchErrorBoundaryProps {
12+
children: React.ReactNode;
13+
style?: React.CSSProperties;
14+
}
15+
16+
const Fallback: React.FC<FallbackProps & { style?: React.CSSProperties }> = ({
17+
error,
18+
style,
19+
}) => {
20+
'use memo';
21+
const { t } = useTranslation();
22+
if (!(error instanceof StorageHostFetchError)) {
23+
// Re-throw non-storage errors so the outer BAIErrorBoundary handles them.
24+
throw error;
25+
}
26+
return (
27+
<BAIFlex
28+
style={{ margin: 'auto', ...style }}
29+
justify="center"
30+
align="center"
31+
>
32+
<Result
33+
status="warning"
34+
title={t('errorBoundary.StorageHostFetchFailedTitle')}
35+
extra={
36+
<BAIFlex direction="column" gap="md">
37+
<Button
38+
type="primary"
39+
onClick={() => {
40+
globalThis.history.back();
41+
}}
42+
>
43+
{t('button.GoBackToPreviousPage')}
44+
</Button>
45+
</BAIFlex>
46+
}
47+
/>
48+
</BAIFlex>
49+
);
50+
};
51+
52+
const StorageHostFetchErrorBoundary: React.FC<
53+
StorageHostFetchErrorBoundaryProps
54+
> = ({ children, style }) => {
55+
'use memo';
56+
return (
57+
<ErrorBoundary
58+
fallbackRender={(props) => <Fallback {...props} style={style} />}
59+
>
60+
{children}
61+
</ErrorBoundary>
62+
);
63+
};
64+
65+
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/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@
362362
"Finish": "Fertig",
363363
"ForceTerminate": "Beenden erzwingen",
364364
"Generate": "Generieren",
365+
"GoBackToPreviousPage": "Zurück zur vorherigen Seite",
365366
"GoBackToStartPage": "Zurück zur Seite {{title}}",
366367
"Info": "Info",
367368
"More": "Mehr",
@@ -1357,6 +1358,7 @@
13571358
"ReloadPage": "Laden Sie die Seite neu",
13581359
"ResetErrorBoundary": "ErrorBoundary zurücksetzen",
13591360
"StackTrace": "Stack-Trace:",
1361+
"StorageHostFetchFailedTitle": "Speicher-Host-Informationen konnten nicht abgerufen werden.",
13601362
"Title": "Es ist ein Fehler aufgetreten."
13611363
},
13621364
"explorer": {

resources/i18n/el.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": "Περισσότερα",
@@ -1357,6 +1358,7 @@
13571358
"ReloadPage": "Επαναφόρτωση της σελίδας",
13581359
"ResetErrorBoundary": "Επαναφορά ErrorBoundary",
13591360
"StackTrace": "Ιχνη στοίβας:",
1361+
"StorageHostFetchFailedTitle": "Αποτυχία ανάκτησης πληροφοριών κεντρικού υπολογιστή αποθήκευσης.",
13601362
"Title": "Εμφανίστηκε σφάλμα."
13611363
},
13621364
"explorer": {

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/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@
362362
"Finish": "Acabado",
363363
"ForceTerminate": "Forzar finalización",
364364
"Generate": "Genere",
365+
"GoBackToPreviousPage": "Volver a la página anterior",
365366
"GoBackToStartPage": "Volver a la página {{title}}",
366367
"Info": "Info",
367368
"More": "Más",
@@ -1357,6 +1358,7 @@
13571358
"ReloadPage": "Recargar la página",
13581359
"ResetErrorBoundary": "Restablecer ErrorBoundary",
13591360
"StackTrace": "Traza de pila:",
1361+
"StorageHostFetchFailedTitle": "No se pudo obtener la información del host de almacenamiento.",
13601362
"Title": "Se ha producido un error."
13611363
},
13621364
"explorer": {

resources/i18n/fi.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@
362362
"Finish": "Viimeistely",
363363
"ForceTerminate": "Pakota lopettamaan",
364364
"Generate": "Luo",
365+
"GoBackToPreviousPage": "Palaa edelliselle sivulle",
365366
"GoBackToStartPage": "Palaa sivulle {{title}}",
366367
"Info": "Tiedot",
367368
"More": "Lisää",
@@ -1357,6 +1358,7 @@
13571358
"ReloadPage": "Lataa sivu uudelleen",
13581359
"ResetErrorBoundary": "Nollaa ErrorBoundary",
13591360
"StackTrace": "Pinon jäljitys:",
1361+
"StorageHostFetchFailedTitle": "Tallennusisännän tietojen hakeminen epäonnistui.",
13601362
"Title": "On tapahtunut virhe."
13611363
},
13621364
"explorer": {

resources/i18n/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@
362362
"Finish": "Finir",
363363
"ForceTerminate": "Forcer l'arrêt",
364364
"Generate": "produire",
365+
"GoBackToPreviousPage": "Retour à la page précédente",
365366
"GoBackToStartPage": "Retour à la page {{title}}",
366367
"Info": "Info",
367368
"More": "Plus",
@@ -1358,6 +1359,7 @@
13581359
"ReloadPage": "Recharger la page",
13591360
"ResetErrorBoundary": "Réinitialisation de la limite d'erreur",
13601361
"StackTrace": "Trace de la pile :",
1362+
"StorageHostFetchFailedTitle": "Échec de la récupération des informations sur l'hôte de stockage.",
13611363
"Title": "Une erreur s'est produite."
13621364
},
13631365
"explorer": {

0 commit comments

Comments
 (0)