Skip to content

Commit 40ec8a3

Browse files
committed
fix(FR-2870): catch storage host fetch errors with session launcher boundary
1 parent 908117b commit 40ec8a3

25 files changed

Lines changed: 169 additions & 27 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: 52 additions & 17 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[];
@@ -15,19 +36,20 @@ interface VolumeInfo {
1536
sftp_scaling_groups?: string[];
1637
}
1738

39+
interface ScalingGroupsResponse {
40+
scaling_groups: ScalingGroupItem[];
41+
}
42+
43+
interface StorageHostsResponse {
44+
allowed: string[];
45+
default: string;
46+
volume_info: {
47+
[key: string]: VolumeInfo;
48+
};
49+
}
50+
1851
type ProjectResourceGroupsQueryResult =
19-
| [
20-
{
21-
scaling_groups: ScalingGroupItem[];
22-
},
23-
{
24-
allowed: string[];
25-
default: string;
26-
volume_info: {
27-
[key: string]: VolumeInfo;
28-
};
29-
},
30-
]
52+
| [ScalingGroupsResponse, StorageHostsResponse]
3153
| null;
3254

3355
interface UseProjectResourceGroupsOptions {
@@ -60,24 +82,37 @@ export const useProjectResourceGroups = (
6082

6183
const { data } = useSuspenseTanQuery<ProjectResourceGroupsQueryResult>({
6284
queryKey: ['ResourceGroupSelectQuery', projectName],
63-
queryFn: () => {
85+
queryFn: async () => {
6486
// Short-circuit when there is no project context yet — avoids hitting
6587
// `/scaling-groups?group=` and `/folders/_/hosts` with an unscoped query.
6688
if (!projectName) {
67-
return Promise.resolve(null);
89+
return null;
6890
}
6991
const search = new URLSearchParams();
7092
search.set('group', projectName);
71-
return Promise.all([
93+
// Run both fetches concurrently but discriminate failures: a host-info
94+
// failure is tagged with `StorageHostFetchError` so a dedicated boundary
95+
// can surface it, while a scaling-groups failure is re-thrown as-is and
96+
// bubbles up to the generic error boundary. Host-info failure takes
97+
// precedence when both fail because SFTP filtering depends on it and
98+
// the result is otherwise unusable.
99+
const [scalingGroupsResult, hostsResult] = await Promise.allSettled([
72100
baiRequestWithPromise({
73101
method: 'GET',
74102
url: `/scaling-groups?${search.toString()}`,
75-
}),
103+
}) as Promise<ScalingGroupsResponse>,
76104
baiRequestWithPromise({
77105
method: 'GET',
78106
url: `/folders/_/hosts`,
79-
}),
107+
}) as Promise<StorageHostsResponse>,
80108
]);
109+
if (hostsResult.status === 'rejected') {
110+
throw new StorageHostFetchError(hostsResult.reason);
111+
}
112+
if (scalingGroupsResult.status === 'rejected') {
113+
throw scalingGroupsResult.reason;
114+
}
115+
return [scalingGroupsResult.value, hostsResult.value];
81116
},
82117
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
83118
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 } 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 StorageHostFetchErrorBoundary: React.FC<
17+
StorageHostFetchErrorBoundaryProps
18+
> = ({ children, style }) => {
19+
'use memo';
20+
const { t } = useTranslation();
21+
return (
22+
<ErrorBoundary
23+
fallbackRender={({ error }) => {
24+
if (!(error instanceof StorageHostFetchError)) {
25+
// Re-throw non-storage errors so the outer BAIErrorBoundary handles them.
26+
throw error;
27+
}
28+
return (
29+
<BAIFlex
30+
style={{ margin: 'auto', ...style }}
31+
justify="center"
32+
align="center"
33+
>
34+
<Result
35+
status="warning"
36+
title={t('errorBoundary.StorageHostFetchFailedTitle')}
37+
extra={
38+
<BAIFlex direction="column" gap="md">
39+
<Button
40+
type="primary"
41+
onClick={() => {
42+
globalThis.history.back();
43+
}}
44+
>
45+
{t('button.GoBackToPreviousPage')}
46+
</Button>
47+
</BAIFlex>
48+
}
49+
/>
50+
</BAIFlex>
51+
);
52+
}}
53+
>
54+
{children}
55+
</ErrorBoundary>
56+
);
57+
};
58+
59+
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
@@ -368,6 +368,7 @@
368368
"Finish": "Fertig",
369369
"ForceTerminate": "Beenden erzwingen",
370370
"Generate": "Generieren",
371+
"GoBackToPreviousPage": "Zurück zur vorherigen Seite",
371372
"GoBackToStartPage": "Zurück zur Seite {{title}}",
372373
"Info": "Info",
373374
"More": "Mehr",
@@ -1384,6 +1385,7 @@
13841385
"ReloadPage": "Laden Sie die Seite neu",
13851386
"ResetErrorBoundary": "ErrorBoundary zurücksetzen",
13861387
"StackTrace": "Stack-Trace:",
1388+
"StorageHostFetchFailedTitle": "Speicher-Host-Informationen konnten nicht abgerufen werden.",
13871389
"Title": "Es ist ein Fehler aufgetreten."
13881390
},
13891391
"explorer": {

resources/i18n/el.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@
368368
"Finish": "Φινίρισμα",
369369
"ForceTerminate": "Δύναμη τερματισμού",
370370
"Generate": "Παράγω",
371+
"GoBackToPreviousPage": "Επιστροφή στην προηγούμενη σελίδα",
371372
"GoBackToStartPage": "Επιστροφή στη σελίδα {{title}}",
372373
"Info": "Πληροφορίες",
373374
"More": "Περισσότερα",
@@ -1384,6 +1385,7 @@
13841385
"ReloadPage": "Επαναφόρτωση της σελίδας",
13851386
"ResetErrorBoundary": "Επαναφορά ErrorBoundary",
13861387
"StackTrace": "Ιχνη στοίβας:",
1388+
"StorageHostFetchFailedTitle": "Αποτυχία ανάκτησης πληροφοριών κεντρικού υπολογιστή αποθήκευσης.",
13871389
"Title": "Εμφανίστηκε σφάλμα."
13881390
},
13891391
"explorer": {

resources/i18n/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@
385385
"Finish": "Finish",
386386
"ForceTerminate": "Force Terminate",
387387
"Generate": "Generate",
388+
"GoBackToPreviousPage": "Go back to the previous page",
388389
"GoBackToStartPage": "Go back to the {{title}} page",
389390
"Info": "Info",
390391
"More": "More",
@@ -1403,6 +1404,7 @@
14031404
"ReloadPage": "Reload the page",
14041405
"ResetErrorBoundary": "Reset ErrorBoundary",
14051406
"StackTrace": "Stack Trace:",
1407+
"StorageHostFetchFailedTitle": "Failed to fetch storage host information.",
14061408
"Title": "An error has occurred."
14071409
},
14081410
"explorer": {

resources/i18n/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@
368368
"Finish": "Acabado",
369369
"ForceTerminate": "Forzar finalización",
370370
"Generate": "Genere",
371+
"GoBackToPreviousPage": "Volver a la página anterior",
371372
"GoBackToStartPage": "Volver a la página {{title}}",
372373
"Info": "Info",
373374
"More": "Más",
@@ -1384,6 +1385,7 @@
13841385
"ReloadPage": "Recargar la página",
13851386
"ResetErrorBoundary": "Restablecer ErrorBoundary",
13861387
"StackTrace": "Traza de pila:",
1388+
"StorageHostFetchFailedTitle": "No se pudo obtener la información del host de almacenamiento.",
13871389
"Title": "Se ha producido un error."
13881390
},
13891391
"explorer": {

resources/i18n/fi.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@
368368
"Finish": "Viimeistely",
369369
"ForceTerminate": "Pakota lopettamaan",
370370
"Generate": "Luo",
371+
"GoBackToPreviousPage": "Palaa edelliselle sivulle",
371372
"GoBackToStartPage": "Palaa sivulle {{title}}",
372373
"Info": "Tiedot",
373374
"More": "Lisää",
@@ -1384,6 +1385,7 @@
13841385
"ReloadPage": "Lataa sivu uudelleen",
13851386
"ResetErrorBoundary": "Nollaa ErrorBoundary",
13861387
"StackTrace": "Pinon jäljitys:",
1388+
"StorageHostFetchFailedTitle": "Tallennusisännän tietojen hakeminen epäonnistui.",
13871389
"Title": "On tapahtunut virhe."
13881390
},
13891391
"explorer": {

resources/i18n/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@
368368
"Finish": "Finir",
369369
"ForceTerminate": "Forcer l'arrêt",
370370
"Generate": "produire",
371+
"GoBackToPreviousPage": "Retour à la page précédente",
371372
"GoBackToStartPage": "Retour à la page {{title}}",
372373
"Info": "Info",
373374
"More": "Plus",
@@ -1385,6 +1386,7 @@
13851386
"ReloadPage": "Recharger la page",
13861387
"ResetErrorBoundary": "Réinitialisation de la limite d'erreur",
13871388
"StackTrace": "Trace de la pile :",
1389+
"StorageHostFetchFailedTitle": "Échec de la récupération des informations sur l'hôte de stockage.",
13881390
"Title": "Une erreur s'est produite."
13891391
},
13901392
"explorer": {

0 commit comments

Comments
 (0)