Skip to content

Commit

Permalink
feat(FR-408): add soft timeout feature to NetworkStatusBanner (#3107)
Browse files Browse the repository at this point in the history
resolves #3056 (FR-408)

> [!NOTE]
>This PR implements the Global soft timeout section from the Network Timeout UX guide written in Loop document.

Implements network status monitoring improvements and request timeout handling:

1. Added a soft timeout notification that appears after 15 seconds of waiting for network requests
2. Moved the NetworkStatusBanner below the WebUIHeader for better visual hierarchy
3. Standardized request timeout handling:
   - Default timeout: 30 seconds
   - Soft timeout: 15 seconds
   - Removed hardcoded timeout values across the codebase
     - only [`recalculate_usage`](https://github.com/lablup/backend.ai-webui/blob/4fceaeaafcf23c3ab9acb45999c5aaae813eff7d/src/lib/backend.ai-client-esm.ts#L4520-L4530) request remain specific hardcoded timeout.

## Soft-timeout Alert view
<img width="1067" alt="image" src="https://github.com/user-attachments/assets/75e010c3-bb60-4a3d-8d4a-9fa69e81fece" />
The soft timeout alert view will appear after 15 seconds of waiting, while still allowing the request to complete. If the request fails after 30 seconds, the error notification will be shown instead. This provides better feedback to users during slow network conditions.
The soft timeout alert view appears with a debounced setting. It's displayed for at least 5 seconds after the soft timeout is triggered. Additionally, if there is a successful network request without hitting the soft timeout, the soft timeout alert view will disappear automatically.

## Offline Alert view
<img width="1066" alt="image" src="https://github.com/user-attachments/assets/860c7977-ac7e-4e59-925c-33f4f46db900" />
Offline alert view works same as before this PR.

## How to Test
- Reduce `requestSoftTimeout` to 1000 in `backend.ai-client-esm.ts` for testing.
- Open WebUI, and open the browser developer tools. Set the throttling 3G and navigate to another page or wait for an automatic update request.
- You should see the soft timeout alert view.- After removing the throttling setting, the soft timeout alert view will disappear automatically.
- If you click the close button, the soft timeout view will not reappear until you refresh the browser hard.
  • Loading branch information
yomybaby committed Feb 7, 2025
1 parent c3e51b8 commit 37c6e2c
Show file tree
Hide file tree
Showing 9 changed files with 104 additions and 49 deletions.
2 changes: 1 addition & 1 deletion react/src/RelayEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const fetchFn: FetchFunction = async (
const result =
//@ts-ignore
(await globalThis.backendaiclient
?._wrapWithPromise(reqInfo, false, null, 10000, 0)
?._wrapWithPromise(reqInfo)
.catch((err: any) => {
if (err.isError && err.statusCode === 401) {
const error = new Error('GraphQL Authorization Error');
Expand Down
2 changes: 1 addition & 1 deletion react/src/components/MainLayout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,11 @@ function MainLayout() {
zIndex: HEADER_Z_INDEX_IN_MAIN_LAYOUT,
}}
>
<NetworkStatusBanner />
<WebUIHeader
onClickMenuIcon={() => setSideCollapsed((v) => !v)}
containerElement={contentScrollFlexRef.current}
/>
<NetworkStatusBanner />
</div>
</Suspense>
<Suspense>
Expand Down
10 changes: 8 additions & 2 deletions react/src/components/MainLayout/WebUIHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ const WebUIHeader: React.FC<WebUIHeaderProps> = ({
style={{
height: token.Layout?.headerHeight || 60,
backgroundColor: token.Layout?.headerBg,
paddingRight: token.marginMD,
paddingLeft: token.marginMD,
paddingRight: token.marginLG,
paddingLeft: token.marginLG,
color: token.colorBgBase,
}}
className={styles.webuiHeader}
Expand Down Expand Up @@ -145,6 +145,12 @@ const WebUIHeader: React.FC<WebUIHeaderProps> = ({
<ReverseThemeProvider>{btn}</ReverseThemeProvider>
</div>
)}
style={{
marginLeft: token.marginXXS,
marginRight: token.marginSM * -1,
paddingLeft: token.paddingSM,
paddingRight: token.paddingSM,
}}
/>
</Flex>
</Flex>
Expand Down
96 changes: 62 additions & 34 deletions react/src/components/NetworkStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,83 @@
import { useNetwork } from 'ahooks';
import { useDebounce, useNetwork } from 'ahooks';
import { Alert } from 'antd';
import { createStyles } from 'antd-style';
import { atom, useAtom } from 'jotai';
import { atom, useSetAtom } from 'jotai';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

const networkSoftTimeoutAtom = atom(false);
const isDisplayedNetworkStatusState = atom(false);

const useStyles = createStyles(({ token, css }) => ({
borderError: css`
border-bottom: 1px solid ${token.colorErrorBorder} !important;
padding-left: ${token.marginLG}px;
padding-right: ${token.marginLG}px;
`,
borderWarning: css`
border-bottom: 1px solid ${token.colorWarningBorder} !important;
padding-left: ${token.marginLG}px;
padding-right: ${token.marginLG}px;
`,
}));
const NetworkStatusBanner = () => {
const { t } = useTranslation();
const network = useNetwork();

const setDisplayedStatus = useSetAtom(isDisplayedNetworkStatusState);
const { styles } = useStyles();
const [showSoftTimeoutAlert, setShowSoftTimeoutAlert] = useState(false);
const [dismissSoftTimeoutAlert, setDismissSoftTimeoutAlert] = useState(false);

useEffect(() => {
const softHandler = () => {
setShowSoftTimeoutAlert(true);
};
const successHandler = () => {
setShowSoftTimeoutAlert(false);
};
document.addEventListener('backend-ai-network-soft-time-out', softHandler);
document.addEventListener(
'backend-ai-network-success-without-soft-time-out',
successHandler,
);
}, []);

const debouncedShowAlert = useDebounce(showSoftTimeoutAlert, {
leading: true,
trailing: true,
wait: 5_000,
});

const shouldOpenOfflineAlert = !network.online;
const shouldOpenSoftAlert =
!shouldOpenOfflineAlert && debouncedShowAlert && !dismissSoftTimeoutAlert;

useEffect(() => {
setDisplayedStatus(shouldOpenOfflineAlert || shouldOpenSoftAlert);
}, [setDisplayedStatus, shouldOpenOfflineAlert, shouldOpenSoftAlert]);

const [softTimeout, setSoftTimeout] = useAtom(networkSoftTimeoutAtom);

// const handler = (()=>{
// });

// useEffect(()=>{
// document.addEventListener('backendai.client.softtimeout', handler);
// return ()=>{
// document.removeEventListener('backendai.client.softtimeout', handler);
// }
// },[])

return !network.online ? (
<Alert
message={t('webui.YouAreOffline')}
className={styles.borderError}
type="error"
banner
/>
) : softTimeout ? (
<Alert
message={t('webui.NetworkSoftTimeout')}
className={styles.borderWarning}
banner
closable
onClose={() => {
setSoftTimeout(false);
}}
/>
) : null;
return (
<>
{shouldOpenOfflineAlert && (
<Alert
message={t('webui.YouAreOffline')}
className={styles.borderError}
type="error"
banner
/>
)}
{shouldOpenSoftAlert && (
<Alert
message={t('webui.NetworkSoftTimeout')}
className={styles.borderWarning}
banner
closable
onClose={() => {
setDismissSoftTimeoutAlert(true);
}}
/>
)}
</>
);
};

export default NetworkStatusBanner;
3 changes: 2 additions & 1 deletion react/src/components/SiderToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Flex from './Flex';
import { HEADER_Z_INDEX_IN_MAIN_LAYOUT } from './MainLayout/MainLayout';
import { Button, ConfigProvider, theme, Tooltip, Typography } from 'antd';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import React from 'react';
Expand Down Expand Up @@ -27,7 +28,7 @@ const SiderToggleButton: React.FC<SiderToggleButtonProps> = ({
right: 0,
transform: 'translateX(12px)',
paddingTop: buttonTop,
zIndex: 1,
zIndex: HEADER_Z_INDEX_IN_MAIN_LAYOUT + 1,
}}
direction="column"
justify={buttonTop ? 'start' : 'center'}
Expand Down
6 changes: 4 additions & 2 deletions react/src/components/UserDropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
theme,
} from 'antd';
import _ from 'lodash';
import React, { Suspense, useTransition } from 'react';
import React, { CSSProperties, Suspense, useTransition } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryLoader } from 'react-relay';

Expand All @@ -38,7 +38,8 @@ const UserProfileSettingModal = React.lazy(

const UserDropdownMenu: React.FC<{
buttonRender?: (defaultButton: React.ReactNode) => React.ReactNode;
}> = ({ buttonRender = (btn) => btn }) => {
style?: CSSProperties;
}> = ({ buttonRender = (btn) => btn, style }) => {
const { t } = useTranslation();
const { token } = theme.useToken();
const [userInfo] = useCurrentUserInfo();
Expand Down Expand Up @@ -193,6 +194,7 @@ const UserDropdownMenu: React.FC<{
justifyContent: 'center',
marginTop: -2,
fontSize: token.fontSizeLG,
...style,
}}
// icon={<UserOutlined />}
icon={
Expand Down
2 changes: 1 addition & 1 deletion react/src/pages/SessionLauncherPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ const SessionLauncherPage = () => {
sessionInfo.kernelName,
formattedSessionName,
sessionInfo.config,
30000,
undefined,
sessionInfo.architecture,
sessionInfo.batchTimeout,
)
Expand Down
30 changes: 24 additions & 6 deletions src/lib/backend.ai-client-esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ class Client {
public abortController: any;
public abortSignal: any;
public requestTimeout: number;
public requestSoftTimeout: number;
static ERR_REQUEST: any;
static ERR_RESPONSE: any;
static ERR_ABORT: any;
Expand Down Expand Up @@ -271,7 +272,8 @@ class Client {
this._features = {}; // feature support list
this.abortController = new AbortController();
this.abortSignal = this.abortController.signal;
this.requestTimeout = 30000;
this.requestTimeout = 30_000;
this.requestSoftTimeout = 15_000;
if (localStorage.getItem('backendaiwebui.sessionid')) {
this._loginSessionId = localStorage.getItem('backendaiwebui.sessionid');
} else {
Expand Down Expand Up @@ -318,7 +320,8 @@ class Client {
let errorTitle = '';
let errorMsg;
let errorDesc = '';
let resp, body, requestTimer;
let resp, body, requestTimer, requestTimerForSoftTimeout;
let isSoftTimeoutTriggered = false;
try {
if (rqst.method === 'GET') {
rqst.body = undefined;
Expand All @@ -342,10 +345,25 @@ class Client {
timeout === 0 ? this.requestTimeout : timeout,
);
}
requestTimerForSoftTimeout = setTimeout(
() => {
document?.dispatchEvent(new CustomEvent('backend-ai-network-soft-time-out'));
isSoftTimeoutTriggered = true;
},
this.requestSoftTimeout
);
resp = await fetch(rqst.uri, rqst);
if (typeof requestTimer !== 'undefined') {
clearTimeout(requestTimer);
}
if (typeof requestTimerForSoftTimeout !== 'undefined') {
clearTimeout(requestTimerForSoftTimeout);
if(!isSoftTimeoutTriggered) {
document?.dispatchEvent(new CustomEvent('backend-ai-network-success-without-soft-time-out'));
isSoftTimeoutTriggered = true;
}
}

let loginSessionId = resp.headers.get('X-BackendAI-SessionID'); // Login session ID handler
if (loginSessionId) {
this._loginSessionId = loginSessionId;
Expand Down Expand Up @@ -1035,7 +1053,7 @@ class Client {
kernelType: string,
sessionId: string,
resources = {},
timeout: number = 30000,
timeout?: number,
architecture: string = 'x86_64',
batchTimeout?: string,
) {
Expand Down Expand Up @@ -4242,8 +4260,7 @@ class Resources {
null,
);
// return this.client._wrapWithPromise(rqst);
// FIXME: temporally use hardcoded timeout value (10sec) for preventing timeout error on fetching data
return this.client._wrapWithPromise(rqst, false, null, 10 * 1000);
return this.client._wrapWithPromise(rqst, false, null);
}
}

Expand Down Expand Up @@ -4498,7 +4515,7 @@ class Maintenance {
`}`;
v = {};
}
return this.client.query(q, v, null, 600 * 1000);
return this.client.query(q, v, null);
} else {
return Promise.resolve(false);
}
Expand All @@ -4511,6 +4528,7 @@ class Maintenance {
`${this.urlPrefix}/recalculate-usage`,
null,
);
// Set specific timeout due to time for recalculate
return this.client._wrapWithPromise(rqst, false, null, 60 * 1000);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/backend.ai-client-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3747,7 +3747,7 @@ class Maintenance {
`}`;
v = {};
}
return this.client.query(q, v, null, 600 * 1000);
return this.client.query(q, v, null);
} else {
return Promise.resolve(false);
}
Expand Down

0 comments on commit 37c6e2c

Please sign in to comment.