Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/components/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import React from 'react';
import {Xmark} from '@gravity-ui/icons';
import {DrawerItem, Drawer as GravityDrawer} from '@gravity-ui/navigation';
import {ActionTooltip, Button, Flex, Icon, Text} from '@gravity-ui/uikit';
import {debounce} from 'lodash';

import {cn} from '../../utils/cn';
import {useSetting} from '../../utils/hooks/useSetting';
import {isNumeric} from '../../utils/utils';
import {CopyLinkButton} from '../CopyLinkButton/CopyLinkButton';
import {Portal} from '../Portal/Portal';
Expand All @@ -16,6 +18,7 @@ import './Drawer.scss';
const DEFAULT_DRAWER_WIDTH_PERCENTS = 60;
const DEFAULT_DRAWER_WIDTH = 600;
const DRAWER_WIDTH_KEY = 'drawer-width';
const SAVE_DEBOUNCE_MS = 200;
const b = cn('ydb-drawer');

type DrawerEvent = MouseEvent & {
Expand Down Expand Up @@ -49,9 +52,9 @@ const DrawerPaneContentWrapper = ({
isPercentageWidth,
hideVeil = true,
}: DrawerPaneContentWrapperProps) => {
const [drawerWidth, setDrawerWidth] = React.useState(() => {
const savedWidth = localStorage.getItem(storageKey);
return isNumeric(savedWidth) ? Number(savedWidth) : defaultWidth;
const [savedWidthString, setSavedWidthString] = useSetting<string | undefined>(storageKey);
const [drawerWidth, setDrawerWidth] = React.useState<number | undefined>(() => {
return isNumeric(savedWidthString) ? Number(savedWidthString) : defaultWidth;
});
Comment on lines +55 to 58
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The drawer width is initialized from savedWidthString in the useState initializer, but when savedWidthString is loaded asynchronously (e.g., from remote settings), the component won't update its drawerWidth state. This means the drawer will always use the default width until the user manually resizes it. Consider adding a useEffect that updates setDrawerWidth when savedWidthString changes and the current width is still undefined or the default.

Copilot uses AI. Check for mistakes.

const drawerRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -91,14 +94,24 @@ const DrawerPaneContentWrapper = ({
};
}, [isVisible, onClose, detectClickOutside]);

const saveWidthDebounced = React.useMemo(() => {
return debounce((value: string) => setSavedWidthString(value), SAVE_DEBOUNCE_MS);
}, [setSavedWidthString]);

React.useEffect(() => {
return () => {
saveWidthDebounced.cancel();
};
}, [saveWidthDebounced]);

const handleResizeDrawer = (width: number) => {
if (isPercentageWidth && containerWidth > 0) {
const percentageWidth = Math.round((width / containerWidth) * 100);
setDrawerWidth(percentageWidth);
localStorage.setItem(storageKey, percentageWidth.toString());
saveWidthDebounced(percentageWidth.toString());
} else {
setDrawerWidth(width);
localStorage.setItem(storageKey, width.toString());
saveWidthDebounced(width.toString());
}
};

Expand Down
52 changes: 37 additions & 15 deletions src/components/SplitPane/SplitPane.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React from 'react';

import {debounce} from 'lodash';
import type {SplitProps} from 'react-split';
import SplitPaneLib from 'react-split';

import {cn} from '../../utils/cn';
import {useSetting} from '../../utils/hooks/useSetting';

import './SplitPane.scss';

Expand All @@ -25,22 +27,44 @@ interface SplitPaneProps {

const minSizeDefaultInner = [0, 100];
const sizesDefaultInner = [50, 50];
const SAVE_DEBOUNCE_MS = 200;

function SplitPane(props: SplitPaneProps) {
const [innerSizes, setInnerSizes] = React.useState<number[]>();
const {
collapsedSizes,
triggerCollapse,
triggerExpand,
defaultSizes: defaultSizesProp,
initialSizes,
} = props;
const [savedSizesString, setSavedSizesString] = useSetting<string | undefined>(
props.defaultSizePaneKey,
);

const saveSizesStringDebounced = React.useMemo(() => {
return debounce((value: string) => setSavedSizesString(value), SAVE_DEBOUNCE_MS);
}, [setSavedSizesString]);

React.useEffect(() => {
return () => {
saveSizesStringDebounced.cancel();
};
}, [saveSizesStringDebounced]);

const getDefaultSizePane = () => {
const {defaultSizePaneKey, defaultSizes = sizesDefaultInner, initialSizes} = props;
const defaultSizePane = React.useMemo(() => {
if (initialSizes) {
return initialSizes;
}
const sizes = localStorage.getItem(defaultSizePaneKey)?.split(',').map(Number);
return sizes || defaultSizes;
};
const setDefaultSizePane = (sizes: number[]) => {
const {defaultSizePaneKey} = props;
localStorage.setItem(defaultSizePaneKey, sizes.join(','));
};
const sizes = savedSizesString?.split(',').map(Number);
return sizes || defaultSizesProp || sizesDefaultInner;
}, [defaultSizesProp, initialSizes, savedSizesString]);
Comment on lines +55 to +61
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The defaultSizePane is computed in a useMemo from savedSizesString, which means it will update when savedSizesString loads asynchronously. However, if the component has already set innerSizes via user interaction before the remote setting loads, the remote value might override the user's choice. Consider tracking whether the user has manually adjusted sizes and preserving that over late-loading remote settings.

Copilot uses AI. Check for mistakes.
const setDefaultSizePane = React.useCallback(
(sizes: number[]) => {
saveSizesStringDebounced(sizes.join(','));
},
[saveSizesStringDebounced],
);
const onDragHandler = (sizes: number[]) => {
const {onSplitDragAdditional} = props;
if (onSplitDragAdditional) {
Expand All @@ -58,27 +82,25 @@ function SplitPane(props: SplitPaneProps) {
};

React.useEffect(() => {
const {collapsedSizes, triggerCollapse} = props;
if (triggerCollapse) {
const newSizes = collapsedSizes || minSizeDefaultInner;
setDefaultSizePane(newSizes);
setInnerSizes(newSizes);
}
}, [props.triggerCollapse]);
}, [collapsedSizes, triggerCollapse, setDefaultSizePane]);

React.useEffect(() => {
const {triggerExpand, defaultSizes} = props;
const newSizes = defaultSizes || sizesDefaultInner;
const newSizes = defaultSizesProp || sizesDefaultInner;
if (triggerExpand) {
setDefaultSizePane(newSizes);
setInnerSizes(newSizes);
}
}, [props.triggerExpand]);
}, [defaultSizesProp, triggerExpand, setDefaultSizePane]);
return (
<React.Fragment>
<SplitPaneLib
direction={props.direction || 'horizontal'}
sizes={innerSizes || getDefaultSizePane()}
sizes={innerSizes || defaultSizePane}
minSize={props.minSize || [0, 0]}
onDrag={onDragHandler}
className={b(null, props.direction || 'horizontal')}
Expand Down
3 changes: 2 additions & 1 deletion src/containers/App/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import Authentication from '../Authentication/Authentication';
import Header from '../Header/Header';

import {useAppTitle} from './AppTitleContext';
import {SettingsBootstrap} from './SettingsBootstrap';
import {
ClusterSlot,
ClustersSlot,
Expand Down Expand Up @@ -223,7 +224,7 @@ function GetUser({children, useMeta}: {children: React.ReactNode; useMeta?: bool
return (
<LoaderWrapper loading={isFetching} size="l" delay={0}>
<PageError error={error} {...errorProps} errorPageTitle={appTitle}>
{children}
<SettingsBootstrap>{children}</SettingsBootstrap>
</PageError>
</LoaderWrapper>
);
Expand Down
81 changes: 81 additions & 0 deletions src/containers/App/SettingsBootstrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';

import {skipToken} from '@reduxjs/toolkit/query';

import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper';
import {selectMetaUser} from '../../store/reducers/authentication/authentication';
import {settingsApi} from '../../store/reducers/settings/api';
import {DEFAULT_USER_SETTINGS} from '../../store/reducers/settings/constants';
import {uiFactory} from '../../uiFactory/uiFactory';
import {
DEFAULT_CLUSTER_TAB_KEY,
DEFAULT_IS_QUERY_RESULT_COLLAPSED,
DEFAULT_IS_TENANT_COMMON_INFO_COLLAPSED,
DEFAULT_IS_TENANT_SUMMARY_COLLAPSED,
DEFAULT_SIZE_RESULT_PANE_KEY,
DEFAULT_SIZE_TENANT_KEY,
DEFAULT_SIZE_TENANT_SUMMARY_KEY,
} from '../../utils/constants';
import {useTypedSelector} from '../../utils/hooks/useTypedSelector';

const DRAWER_WIDTH_KEY = 'drawer-width';

/**
* Preloaded keys for "UI layout" state (pane sizes, collapsed flags, etc.) that are stored in the same
* settings backend but are not part of `DEFAULT_USER_SETTINGS`.
*
* If a new UI layout key should be preloaded to prevent UI "pop-in" in remote-settings mode, add it here.
*/
const PRELOADED_UI_LAYOUT_KEYS = [
DEFAULT_CLUSTER_TAB_KEY,
DEFAULT_SIZE_RESULT_PANE_KEY,
DEFAULT_SIZE_TENANT_SUMMARY_KEY,
DEFAULT_SIZE_TENANT_KEY,
DEFAULT_IS_TENANT_SUMMARY_COLLAPSED,
DEFAULT_IS_TENANT_COMMON_INFO_COLLAPSED,
DEFAULT_IS_QUERY_RESULT_COLLAPSED,
DRAWER_WIDTH_KEY,
] as const;

interface SettingsBootstrapProps {
children: React.ReactNode;
}

export function SettingsBootstrap({children}: SettingsBootstrapProps) {
const fallbackUser = useTypedSelector(selectMetaUser);
const userFromFactory = uiFactory.settingsBackend?.getUserId?.();
const endpointFromFactory = uiFactory.settingsBackend?.getEndpoint?.();
const remoteAvailable = Boolean(
(endpointFromFactory && userFromFactory && window.api?.settingsService) ||
window.api?.metaSettings,
);

const user = userFromFactory ?? fallbackUser;

const settingsKeysToPreload = React.useMemo(() => {
const keys = new Set<string>(Object.keys(DEFAULT_USER_SETTINGS));

PRELOADED_UI_LAYOUT_KEYS.forEach((key) => keys.add(key));

return Array.from(keys);
}, []);

const params = React.useMemo(() => {
if (user && remoteAvailable) {
return {user, name: settingsKeysToPreload};
}
return skipToken;
}, [remoteAvailable, user, settingsKeysToPreload]);

const {isLoading} = settingsApi.useGetSettingsQuery(params);

if (!remoteAvailable) {
return children;
}

return (
<LoaderWrapper loading={isLoading} size="l" delay={0}>
{children}
</LoaderWrapper>
);
}
47 changes: 36 additions & 11 deletions src/containers/Cluster/Cluster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ import type {AdditionalClusterProps, AdditionalTenantsProps} from '../../types/a
import {EFlag} from '../../types/api/enums';
import {uiFactory} from '../../uiFactory/uiFactory';
import {cn} from '../../utils/cn';
import {DEFAULT_CLUSTER_TAB_KEY} from '../../utils/constants';
import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks';
import {useIsViewerUser} from '../../utils/hooks/useIsUserAllowedToMakeChanges';
import {useSetting} from '../../utils/hooks/useSetting';
import {useAppTitle} from '../App/AppTitleContext';
import {Configs} from '../Configs/Configs';
import {Nodes} from '../Nodes/Nodes';
Expand Down Expand Up @@ -68,7 +70,7 @@ export function Cluster({additionalClusterProps, additionalTenantsProps}: Cluste

const dispatch = useTypedDispatch();

const activeTabId = useClusterTab();
const {activeTabId, onClusterTabClick} = useClusterTab();

const [{clusterName, backend}] = useQueryParams({
clusterName: StringParam,
Expand Down Expand Up @@ -182,9 +184,8 @@ export function Cluster({additionalClusterProps, additionalTenantsProps}: Cluste
view="primary"
as="tab"
to={path}
onClick={() => {
dispatch(updateDefaultClusterTab(id));
}}
data-cluster-tab-id={id}
onClick={onClusterTabClick}
>
{title}
</InternalLink>
Expand Down Expand Up @@ -304,7 +305,15 @@ export function Cluster({additionalClusterProps, additionalTenantsProps}: Cluste
function useClusterTab() {
const dispatch = useTypedDispatch();

const defaultTab = useTypedSelector((state) => state.cluster.defaultClusterTab);
const defaultTabFromStore = useTypedSelector((state) => state.cluster.defaultClusterTab);
const [savedDefaultTab, setSavedDefaultTab] = useSetting<string | undefined>(
DEFAULT_CLUSTER_TAB_KEY,
INITIAL_DEFAULT_CLUSTER_TAB,
);
const savedDefaultTabValidated = React.useMemo(() => {
return isClusterTab(savedDefaultTab) ? savedDefaultTab : undefined;
}, [savedDefaultTab]);
const defaultTab = savedDefaultTabValidated ?? defaultTabFromStore;

const shouldShowNetworkTable = useShouldShowClusterNetworkTable();
const shouldShowEventsTab = useShouldShowEventsTab();
Expand All @@ -327,11 +336,27 @@ function useClusterTab() {
activeTab = defaultTab;
}

React.useEffect(() => {
if (activeTab !== defaultTab) {
dispatch(updateDefaultClusterTab(activeTab));
}
}, [activeTab, defaultTab, dispatch]);
const onClusterTabClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) {
return;
}

const rawTabId = event.currentTarget.getAttribute('data-cluster-tab-id');
if (!rawTabId || !isClusterTab(rawTabId)) {
return;
}

if (rawTabId !== defaultTabFromStore) {
dispatch(updateDefaultClusterTab(rawTabId));
}

if (rawTabId !== savedDefaultTabValidated) {
setSavedDefaultTab(rawTabId);
}
},
[defaultTabFromStore, dispatch, savedDefaultTabValidated, setSavedDefaultTab],
);

return activeTab;
return {activeTabId: activeTab, onClusterTabClick} as const;
}
Loading
Loading