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/__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-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..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 @@ -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(); + }); +}); + +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(); + }); +}); 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 ( + + {servers ? ( + <> +
+ {staleServers.length > 0 && ( + + {staleServers.length} selected server{staleServers.length > 1 ? 's are' : ' is'} no + longer in the cluster + {staleServers.length <= 5 ? `: ${staleServers.join(', ')}` : ''}.{' '} + + + )} + setSearchText(e.target.value)} + style={{ marginBottom: 10 }} + /> +
+ {filteredTiers.map(({ tier, tierServers, filtered: filteredServers }) => { + if (filteredServers.length === 0) return null; + + const hiddenCount = tierServers.length - filteredServers.length; + const allSelected = filteredServers.every(s => selected.has(s)); + const someSelected = !allSelected && filteredServers.some(s => selected.has(s)); + + return ( +
+ + {tier} + {hiddenCount > 0 && ( + + {' '} + ({hiddenCount} hidden by filter) + + )} + + } + onChange={() => toggleTier(tier, filteredServers)} + /> +
+ {filteredServers.map(server => ( + toggleServer(server)} + /> + ))} +
+
+ ); + })} + {servers.allServers.length === 0 && ( + No historical servers found in the cluster. + )} +
+
+
+
+ + {selected.size} server{selected.size !== 1 ? 's' : ''} selected + +
+
+ + ) : ( + + )} +
+ ); +}); 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.spec.ts b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.spec.ts new file mode 100644 index 000000000000..027f38edb689 --- /dev/null +++ b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.spec.ts @@ -0,0 +1,41 @@ +/* + * 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 { 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'); + }); +}); 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, }, ];