From 74970a5d99551a4d78c9f5e7ad176e8179a4a890 Mon Sep 17 00:00:00 2001
From: Lucas Capistrant
Date: Wed, 1 Apr 2026 18:26:39 -0500
Subject: [PATCH 1/5] Improve host selectors in coordinator dynamic config form
for decom and turbo load
---
.../src/components/header-bar/header-bar.tsx | 1 +
...coordinator-dynamic-config-dialog.spec.tsx | 5 +-
.../coordinator-dynamic-config-dialog.tsx | 90 ++++++++-
.../server-multi-select-dialog.spec.tsx | 77 ++++++++
.../server-multi-select-dialog.tsx | 186 ++++++++++++++++++
.../tiered-servers.spec.ts | 69 +++++++
.../tiered-servers.ts | 42 ++++
.../coordinator-dynamic-config.tsx | 12 +-
8 files changed, 470 insertions(+), 12 deletions(-)
create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.spec.tsx
create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.tsx
create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.spec.ts
create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts
diff --git a/web-console/src/components/header-bar/header-bar.tsx b/web-console/src/components/header-bar/header-bar.tsx
index 63bc0626f7c5..9232dd3e0b70 100644
--- a/web-console/src/components/header-bar/header-bar.tsx
+++ b/web-console/src/components/header-bar/header-bar.tsx
@@ -405,6 +405,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
{doctorDialogOpen && setDoctorDialogOpen(false)} />}
{coordinatorDynamicConfigDialogOpen && (
setCoordinatorDynamicConfigDialogOpen(false)}
/>
)}
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx
index cc386ea5aefa..53bb62ab6f09 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.spec.tsx
@@ -16,13 +16,16 @@
* limitations under the License.
*/
+import { Capabilities } from '../../helpers';
import { shallow } from '../../utils/shallow-renderer';
import { CoordinatorDynamicConfigDialog } from './coordinator-dynamic-config-dialog';
describe('CoordinatorDynamicConfigDialog', () => {
it('matches snapshot', () => {
- const coordinatorDynamicConfig = shallow( {}} />);
+ const coordinatorDynamicConfig = shallow(
+ {}} />,
+ );
expect(coordinatorDynamicConfig).toMatchSnapshot();
});
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
index d8cffc5abf7c..56b4da91e9e0 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
@@ -18,30 +18,73 @@
import { Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
-import type { FormJsonTabs } from '../../components';
+import type { field, FormJsonTabs } from '../../components';
import { AutoForm, ExternalLink, FormJsonSelector, JsonInput, Loader } from '../../components';
import type { CoordinatorDynamicConfig } from '../../druid-models';
import { COORDINATOR_DYNAMIC_CONFIG_FIELDS } from '../../druid-models';
+import type { Capabilities } from '../../helpers';
import { useQueryManager } from '../../hooks';
import { getLink } from '../../links';
import { Api, AppToaster } from '../../singletons';
-import { getApiArray, getDruidErrorMessage } from '../../utils';
+import { filterMap, getApiArray, getDruidErrorMessage, queryDruidSql } from '../../utils';
import { SnitchDialog } from '..';
import { COORDINATOR_DYNAMIC_CONFIG_COMPLETIONS } from './coordinator-dynamic-config-completions';
+import { ServerMultiSelectDialog } from './server-multi-select-dialog';
+import type { TieredServers } from './tiered-servers';
+import { buildTieredServers } from './tiered-servers';
import './coordinator-dynamic-config-dialog.scss';
export interface CoordinatorDynamicConfigDialogProps {
+ capabilities: Capabilities;
onClose(): void;
}
+function attachServerPickerDialogs(
+ fields: Field[],
+ servers: TieredServers | undefined,
+): Field[] {
+ return fields.map(field => {
+ switch (field.name) {
+ case 'decommissioningNodes':
+ return {
+ ...field,
+ customDialog: ({ value, onValueChange, onClose }) => (
+ onValueChange(v)}
+ onClose={onClose}
+ />
+ ),
+ };
+ case 'turboLoadingNodes':
+ return {
+ ...field,
+ customDialog: ({ value, onValueChange, onClose }) => (
+ onValueChange(v)}
+ onClose={onClose}
+ />
+ ),
+ };
+ default:
+ return field;
+ }
+ });
+}
+
export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDynamicConfigDialog(
props: CoordinatorDynamicConfigDialogProps,
) {
- const { onClose } = props;
+ const { capabilities, onClose } = props;
const [currentTab, setCurrentTab] = useState('form');
const [dynamicConfig, setDynamicConfig] = useState();
const [jsonError, setJsonError] = useState();
@@ -71,6 +114,38 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn
},
});
+const [serversState] = useQueryManager({
+ initQuery: capabilities,
+ processQuery: async (capabilities, signal) => {
+ if (capabilities.hasSql()) {
+ const sqlResp = await queryDruidSql<{ server: string; tier: string }>(
+ {
+ query: `SELECT "server", "tier"
+FROM "sys"."servers"
+WHERE "server_type" = 'historical'
+ORDER BY "tier", "server"`,
+ context: { engine: 'native' },
+ },
+ signal,
+ );
+ return buildTieredServers(sqlResp);
+ } else if (capabilities.hasCoordinatorAccess()) {
+ const servers = await getApiArray('/druid/coordinator/v1/servers?simple', signal);
+ const rows = filterMap(servers, (s: any) =>
+ s.type === 'historical' ? { server: s.host, tier: s.tier } : undefined,
+ );
+ return buildTieredServers(rows);
+ } else {
+ throw new Error('Must have SQL or coordinator access');
+ }
+ },
+ });
+
+ const fields = useMemo(
+ () => attachServerPickerDialogs(COORDINATOR_DYNAMIC_CONFIG_FIELDS, serversState.data),
+ [serversState.data],
+ );
+
async function saveConfig(comment: string) {
try {
await Api.instance.post('/druid/coordinator/v1/config', dynamicConfig, {
@@ -85,6 +160,7 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn
intent: Intent.DANGER,
message: `Could not save coordinator dynamic config: ${getDruidErrorMessage(e)}`,
});
+ return;
}
AppToaster.show({
@@ -121,11 +197,7 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn
}}
/>
{currentTab === 'form' ? (
-
+
) : (
{
+ it('matches snapshot with no selection', () => {
+ const dialog = shallow(
+ {}}
+ onClose={() => {}}
+ />,
+ );
+ expect(dialog).toMatchSnapshot();
+ });
+
+ it('matches snapshot with existing selection', () => {
+ const dialog = shallow(
+ {}}
+ onClose={() => {}}
+ />,
+ );
+ expect(dialog).toMatchSnapshot();
+ });
+
+ it('matches snapshot when servers are loading', () => {
+ const dialog = shallow(
+ {}}
+ onClose={() => {}}
+ />,
+ );
+ expect(dialog).toMatchSnapshot();
+ });
+});
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.tsx
new file mode 100644
index 000000000000..72a3de60664c
--- /dev/null
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.tsx
@@ -0,0 +1,186 @@
+/*
+ * 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 { Button, Callout, Checkbox, Classes, Dialog, InputGroup, Intent } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import React, { useMemo, useState } from 'react';
+
+import { Loader } from '../../components';
+
+import type { TieredServers } from './tiered-servers';
+
+export interface ServerMultiSelectDialogProps {
+ title: string;
+ servers: TieredServers | undefined;
+ selectedServers: string[];
+ onSave(servers: string[]): void;
+ onClose(): void;
+}
+
+export const ServerMultiSelectDialog = React.memo(function ServerMultiSelectDialog(
+ props: ServerMultiSelectDialogProps,
+) {
+ const { title, servers, selectedServers: initialSelection, onSave, onClose } = props;
+ const [selected, setSelected] = useState>(() => new Set(initialSelection));
+ const [searchText, setSearchText] = useState('');
+
+ const staleServers = useMemo(() => {
+ if (!servers) return [];
+ const allSet = new Set(servers.allServers);
+ return initialSelection.filter(s => !allSet.has(s));
+ }, [servers, initialSelection]);
+
+ function toggleServer(server: string) {
+ setSelected(prev => {
+ const next = new Set(prev);
+ if (next.has(server)) {
+ next.delete(server);
+ } else {
+ next.add(server);
+ }
+ return next;
+ });
+ }
+
+ function toggleTier(_tier: string, tierServers: string[]) {
+ setSelected(prev => {
+ const next = new Set(prev);
+ const allSelected = tierServers.every(s => next.has(s));
+ for (const s of tierServers) {
+ if (allSelected) {
+ next.delete(s);
+ } else {
+ next.add(s);
+ }
+ }
+ return next;
+ });
+ }
+
+ function removeStaleServers() {
+ setSelected(prev => {
+ const next = new Set(prev);
+ for (const s of staleServers) {
+ next.delete(s);
+ }
+ return next;
+ });
+ }
+
+ const filteredTiers = useMemo(() => {
+ const lowerSearch = searchText.toLowerCase();
+ return servers
+ ? servers.tiers.map(tier => {
+ const tierServers = servers.serversByTier[tier] || [];
+ const filtered = lowerSearch
+ ? tierServers.filter(s => s.toLowerCase().includes(lowerSearch))
+ : tierServers;
+ return { tier, tierServers, filtered };
+ })
+ : [];
+ }, [servers, searchText]);
+
+ return (
+
+ );
+});
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.spec.ts b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.spec.ts
new file mode 100644
index 000000000000..2735dcc66d54
--- /dev/null
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.spec.ts
@@ -0,0 +1,69 @@
+/*
+ * 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 { buildTieredServers } from './tiered-servers';
+
+describe('buildTieredServers', () => {
+ it('returns empty structure for empty input', () => {
+ const result = buildTieredServers([]);
+ expect(result).toEqual({
+ tiers: [],
+ serversByTier: {},
+ serverToTier: {},
+ allServers: [],
+ });
+ });
+
+ it('sorts tiers alphabetically', () => {
+ const result = buildTieredServers([
+ { server: 'host1:8083', tier: '_default_tier' },
+ { server: 'host2:8083', tier: 'hot' },
+ { server: 'host3:8083', tier: 'cold' },
+ ]);
+ expect(result.tiers).toEqual(['_default_tier', 'cold', 'hot']);
+ });
+
+ it('sorts servers within each tier', () => {
+ const result = buildTieredServers([
+ { server: 'host-c:8083', tier: 'hot' },
+ { server: 'host-a:8083', tier: 'hot' },
+ { server: 'host-b:8083', tier: 'hot' },
+ ]);
+ expect(result.serversByTier['hot']).toEqual(['host-a:8083', 'host-b:8083', 'host-c:8083']);
+ });
+
+ it('builds serverToTier map correctly', () => {
+ const result = buildTieredServers([
+ { server: 'host1:8083', tier: 'hot' },
+ { server: 'host2:8083', tier: 'cold' },
+ ]);
+ expect(result.serverToTier).toEqual({
+ 'host1:8083': 'hot',
+ 'host2:8083': 'cold',
+ });
+ });
+
+ it('builds allServers in tier-sorted order', () => {
+ const result = buildTieredServers([
+ { server: 'cold-host:8083', tier: 'cold' },
+ { server: 'hot-host2:8083', tier: 'hot' },
+ { server: 'hot-host1:8083', tier: 'hot' },
+ ]);
+ expect(result.allServers).toEqual(['cold-host:8083', 'hot-host1:8083', 'hot-host2:8083']);
+ });
+});
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts
new file mode 100644
index 000000000000..b8a5cab9a4f6
--- /dev/null
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+export interface TieredServers {
+ tiers: string[];
+ serversByTier: Record;
+ serverToTier: Record;
+ allServers: string[];
+}
+
+export function buildTieredServers(rows: { server: string; tier: string }[]): TieredServers {
+ const serversByTier: Record = {};
+ const serverToTier: Record = {};
+ for (const row of rows) {
+ if (!serversByTier[row.tier]) {
+ serversByTier[row.tier] = [];
+ }
+ serversByTier[row.tier].push(row.server);
+ serverToTier[row.server] = row.tier;
+ }
+ const tiers = Object.keys(serversByTier).sort();
+ for (const tier of tiers) {
+ serversByTier[tier].sort();
+ }
+ const allServers = tiers.flatMap(t => serversByTier[t]);
+ return { tiers, serversByTier, serverToTier, allServers };
+}
diff --git a/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
index 3794ecb60376..94714aba1c3f 100644
--- a/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
+++ b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
@@ -44,6 +44,11 @@ export interface CoordinatorDynamicConfig {
debugDimensions?: any;
}
+export function serverCountSummary(v: any): string {
+ if (!v || !Array.isArray(v) || v.length === 0) return 'None';
+ return `${v.length} server${v.length !== 1 ? 's' : ''}`;
+}
+
export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[] = [
{
name: 'pauseCoordination',
@@ -146,7 +151,7 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[
{
name: 'decommissioningNodes',
- type: 'string-array',
+ type: 'custom',
emptyValue: [],
info: (
<>
@@ -156,6 +161,7 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[
maxSegmentsToMove.
>
),
+ customSummary: serverCountSummary,
},
{
name: 'killDataSourceWhitelist',
@@ -247,7 +253,8 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[
},
{
name: 'turboLoadingNodes',
- type: 'string-array',
+ type: 'custom',
+ emptyValue: [],
experimental: true,
info: (
<>
@@ -264,5 +271,6 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[
>
),
+ customSummary: serverCountSummary,
},
];
From a0762afd9156d479ed57cc7740fa48fd4514bcb6 Mon Sep 17 00:00:00 2001
From: Lucas Capistrant
Date: Wed, 1 Apr 2026 18:33:41 -0500
Subject: [PATCH 2/5] fix formatting issues and import issues
---
.../coordinator-dynamic-config-dialog.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
index 56b4da91e9e0..5c995edb06e2 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog.tsx
@@ -20,7 +20,7 @@ import { Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import React, { useMemo, useState } from 'react';
-import type { field, FormJsonTabs } from '../../components';
+import type { Field, FormJsonTabs } from '../../components';
import { AutoForm, ExternalLink, FormJsonSelector, JsonInput, Loader } from '../../components';
import type { CoordinatorDynamicConfig } from '../../druid-models';
import { COORDINATOR_DYNAMIC_CONFIG_FIELDS } from '../../druid-models';
@@ -114,7 +114,7 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn
},
});
-const [serversState] = useQueryManager({
+ const [serversState] = useQueryManager({
initQuery: capabilities,
processQuery: async (capabilities, signal) => {
if (capabilities.hasSql()) {
From 6149005a4bd0801968a3121b71be3ec73b35060b Mon Sep 17 00:00:00 2001
From: Lucas Capistrant
Date: Wed, 1 Apr 2026 18:33:48 -0500
Subject: [PATCH 3/5] add missing tests
---
.../coordinator-dynamic-config.spec.ts | 63 +++++++++++++++++++
1 file changed, 63 insertions(+)
create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config.spec.ts
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config.spec.ts b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config.spec.ts
new file mode 100644
index 000000000000..fa4f5ac238e3
--- /dev/null
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config.spec.ts
@@ -0,0 +1,63 @@
+/*
+ * 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 { cloneCountSummary, serverCountSummary } from './coordinator-dynamic-config';
+
+describe('serverCountSummary', () => {
+ it('returns None for undefined', () => {
+ expect(serverCountSummary(undefined)).toBe('None');
+ });
+
+ it('returns None for empty array', () => {
+ expect(serverCountSummary([])).toBe('None');
+ });
+
+ it('returns None for non-array', () => {
+ expect(serverCountSummary('not an array')).toBe('None');
+ });
+
+ it('returns singular for one server', () => {
+ expect(serverCountSummary(['server1'])).toBe('1 server');
+ });
+
+ it('returns plural for multiple servers', () => {
+ expect(serverCountSummary(['server1', 'server2', 'server3'])).toBe('3 servers');
+ });
+});
+
+describe('cloneCountSummary', () => {
+ it('returns None for undefined', () => {
+ expect(cloneCountSummary(undefined)).toBe('None');
+ });
+
+ it('returns None for non-object', () => {
+ expect(cloneCountSummary('not an object')).toBe('None');
+ });
+
+ it('returns None for empty object', () => {
+ expect(cloneCountSummary({})).toBe('None');
+ });
+
+ it('returns singular for one mapping', () => {
+ expect(cloneCountSummary({ target1: 'source1' })).toBe('1 mapping');
+ });
+
+ it('returns plural for multiple mappings', () => {
+ expect(cloneCountSummary({ target1: 'source1', target2: 'source2' })).toBe('2 mappings');
+ });
+});
From 9cb6c625c55960ede691025d0fef3f5a9bd716f2 Mon Sep 17 00:00:00 2001
From: Lucas Capistrant
Date: Wed, 1 Apr 2026 18:45:42 -0500
Subject: [PATCH 4/5] fixup file location and snapshot
---
.../server-multi-select-dialog.spec.tsx.snap | 289 ++++++++++++++++++
.../coordinator-dynamic-config.spec.ts | 24 +-
2 files changed, 290 insertions(+), 23 deletions(-)
create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/server-multi-select-dialog.spec.tsx.snap
rename web-console/src/{dialogs/coordinator-dynamic-config-dialog => druid-models/coordinator-dynamic-config}/coordinator-dynamic-config.spec.ts (66%)
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/server-multi-select-dialog.spec.tsx.snap b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/server-multi-select-dialog.spec.tsx.snap
new file mode 100644
index 000000000000..c6c657d25a02
--- /dev/null
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/server-multi-select-dialog.spec.tsx.snap
@@ -0,0 +1,289 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ServerMultiSelectDialog matches snapshot when servers are loading 1`] = `
+
+
+
+`;
+
+exports[`ServerMultiSelectDialog matches snapshot with existing selection 1`] = `
+
+
+
+
+
+
+
+
+ hot
+
+
+ }
+ onChange={[Function]}
+ />
+
+
+
+
+
+
+
+
+ cold
+
+
+ }
+ onChange={[Function]}
+ />
+
+
+
+
+
+
+
+
+
+ 1
+ server
+
+ selected
+
+
+
+
+
+
+
+`;
+
+exports[`ServerMultiSelectDialog matches snapshot with no selection 1`] = `
+
+
+
+
+
+
+
+
+ hot
+
+
+ }
+ onChange={[Function]}
+ />
+
+
+
+
+
+
+
+
+ cold
+
+
+ }
+ onChange={[Function]}
+ />
+
+
+
+
+
+
+
+
+
+ 0
+ server
+ s
+ selected
+
+
+
+
+
+
+
+`;
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config.spec.ts b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.spec.ts
similarity index 66%
rename from web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config.spec.ts
rename to web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.spec.ts
index fa4f5ac238e3..027f38edb689 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config.spec.ts
+++ b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.spec.ts
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { cloneCountSummary, serverCountSummary } from './coordinator-dynamic-config';
+import { serverCountSummary } from './coordinator-dynamic-config';
describe('serverCountSummary', () => {
it('returns None for undefined', () => {
@@ -39,25 +39,3 @@ describe('serverCountSummary', () => {
expect(serverCountSummary(['server1', 'server2', 'server3'])).toBe('3 servers');
});
});
-
-describe('cloneCountSummary', () => {
- it('returns None for undefined', () => {
- expect(cloneCountSummary(undefined)).toBe('None');
- });
-
- it('returns None for non-object', () => {
- expect(cloneCountSummary('not an object')).toBe('None');
- });
-
- it('returns None for empty object', () => {
- expect(cloneCountSummary({})).toBe('None');
- });
-
- it('returns singular for one mapping', () => {
- expect(cloneCountSummary({ target1: 'source1' })).toBe('1 mapping');
- });
-
- it('returns plural for multiple mappings', () => {
- expect(cloneCountSummary({ target1: 'source1', target2: 'source2' })).toBe('2 mappings');
- });
-});
From c0897c920be907c5417b75958c8a535a826640b9 Mon Sep 17 00:00:00 2001
From: Lucas Capistrant
Date: Wed, 1 Apr 2026 19:10:47 -0500
Subject: [PATCH 5/5] add behavior tests
---
.../server-multi-select-dialog.spec.tsx | 200 ++++++++++++++++++
1 file changed, 200 insertions(+)
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.spec.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.spec.tsx
index 2060b35be845..57291f15cfc7 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.spec.tsx
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.spec.tsx
@@ -16,6 +16,8 @@
* limitations under the License.
*/
+import { fireEvent, render, screen } from '@testing-library/react';
+
import { shallow } from '../../utils/shallow-renderer';
import { ServerMultiSelectDialog } from './server-multi-select-dialog';
@@ -75,3 +77,201 @@ describe('ServerMultiSelectDialog', () => {
expect(dialog).toMatchSnapshot();
});
});
+
+const EMPTY_SERVERS: TieredServers = {
+ tiers: [],
+ serversByTier: {},
+ serverToTier: {},
+ allServers: [],
+};
+
+function clickCheckbox(label: string | RegExp) {
+ const checkbox = screen.getByLabelText(label);
+ fireEvent.click(checkbox);
+}
+
+function clickButton(name: string) {
+ fireEvent.click(screen.getByText(name));
+}
+
+describe('ServerMultiSelectDialog behavior', () => {
+ it('toggles individual server and saves', () => {
+ const onSave = jest.fn();
+ const onClose = jest.fn();
+ render(
+ ,
+ );
+
+ clickCheckbox('cold-host1:8083');
+ clickButton('Save');
+
+ expect(onSave).toHaveBeenCalledWith(['cold-host1:8083']);
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('toggles tier to select all servers in that tier', () => {
+ const onSave = jest.fn();
+ render(
+ {}}
+ />,
+ );
+
+ clickCheckbox('hot');
+ clickButton('Save');
+
+ expect(onSave).toHaveBeenCalledWith(['hot-host1:8083', 'hot-host2:8083']);
+ });
+
+ it('toggles tier to deselect all servers when all are selected', () => {
+ const onSave = jest.fn();
+ render(
+ {}}
+ />,
+ );
+
+ clickCheckbox('hot');
+ clickButton('Save');
+
+ // Only cold-host1 was never touched, hot tier was deselected
+ expect(onSave).toHaveBeenCalledWith([]);
+ });
+
+ it('filters servers by search text', () => {
+ render(
+ {}}
+ onClose={() => {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('Search servers...'), {
+ target: { value: 'hot-host1' },
+ });
+
+ expect(screen.getByLabelText('hot-host1:8083')).toBeTruthy();
+ expect(screen.queryByLabelText('hot-host2:8083')).toBeNull();
+ expect(screen.queryByLabelText('cold-host1:8083')).toBeNull();
+ });
+
+ it('tier toggle only affects filtered servers when search is active', () => {
+ const onSave = jest.fn();
+ render(
+ {}}
+ />,
+ );
+
+ // Filter to just hot-host1
+ fireEvent.change(screen.getByPlaceholderText('Search servers...'), {
+ target: { value: 'hot-host1' },
+ });
+
+ // Toggle the hot tier (only hot-host1 is visible, label includes "hidden by filter")
+ clickCheckbox(/^hot \(/);
+
+ // Clear search
+ fireEvent.change(screen.getByPlaceholderText('Search servers...'), {
+ target: { value: '' },
+ });
+
+ // Save — only hot-host1 should be selected, not hot-host2
+ clickButton('Save');
+ expect(onSave).toHaveBeenCalledWith(['hot-host1:8083']);
+ });
+
+ it('shows stale server warning and removes them', () => {
+ const onSave = jest.fn();
+ render(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText(/no longer in the cluster/)).toBeTruthy();
+ expect(screen.getByText(/gone-server:8083/)).toBeTruthy();
+
+ clickButton('Remove');
+ clickButton('Save');
+
+ // gone-server removed, hot-host1 retained
+ expect(onSave).toHaveBeenCalledWith(['hot-host1:8083']);
+ });
+
+ it('cancel calls onClose but not onSave', () => {
+ const onSave = jest.fn();
+ const onClose = jest.fn();
+ render(
+ ,
+ );
+
+ clickButton('Cancel');
+
+ expect(onClose).toHaveBeenCalled();
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('shows empty cluster message', () => {
+ render(
+ {}}
+ onClose={() => {}}
+ />,
+ );
+
+ expect(screen.getByText('No historical servers found in the cluster.')).toBeTruthy();
+ });
+
+ it('shows correct selected count with singular and plural', () => {
+ render(
+ {}}
+ onClose={() => {}}
+ />,
+ );
+
+ expect(screen.getByText('1 server selected')).toBeTruthy();
+
+ clickCheckbox('hot-host2:8083');
+
+ expect(screen.getByText('2 servers selected')).toBeTruthy();
+ });
+});