This is an automated email from the ASF dual-hosted git repository.
bteke pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/hadoop.git
The following commit(s) were added to refs/heads/trunk by this push:
new 780971a446a YARN-11929. Use useShallow in Zustand to minimize
re-renders, remove dead code, split QueueCardNode, and update the handlers in
PropertyPanel. (#8270)
780971a446a is described below
commit 780971a446ad840fe4ee4eb956ee9571a2a04514
Author: Benjamin Teke <[email protected]>
AuthorDate: Mon Feb 23 17:43:03 2026 +0100
YARN-11929. Use useShallow in Zustand to minimize re-renders, remove dead
code, split QueueCardNode, and update the handlers in PropertyPanel. (#8270)
---
.../src/components/search/NodeLabelSelector.tsx | 12 +-
.../src/components/search/SearchBar.test.tsx | 27 +--
.../webapp/src/components/search/SearchBar.tsx | 28 +--
.../components/GlobalSettings.test.tsx | 69 ++++++--
.../global-settings/components/GlobalSettings.tsx | 59 +++++--
.../components/LegacyModeToggle.tsx | 11 +-
.../features/node-labels/components/NodeLabels.tsx | 13 +-
.../components/NodeLabelsPanel.test.tsx | 87 +++++----
.../node-labels/components/NodeLabelsPanel.tsx | 45 +++--
.../node-labels/components/NodesPanel.test.tsx | 90 ++++------
.../features/node-labels/components/NodesPanel.tsx | 24 ++-
.../components/MigrationDialog.test.tsx | 14 +-
.../placement-rules/components/MigrationDialog.tsx | 11 +-
.../components/PlacementRuleDetail.tsx | 12 +-
.../components/PlacementRulesList.test.tsx | 53 ++----
.../components/PlacementRulesList.tsx | 27 ++-
.../property-editor/components/PropertyPanel.tsx | 108 +++++++++---
.../components/QueueCardNode.label-filter.test.tsx | 70 +++-----
.../queue-management/components/QueueCardNode.tsx | 196 ++++++---------------
.../components/QueueVisualizationContainer.tsx | 18 +-
.../queue-management/hooks/useQueueCardHandlers.ts | 151 ++++++++++++++++
.../queue-management/utils/queueCardStyles.ts | 88 +++++++++
.../components/StagedChangesPanel.tsx | 16 +-
.../src/main/webapp/src/lib/api/mocks/handlers.ts | 6 -
.../webapp/src/lib/api/mocks/server-handlers.ts | 5 -
.../main/webapp/src/stores/schedulerStore.test.ts | 41 ++++-
.../src/main/webapp/src/stores/schedulerStore.ts | 3 +-
.../stores/slices/__tests__/queueDataSlice.test.ts | 40 ++++-
.../webapp/src/stores/slices/queueDataSlice.ts | 38 +---
.../webapp/src/stores/slices/stagedChangesSlice.ts | 2 -
.../src/main/webapp/src/stores/slices/types.ts | 2 -
31 files changed, 878 insertions(+), 488 deletions(-)
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/NodeLabelSelector.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/NodeLabelSelector.tsx
index c016395450c..52772bc2028 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/NodeLabelSelector.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/NodeLabelSelector.tsx
@@ -19,6 +19,7 @@
import React from 'react';
import { Tag } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import {
Select,
@@ -30,7 +31,16 @@ import {
import { Badge } from '~/components/ui/badge';
export const NodeLabelSelector: React.FC = () => {
- const { nodeLabels, selectedNodeLabelFilter, selectNodeLabelFilter } =
useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { nodeLabels, selectedNodeLabelFilter } = useSchedulerStore(
+ useShallow((s) => ({
+ nodeLabels: s.nodeLabels,
+ selectedNodeLabelFilter: s.selectedNodeLabelFilter,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const selectNodeLabelFilter = useSchedulerStore((s) =>
s.selectNodeLabelFilter);
const handleChange = (value: string) => {
selectNodeLabelFilter(value === 'DEFAULT' ? '' : value);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.test.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.test.tsx
index f32523c502c..ee3bae54c20 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.test.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.test.tsx
@@ -48,9 +48,16 @@ describe('SearchBar', () => {
getFilteredSettings: mockGetFilteredSettings,
};
+ function mockStore(overrides: Record<string, any> = {}) {
+ const state = { ...defaultStoreState, ...overrides };
+ vi.mocked(useSchedulerStore).mockImplementation((selector?: any) => {
+ return selector ? selector(state) : state;
+ });
+ }
+
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(useSchedulerStore).mockReturnValue(defaultStoreState);
+ mockStore();
vi.mocked(useDebounce).mockImplementation((value) => value);
mockGetFilteredQueues.mockReturnValue(null);
mockGetFilteredNodes.mockReturnValue([]);
@@ -78,8 +85,7 @@ describe('SearchBar', () => {
vi.clearAllMocks();
// Test nodes context
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
searchContext: 'nodes',
getFilteredQueues: mockGetFilteredQueues,
getFilteredNodes: mockGetFilteredNodes,
@@ -90,8 +96,7 @@ describe('SearchBar', () => {
unmount1();
// Test settings context
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
searchContext: 'settings',
getFilteredQueues: mockGetFilteredQueues,
getFilteredNodes: mockGetFilteredNodes,
@@ -109,8 +114,7 @@ describe('SearchBar', () => {
it('should display clear button and match count when search has value', ()
=> {
// Mock the store to have an active search with results
// Use settings context to match the filtered settings we're providing
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
searchQuery: 'test',
searchContext: 'settings',
getFilteredQueues: () => null,
@@ -130,8 +134,7 @@ describe('SearchBar', () => {
it('should display singular match for count of 1', () => {
// Mock the store to have an active search with 1 result
// Use settings context to match the filtered settings we're providing
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
searchQuery: 'test',
searchContext: 'settings',
getFilteredQueues: () => null,
@@ -225,8 +228,7 @@ describe('SearchBar', () => {
it('should clear search on Escape when focused', async () => {
const user = userEvent.setup();
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
isSearchFocused: true,
getFilteredQueues: mockGetFilteredQueues,
getFilteredNodes: mockGetFilteredNodes,
@@ -258,8 +260,7 @@ describe('SearchBar', () => {
});
it('should display keyboard shortcuts hint when focused', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
isSearchFocused: true,
getFilteredQueues: mockGetFilteredQueues,
getFilteredNodes: mockGetFilteredNodes,
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.tsx
index a019639026d..1a3a09ce7c4 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/components/search/SearchBar.tsx
@@ -27,6 +27,7 @@ import { Input } from '~/components/ui/input';
import { Button } from '~/components/ui/button';
import { Badge } from '~/components/ui/badge';
import { cn } from '~/utils/cn';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { useDebounce } from '~/hooks/useDebounce';
import { calculateSearchResults } from '~/utils/searchUtils';
@@ -37,17 +38,22 @@ interface SearchBarProps {
}
export const SearchBar: React.FC<SearchBarProps> = ({ placeholder =
'Search...', className }) => {
- const {
- searchQuery,
- searchContext,
- isSearchFocused,
- setSearchQuery,
- clearSearch,
- setSearchFocused,
- getFilteredQueues,
- getFilteredNodes,
- getFilteredSettings,
- } = useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { searchQuery, searchContext, isSearchFocused } = useSchedulerStore(
+ useShallow((s) => ({
+ searchQuery: s.searchQuery,
+ searchContext: s.searchContext,
+ isSearchFocused: s.isSearchFocused,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const setSearchQuery = useSchedulerStore((s) => s.setSearchQuery);
+ const clearSearch = useSchedulerStore((s) => s.clearSearch);
+ const setSearchFocused = useSchedulerStore((s) => s.setSearchFocused);
+ const getFilteredQueues = useSchedulerStore((s) => s.getFilteredQueues);
+ const getFilteredNodes = useSchedulerStore((s) => s.getFilteredNodes);
+ const getFilteredSettings = useSchedulerStore((s) => s.getFilteredSettings);
const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState(searchQuery);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.test.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.test.tsx
index 90e15859e5e..f7e5d2fe609 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.test.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.test.tsx
@@ -17,7 +17,7 @@
*/
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import type { ReactNode } from 'react';
import { GlobalSettings } from './GlobalSettings';
@@ -162,8 +162,8 @@ describe('GlobalSettings', () => {
renderWithValidation(<GlobalSettings />);
- expect(screen.getByText('Core Settings')).toBeInTheDocument();
- expect(screen.getByText('Security & Access
Control')).toBeInTheDocument();
+ expect(screen.getAllByText('Core
Settings').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('Security & Access
Control').length).toBeGreaterThanOrEqual(1);
});
it('should render all properties within their categories', () => {
@@ -284,11 +284,13 @@ describe('GlobalSettings', () => {
renderWithValidation(<GlobalSettings />, { stagedChanges });
// Find the general category heading and its badge
- const generalHeading = screen.getByText('Core Settings').closest('div');
+ const generalHeading = screen.getByRole('heading', { name: 'Core
Settings' }).closest('div');
expect(generalHeading).toHaveTextContent('Has Changes');
// Security category should not have the badge
- const securityHeading = screen.getByText('Security & Access
Control').closest('div');
+ const securityHeading = screen
+ .getByRole('heading', { name: 'Security & Access Control' })
+ .closest('div');
expect(securityHeading).not.toHaveTextContent('Has Changes');
});
@@ -300,7 +302,7 @@ describe('GlobalSettings', () => {
(globalPropertyDefinitions as PropertyDescriptor[]).push(...properties);
renderWithValidation(<GlobalSettings />);
- const generalHeading = screen.getByText('Core Settings').closest('div');
+ const generalHeading = screen.getByRole('heading', { name: 'Core
Settings' }).closest('div');
expect(generalHeading).not.toHaveTextContent('Has Changes');
});
});
@@ -421,6 +423,53 @@ describe('GlobalSettings', () => {
});
});
+ describe('quick-jump navigation', () => {
+ it('should render quick-jump links when multiple categories exist', () => {
+ const properties = [
+ getMockPropertyDescriptor({ name: 'prop1', category: 'core' }),
+ getMockPropertyDescriptor({ name: 'prop2', category: 'security' }),
+ ];
+ (globalPropertyDefinitions as PropertyDescriptor[]).push(...properties);
+
+ renderWithValidation(<GlobalSettings />);
+
+ const nav = screen.getByRole('navigation', { name: /settings sections/i
});
+ expect(nav).toBeInTheDocument();
+
+ const links = within(nav).getAllByRole('link');
+ expect(links).toHaveLength(2);
+ expect(links[0]).toHaveTextContent('Core Settings');
+ expect(links[1]).toHaveTextContent('Security & Access Control');
+ });
+
+ it('should not render quick-jump nav when only one category exists', () =>
{
+ const properties = [
+ getMockPropertyDescriptor({ name: 'prop1', category: 'core' }),
+ getMockPropertyDescriptor({ name: 'prop2', category: 'core' }),
+ ];
+ (globalPropertyDefinitions as PropertyDescriptor[]).push(...properties);
+
+ renderWithValidation(<GlobalSettings />);
+
+ expect(
+ screen.queryByRole('navigation', { name: /settings sections/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should add id attributes to accordion sections', () => {
+ const properties = [
+ getMockPropertyDescriptor({ name: 'prop1', category: 'core' }),
+ getMockPropertyDescriptor({ name: 'prop2', category: 'security' }),
+ ];
+ (globalPropertyDefinitions as PropertyDescriptor[]).push(...properties);
+
+ renderWithValidation(<GlobalSettings />);
+
+ expect(document.getElementById('section-core')).toBeInTheDocument();
+ expect(document.getElementById('section-security')).toBeInTheDocument();
+ });
+ });
+
describe('accordion behavior', () => {
it('should render all category accordions', () => {
const properties = [
@@ -432,10 +481,10 @@ describe('GlobalSettings', () => {
renderWithValidation(<GlobalSettings />);
- // Verify all category accordions are rendered
- expect(screen.getByText('Core Settings')).toBeInTheDocument();
- expect(screen.getByText('Security & Access
Control')).toBeInTheDocument();
- expect(screen.getByText('Scheduling Policy')).toBeInTheDocument();
+ // Verify all category accordions are rendered (labels also appear in
quick-jump nav)
+ expect(screen.getAllByText('Core
Settings').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('Security & Access
Control').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('Scheduling
Policy').length).toBeGreaterThanOrEqual(1);
// Verify correct number of accordion items
const accordionItems = screen.getAllByTestId('accordion-item');
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.tsx
index 89dc956c268..d7503d280ba 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/GlobalSettings.tsx
@@ -18,6 +18,7 @@
import React from 'react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { globalPropertyDefinitions } from
'~/config/properties/global-properties';
import { SPECIAL_VALUES, type PropertyCategory } from '~/types';
@@ -41,17 +42,22 @@ import {
} from '~/features/property-editor/constants/categoryConfig';
export const GlobalSettings: React.FC = () => {
- const {
- getGlobalPropertyValue,
- getQueuePropertyValue,
- stageGlobalChange,
- stagedChanges,
- searchQuery,
- getFilteredSettings,
- applyError,
- configData,
- schedulerData,
- } = useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { stagedChanges, searchQuery, applyError, configData, schedulerData }
= useSchedulerStore(
+ useShallow((s) => ({
+ stagedChanges: s.stagedChanges,
+ searchQuery: s.searchQuery,
+ applyError: s.applyError,
+ configData: s.configData,
+ schedulerData: s.schedulerData,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const getGlobalPropertyValue = useSchedulerStore((s) =>
s.getGlobalPropertyValue);
+ const getQueuePropertyValue = useSchedulerStore((s) =>
s.getQueuePropertyValue);
+ const stageGlobalChange = useSchedulerStore((s) => s.stageGlobalChange);
+ const getFilteredSettings = useSchedulerStore((s) => s.getFilteredSettings);
const { validateGlobalProperty } = useGlobalPropertyValidation();
// Use filtered settings if search is active
@@ -188,6 +194,30 @@ export const GlobalSettings: React.FC = () => {
</Alert>
)}
+ {categories.length > 1 && (
+ <nav className="flex flex-wrap gap-2" aria-label="Settings sections">
+ {categories.map((category) => {
+ const label = categoryConfig[category]?.label || `${category}
Settings`;
+ return (
+ <a
+ key={category}
+ href={`#section-${category}`}
+ onClick={(e) => {
+ e.preventDefault();
+ document
+ .getElementById(`section-${category}`)
+ ?.scrollIntoView({ behavior: 'smooth' });
+ }}
+ className="inline-flex items-center gap-1.5 rounded-md border
px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors
hover:bg-accent hover:text-foreground"
+ >
+ {categoryConfig[category]?.icon}
+ {label}
+ </a>
+ );
+ })}
+ </nav>
+ )}
+
{categories.length > 0 ? (
<Accordion type="multiple" defaultValue={categories}
className="space-y-4">
{categories.map((category) => {
@@ -197,7 +227,12 @@ export const GlobalSettings: React.FC = () => {
);
return (
- <AccordionItem key={category} value={category} className="border
rounded-lg">
+ <AccordionItem
+ key={category}
+ value={category}
+ className="border rounded-lg"
+ id={`section-${category}`}
+ >
<AccordionTrigger className="px-6 hover:no-underline">
<div className="flex items-center gap-2">
{categoryConfig[category]?.icon}
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/LegacyModeToggle.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/LegacyModeToggle.tsx
index 7fa73ff151f..e9d457ecc93 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/LegacyModeToggle.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/global-settings/components/LegacyModeToggle.tsx
@@ -31,6 +31,7 @@ import {
DialogTrigger,
} from '~/components/ui/dialog';
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Info } from
'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { validateQueue } from '~/features/validation/service';
import type { ValidationIssue } from '~/types';
@@ -67,7 +68,15 @@ export const LegacyModeToggle:
React.FC<LegacyModeToggleProps> = ({
searchQuery,
}) => {
const [showPreview, setShowPreview] = useState(false);
- const { schedulerData, configData, stagedChanges } = useSchedulerStore();
+
+ // State values (trigger re-renders only when these specific values change)
+ const { schedulerData, configData, stagedChanges } = useSchedulerStore(
+ useShallow((s) => ({
+ schedulerData: s.schedulerData,
+ configData: s.configData,
+ stagedChanges: s.stagedChanges,
+ })),
+ );
const currentEnabled = value === 'true';
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabels.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabels.tsx
index 7e391d8dbb1..465c70ec7b8 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabels.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabels.tsx
@@ -19,6 +19,7 @@
import React from 'react';
import { AlertCircle } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
import { Card, CardContent, CardHeader, CardTitle } from
'~/components/ui/card';
@@ -27,8 +28,18 @@ import { NodeLabelsPanel } from './NodeLabelsPanel';
import { NodesPanel } from './NodesPanel';
export const NodeLabels: React.FC = () => {
+ // State values (trigger re-renders only when these specific values change)
const { isLoading, error, errorContext, applyError, nodeLabels,
selectedNodeLabel } =
- useSchedulerStore();
+ useSchedulerStore(
+ useShallow((s) => ({
+ isLoading: s.isLoading,
+ error: s.error,
+ errorContext: s.errorContext,
+ applyError: s.applyError,
+ nodeLabels: s.nodeLabels,
+ selectedNodeLabel: s.selectedNodeLabel,
+ })),
+ );
if (isLoading && nodeLabels.length === 0) {
return (
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.test.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.test.tsx
index f29d8551f88..f3fe94ae5a0 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.test.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.test.tsx
@@ -58,11 +58,18 @@ describe('NodeLabelsPanel', () => {
nodeToLabels: [],
};
+ function mockStore(overrides: Record<string, any> = {}) {
+ const state = { ...defaultStoreState, ...overrides };
+ vi.mocked(useSchedulerStore).mockImplementation((selector?: any) => {
+ return selector ? selector(state) : state;
+ });
+ vi.mocked(useSchedulerStore).getState = mockGetState;
+ }
+
beforeEach(() => {
vi.clearAllMocks();
mockConsoleError.mockClear();
- vi.mocked(useSchedulerStore).mockReturnValue(defaultStoreState);
- vi.mocked(useSchedulerStore).getState = mockGetState;
+ mockStore();
mockGetState.mockReturnValue({ nodeToLabels: [] });
vi.mocked(validateLabelRemoval).mockReturnValue({ valid: true });
});
@@ -76,7 +83,30 @@ describe('NodeLabelsPanel', () => {
render(<NodeLabelsPanel />);
expect(screen.getByText('No node labels found')).toBeInTheDocument();
- expect(screen.getByText('Click "Add" to create the first
label')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Node labels let you partition cluster nodes for dedicated resource
allocation.',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('should show CTA button in empty state', () => {
+ render(<NodeLabelsPanel />);
+
+ const ctaButton = screen.getByRole('button', { name: /create first
label/i });
+ expect(ctaButton).toBeInTheDocument();
+ expect(ctaButton).not.toBeDisabled();
+ });
+
+ it('should open add dialog when CTA button is clicked', async () => {
+ render(<NodeLabelsPanel />);
+
+ const ctaButton = screen.getByRole('button', { name: /create first
label/i });
+ await userEvent.click(ctaButton as HTMLElement);
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
});
it('should show correct label count for empty state', () => {
@@ -94,8 +124,7 @@ describe('NodeLabelsPanel', () => {
];
it('should display all node labels', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -107,8 +136,7 @@ describe('NodeLabelsPanel', () => {
});
it('should show correct label count', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -118,8 +146,7 @@ describe('NodeLabelsPanel', () => {
});
it('should display exclusive badge for exclusive labels', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -130,8 +157,7 @@ describe('NodeLabelsPanel', () => {
});
it('should show shield icon for exclusive labels', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -146,8 +172,7 @@ describe('NodeLabelsPanel', () => {
});
it('should handle singular vs plural label text', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: [getMockNodeLabel()],
});
@@ -164,8 +189,7 @@ describe('NodeLabelsPanel', () => {
];
it('should call selectNodeLabel when clicking on a label', async () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -178,8 +202,7 @@ describe('NodeLabelsPanel', () => {
});
it('should deselect label when clicking on selected label', async () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
selectedNodeLabel: 'gpu',
});
@@ -193,8 +216,7 @@ describe('NodeLabelsPanel', () => {
});
it('should highlight selected label', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
selectedNodeLabel: 'highmem',
});
@@ -219,8 +241,7 @@ describe('NodeLabelsPanel', () => {
});
it('should disable add button when loading', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
isLoading: true,
});
@@ -247,8 +268,7 @@ describe('NodeLabelsPanel', () => {
getMockNodeLabel({ name: 'highmem' }),
];
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -343,8 +363,7 @@ describe('NodeLabelsPanel', () => {
];
beforeEach(() => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
});
@@ -355,8 +374,7 @@ describe('NodeLabelsPanel', () => {
getMockNodeLabel({ name: 'gpu', exclusivity: true }),
];
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabelsWithDefault,
});
@@ -430,8 +448,7 @@ describe('NodeLabelsPanel', () => {
});
it('should disable delete button when loading', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
isLoading: true,
});
@@ -472,8 +489,7 @@ describe('NodeLabelsPanel', () => {
];
it('should show hover effect on labels', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -488,8 +504,7 @@ describe('NodeLabelsPanel', () => {
});
it('should show cursor pointer on labels', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -510,8 +525,7 @@ describe('NodeLabelsPanel', () => {
];
it('should have accessible list structure', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
@@ -525,8 +539,7 @@ describe('NodeLabelsPanel', () => {
});
it('should have accessible tooltips for delete buttons', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodeLabels: mockLabels,
});
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.tsx
index dcd63a859e8..f4245e89672 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodeLabelsPanel.tsx
@@ -18,7 +18,8 @@
import React, { useState } from 'react';
-import { Tag, Plus, Trash2, Shield } from 'lucide-react';
+import { Tag, Plus, Trash2, Shield, Tags } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { Button } from '~/components/ui/button';
import { Badge } from '~/components/ui/badge';
@@ -26,14 +27,19 @@ import { Tooltip, TooltipContent, TooltipProvider,
TooltipTrigger } from '~/comp
import { AddLabelDialog } from '~/features/node-labels';
export const NodeLabelsPanel: React.FC = () => {
- const {
- nodeLabels,
- selectedNodeLabel,
- selectNodeLabel,
- addNodeLabel,
- removeNodeLabel,
- isLoading,
- } = useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { nodeLabels, selectedNodeLabel, isLoading } = useSchedulerStore(
+ useShallow((s) => ({
+ nodeLabels: s.nodeLabels,
+ selectedNodeLabel: s.selectedNodeLabel,
+ isLoading: s.isLoading,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const selectNodeLabel = useSchedulerStore((s) => s.selectNodeLabel);
+ const addNodeLabel = useSchedulerStore((s) => s.addNodeLabel);
+ const removeNodeLabel = useSchedulerStore((s) => s.removeNodeLabel);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -142,11 +148,24 @@ export const NodeLabelsPanel: React.FC = () => {
{nodeLabels.length === 0 && (
<li>
- <div className="py-8 text-center">
- <p className="text-sm text-muted-foreground">No node labels
found</p>
- <p className="text-xs text-muted-foreground">
- Click "Add" to create the first label
+ <div className="flex flex-col items-center py-10 text-center">
+ <div className="mb-3 rounded-full bg-muted p-3">
+ <Tags className="h-6 w-6 text-muted-foreground" />
+ </div>
+ <p className="mb-1 text-sm font-medium">No node labels
found</p>
+ <p className="mb-4 max-w-[220px] text-xs
text-muted-foreground">
+ Node labels let you partition cluster nodes for dedicated
resource allocation.
</p>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setAddDialogOpen(true)}
+ disabled={isLoading}
+ className="gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ Create First Label
+ </Button>
</div>
</li>
)}
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.test.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.test.tsx
index 495d356b8f8..a85be382d98 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.test.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.test.tsx
@@ -96,10 +96,17 @@ describe('NodesPanel', () => {
getFilteredNodes: vi.fn(() => []),
};
+ function mockStore(overrides: Record<string, any> = {}) {
+ const state = { ...defaultStoreState, ...overrides };
+ vi.mocked(useSchedulerStore).mockImplementation((selector?: any) => {
+ return selector ? selector(state) : state;
+ });
+ }
+
beforeEach(() => {
vi.clearAllMocks();
mockConsoleError.mockClear();
- (useSchedulerStore as any).mockReturnValue(defaultStoreState);
+ mockStore();
(formatMemory as any).mockImplementation((mb: number) => `${mb} MB`);
});
@@ -135,8 +142,7 @@ describe('NodesPanel', () => {
];
it('should display all nodes when no label is selected', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: mockNodes,
});
@@ -148,8 +154,7 @@ describe('NodesPanel', () => {
});
it('should show correct header text for all nodes', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: mockNodes,
});
@@ -174,8 +179,7 @@ describe('NodesPanel', () => {
];
it('should filter nodes by selected label', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: mockNodes,
nodeToLabels: mockNodeToLabels,
});
@@ -188,8 +192,7 @@ describe('NodesPanel', () => {
});
it('should show correct header text for filtered nodes', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: mockNodes,
nodeToLabels: mockNodeToLabels,
});
@@ -207,8 +210,7 @@ describe('NodesPanel', () => {
});
it('should show empty state for label with no nodes', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: mockNodes,
nodeToLabels: mockNodeToLabels,
});
@@ -236,8 +238,7 @@ describe('NodesPanel', () => {
});
it('should display node basic information', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
});
@@ -254,8 +255,7 @@ describe('NodesPanel', () => {
createMockNode({ id: 'n3', state: 'SHUTDOWN' }),
];
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes,
});
@@ -273,8 +273,7 @@ describe('NodesPanel', () => {
});
it('should display container count', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
});
@@ -296,8 +295,7 @@ describe('NodesPanel', () => {
});
it('should display memory utilization', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
});
@@ -309,8 +307,7 @@ describe('NodesPanel', () => {
});
it('should display CPU utilization', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
});
@@ -320,8 +317,7 @@ describe('NodesPanel', () => {
});
it('should show progress bars for utilization', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
});
@@ -345,8 +341,7 @@ describe('NodesPanel', () => {
availableVirtualCores: 1,
});
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [highUtilNode],
});
@@ -366,8 +361,7 @@ describe('NodesPanel', () => {
availableVirtualCores: 0,
});
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [criticalNode],
});
@@ -389,8 +383,7 @@ describe('NodesPanel', () => {
it('should display assigned labels as badges', () => {
const node = createMockNode({ id: 'node-1' });
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [node],
nodeToLabels: mockNodeToLabels,
});
@@ -407,8 +400,7 @@ describe('NodesPanel', () => {
it('should display Default badge for nodes without labels', () => {
const node = createMockNode({ id: 'node-2' });
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [node],
nodeToLabels: mockNodeToLabels,
});
@@ -421,8 +413,7 @@ describe('NodesPanel', () => {
it('should highlight selected label badge', () => {
const node = createMockNode({ id: 'node-1' });
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [node],
nodeToLabels: mockNodeToLabels,
});
@@ -446,8 +437,7 @@ describe('NodesPanel', () => {
const mockNodeToLabels: NodeToLabelMapping[] = [{ nodeId: 'node-1',
nodeLabels: ['gpu'] }];
beforeEach(() => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
nodeToLabels: mockNodeToLabels,
});
@@ -506,8 +496,7 @@ describe('NodesPanel', () => {
});
it('should disable select when loading', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
nodeToLabels: mockNodeToLabels,
isLoading: true,
@@ -540,8 +529,7 @@ describe('NodesPanel', () => {
const mockNodeToLabels: NodeToLabelMapping[] = [{ nodeId: 'node-1',
nodeLabels: ['gpu'] }];
it('should show remove button for nodes with labels', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
nodeToLabels: mockNodeToLabels,
});
@@ -560,8 +548,7 @@ describe('NodesPanel', () => {
it('should not show remove button for nodes without labels', () => {
const nodeToLabels: NodeToLabelMapping[] = [{ nodeId: 'node-1',
nodeLabels: [] }];
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
nodeToLabels,
});
@@ -580,8 +567,7 @@ describe('NodesPanel', () => {
it('should call assignNodeToLabel with null when remove is clicked', async
() => {
mockAssignNodeToLabel.mockResolvedValue(undefined);
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
nodeToLabels: mockNodeToLabels,
});
@@ -599,8 +585,7 @@ describe('NodesPanel', () => {
});
it('should disable remove button when loading', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
nodeToLabels: mockNodeToLabels,
isLoading: true,
@@ -624,8 +609,7 @@ describe('NodesPanel', () => {
];
it('should render table with correct headers', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: mockNodes,
});
@@ -641,8 +625,7 @@ describe('NodesPanel', () => {
});
it('should render correct number of rows', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: mockNodes,
});
@@ -657,8 +640,7 @@ describe('NodesPanel', () => {
const mockNode = createMockNode({ id: 'node-1' });
it('should have accessible table structure', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
});
@@ -672,8 +654,7 @@ describe('NodesPanel', () => {
});
it('should have accessible progress bars', () => {
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
});
@@ -696,8 +677,7 @@ describe('NodesPanel', () => {
it('should have accessible tooltips', () => {
const mockNodeToLabels: NodeToLabelMapping[] = [{ nodeId: 'node-1',
nodeLabels: ['gpu'] }];
- (useSchedulerStore as any).mockReturnValue({
- ...defaultStoreState,
+ mockStore({
nodes: [mockNode],
nodeToLabels: mockNodeToLabels,
});
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.tsx
index e0e34bf9d8b..d2df995d50f 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/node-labels/components/NodesPanel.tsx
@@ -19,6 +19,7 @@
import React from 'react';
import { Monitor, HardDrive, Cpu, X } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { Badge } from '~/components/ui/badge';
import { Button } from '~/components/ui/button';
@@ -48,15 +49,20 @@ interface NodesPanelProps {
}
export const NodesPanel: React.FC<NodesPanelProps> = ({ selectedLabel }) => {
- const {
- nodes,
- nodeToLabels,
- nodeLabels,
- assignNodeToLabel,
- isLoading,
- searchQuery,
- getFilteredNodes,
- } = useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { nodes, nodeToLabels, nodeLabels, isLoading, searchQuery } =
useSchedulerStore(
+ useShallow((s) => ({
+ nodes: s.nodes,
+ nodeToLabels: s.nodeToLabels,
+ nodeLabels: s.nodeLabels,
+ isLoading: s.isLoading,
+ searchQuery: s.searchQuery,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const assignNodeToLabel = useSchedulerStore((s) => s.assignNodeToLabel);
+ const getFilteredNodes = useSchedulerStore((s) => s.getFilteredNodes);
// Create a map of nodeId -> labels for quick lookup
const nodeLabelsMap = new Map<string, string[]>();
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.test.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.test.tsx
index de2e9d58804..4ee6a1f32a5 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.test.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.test.tsx
@@ -33,6 +33,13 @@ describe('PlacementRulesMigrationDialog', () => {
migrateLegacyRules: vi.fn(),
};
+ function mockStore(overrides: Record<string, any> = {}) {
+ const state = { ...mockStoreState, ...overrides };
+ vi.mocked(useSchedulerStore).mockImplementation((selector?: any) => {
+ return selector ? selector(state) : state;
+ });
+ }
+
const defaultProps = {
open: true,
onOpenChange: vi.fn(),
@@ -40,7 +47,7 @@ describe('PlacementRulesMigrationDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(useSchedulerStore).mockReturnValue(mockStoreState);
+ mockStore();
});
it('should render when open is true', () => {
@@ -138,10 +145,7 @@ describe('PlacementRulesMigrationDialog', () => {
});
it('should handle missing legacy rules', async () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreState,
- legacyRules: null,
- });
+ mockStore({ legacyRules: null });
render(<PlacementRulesMigrationDialog {...defaultProps} />);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.tsx
index 50bd0f6346e..e048e062600 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/MigrationDialog.tsx
@@ -33,6 +33,7 @@ import {
} from '~/components/ui/dialog';
import { Alert, AlertDescription } from '~/components/ui/alert';
import { AlertCircle, CheckCircle2 } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import type { MigrationResult } from '~/types/features/placement-rules';
@@ -45,7 +46,15 @@ export const PlacementRulesMigrationDialog = ({
open,
onOpenChange,
}: PlacementRulesMigrationDialogProps) => {
- const { legacyRules, migrateLegacyRules: storeMigrateLegacyRules } =
useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { legacyRules } = useSchedulerStore(
+ useShallow((s) => ({
+ legacyRules: s.legacyRules,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const storeMigrateLegacyRules = useSchedulerStore((s) =>
s.migrateLegacyRules);
const [migrationResult, setMigrationResult] = useState<MigrationResult |
null>(null);
const [isMigrating, setIsMigrating] = useState(false);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRuleDetail.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRuleDetail.tsx
index 34f49cc34c2..12a973657cc 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRuleDetail.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRuleDetail.tsx
@@ -22,13 +22,23 @@ import { Card, CardContent, CardDescription, CardHeader,
CardTitle } from '~/com
import { Button } from '~/components/ui/button';
import { Separator } from '~/components/ui/separator';
import { Badge } from '~/components/ui/badge';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { PlacementRuleForm } from './PlacementRuleForm';
import { getPolicyDescription } from
'~/features/placement-rules/constants/policy-descriptions';
import type { PlacementRule } from '~/types/features/placement-rules';
export function PlacementRuleDetail() {
- const { rules, selectedRuleIndex, updateRule } = useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { rules, selectedRuleIndex } = useSchedulerStore(
+ useShallow((s) => ({
+ rules: s.rules,
+ selectedRuleIndex: s.selectedRuleIndex,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const updateRule = useSchedulerStore((s) => s.updateRule);
const [isEditing, setIsEditing] = useState(false);
if (selectedRuleIndex === null || !rules[selectedRuleIndex]) {
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.test.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.test.tsx
index d12c243220f..877846e78d5 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.test.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.test.tsx
@@ -116,9 +116,16 @@ describe('PlacementRulesList', () => {
applyError: null,
};
+ function mockStore(overrides: Record<string, any> = {}) {
+ const state = { ...mockStoreFunctions, ...overrides };
+ vi.mocked(useSchedulerStore).mockImplementation((selector?: any) => {
+ return selector ? selector(state) : state;
+ });
+ }
+
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(useSchedulerStore).mockReturnValue(mockStoreFunctions);
+ mockStore();
});
it('should render empty state when no rules exist', () => {
@@ -132,10 +139,7 @@ describe('PlacementRulesList', () => {
});
it('should render table view when rules exist', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- rules: mockRules,
- });
+ mockStore({ rules: mockRules });
render(<PlacementRulesList />);
@@ -185,10 +189,7 @@ describe('PlacementRulesList', () => {
it('should call deleteRule when delete is clicked on a rule', async () => {
const user = userEvent.setup();
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- rules: mockRules,
- });
+ mockStore({ rules: mockRules });
render(<PlacementRulesList />);
@@ -200,10 +201,7 @@ describe('PlacementRulesList', () => {
});
it('should display info alert about rule evaluation order', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- rules: mockRules,
- });
+ mockStore({ rules: mockRules });
render(<PlacementRulesList />);
@@ -215,10 +213,7 @@ describe('PlacementRulesList', () => {
});
it('should display apply error alert when present', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- applyError: 'HTTP 400: Invalid configuration',
- });
+ mockStore({ applyError: 'HTTP 400: Invalid configuration' });
render(<PlacementRulesList />);
@@ -229,10 +224,7 @@ describe('PlacementRulesList', () => {
it('should call loadPlacementRules on mount when configData is available',
() => {
// Set up configData with some content
const configWithData = new Map([['some.property', 'value']]);
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- configData: configWithData,
- });
+ mockStore({ configData: configWithData });
render(<PlacementRulesList />);
@@ -248,8 +240,7 @@ describe('PlacementRulesList', () => {
describe('legacy mode behavior', () => {
it('should show legacy mode UI when isLegacyMode is true', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
+ mockStore({
isLegacyMode: true,
legacyRules: 'u:user1:root.default,u:user2:root.production',
});
@@ -264,10 +255,7 @@ describe('PlacementRulesList', () => {
});
it('should show migrate button in legacy mode', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- isLegacyMode: true,
- });
+ mockStore({ isLegacyMode: true });
render(<PlacementRulesList />);
@@ -276,10 +264,7 @@ describe('PlacementRulesList', () => {
});
it('should not show add form in legacy mode', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- isLegacyMode: true,
- });
+ mockStore({ isLegacyMode: true });
render(<PlacementRulesList />);
@@ -288,11 +273,7 @@ describe('PlacementRulesList', () => {
});
it('should not show rules table in legacy mode', () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- ...mockStoreFunctions,
- isLegacyMode: true,
- rules: mockRules,
- });
+ mockStore({ isLegacyMode: true, rules: mockRules });
render(<PlacementRulesList />);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.tsx
index 76a569bbad9..8a1bb32eeee 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/placement-rules/components/PlacementRulesList.tsx
@@ -26,25 +26,40 @@ import { PlacementRuleForm } from './PlacementRuleForm';
import { PlacementRulesTable } from './PlacementRulesTable';
import { PolicyReferenceDialog } from './PolicyReferenceDialog';
import { PlacementRulesMigrationDialog } from './MigrationDialog';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import type { PlacementRule } from '~/types/features/placement-rules';
export function PlacementRulesList() {
+ // State values (trigger re-renders only when these specific values change)
const {
rules,
selectedRuleIndex,
- addRule,
- deleteRule,
- reorderRules,
- selectRule,
- loadPlacementRules,
isLegacyMode,
legacyRules,
configData,
stagedChanges,
formatWarning,
applyError,
- } = useSchedulerStore();
+ } = useSchedulerStore(
+ useShallow((s) => ({
+ rules: s.rules,
+ selectedRuleIndex: s.selectedRuleIndex,
+ isLegacyMode: s.isLegacyMode,
+ legacyRules: s.legacyRules,
+ configData: s.configData,
+ stagedChanges: s.stagedChanges,
+ formatWarning: s.formatWarning,
+ applyError: s.applyError,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const addRule = useSchedulerStore((s) => s.addRule);
+ const deleteRule = useSchedulerStore((s) => s.deleteRule);
+ const reorderRules = useSchedulerStore((s) => s.reorderRules);
+ const selectRule = useSchedulerStore((s) => s.selectRule);
+ const loadPlacementRules = useSchedulerStore((s) => s.loadPlacementRules);
const [showAddForm, setShowAddForm] = useState(false);
const [showMigrationDialog, setShowMigrationDialog] = useState(false);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyPanel.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyPanel.tsx
index 07f96d82700..7a78f72e768 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyPanel.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/property-editor/components/PropertyPanel.tsx
@@ -17,8 +17,9 @@
*/
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useReducer, useState, useEffect, useRef } from 'react';
import { Save, RotateCcw, GitBranch, Info, Settings, Edit, AlertTriangle }
from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from
'~/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from
'~/components/ui/tabs';
@@ -39,27 +40,83 @@ import { AUTO_CREATION_PROPS } from
'~/types/constants/auto-creation';
import { useKeyboardShortcuts, getModifierKey } from
'~/hooks/useKeyboardShortcuts';
import { Kbd } from '~/components/ui/kbd';
+// -- Form lifecycle reducer --------------------------------------------------
+// Replaces individual useState calls for hasChanges, isFormDirty,
isSubmitting,
+// showUnsavedDialog, and pendingClose with a single reducer that prevents
+// impossible state combinations (e.g. confirming-close without pendingClose).
+
+type FormState =
+ | { status: 'idle'; hasChanges: boolean; isFormDirty: boolean }
+ | { status: 'submitting'; hasChanges: boolean; isFormDirty: boolean }
+ | { status: 'confirming-close'; hasChanges: boolean; isFormDirty: boolean };
+
+type FormAction =
+ | { type: 'SET_HAS_CHANGES'; value: boolean }
+ | { type: 'SET_FORM_DIRTY'; value: boolean }
+ | { type: 'START_SUBMIT' }
+ | { type: 'END_SUBMIT' }
+ | { type: 'REQUEST_CLOSE' }
+ | { type: 'CANCEL_CLOSE' }
+ | { type: 'RESET' };
+
+const INITIAL_FORM_STATE: FormState = {
+ status: 'idle',
+ hasChanges: false,
+ isFormDirty: false,
+};
+
+function formReducer(state: FormState, action: FormAction): FormState {
+ switch (action.type) {
+ case 'SET_HAS_CHANGES':
+ return { ...state, hasChanges: action.value };
+ case 'SET_FORM_DIRTY':
+ return { ...state, isFormDirty: action.value };
+ case 'START_SUBMIT':
+ return { ...state, status: 'submitting' };
+ case 'END_SUBMIT':
+ return { ...state, status: state.status === 'submitting' ? 'idle' :
state.status };
+ case 'REQUEST_CLOSE':
+ return { ...state, status: 'confirming-close' };
+ case 'CANCEL_CLOSE':
+ return { ...state, status: state.status === 'confirming-close' ? 'idle'
: state.status };
+ case 'RESET':
+ return INITIAL_FORM_STATE;
+ default:
+ return state;
+ }
+}
+
export const PropertyPanel: React.FC = () => {
+ // State values (trigger re-renders only when these specific values change)
const {
selectedQueuePath,
isPropertyPanelOpen,
- setPropertyPanelOpen,
- getQueueByPath,
- selectQueue,
propertyPanelInitialTab,
shouldOpenTemplateConfig,
- clearTemplateConfigRequest,
- } = useSchedulerStore();
+ stagedChanges,
+ } = useSchedulerStore(
+ useShallow((s) => ({
+ selectedQueuePath: s.selectedQueuePath,
+ isPropertyPanelOpen: s.isPropertyPanelOpen,
+ propertyPanelInitialTab: s.propertyPanelInitialTab,
+ shouldOpenTemplateConfig: s.shouldOpenTemplateConfig,
+ stagedChanges: s.stagedChanges,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const setPropertyPanelOpen = useSchedulerStore((s) =>
s.setPropertyPanelOpen);
+ const getQueueByPath = useSchedulerStore((s) => s.getQueueByPath);
+ const selectQueue = useSchedulerStore((s) => s.selectQueue);
+ const clearTemplateConfigRequest = useSchedulerStore((s) =>
s.clearTemplateConfigRequest);
+ const getQueuePropertyValue = useSchedulerStore((s) =>
s.getQueuePropertyValue);
- const getQueuePropertyValue = useSchedulerStore((state) =>
state.getQueuePropertyValue);
- const stagedChanges = useSchedulerStore((state) => state.stagedChanges);
+ const [formState, dispatch] = useReducer(formReducer, INITIAL_FORM_STATE);
+ const { hasChanges, isFormDirty } = formState;
+ const isSubmitting = formState.status === 'submitting';
+ const showUnsavedDialog = formState.status === 'confirming-close';
const [tabValue, setTabValue] = useState('overview');
- const [hasChanges, setHasChanges] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [isFormDirty, setIsFormDirty] = useState(false);
- const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
- const [pendingClose, setPendingClose] = useState(false);
const [isSummaryOpen, setIsSummaryOpen] = useState(false);
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
@@ -145,12 +202,10 @@ export const PropertyPanel: React.FC = () => {
const handleClose = (force = false) => {
if (!force && isFormDirty && tabValue === 'settings') {
- setShowUnsavedDialog(true);
- setPendingClose(true);
+ dispatch({ type: 'REQUEST_CLOSE' });
} else {
setPropertyPanelOpen(false);
selectQueue(null); // Deselect queue when panel closes
- setPendingClose(false);
}
};
@@ -181,15 +236,15 @@ export const PropertyPanel: React.FC = () => {
};
const handleHasChangesChange = (newHasChanges: boolean) => {
- setHasChanges(newHasChanges);
+ dispatch({ type: 'SET_HAS_CHANGES', value: newHasChanges });
};
const handleFormDirtyChange = (newIsFormDirty: boolean) => {
- setIsFormDirty(newIsFormDirty);
+ dispatch({ type: 'SET_FORM_DIRTY', value: newIsFormDirty });
};
const handleIsSubmittingChange = (newIsSubmitting: boolean) => {
- setIsSubmitting(newIsSubmitting);
+ dispatch(newIsSubmitting ? { type: 'START_SUBMIT' } : { type: 'END_SUBMIT'
});
};
const handleSaveAndClose = async () => {
@@ -201,23 +256,22 @@ export const PropertyPanel: React.FC = () => {
}
await handleSubmit();
- if (pendingClose) {
+ if (showUnsavedDialog) {
handleClose(true);
}
- setShowUnsavedDialog(false);
+ dispatch({ type: 'CANCEL_CLOSE' });
};
const handleDiscardAndClose = () => {
handleReset();
handleClose(true);
- setShowUnsavedDialog(false);
+ dispatch({ type: 'CANCEL_CLOSE' });
};
- // Reset hasChanges and form dirty state when panel opens/closes or queue
changes
+ // Reset form state when panel opens/closes or queue changes
useEffect(() => {
if (!isPropertyPanelOpen || !selectedQueuePath) {
- setHasChanges(false);
- setIsFormDirty(false);
+ dispatch({ type: 'RESET' });
setIsSummaryOpen(false);
}
}, [isPropertyPanelOpen, selectedQueuePath]);
@@ -429,7 +483,9 @@ export const PropertyPanel: React.FC = () => {
<UnsavedChangesDialog
open={showUnsavedDialog}
- onOpenChange={setShowUnsavedDialog}
+ onOpenChange={(open) => {
+ if (!open) dispatch({ type: 'CANCEL_CLOSE' });
+ }}
onSave={handleSaveAndClose}
onDiscard={handleDiscardAndClose}
isSaving={isSubmitting}
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.label-filter.test.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.label-filter.test.tsx
index ecce090cd69..c2829a2aa09 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.label-filter.test.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.label-filter.test.tsx
@@ -83,23 +83,34 @@ describe('QueueCardNode - Node Label Filtering', () => {
deletable: false,
};
+ const defaultStoreState: Record<string, any> = {
+ comparisonQueues: [],
+ selectedQueuePath: null,
+ selectQueue: vi.fn(),
+ setPropertyPanelOpen: vi.fn(),
+ isPropertyPanelOpen: false,
+ propertyPanelInitialTab: 'overview',
+ setPropertyPanelInitialTab: vi.fn(),
+ toggleComparisonQueue: vi.fn(),
+ selectedNodeLabelFilter: '',
+ getQueueLabelCapacity: mockGetQueueLabelCapacity,
+ hasPendingDeletion: vi.fn().mockReturnValue(false),
+ clearQueueChanges: vi.fn(),
+ requestTemplateConfigOpen: vi.fn(),
+ searchQuery: '',
+ isComparisonModeActive: false,
+ };
+
+ function mockStore(overrides: Record<string, any> = {}) {
+ const state = { ...defaultStoreState, ...overrides };
+ vi.mocked(useSchedulerStore).mockImplementation((selector?: any) => {
+ return selector ? selector(state) : state;
+ });
+ }
+
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(useSchedulerStore).mockReturnValue({
- comparisonQueues: [],
- selectedQueuePath: null,
- selectQueue: vi.fn(),
- setPropertyPanelOpen: vi.fn(),
- isPropertyPanelOpen: false,
- propertyPanelInitialTab: 'overview',
- setPropertyPanelInitialTab: vi.fn(),
- toggleComparisonQueue: vi.fn(),
- selectedNodeLabelFilter: '',
- getQueueLabelCapacity: mockGetQueueLabelCapacity,
- hasPendingDeletion: vi.fn().mockReturnValue(false),
- clearQueueChanges: vi.fn(),
- requestTemplateConfigOpen: vi.fn(),
- } as any);
+ mockStore();
});
describe('DEFAULT label (no filter)', () => {
@@ -144,21 +155,7 @@ describe('QueueCardNode - Node Label Filtering', () => {
describe('Label-specific filtering', () => {
beforeEach(() => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- comparisonQueues: [],
- selectedQueuePath: null,
- selectQueue: vi.fn(),
- setPropertyPanelOpen: vi.fn(),
- isPropertyPanelOpen: false,
- propertyPanelInitialTab: 'overview',
- setPropertyPanelInitialTab: vi.fn(),
- toggleComparisonQueue: vi.fn(),
- selectedNodeLabelFilter: 'gpu',
- getQueueLabelCapacity: mockGetQueueLabelCapacity,
- hasPendingDeletion: vi.fn().mockReturnValue(false),
- clearQueueChanges: vi.fn(),
- requestTemplateConfigOpen: vi.fn(),
- } as any);
+ mockStore({ selectedNodeLabelFilter: 'gpu' });
});
it('should show label-specific capacity when queue has access', () => {
@@ -257,18 +254,7 @@ describe('QueueCardNode - Node Label Filtering', () => {
describe('Label badge tooltip', () => {
it('should show tooltip for label badge', async () => {
- vi.mocked(useSchedulerStore).mockReturnValue({
- comparisonQueues: [],
- selectedQueuePath: null,
- selectQueue: vi.fn(),
- setPropertyPanelOpen: vi.fn(),
- toggleComparisonQueue: vi.fn(),
- selectedNodeLabelFilter: 'gpu',
- getQueueLabelCapacity: mockGetQueueLabelCapacity,
- hasPendingDeletion: vi.fn().mockReturnValue(false),
- clearQueueChanges: vi.fn(),
- requestTemplateConfigOpen: vi.fn(),
- } as any);
+ mockStore({ selectedNodeLabelFilter: 'gpu' });
mockGetQueueLabelCapacity.mockReturnValue({
capacity: '80',
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.tsx
index 30eb6dcc0f8..233711329ae 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueCardNode.tsx
@@ -19,6 +19,7 @@
import React, { useState } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
+import { useShallow } from 'zustand/react/shallow';
import {
Card,
@@ -32,7 +33,6 @@ import { Checkbox } from '~/components/ui/checkbox';
import type { QueueCardData } from
'~/features/queue-management/hooks/useQueueTreeData';
import { useQueueActions } from
'~/features/queue-management/hooks/useQueueActions';
import { useSchedulerStore } from '~/stores/schedulerStore';
-import { cn } from '~/utils/cn';
import { HighlightedText } from '~/components/search/HighlightedText';
import { AddQueueDialog } from './dialogs/AddQueueDialog';
import { DeleteQueueDialog } from './dialogs/DeleteQueueDialog';
@@ -43,9 +43,9 @@ import { QueueValidationBadges } from
'./QueueValidationBadges';
import { QueueVectorCapacityDisplay } from './QueueVectorCapacityDisplay';
import { QueueCardContextMenu } from './QueueCardContextMenu';
import { getCapacityDisplay } from '../utils/capacityDisplay';
-import { QUEUE_STATES, SPECIAL_VALUES } from '~/types';
import { parseCapacityValue } from '~/utils/capacityUtils';
-import { useCapacityEditor } from
'~/features/queue-management/hooks/useCapacityEditor';
+import { getQueueCardClassName } from '../utils/queueCardStyles';
+import { useQueueCardHandlers } from '../hooks/useQueueCardHandlers';
import { QUEUE_CARD_HEIGHT, QUEUE_CARD_WIDTH } from
'~/features/queue-management/constants';
export const QueueCardNode: React.FC<NodeProps> = ({ data }) => {
@@ -55,25 +55,30 @@ export const QueueCardNode: React.FC<NodeProps> = ({ data
}) => {
// Cast data to QueueCardData type
const queueData = data as QueueCardData;
+ // State values (trigger re-renders only when these specific values change)
const {
comparisonQueues,
selectedQueuePath,
- selectQueue,
- setPropertyPanelOpen,
isPropertyPanelOpen,
- setPropertyPanelInitialTab,
- requestTemplateConfigOpen,
- toggleComparisonQueue,
selectedNodeLabelFilter,
- getQueueLabelCapacity,
- clearQueueChanges,
- hasPendingDeletion,
searchQuery,
isComparisonModeActive,
- } = useSchedulerStore();
+ } = useSchedulerStore(
+ useShallow((s) => ({
+ comparisonQueues: s.comparisonQueues,
+ selectedQueuePath: s.selectedQueuePath,
+ isPropertyPanelOpen: s.isPropertyPanelOpen,
+ selectedNodeLabelFilter: s.selectedNodeLabelFilter,
+ searchQuery: s.searchQuery,
+ isComparisonModeActive: s.isComparisonModeActive,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const getQueueLabelCapacity = useSchedulerStore((s) =>
s.getQueueLabelCapacity);
+ const hasPendingDeletion = useSchedulerStore((s) => s.hasPendingDeletion);
- const { canAddChildQueue, canDeleteQueue, updateQueueProperty } =
useQueueActions();
- const { openCapacityEditor } = useCapacityEditor();
+ const { canAddChildQueue, canDeleteQueue } = useQueueActions();
const {
queuePath,
@@ -126,141 +131,42 @@ export const QueueCardNode: React.FC<NodeProps> = ({
data }) => {
const isTemplateManageable =
autoCreationStatus?.status === 'legacy' || autoCreationStatus?.status ===
'flexible';
- const openPropertyPanel = (
- event: React.MouseEvent,
- initialTab: 'overview' | 'info' | 'settings' = 'overview',
- ) => {
- event.stopPropagation();
-
- // Don't allow clicking on newly added queues that haven't been applied yet
- if (stagedStatus === 'new') {
- return;
- }
-
- const tabToOpen = isAutoCreatedQueue && initialTab === 'settings' ?
'overview' : initialTab;
- setPropertyPanelInitialTab(tabToOpen);
- // Set selected queue and open property panel
- selectQueue(queuePath);
- setPropertyPanelOpen(true);
- };
-
- const handleOpenCapacityEditor = (event: React.MouseEvent) => {
- event.stopPropagation();
- if (!queuePath || queuePath === SPECIAL_VALUES.ROOT_QUEUE_NAME) {
- return;
- }
-
- const parentPath = queuePath.split('.').slice(0, -1).join('.');
- if (!parentPath) {
- return;
- }
-
- openCapacityEditor({
- origin: 'context-menu',
- parentQueuePath: parentPath,
- originQueuePath: queuePath,
- originQueueName: queueName,
- capacityValue: capacityConfig,
- maxCapacityValue: maxCapacityConfig,
- queueState: state,
- markOriginAsNew: stagedStatus === 'new',
- });
- };
-
- const handleRemoveStagedQueue = (event: React.MouseEvent) => {
- event.stopPropagation();
- event.preventDefault();
- if (queuePath) {
- clearQueueChanges(queuePath);
- }
- };
-
- const handleComparisonToggle = () => {
- toggleComparisonQueue(queuePath);
- };
-
- const handleToggleState = () => {
- const newState = state === QUEUE_STATES.RUNNING ? QUEUE_STATES.STOPPED :
QUEUE_STATES.RUNNING;
- updateQueueProperty(queuePath, 'state', newState);
- };
-
- const handleManageTemplate = (event: React.MouseEvent) => {
- event.stopPropagation();
- setPropertyPanelInitialTab('settings');
- selectQueue(queuePath);
- requestTemplateConfigOpen();
- };
+ const {
+ openPropertyPanel,
+ handleOpenCapacityEditor,
+ handleRemoveStagedQueue,
+ handleComparisonToggle,
+ handleToggleState,
+ handleManageTemplate,
+ handleContextMenuOpenChange,
+ handleCardClick,
+ } = useQueueCardHandlers({
+ queuePath,
+ queueName,
+ state,
+ capacityConfig,
+ maxCapacityConfig,
+ stagedStatus,
+ isAutoCreatedQueue,
+ isComparisonModeActive,
+ isSelectedQueue,
+ isPropertyPanelOpen,
+ });
- const handleContextMenuOpenChange = (open: boolean) => {
- if (!open && isSelectedQueue && !isPropertyPanelOpen) {
- selectQueue(null);
- }
- };
+ const cardClassName = getQueueCardClassName({
+ isAutoCreatedQueue,
+ stagedStatus,
+ isSelectedQueue,
+ isSelectedForComparison,
+ validationErrors,
+ isAffectedByErrors,
+ shouldGrayOut,
+ });
const cardContent = (
<Card
- className={cn(
- 'relative flex flex-col',
- // Smooth transitions with spring-like feel
- 'transition-all duration-200 ease-out',
- // Enhanced background with subtle gradient
- 'bg-gradient-to-br from-gray-50 to-white dark:from-gray-900
dark:to-gray-950',
- 'border-gray-200 dark:border-gray-700/80',
- // Auto-created queue styling
- isAutoCreatedQueue &&
- 'border-amber-400 dark:border-amber-500 border-2 border-dashed
from-amber-50/70 to-amber-50/50 dark:from-amber-900/30 dark:to-amber-950/20',
- // Shadow for depth with hover enhancement
- 'shadow-lg hover:shadow-xl hover:-translate-y-0.5',
- 'dark:shadow-md dark:shadow-black/20 dark:hover:shadow-lg
dark:hover:shadow-black/30',
- // Cursor styling - not clickable for new queues
- stagedStatus === 'new' ? 'opacity-75 cursor-default' :
'cursor-pointer',
- // Border styling based on status
- // Left border for staged status (always visible regardless of errors)
- stagedStatus === 'new' && 'border-l-4 border-l-queue-new',
- stagedStatus === 'deleted' && 'border-l-4 border-l-queue-deleted',
- stagedStatus === 'modified' && 'border-l-4 border-l-queue-modified',
- // Ring for staged status (only if no validation errors)
- stagedStatus === 'new' &&
- !(validationErrors && validationErrors.some((e) => e.severity ===
'error')) &&
- 'ring-2 ring-queue-new',
- stagedStatus === 'deleted' &&
- !(validationErrors && validationErrors.some((e) => e.severity ===
'error')) &&
- 'ring-2 ring-queue-deleted',
- stagedStatus === 'modified' &&
- !(validationErrors && validationErrors.some((e) => e.severity ===
'error')) &&
- 'ring-2 ring-queue-modified',
- !stagedStatus && isSelectedQueue && 'ring-2 ring-primary
shadow-primary/10',
- // Ring for validation errors (can coexist with staged status border)
- validationErrors &&
- validationErrors.some((e) => e.severity === 'error') &&
- 'ring-2 ring-destructive',
- // Left border for affected queues only if no staged status
- !stagedStatus &&
- validationErrors &&
- validationErrors.some((e) => e.severity === 'error') &&
- 'border-l-4 border-l-destructive',
- isAffectedByErrors &&
- !validationErrors &&
- !stagedStatus &&
- 'ring-2 ring-amber-500 border-l-4 border-l-amber-500',
- isAffectedByErrors && !validationErrors && stagedStatus && 'ring-2
ring-amber-500',
- // Background styling for states
- isSelectedQueue &&
- 'from-primary/10 to-primary/5 dark:from-primary/15 dark:to-primary/5
scale-[1.01]',
- isSelectedForComparison &&
- !isSelectedQueue &&
- 'from-gray-100 to-gray-50 dark:from-gray-800 dark:to-gray-900',
- // Gray out inaccessible queues when filtered by label
- shouldGrayOut && 'opacity-50 grayscale',
- 'gap-4 py-5',
- )}
- onClick={(event) => {
- if (isComparisonModeActive) {
- handleComparisonToggle();
- } else {
- openPropertyPanel(event, 'overview');
- }
- }}
+ className={cardClassName}
+ onClick={handleCardClick}
style={{ width: QUEUE_CARD_WIDTH, height: QUEUE_CARD_HEIGHT }}
>
<CardHeader className="px-5 pb-3 gap-1">
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueVisualizationContainer.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueVisualizationContainer.tsx
index a758cde8c5c..3600fa0f9d9 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueVisualizationContainer.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/components/QueueVisualizationContainer.tsx
@@ -31,6 +31,7 @@ import {
import '@xyflow/react/dist/style.css';
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
import { AlertCircle, Tag, Search, X, Info } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import {
useQueueTreeData,
@@ -61,15 +62,26 @@ const edgeTypes = {
};
const FlowInner: React.FC = () => {
+ // State values (trigger re-renders only when these specific values change)
const {
- selectQueue,
stagedChanges,
searchQuery,
selectedNodeLabelFilter,
- getSearchResults,
configData,
isComparisonModeActive,
- } = useSchedulerStore();
+ } = useSchedulerStore(
+ useShallow((s) => ({
+ stagedChanges: s.stagedChanges,
+ searchQuery: s.searchQuery,
+ selectedNodeLabelFilter: s.selectedNodeLabelFilter,
+ configData: s.configData,
+ isComparisonModeActive: s.isComparisonModeActive,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const selectQueue = useSchedulerStore((s) => s.selectQueue);
+ const getSearchResults = useSchedulerStore((s) => s.getSearchResults);
const { theme } = useTheme();
// Get legacy mode status considering staged changes
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/hooks/useQueueCardHandlers.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/hooks/useQueueCardHandlers.ts
new file mode 100644
index 00000000000..465cb1664fa
--- /dev/null
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/hooks/useQueueCardHandlers.ts
@@ -0,0 +1,151 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import React from 'react';
+import { useSchedulerStore } from '~/stores/schedulerStore';
+import { useQueueActions } from
'~/features/queue-management/hooks/useQueueActions';
+import { useCapacityEditor } from
'~/features/queue-management/hooks/useCapacityEditor';
+import { QUEUE_STATES, SPECIAL_VALUES } from '~/types';
+
+interface UseQueueCardHandlersParams {
+ queuePath: string;
+ queueName: string;
+ state: string;
+ capacityConfig: string;
+ maxCapacityConfig: string;
+ stagedStatus: string | undefined;
+ isAutoCreatedQueue: boolean;
+ isComparisonModeActive: boolean;
+ isSelectedQueue: boolean;
+ isPropertyPanelOpen: boolean;
+}
+
+export function useQueueCardHandlers(params: UseQueueCardHandlersParams) {
+ const {
+ queuePath,
+ queueName,
+ state,
+ capacityConfig,
+ maxCapacityConfig,
+ stagedStatus,
+ isAutoCreatedQueue,
+ isComparisonModeActive,
+ isSelectedQueue,
+ isPropertyPanelOpen,
+ } = params;
+
+ const selectQueue = useSchedulerStore((s) => s.selectQueue);
+ const setPropertyPanelOpen = useSchedulerStore((s) =>
s.setPropertyPanelOpen);
+ const setPropertyPanelInitialTab = useSchedulerStore((s) =>
s.setPropertyPanelInitialTab);
+ const requestTemplateConfigOpen = useSchedulerStore((s) =>
s.requestTemplateConfigOpen);
+ const toggleComparisonQueue = useSchedulerStore((s) =>
s.toggleComparisonQueue);
+ const clearQueueChanges = useSchedulerStore((s) => s.clearQueueChanges);
+
+ const { updateQueueProperty } = useQueueActions();
+ const { openCapacityEditor } = useCapacityEditor();
+
+ const openPropertyPanel = (
+ event: React.MouseEvent,
+ initialTab: 'overview' | 'info' | 'settings' = 'overview',
+ ) => {
+ event.stopPropagation();
+
+ // Don't allow clicking on newly added queues that haven't been applied yet
+ if (stagedStatus === 'new') {
+ return;
+ }
+
+ const tabToOpen = isAutoCreatedQueue && initialTab === 'settings' ?
'overview' : initialTab;
+ setPropertyPanelInitialTab(tabToOpen);
+ selectQueue(queuePath);
+ setPropertyPanelOpen(true);
+ };
+
+ const handleOpenCapacityEditor = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (!queuePath || queuePath === SPECIAL_VALUES.ROOT_QUEUE_NAME) {
+ return;
+ }
+
+ const parentPath = queuePath.split('.').slice(0, -1).join('.');
+ if (!parentPath) {
+ return;
+ }
+
+ openCapacityEditor({
+ origin: 'context-menu',
+ parentQueuePath: parentPath,
+ originQueuePath: queuePath,
+ originQueueName: queueName,
+ capacityValue: capacityConfig,
+ maxCapacityValue: maxCapacityConfig,
+ queueState: state,
+ markOriginAsNew: stagedStatus === 'new',
+ });
+ };
+
+ const handleRemoveStagedQueue = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ if (queuePath) {
+ clearQueueChanges(queuePath);
+ }
+ };
+
+ const handleComparisonToggle = () => {
+ toggleComparisonQueue(queuePath);
+ };
+
+ const handleToggleState = () => {
+ const newState = state === QUEUE_STATES.RUNNING ? QUEUE_STATES.STOPPED :
QUEUE_STATES.RUNNING;
+ updateQueueProperty(queuePath, 'state', newState);
+ };
+
+ const handleManageTemplate = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ setPropertyPanelInitialTab('settings');
+ selectQueue(queuePath);
+ requestTemplateConfigOpen();
+ };
+
+ const handleContextMenuOpenChange = (open: boolean) => {
+ if (!open && isSelectedQueue && !isPropertyPanelOpen) {
+ selectQueue(null);
+ }
+ };
+
+ const handleCardClick = (event: React.MouseEvent) => {
+ if (isComparisonModeActive) {
+ handleComparisonToggle();
+ } else {
+ openPropertyPanel(event, 'overview');
+ }
+ };
+
+ return {
+ openPropertyPanel,
+ handleOpenCapacityEditor,
+ handleRemoveStagedQueue,
+ handleComparisonToggle,
+ handleToggleState,
+ handleManageTemplate,
+ handleContextMenuOpenChange,
+ handleCardClick,
+ };
+}
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/utils/queueCardStyles.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/utils/queueCardStyles.ts
new file mode 100644
index 00000000000..8cfd1f1639d
--- /dev/null
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/queue-management/utils/queueCardStyles.ts
@@ -0,0 +1,88 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import { cn } from '~/utils/cn';
+
+interface QueueCardStyleParams {
+ isAutoCreatedQueue: boolean;
+ stagedStatus: string | undefined;
+ isSelectedQueue: boolean;
+ isSelectedForComparison: boolean;
+ validationErrors: Array<{ severity: string }> | undefined;
+ isAffectedByErrors: boolean | undefined;
+ shouldGrayOut: boolean;
+}
+
+export function getQueueCardClassName(params: QueueCardStyleParams): string {
+ const {
+ isAutoCreatedQueue,
+ stagedStatus,
+ isSelectedQueue,
+ isSelectedForComparison,
+ validationErrors,
+ isAffectedByErrors,
+ shouldGrayOut,
+ } = params;
+
+ const hasErrors = validationErrors && validationErrors.some((e) =>
e.severity === 'error');
+
+ return cn(
+ 'relative flex flex-col',
+ // Smooth transitions with spring-like feel
+ 'transition-all duration-200 ease-out',
+ // Enhanced background with subtle gradient
+ 'bg-gradient-to-br from-gray-50 to-white dark:from-gray-900
dark:to-gray-950',
+ 'border-gray-200 dark:border-gray-700/80',
+ // Auto-created queue styling
+ isAutoCreatedQueue &&
+ 'border-amber-400 dark:border-amber-500 border-2 border-dashed
from-amber-50/70 to-amber-50/50 dark:from-amber-900/30 dark:to-amber-950/20',
+ // Shadow for depth with hover enhancement
+ 'shadow-lg hover:shadow-xl hover:-translate-y-0.5',
+ 'dark:shadow-md dark:shadow-black/20 dark:hover:shadow-lg
dark:hover:shadow-black/30',
+ // Cursor styling - not clickable for new queues
+ stagedStatus === 'new' ? 'opacity-75 cursor-default' : 'cursor-pointer',
+ // Left border for staged status (always visible regardless of errors)
+ stagedStatus === 'new' && 'border-l-4 border-l-queue-new',
+ stagedStatus === 'deleted' && 'border-l-4 border-l-queue-deleted',
+ stagedStatus === 'modified' && 'border-l-4 border-l-queue-modified',
+ // Ring for staged status (only if no validation errors)
+ stagedStatus === 'new' && !hasErrors && 'ring-2 ring-queue-new',
+ stagedStatus === 'deleted' && !hasErrors && 'ring-2 ring-queue-deleted',
+ stagedStatus === 'modified' && !hasErrors && 'ring-2 ring-queue-modified',
+ !stagedStatus && isSelectedQueue && 'ring-2 ring-primary
shadow-primary/10',
+ // Ring for validation errors (can coexist with staged status border)
+ hasErrors && 'ring-2 ring-destructive',
+ // Left border for affected queues only if no staged status
+ !stagedStatus && hasErrors && 'border-l-4 border-l-destructive',
+ isAffectedByErrors &&
+ !validationErrors &&
+ !stagedStatus &&
+ 'ring-2 ring-amber-500 border-l-4 border-l-amber-500',
+ isAffectedByErrors && !validationErrors && stagedStatus && 'ring-2
ring-amber-500',
+ // Background styling for states
+ isSelectedQueue &&
+ 'from-primary/10 to-primary/5 dark:from-primary/15 dark:to-primary/5
scale-[1.01]',
+ isSelectedForComparison &&
+ !isSelectedQueue &&
+ 'from-gray-100 to-gray-50 dark:from-gray-800 dark:to-gray-900',
+ // Gray out inaccessible queues when filtered by label
+ shouldGrayOut && 'opacity-50 grayscale',
+ 'gap-4 py-5',
+ );
+}
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/staged-changes/components/StagedChangesPanel.tsx
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/staged-changes/components/StagedChangesPanel.tsx
index 7da2968777e..e7c8fd3cab7 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/staged-changes/components/StagedChangesPanel.tsx
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/features/staged-changes/components/StagedChangesPanel.tsx
@@ -29,6 +29,7 @@ import {
import { Button } from '~/components/ui/button';
import { Badge } from '~/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
+import { useShallow } from 'zustand/react/shallow';
import { useSchedulerStore } from '~/stores/schedulerStore';
import type { StagedChange } from '~/types';
import { QueueChangeGroup } from './QueueChangeGroup';
@@ -46,8 +47,19 @@ interface StagedChangesPanelProps {
export function StagedChangesPanel({ open, onClose, onOpen }:
StagedChangesPanelProps) {
const [isApplying, setIsApplying] = useState(false);
- const { stagedChanges, revertChange, clearAllChanges, applyChanges,
applyError, isReadOnly } =
- useSchedulerStore();
+ // State values (trigger re-renders only when these specific values change)
+ const { stagedChanges, applyError, isReadOnly } = useSchedulerStore(
+ useShallow((s) => ({
+ stagedChanges: s.stagedChanges,
+ applyError: s.applyError,
+ isReadOnly: s.isReadOnly,
+ })),
+ );
+
+ // Actions (stable references, never trigger re-renders)
+ const revertChange = useSchedulerStore((s) => s.revertChange);
+ const clearAllChanges = useSchedulerStore((s) => s.clearAllChanges);
+ const applyChanges = useSchedulerStore((s) => s.applyChanges);
// Group changes by queue path for organized display
const changesByQueue = stagedChanges.reduce(
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts
index 816ec66ef55..ebd21700c04 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/handlers.ts
@@ -91,12 +91,6 @@ const staticHandlers: HttpHandler[] = [
return HttpResponse.json(data);
}),
- http.get(`${baseUrl}/get-labels-to-nodes`, async () => {
- const response = await
fetch(`${MOCK_ASSET_BASE}/get-labels-to-nodes.json`);
- const data = await response.json();
- return HttpResponse.json(data);
- }),
-
http.post(`${baseUrl}/add-node-labels`, async ({ request }) => {
const body = await request.json();
console.log('Mock: Adding node labels:', body);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/server-handlers.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/server-handlers.ts
index 1af19973cfa..c5c0542cf71 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/server-handlers.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/lib/api/mocks/server-handlers.ts
@@ -91,11 +91,6 @@ export const serverHandlers = [
return HttpResponse.json(data);
}),
- http.get('/ws/v1/cluster/get-labels-to-nodes', () => {
- const data = loadMockData('get-labels-to-nodes.json');
- return HttpResponse.json(data);
- }),
-
http.post('/ws/v1/cluster/add-node-labels', async ({ request }) => {
const body = await request.json();
console.log('Mock: Adding node labels:', body);
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.test.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.test.ts
index 3d170cc2c84..dac74653707 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.test.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.test.ts
@@ -18,7 +18,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import { createSchedulerStore, traverseQueueTree } from
'~/stores/schedulerStore';
+import { createSchedulerStore } from '~/stores/schedulerStore';
import { buildMutationRequest } from
'~/features/staged-changes/utils/mutationBuilder';
import type { YarnApiClient } from '~/lib/api/YarnApiClient';
import type {
@@ -31,9 +31,46 @@ import type {
VersionResponse,
SchedulerResponse,
} from '~/types';
-import { QUEUE_TYPES, SPECIAL_VALUES } from '~/types/constants';
+import { CONFIG_PREFIXES, QUEUE_TYPES, SPECIAL_VALUES } from
'~/types/constants';
import { AUTO_CREATION_PROPS } from '~/types/constants/auto-creation';
+/**
+ * Local helper: traverse queue tree and apply a visitor function.
+ * Combines queue info with configured properties from configData.
+ */
+function traverseQueueTree(
+ queueInfo: QueueInfo,
+ configData: Map<string, string>,
+ visitor: (queue: QueueInfo & { configured: Record<string, string> }) => void,
+): void {
+ const configured: Record<string, string> = {};
+
+ const prefix = `${CONFIG_PREFIXES.BASE}.${queueInfo.queuePath}.`;
+ for (const [key, value] of configData.entries()) {
+ if (key.startsWith(prefix)) {
+ const property = key.substring(prefix.length);
+ configured[property] = value;
+ }
+ }
+
+ const combinedQueue = {
+ ...queueInfo,
+ configured,
+ };
+
+ visitor(combinedQueue);
+
+ if (queueInfo.queues?.queue) {
+ const children = Array.isArray(queueInfo.queues.queue)
+ ? queueInfo.queues.queue
+ : [queueInfo.queues.queue];
+
+ for (const child of children) {
+ traverseQueueTree(child, configData, visitor);
+ }
+ }
+}
+
const toEntryRecord = (entries?: Array<{ key: string; value: string }>) =>
Object.fromEntries((entries ?? []).map(({ key, value }) => [key, value]));
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.ts
index eaf014d7fb0..94f483f332c 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/schedulerStore.ts
@@ -74,6 +74,5 @@ export const createSchedulerStore = (apiClient:
YarnApiClient) => {
return create(createStoreImplementation(apiClient));
};
-// Re-export types and utilities
+// Re-export types
export type { SchedulerStore } from './slices';
-export { traverseQueueTree } from './slices/queueDataSlice';
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/__tests__/queueDataSlice.test.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/__tests__/queueDataSlice.test.ts
index f2f2f568565..9822355d3ad 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/__tests__/queueDataSlice.test.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/__tests__/queueDataSlice.test.ts
@@ -20,9 +20,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createSchedulerStore } from '~/stores/schedulerStore';
import { YarnApiClient } from '~/lib/api/YarnApiClient';
-import { traverseQueueTree } from '~/stores/slices/queueDataSlice';
import type { QueueInfo, SchedulerInfo } from '~/types';
-import { SPECIAL_VALUES } from '~/types';
+import { SPECIAL_VALUES, CONFIG_PREFIXES } from '~/types';
+
+/**
+ * Local helper: traverse queue tree and apply a visitor function.
+ * Combines queue info with configured properties from configData.
+ */
+function traverseQueueTree(
+ queueInfo: QueueInfo,
+ configData: Map<string, string>,
+ visitor: (queue: QueueInfo & { configured: Record<string, string> }) => void,
+): void {
+ const configured: Record<string, string> = {};
+
+ const prefix = `${CONFIG_PREFIXES.BASE}.${queueInfo.queuePath}.`;
+ for (const [key, value] of configData.entries()) {
+ if (key.startsWith(prefix)) {
+ const property = key.substring(prefix.length);
+ configured[property] = value;
+ }
+ }
+
+ const combinedQueue = {
+ ...queueInfo,
+ configured,
+ };
+
+ visitor(combinedQueue);
+
+ if (queueInfo.queues?.queue) {
+ const children = Array.isArray(queueInfo.queues.queue)
+ ? queueInfo.queues.queue
+ : [queueInfo.queues.queue];
+
+ for (const child of children) {
+ traverseQueueTree(child, configData, visitor);
+ }
+ }
+}
describe('queueDataSlice', () => {
const createTestStore = () => {
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/queueDataSlice.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/queueDataSlice.ts
index 4ea7d3b5200..827d6a58b9b 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/queueDataSlice.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/queueDataSlice.ts
@@ -22,7 +22,7 @@
*/
import type { StateCreator } from 'zustand';
-import { SPECIAL_VALUES, CONFIG_PREFIXES } from '~/types';
+import { SPECIAL_VALUES } from '~/types';
import type { QueueInfo, CapacitySchedulerInfo, QueueCapacitiesByPartition }
from '~/types';
import { buildGlobalPropertyKey, buildPropertyKey } from
'~/utils/propertyUtils';
import { globalPropertyDefinitions } from
'~/config/properties/global-properties';
@@ -218,39 +218,3 @@ export const createQueueDataSlice: StateCreator<
return partition || null;
},
});
-
-/**
- * Helper function to traverse queue tree and apply a visitor function
- */
-export function traverseQueueTree(
- queueInfo: QueueInfo,
- configData: Map<string, string>,
- visitor: (queue: QueueInfo & { configured: Record<string, string> }) => void,
-): void {
- const configured: Record<string, string> = {};
-
- const prefix = `${CONFIG_PREFIXES.BASE}.${queueInfo.queuePath}.`;
- for (const [key, value] of configData.entries()) {
- if (key.startsWith(prefix)) {
- const property = key.substring(prefix.length);
- configured[property] = value;
- }
- }
-
- const combinedQueue = {
- ...queueInfo,
- configured,
- };
-
- visitor(combinedQueue);
-
- if (queueInfo.queues?.queue) {
- const children = Array.isArray(queueInfo.queues.queue)
- ? queueInfo.queues.queue
- : [queueInfo.queues.queue];
-
- for (const child of children) {
- traverseQueueTree(child, configData, visitor);
- }
- }
-}
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/stagedChangesSlice.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/stagedChangesSlice.ts
index 9ab87c5469b..d4b8fe02912 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/stagedChangesSlice.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/stagedChangesSlice.ts
@@ -67,7 +67,6 @@ export const createStagedChangesSlice: StateCreator<
> = (set, get) => ({
stagedChanges: [],
applyError: null,
- orphanedValidationErrors: [],
stageQueueChange: (queuePath, property, value, validationErrors) => {
if (!queuePath || !queuePath.startsWith(SPECIAL_VALUES.ROOT_QUEUE_NAME)) {
@@ -513,7 +512,6 @@ export const createStagedChangesSlice: StateCreator<
set((state) => {
if (state.stagedChanges.length > 0) {
state.stagedChanges = [];
- state.orphanedValidationErrors = [];
clearMutationError(state);
}
});
diff --git
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/types.ts
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/types.ts
index 53183007a70..e9365641517 100644
---
a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/types.ts
+++
b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-capacity-scheduler-ui/src/main/webapp/src/stores/slices/types.ts
@@ -67,8 +67,6 @@ export interface NodeLabelsSlice {
export interface StagedChangesSlice {
stagedChanges: StagedChange[];
applyError: string | null;
- orphanedValidationErrors: ValidationIssue[];
-
stageQueueChange: (
queuePath: string,
property: string,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]