From 138f7f23ccfde6bb400be5496ebee852010f627e Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Tue, 31 Mar 2026 16:06:18 -0500 Subject: [PATCH 01/10] checkpoint --- .../src/components/header-bar/header-bar.tsx | 1 + .../clone-server-mapping-dialog.tsx | 220 ++++++++++++++++++ .../coordinator-dynamic-config-completions.ts | 5 + ...coordinator-dynamic-config-dialog.spec.tsx | 5 +- .../coordinator-dynamic-config-dialog.tsx | 167 ++++++++++++- .../server-multi-select-dialog.tsx | 168 +++++++++++++ .../tiered-servers.ts | 23 ++ .../coordinator-dynamic-config.tsx | 51 ++-- 8 files changed, 594 insertions(+), 46 deletions(-) create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.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.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/clone-server-mapping-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx new file mode 100644 index 000000000000..4a14b15a9d77 --- /dev/null +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx @@ -0,0 +1,220 @@ +/* + * 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, Classes, Dialog, Intent, MenuDivider, MenuItem } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { Select } from '@blueprintjs/select'; +import React, { useState } from 'react'; + +import { Loader } from '../../components'; + +import type { TieredServers } from './tiered-servers'; + +interface CloneMapping { + target: string; + source: string; +} + +export interface CloneServerMappingDialogProps { + servers: TieredServers | undefined; + cloneServers: Record; + onSave(mapping: Record): void; + onClose(): void; +} + +export const CloneServerMappingDialog = React.memo(function CloneServerMappingDialog( + props: CloneServerMappingDialogProps, +) { + const { servers, cloneServers: initialMapping, onSave, onClose } = props; + const [mappings, setMappings] = useState(() => + Object.entries(initialMapping || {}).map(([target, source]) => ({ target, source })), + ); + + function updateMapping(index: number, field: 'target' | 'source', value: string) { + setMappings(prev => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m))); + } + + function removeMapping(index: number) { + setMappings(prev => prev.filter((_, i) => i !== index)); + } + + function addMapping() { + setMappings(prev => [...prev, { target: '', source: '' }]); + } + + function handleSave() { + const result: Record = {}; + for (const m of mappings) { + if (m.target && m.source) { + result[m.target] = m.source; + } + } + onSave(Object.keys(result).length > 0 ? result : (undefined as any)); + onClose(); + } + + const usedTargets = new Set(mappings.map(m => m.target).filter(Boolean)); + + return ( + + {servers ? ( + <> +
+ + Each target server clones all segments from its source server. The target does not + participate in regular segment assignment or balancing. + + {mappings.length > 0 && ( + + + + + + + + + {mappings.map((mapping, i) => ( + + + + + + + ))} + +
Target + Source +
+ updateMapping(i, 'target', v)} + /> + clones + updateMapping(i, 'source', v)} + /> + +
+ )} +
+
+
+
+
+ + ) : ( + + )} +
+ ); +}); + +// --- Internal server select component --- + +interface ServerSelectProps { + servers: TieredServers; + value: string; + disabledServers?: Set; + currentValue?: string; + onChange(value: string): void; +} + +function ServerSelect(props: ServerSelectProps) { + const { servers, value, disabledServers, currentValue, onChange } = props; + + // Build a flat list of items with tier headers handled in the renderer + const allItems = servers.allServers; + + return ( + + items={allItems} + itemPredicate={(query, item) => item.toLowerCase().includes(query.toLowerCase())} + itemListRenderer={({ filteredItems, renderItem }) => { + const elements: React.ReactNode[] = []; + let lastTier: string | undefined; + for (const item of filteredItems) { + // Find which tier this server belongs to + const tier = servers.tiers.find(t => (servers.serversByTier[t] || []).includes(item)); + if (tier && tier !== lastTier) { + elements.push(); + lastTier = tier; + } + elements.push(renderItem(item, filteredItems.indexOf(item))); + } + return <>{elements}; + }} + itemRenderer={(item, { handleClick, handleFocus, modifiers }) => { + if (!modifiers.matchesPredicate) return null; + const disabled = disabledServers + ? disabledServers.has(item) && item !== currentValue + : false; + return ( + + ); + }} + onItemSelect={onChange} + popoverProps={{ minimal: true }} + filterable + > + + + )} + setSearchText(e.target.value)} + style={{ marginBottom: 10 }} + /> +
+ {servers.tiers.map(tier => { + const tierServers = servers.serversByTier[tier] || []; + const filteredServers = lowerSearch + ? tierServers.filter(s => s.toLowerCase().includes(lowerSearch)) + : tierServers; + + if (filteredServers.length === 0) return null; + + const allSelected = filteredServers.every(s => selected.has(s)); + const someSelected = !allSelected && filteredServers.some(s => selected.has(s)); + + return ( +
+ {tier}} + 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.ts b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts new file mode 100644 index 000000000000..763f42b168ef --- /dev/null +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts @@ -0,0 +1,23 @@ +/* + * 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; + allServers: string[]; +} 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..757dc9dea0e7 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 @@ -39,11 +39,24 @@ export interface CoordinatorDynamicConfig { useRoundRobinSegmentAssignment?: boolean; smartSegmentLoading?: boolean; turboLoadingNodes?: string[]; + cloneServers?: Record; // Undocumented 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 function cloneCountSummary(v: any): string { + if (!v || typeof v !== 'object') return 'None'; + const count = Object.keys(v).length; + if (count === 0) return 'None'; + return `${count} mapping${count !== 1 ? 's' : ''}`; +} + export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[] = [ { name: 'pauseCoordination', @@ -144,19 +157,9 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[ // End "smart" segment loading section - { - name: 'decommissioningNodes', - type: 'string-array', - emptyValue: [], - info: ( - <> - List of historical services to 'decommission'. Coordinator will not assign new - segments to 'decommissioning' services, and segments will be moved away from them - to be placed on non-decommissioning services at the maximum rate specified by{' '} - maxSegmentsToMove. - - ), - }, + // decommissioningNodes, turboLoadingNodes, and cloneServers are added dynamically + // in the dialog component with server picker integration + { name: 'killDataSourceWhitelist', label: 'Kill datasource whitelist', @@ -190,7 +193,7 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[ <> Ratio of total available task slots, including autoscaling if applicable that will be allowed for kill tasks. This limit only applies for kill tasks that are spawned - automatically by the Coordinator's auto kill duty, which is enabled when + automatically by the Coordinator's auto kill duty, which is enabled when{' '} druid.coordinator.kill.on is true. ), @@ -245,24 +248,4 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[ ), }, - { - name: 'turboLoadingNodes', - type: 'string-array', - experimental: true, - info: ( - <> -

- List of Historical servers to place in turbo loading mode. These servers use a larger - thread-pool to load segments faster but at the cost of query performance. For servers - specified in turboLoadingNodes,{' '} - druid.coordinator.loadqueuepeon.http.batchSize is ignored and the coordinator - uses the value of the respective numLoadingThreads instead. -

-

- Please use this config with caution. All servers should eventually be removed from this - list once the segment loading on the respective historicals is finished. -

- - ), - }, ]; From 83d655c8c289c6e66e369a0f998b08ca634009e5 Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Tue, 31 Mar 2026 17:03:01 -0500 Subject: [PATCH 02/10] services-view --- .../__snapshots__/services-view.spec.tsx.snap | 654 +++++++++--------- .../src/views/services-view/services-view.tsx | 97 ++- 2 files changed, 404 insertions(+), 347 deletions(-) diff --git a/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap b/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap index afd053ddc75b..429af780a88b 100644 --- a/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap +++ b/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap @@ -74,340 +74,342 @@ exports[`ServicesView renders data 1`] = ` - + - Build -
- revision - , - "accessor": "build_revision", - "show": true, - "width": 200, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": - Available -
- processors -
, - "accessor": "available_processors", - "className": "padded", - "filterable": false, - "show": true, - "width": 100, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Total memory", - "accessor": "total_memory", - "className": "padded", - "filterable": false, - "show": true, - "width": 120, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Labels", - "accessor": "labels", - "className": "padded", - "filterable": false, - "show": true, - "width": 200, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Detail", - "accessor": "service", - "className": "padded", - "filterable": false, - "id": "queue", - "show": true, - "width": 400, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Actions", - "accessor": "service", - "filterable": false, - "id": "actions", - "show": true, - "sortable": false, - "width": 70, - }, - ] - } - data={ - [ + "sortMethod": undefined, + "sortable": undefined, + "style": {}, + } + } + columns={ [ { - "curr_size": 0, - "host": "localhost", - "is_leader": 0, - "max_size": 0, - "plaintext_port": 8082, - "service": "localhost:8082", - "service_type": "broker", - "start_time": 0, - "tier": null, - "tls_port": -1, + "Aggregated": [Function], + "Cell": [Function], + "Header": "Service", + "accessor": "service", + "show": true, + "width": 300, }, { - "curr_size": 179744287, - "host": "localhost", - "is_leader": 0, - "max_size": 3000000000n, - "plaintext_port": 8083, - "segmentsToDrop": 0, - "segmentsToDropSize": 0, - "segmentsToLoad": 0, - "segmentsToLoadSize": 0, - "service": "localhost:8083", - "service_type": "historical", - "start_time": 0, - "tier": "_default_tier", - "tls_port": -1, + "Cell": [Function], + "Filter": [Function], + "Header": "Type", + "accessor": "service_type", + "show": true, + "width": 150, }, - ], - ] - } - defaultExpanded={{}} - defaultFilterMethod={[Function]} - defaultFiltered={[]} - defaultPage={0} - defaultPageSize={50} - defaultResized={[]} - defaultSortDesc={false} - defaultSortMethod={[Function]} - defaultSorted={[]} - expanderDefaults={ - { - "filterable": false, - "resizable": false, - "sortable": false, - "width": 35, + { + "Cell": [Function], + "Header": "Tier", + "accessor": [Function], + "id": "tier", + "show": true, + "width": 180, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Host", + "accessor": "host", + "show": true, + "width": 200, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Port", + "accessor": [Function], + "id": "port", + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Assigned size", + "accessor": "curr_size", + "className": "padded", + "filterable": false, + "id": "curr_size", + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Effective size", + "accessor": "effective_size", + "className": "padded", + "filterable": false, + "id": "effective_size", + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Usage", + "accessor": [Function], + "className": "padded", + "filterable": false, + "id": "usage", + "show": true, + "width": 140, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Start time", + "accessor": "start_time", + "filterMethod": [Function], + "id": "start_time", + "show": true, + "width": 220, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Version", + "accessor": "version", + "show": true, + "width": 200, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": + Build +
+ revision +
, + "accessor": "build_revision", + "show": true, + "width": 200, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": + Available +
+ processors +
, + "accessor": "available_processors", + "className": "padded", + "filterable": false, + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Total memory", + "accessor": "total_memory", + "className": "padded", + "filterable": false, + "show": true, + "width": 120, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Labels", + "accessor": "labels", + "className": "padded", + "filterable": false, + "show": true, + "width": 200, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Detail", + "accessor": "service", + "className": "padded", + "filterable": false, + "id": "queue", + "show": true, + "width": 400, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Actions", + "accessor": "service", + "filterable": false, + "id": "actions", + "show": true, + "sortable": false, + "width": 70, + }, + ] } - } - filterable={true} - filtered={[]} - freezeWhenExpanded={false} - getLoadingProps={[Function]} - getNoDataProps={[Function]} - getPaginationProps={[Function]} - getProps={[Function]} - getResizerProps={[Function]} - getTableProps={[Function]} - getTbodyProps={[Function]} - getTdProps={[Function]} - getTfootProps={[Function]} - getTfootTdProps={[Function]} - getTfootTrProps={[Function]} - getTheadFilterProps={[Function]} - getTheadFilterThProps={[Function]} - getTheadFilterTrProps={[Function]} - getTheadGroupProps={[Function]} - getTheadGroupThProps={[Function]} - getTheadGroupTrProps={[Function]} - getTheadProps={[Function]} - getTheadThProps={[Function]} - getTheadTrProps={[Function]} - getTrGroupProps={[Function]} - getTrProps={[Function]} - groupedByPivotKey="_groupedByPivot" - indexKey="_index" - loading={false} - loadingText="Loading..." - multiSort={true} - nestingLevelKey="_nestingLevel" - nextText="Next" - noDataText="" - ofText="of" - onFetchData={[Function]} - onFilteredChange={[Function]} - originalKey="_original" - pageJumpText="jump to page" - pageSizeOptions={ - [ - 50, - 100, - 200, - ] - } - pageText="Page" - pivotBy={[]} - pivotDefaults={{}} - pivotIDKey="_pivotID" - pivotValKey="_pivotVal" - previousText="Previous" - resizable={true} - resolveData={[Function]} - rowsSelectorText="rows per page" - rowsText="rows" - showPageJump={true} - showPageSizeOptions={true} - showPagination={false} - showPaginationBottom={true} - showPaginationTop={false} - sortable={true} - style={{}} - subRowsKey="_subRows" - /> + data={ + [ + [ + { + "curr_size": 0, + "host": "localhost", + "is_leader": 0, + "max_size": 0, + "plaintext_port": 8082, + "service": "localhost:8082", + "service_type": "broker", + "start_time": 0, + "tier": null, + "tls_port": -1, + }, + { + "curr_size": 179744287, + "host": "localhost", + "is_leader": 0, + "max_size": 3000000000n, + "plaintext_port": 8083, + "segmentsToDrop": 0, + "segmentsToDropSize": 0, + "segmentsToLoad": 0, + "segmentsToLoadSize": 0, + "service": "localhost:8083", + "service_type": "historical", + "start_time": 0, + "tier": "_default_tier", + "tls_port": -1, + }, + ], + ] + } + defaultExpanded={{}} + defaultFilterMethod={[Function]} + defaultFiltered={[]} + defaultPage={0} + defaultPageSize={50} + defaultResized={[]} + defaultSortDesc={false} + defaultSortMethod={[Function]} + defaultSorted={[]} + expanderDefaults={ + { + "filterable": false, + "resizable": false, + "sortable": false, + "width": 35, + } + } + filterable={true} + filtered={[]} + freezeWhenExpanded={false} + getLoadingProps={[Function]} + getNoDataProps={[Function]} + getPaginationProps={[Function]} + getProps={[Function]} + getResizerProps={[Function]} + getTableProps={[Function]} + getTbodyProps={[Function]} + getTdProps={[Function]} + getTfootProps={[Function]} + getTfootTdProps={[Function]} + getTfootTrProps={[Function]} + getTheadFilterProps={[Function]} + getTheadFilterThProps={[Function]} + getTheadFilterTrProps={[Function]} + getTheadGroupProps={[Function]} + getTheadGroupThProps={[Function]} + getTheadGroupTrProps={[Function]} + getTheadProps={[Function]} + getTheadThProps={[Function]} + getTheadTrProps={[Function]} + getTrGroupProps={[Function]} + getTrProps={[Function]} + groupedByPivotKey="_groupedByPivot" + indexKey="_index" + loading={false} + loadingText="Loading..." + multiSort={true} + nestingLevelKey="_nestingLevel" + nextText="Next" + noDataText="" + ofText="of" + onFetchData={[Function]} + onFilteredChange={[Function]} + originalKey="_original" + pageJumpText="jump to page" + pageSizeOptions={ + [ + 50, + 100, + 200, + ] + } + pageText="Page" + pivotBy={[]} + pivotDefaults={{}} + pivotIDKey="_pivotID" + pivotValKey="_pivotVal" + previousText="Previous" + resizable={true} + resolveData={[Function]} + rowsSelectorText="rows per page" + rowsText="rows" + showPageJump={true} + showPageSizeOptions={true} + showPagination={false} + showPaginationBottom={true} + showPaginationTop={false} + sortable={true} + style={{}} + subRowsKey="_subRows" + /> +
`; diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx index 705a8b6f8f60..5dc1d01df248 100644 --- a/web-console/src/views/services-view/services-view.tsx +++ b/web-console/src/views/services-view/services-view.tsx @@ -59,6 +59,7 @@ import { formatDurationWithMsIfNeeded, formatInteger, getApiArray, + getApiArrayFromKey, hasOverlayOpen, LocalStorageBackedVisibility, LocalStorageKeys, @@ -167,13 +168,23 @@ interface ServiceResultRow { readonly total_memory: number; } +interface CloneStatusInfo { + readonly sourceServer: string; + readonly state: string; + readonly segmentLoadsRemaining: number; + readonly segmentDropsRemaining: number; + readonly bytesToLoad: number; +} + interface ServicesWithAuxiliaryInfo { readonly services: ServiceResultRow[]; readonly loadQueueInfo: Record; + readonly cloneStatus: Record; readonly workerInfo: Record; } export const LoadQueueInfoContext = createContext>({}); +export const CloneStatusContext = createContext>({}); interface LoadQueueInfo { readonly segmentsToDrop: NumberLike; @@ -366,6 +377,28 @@ ORDER BY }); } + if (capabilities.hasCoordinatorAccess() && visibleColumns.shown('Detail')) { + auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => { + try { + const cloneStatuses = await getApiArrayFromKey< + CloneStatusInfo & { targetServer: string } + >('/druid/coordinator/v1/config/cloneStatus', 'cloneStatus', signal); + + const cloneStatusLookup: Record = lookupBy( + cloneStatuses, + s => s.targetServer, + ); + + return { + ...servicesWithAuxiliaryInfo, + cloneStatus: cloneStatusLookup, + }; + } catch { + return servicesWithAuxiliaryInfo; + } + }); + } + if (capabilities.hasOverlordAccess()) { auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => { try { @@ -400,7 +433,7 @@ ORDER BY } return new ResultWithAuxiliaryWork( - { services, loadQueueInfo: {}, workerInfo: {} }, + { services, loadQueueInfo: {}, cloneStatus: {}, workerInfo: {} }, auxiliaryQueries, ); }, @@ -451,30 +484,33 @@ ORDER BY const { filters, onFiltersChange } = this.props; const { servicesState, groupServicesBy, visibleColumns } = this.state; - const { services, loadQueueInfo, workerInfo } = servicesState.data || { + const { services, loadQueueInfo, cloneStatus, workerInfo } = servicesState.data || { services: [], loadQueueInfo: {}, + cloneStatus: {}, workerInfo: {}, }; return ( - onFiltersChange(TableFilters.fromFilters(filters))} - pivotBy={groupServicesBy ? [groupServicesBy] : []} - defaultPageSize={STANDARD_TABLE_PAGE_SIZE} - pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS} - showPagination={services.length > STANDARD_TABLE_PAGE_SIZE} - columns={this.getTableColumns(visibleColumns, filters, onFiltersChange, workerInfo)} - /> + + onFiltersChange(TableFilters.fromFilters(filters))} + pivotBy={groupServicesBy ? [groupServicesBy] : []} + defaultPageSize={STANDARD_TABLE_PAGE_SIZE} + pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS} + showPagination={services.length > STANDARD_TABLE_PAGE_SIZE} + columns={this.getTableColumns(visibleColumns, filters, onFiltersChange, workerInfo)} + /> + ); } @@ -817,11 +853,12 @@ ORDER BY id: 'queue', width: 400, filterable: false, - className: 'padded', + className: 'padded wrapped', accessor: 'service', Cell: ({ original }) => { const { service_type, service, is_leader } = original; const loadQueueInfoContext = useContext(LoadQueueInfoContext); + const cloneStatusContext = useContext(CloneStatusContext); switch (service_type) { case 'middle_manager': @@ -849,9 +886,27 @@ ORDER BY case 'historical': { const loadQueueInfo = loadQueueInfoContext[service]; - if (!loadQueueInfo) return null; + const cloneInfo = cloneStatusContext[service]; - return formatLoadQueueInfo(loadQueueInfo); + const parts: string[] = []; + if (loadQueueInfo) { + parts.push(formatLoadQueueInfo(loadQueueInfo)); + } + if (cloneInfo) { + if (cloneInfo.state === 'SOURCE_SERVER_MISSING') { + parts.push(`Clone of ${cloneInfo.sourceServer} (source missing)`); + } else if (cloneInfo.segmentLoadsRemaining > 0) { + parts.push( + `Cloning from ${cloneInfo.sourceServer}: ${pluralIfNeeded( + cloneInfo.segmentLoadsRemaining, + 'segment', + )} to load (${formatBytesCompact(cloneInfo.bytesToLoad)})`, + ); + } else { + parts.push(`Clone of ${cloneInfo.sourceServer} (synced)`); + } + } + return parts.join('; ') || null; } default: From 9e3860f5e525d77b03ee7653a93a7efff87395f2 Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 09:19:08 -0500 Subject: [PATCH 03/10] enhancements and fixes --- .../coordinator/duty/CloneHistoricals.java | 5 +- .../clone-server-mapping-dialog.tsx | 2 +- .../coordinator-dynamic-config-dialog.tsx | 5 +- .../__snapshots__/services-view.spec.tsx.snap | 654 +++++++++--------- .../src/views/services-view/services-view.tsx | 82 ++- 5 files changed, 400 insertions(+), 348 deletions(-) diff --git a/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java b/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java index da201127f60a..c12193c9b0e5 100644 --- a/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java +++ b/server/src/main/java/org/apache/druid/server/coordinator/duty/CloneHistoricals.java @@ -68,7 +68,10 @@ public DruidCoordinatorRuntimeParams run(DruidCoordinatorRuntimeParams params) final DruidCluster cluster = params.getDruidCluster(); if (cloneServers.isEmpty()) { - // No servers to be cloned. + if (!cloneStatusManager.getStatusForAllServers().isEmpty()) { + // Clear the status manager if the dynamic config no longer has mappings to avoid showing stale clone statuses. + cloneStatusManager.updateStatus(Map.of()); + } return params; } diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx index 4a14b15a9d77..50fa2e703535 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx @@ -64,7 +64,7 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi result[m.target] = m.source; } } - onSave(Object.keys(result).length > 0 ? result : (undefined as any)); + onSave(result); onClose(); } 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 2ec913084982..4b60c60fb348 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 @@ -85,7 +85,7 @@ function buildServerPickerFields( title="Decommissioning nodes" servers={servers} selectedServers={value || []} - onSave={v => onValueChange(v.length > 0 ? v : undefined)} + onSave={v => onValueChange(v)} onClose={onClose} /> ), @@ -115,7 +115,7 @@ function buildServerPickerFields( title="Turbo loading nodes" servers={servers} selectedServers={value || []} - onSave={v => onValueChange(v.length > 0 ? v : undefined)} + onSave={v => onValueChange(v)} onClose={onClose} /> ), @@ -123,6 +123,7 @@ function buildServerPickerFields( { name: 'cloneServers', type: 'custom', + experimental: true, info: ( <>

diff --git a/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap b/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap index 429af780a88b..3f265d48186e 100644 --- a/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap +++ b/web-console/src/views/services-view/__snapshots__/services-view.spec.tsx.snap @@ -75,340 +75,342 @@ exports[`ServicesView renders data 1`] = ` value={{}} > - + - Build -
- revision - , - "accessor": "build_revision", - "show": true, - "width": 200, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": - Available -
- processors -
, - "accessor": "available_processors", - "className": "padded", - "filterable": false, - "show": true, - "width": 100, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Total memory", - "accessor": "total_memory", - "className": "padded", - "filterable": false, - "show": true, - "width": 120, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Labels", - "accessor": "labels", - "className": "padded", - "filterable": false, - "show": true, - "width": 200, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Detail", - "accessor": "service", - "className": "padded", - "filterable": false, - "id": "queue", - "show": true, - "width": 400, - }, - { - "Aggregated": [Function], - "Cell": [Function], - "Header": "Actions", - "accessor": "service", - "filterable": false, - "id": "actions", + "Aggregated": undefined, + "Cell": undefined, + "Expander": undefined, + "Filter": undefined, + "Footer": undefined, + "Header": undefined, + "Pivot": undefined, + "PivotValue": undefined, + "Placeholder": undefined, + "aggregate": undefined, + "className": "", + "filterAll": false, + "filterMethod": undefined, + "filterable": undefined, + "footerClassName": "", + "footerStyle": {}, + "getFooterProps": [Function], + "getHeaderProps": [Function], + "getProps": [Function], + "headerClassName": "", + "headerStyle": {}, + "minResizeWidth": 11, + "minWidth": 100, + "resizable": undefined, "show": true, - "sortable": false, - "width": 70, - }, - ] - } - data={ - [ + "sortMethod": undefined, + "sortable": undefined, + "style": {}, + } + } + columns={ [ { - "curr_size": 0, - "host": "localhost", - "is_leader": 0, - "max_size": 0, - "plaintext_port": 8082, - "service": "localhost:8082", - "service_type": "broker", - "start_time": 0, - "tier": null, - "tls_port": -1, + "Aggregated": [Function], + "Cell": [Function], + "Header": "Service", + "accessor": "service", + "show": true, + "width": 300, + }, + { + "Cell": [Function], + "Filter": [Function], + "Header": "Type", + "accessor": "service_type", + "show": true, + "width": 150, + }, + { + "Cell": [Function], + "Header": "Tier", + "accessor": [Function], + "id": "tier", + "show": true, + "width": 180, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Host", + "accessor": "host", + "show": true, + "width": 200, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Port", + "accessor": [Function], + "id": "port", + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Assigned size", + "accessor": "curr_size", + "className": "padded", + "filterable": false, + "id": "curr_size", + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Effective size", + "accessor": "effective_size", + "className": "padded", + "filterable": false, + "id": "effective_size", + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Usage", + "accessor": [Function], + "className": "padded", + "filterable": false, + "id": "usage", + "show": true, + "width": 140, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Start time", + "accessor": "start_time", + "filterMethod": [Function], + "id": "start_time", + "show": true, + "width": 220, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Version", + "accessor": "version", + "show": true, + "width": 200, }, { - "curr_size": 179744287, - "host": "localhost", - "is_leader": 0, - "max_size": 3000000000n, - "plaintext_port": 8083, - "segmentsToDrop": 0, - "segmentsToDropSize": 0, - "segmentsToLoad": 0, - "segmentsToLoadSize": 0, - "service": "localhost:8083", - "service_type": "historical", - "start_time": 0, - "tier": "_default_tier", - "tls_port": -1, + "Aggregated": [Function], + "Cell": [Function], + "Header": + Build +
+ revision +
, + "accessor": "build_revision", + "show": true, + "width": 200, }, - ], - ] - } - defaultExpanded={{}} - defaultFilterMethod={[Function]} - defaultFiltered={[]} - defaultPage={0} - defaultPageSize={50} - defaultResized={[]} - defaultSortDesc={false} - defaultSortMethod={[Function]} - defaultSorted={[]} - expanderDefaults={ - { - "filterable": false, - "resizable": false, - "sortable": false, - "width": 35, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": + Available +
+ processors +
, + "accessor": "available_processors", + "className": "padded", + "filterable": false, + "show": true, + "width": 100, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Total memory", + "accessor": "total_memory", + "className": "padded", + "filterable": false, + "show": true, + "width": 120, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Labels", + "accessor": "labels", + "className": "padded", + "filterable": false, + "show": true, + "width": 200, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Detail", + "accessor": "service", + "className": "padded wrapped", + "filterable": false, + "id": "queue", + "show": true, + "width": 400, + }, + { + "Aggregated": [Function], + "Cell": [Function], + "Header": "Actions", + "accessor": "service", + "filterable": false, + "id": "actions", + "show": true, + "sortable": false, + "width": 70, + }, + ] } - } - filterable={true} - filtered={[]} - freezeWhenExpanded={false} - getLoadingProps={[Function]} - getNoDataProps={[Function]} - getPaginationProps={[Function]} - getProps={[Function]} - getResizerProps={[Function]} - getTableProps={[Function]} - getTbodyProps={[Function]} - getTdProps={[Function]} - getTfootProps={[Function]} - getTfootTdProps={[Function]} - getTfootTrProps={[Function]} - getTheadFilterProps={[Function]} - getTheadFilterThProps={[Function]} - getTheadFilterTrProps={[Function]} - getTheadGroupProps={[Function]} - getTheadGroupThProps={[Function]} - getTheadGroupTrProps={[Function]} - getTheadProps={[Function]} - getTheadThProps={[Function]} - getTheadTrProps={[Function]} - getTrGroupProps={[Function]} - getTrProps={[Function]} - groupedByPivotKey="_groupedByPivot" - indexKey="_index" - loading={false} - loadingText="Loading..." - multiSort={true} - nestingLevelKey="_nestingLevel" - nextText="Next" - noDataText="" - ofText="of" - onFetchData={[Function]} - onFilteredChange={[Function]} - originalKey="_original" - pageJumpText="jump to page" - pageSizeOptions={ - [ - 50, - 100, - 200, - ] - } - pageText="Page" - pivotBy={[]} - pivotDefaults={{}} - pivotIDKey="_pivotID" - pivotValKey="_pivotVal" - previousText="Previous" - resizable={true} - resolveData={[Function]} - rowsSelectorText="rows per page" - rowsText="rows" - showPageJump={true} - showPageSizeOptions={true} - showPagination={false} - showPaginationBottom={true} - showPaginationTop={false} - sortable={true} - style={{}} - subRowsKey="_subRows" - /> + data={ + [ + [ + { + "curr_size": 0, + "host": "localhost", + "is_leader": 0, + "max_size": 0, + "plaintext_port": 8082, + "service": "localhost:8082", + "service_type": "broker", + "start_time": 0, + "tier": null, + "tls_port": -1, + }, + { + "curr_size": 179744287, + "host": "localhost", + "is_leader": 0, + "max_size": 3000000000n, + "plaintext_port": 8083, + "segmentsToDrop": 0, + "segmentsToDropSize": 0, + "segmentsToLoad": 0, + "segmentsToLoadSize": 0, + "service": "localhost:8083", + "service_type": "historical", + "start_time": 0, + "tier": "_default_tier", + "tls_port": -1, + }, + ], + ] + } + defaultExpanded={{}} + defaultFilterMethod={[Function]} + defaultFiltered={[]} + defaultPage={0} + defaultPageSize={50} + defaultResized={[]} + defaultSortDesc={false} + defaultSortMethod={[Function]} + defaultSorted={[]} + expanderDefaults={ + { + "filterable": false, + "resizable": false, + "sortable": false, + "width": 35, + } + } + filterable={true} + filtered={[]} + freezeWhenExpanded={false} + getLoadingProps={[Function]} + getNoDataProps={[Function]} + getPaginationProps={[Function]} + getProps={[Function]} + getResizerProps={[Function]} + getTableProps={[Function]} + getTbodyProps={[Function]} + getTdProps={[Function]} + getTfootProps={[Function]} + getTfootTdProps={[Function]} + getTfootTrProps={[Function]} + getTheadFilterProps={[Function]} + getTheadFilterThProps={[Function]} + getTheadFilterTrProps={[Function]} + getTheadGroupProps={[Function]} + getTheadGroupThProps={[Function]} + getTheadGroupTrProps={[Function]} + getTheadProps={[Function]} + getTheadThProps={[Function]} + getTheadTrProps={[Function]} + getTrGroupProps={[Function]} + getTrProps={[Function]} + groupedByPivotKey="_groupedByPivot" + indexKey="_index" + loading={false} + loadingText="Loading..." + multiSort={true} + nestingLevelKey="_nestingLevel" + nextText="Next" + noDataText="" + ofText="of" + onFetchData={[Function]} + onFilteredChange={[Function]} + originalKey="_original" + pageJumpText="jump to page" + pageSizeOptions={ + [ + 50, + 100, + 200, + ] + } + pageText="Page" + pivotBy={[]} + pivotDefaults={{}} + pivotIDKey="_pivotID" + pivotValKey="_pivotVal" + previousText="Previous" + resizable={true} + resolveData={[Function]} + rowsSelectorText="rows per page" + rowsText="rows" + showPageJump={true} + showPageSizeOptions={true} + showPagination={false} + showPaginationBottom={true} + showPaginationTop={false} + sortable={true} + style={{}} + subRowsKey="_subRows" + /> +
diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx index 5dc1d01df248..8544c54ce6ce 100644 --- a/web-console/src/views/services-view/services-view.tsx +++ b/web-console/src/views/services-view/services-view.tsx @@ -176,16 +176,28 @@ interface CloneStatusInfo { readonly bytesToLoad: number; } +interface ServerModeInfo { + readonly turboLoadingNodes: Set; + readonly decommissioningNodes: Set; +} + interface ServicesWithAuxiliaryInfo { readonly services: ServiceResultRow[]; readonly loadQueueInfo: Record; readonly cloneStatus: Record; + readonly serverMode: ServerModeInfo; readonly workerInfo: Record; } export const LoadQueueInfoContext = createContext>({}); export const CloneStatusContext = createContext>({}); +const DEFAULT_SERVER_MODE: ServerModeInfo = { + turboLoadingNodes: new Set(), + decommissioningNodes: new Set(), +}; +export const ServerModeContext = createContext(DEFAULT_SERVER_MODE); + interface LoadQueueInfo { readonly segmentsToDrop: NumberLike; readonly segmentsToDropSize: NumberLike; @@ -399,6 +411,24 @@ ORDER BY }); } + if (capabilities.hasCoordinatorAccess() && visibleColumns.shown('Detail')) { + auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => { + try { + const config = (await Api.instance.get('/druid/coordinator/v1/config', { signal })) + .data; + return { + ...servicesWithAuxiliaryInfo, + serverMode: { + turboLoadingNodes: new Set(config.turboLoadingNodes || []), + decommissioningNodes: new Set(config.decommissioningNodes || []), + }, + }; + } catch { + return servicesWithAuxiliaryInfo; + } + }); + } + if (capabilities.hasOverlordAccess()) { auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => { try { @@ -433,7 +463,13 @@ ORDER BY } return new ResultWithAuxiliaryWork( - { services, loadQueueInfo: {}, cloneStatus: {}, workerInfo: {} }, + { + services, + loadQueueInfo: {}, + cloneStatus: {}, + serverMode: DEFAULT_SERVER_MODE, + workerInfo: {}, + }, auxiliaryQueries, ); }, @@ -484,32 +520,35 @@ ORDER BY const { filters, onFiltersChange } = this.props; const { servicesState, groupServicesBy, visibleColumns } = this.state; - const { services, loadQueueInfo, cloneStatus, workerInfo } = servicesState.data || { + const { services, loadQueueInfo, cloneStatus, serverMode, workerInfo } = servicesState.data || { services: [], loadQueueInfo: {}, cloneStatus: {}, + serverMode: DEFAULT_SERVER_MODE, workerInfo: {}, }; return ( - onFiltersChange(TableFilters.fromFilters(filters))} - pivotBy={groupServicesBy ? [groupServicesBy] : []} - defaultPageSize={STANDARD_TABLE_PAGE_SIZE} - pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS} - showPagination={services.length > STANDARD_TABLE_PAGE_SIZE} - columns={this.getTableColumns(visibleColumns, filters, onFiltersChange, workerInfo)} - /> + + onFiltersChange(TableFilters.fromFilters(filters))} + pivotBy={groupServicesBy ? [groupServicesBy] : []} + defaultPageSize={STANDARD_TABLE_PAGE_SIZE} + pageSizeOptions={STANDARD_TABLE_PAGE_SIZE_OPTIONS} + showPagination={services.length > STANDARD_TABLE_PAGE_SIZE} + columns={this.getTableColumns(visibleColumns, filters, onFiltersChange, workerInfo)} + /> + ); @@ -859,6 +898,7 @@ ORDER BY const { service_type, service, is_leader } = original; const loadQueueInfoContext = useContext(LoadQueueInfoContext); const cloneStatusContext = useContext(CloneStatusContext); + const serverModeInfo = useContext(ServerModeContext); switch (service_type) { case 'middle_manager': @@ -889,6 +929,12 @@ ORDER BY const cloneInfo = cloneStatusContext[service]; const parts: string[] = []; + if (serverModeInfo.decommissioningNodes.has(service)) { + parts.push('DECOMMISSIONING'); + } + if (serverModeInfo.turboLoadingNodes.has(service)) { + parts.push('TURBO SEGMENT LOADING'); + } if (loadQueueInfo) { parts.push(formatLoadQueueInfo(loadQueueInfo)); } From d3ef2c06cd5395afbc73f00572563d79224c2a6a Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 12:03:36 -0500 Subject: [PATCH 04/10] revie fixups and improvements --- .../clone-server-mapping-dialog.tsx | 11 +-- .../coordinator-dynamic-config-dialog.tsx | 21 +++--- .../tiered-servers.ts | 1 + .../src/views/services-view/services-view.tsx | 70 +++++++++++-------- 4 files changed, 60 insertions(+), 43 deletions(-) diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx index 50fa2e703535..5d819229636a 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx @@ -60,7 +60,7 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi function handleSave() { const result: Record = {}; for (const m of mappings) { - if (m.target && m.source) { + if (m.target && m.source && m.target !== m.source) { result[m.target] = m.source; } } @@ -116,6 +116,7 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi updateMapping(i, 'source', v)} /> @@ -177,14 +178,14 @@ function ServerSelect(props: ServerSelectProps) { itemListRenderer={({ filteredItems, renderItem }) => { const elements: React.ReactNode[] = []; let lastTier: string | undefined; - for (const item of filteredItems) { - // Find which tier this server belongs to - const tier = servers.tiers.find(t => (servers.serversByTier[t] || []).includes(item)); + for (let i = 0; i < filteredItems.length; i++) { + const item = filteredItems[i]; + const tier = servers.serverToTier[item]; if (tier && tier !== lastTier) { elements.push(); lastTier = tier; } - elements.push(renderItem(item, filteredItems.indexOf(item))); + elements.push(renderItem(item, i)); } return <>{elements}; }} 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 4b60c60fb348..5371ea577cf3 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 @@ -49,18 +49,20 @@ export interface CoordinatorDynamicConfigDialogProps { 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, allServers }; + return { tiers, serversByTier, serverToTier, allServers }; } function buildServerPickerFields( @@ -211,15 +213,16 @@ ORDER BY "tier", "server"`, }, }); - const fields = useMemo( - () => [ - // Insert server picker fields after the "smart segment loading" section - ...COORDINATOR_DYNAMIC_CONFIG_FIELDS.slice(0, 7), // pauseCoordination through replicantLifetime + const fields = useMemo(() => { + const insertIndex = COORDINATOR_DYNAMIC_CONFIG_FIELDS.findIndex( + f => f.name === 'killDataSourceWhitelist', + ); + return [ + ...COORDINATOR_DYNAMIC_CONFIG_FIELDS.slice(0, insertIndex), ...buildServerPickerFields(serversState.data), - ...COORDINATOR_DYNAMIC_CONFIG_FIELDS.slice(7), // killDataSourceWhitelist onward - ], - [serversState.data], - ); + ...COORDINATOR_DYNAMIC_CONFIG_FIELDS.slice(insertIndex), + ]; + }, [serversState.data]); async function saveConfig(comment: string) { try { 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 index 763f42b168ef..f03462d4a687 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts @@ -19,5 +19,6 @@ export interface TieredServers { tiers: string[]; serversByTier: Record; + serverToTier: Record; allServers: string[]; } diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx index 8544c54ce6ce..80beac8a90dc 100644 --- a/web-console/src/views/services-view/services-view.tsx +++ b/web-console/src/views/services-view/services-view.tsx @@ -392,36 +392,36 @@ ORDER BY if (capabilities.hasCoordinatorAccess() && visibleColumns.shown('Detail')) { auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => { try { - const cloneStatuses = await getApiArrayFromKey< - CloneStatusInfo & { targetServer: string } - >('/druid/coordinator/v1/config/cloneStatus', 'cloneStatus', signal); + const [cloneStatusResp, configResp] = await Promise.all([ + getApiArrayFromKey( + '/druid/coordinator/v1/config/cloneStatus', + 'cloneStatus', + signal, + ).catch(() => [] as (CloneStatusInfo & { targetServer: string })[]), + Api.instance + .get('/druid/coordinator/v1/config', { signal }) + .then(r => r.data) + .catch(() => null), + ]); const cloneStatusLookup: Record = lookupBy( - cloneStatuses, + cloneStatusResp, s => s.targetServer, ); return { ...servicesWithAuxiliaryInfo, cloneStatus: cloneStatusLookup, - }; - } catch { - return servicesWithAuxiliaryInfo; - } - }); - } - - if (capabilities.hasCoordinatorAccess() && visibleColumns.shown('Detail')) { - auxiliaryQueries.push(async (servicesWithAuxiliaryInfo, signal) => { - try { - const config = (await Api.instance.get('/druid/coordinator/v1/config', { signal })) - .data; - return { - ...servicesWithAuxiliaryInfo, - serverMode: { - turboLoadingNodes: new Set(config.turboLoadingNodes || []), - decommissioningNodes: new Set(config.decommissioningNodes || []), - }, + ...(configResp + ? { + serverMode: { + turboLoadingNodes: new Set(configResp.turboLoadingNodes || []), + decommissioningNodes: new Set( + configResp.decommissioningNodes || [], + ), + }, + } + : {}), }; } catch { return servicesWithAuxiliaryInfo; @@ -941,13 +941,25 @@ ORDER BY if (cloneInfo) { if (cloneInfo.state === 'SOURCE_SERVER_MISSING') { parts.push(`Clone of ${cloneInfo.sourceServer} (source missing)`); - } else if (cloneInfo.segmentLoadsRemaining > 0) { - parts.push( - `Cloning from ${cloneInfo.sourceServer}: ${pluralIfNeeded( - cloneInfo.segmentLoadsRemaining, - 'segment', - )} to load (${formatBytesCompact(cloneInfo.bytesToLoad)})`, - ); + } else if ( + cloneInfo.segmentLoadsRemaining > 0 || + cloneInfo.segmentDropsRemaining > 0 + ) { + const details: string[] = []; + if (cloneInfo.segmentLoadsRemaining > 0) { + details.push( + `${pluralIfNeeded( + cloneInfo.segmentLoadsRemaining, + 'segment', + )} to load (${formatBytesCompact(cloneInfo.bytesToLoad)})`, + ); + } + if (cloneInfo.segmentDropsRemaining > 0) { + details.push( + `${pluralIfNeeded(cloneInfo.segmentDropsRemaining, 'segment')} to drop`, + ); + } + parts.push(`Cloning from ${cloneInfo.sourceServer}: ${details.join(', ')}`); } else { parts.push(`Clone of ${cloneInfo.sourceServer} (synced)`); } From 56255359de773e0edbe0a7e708cd5f32c155d291 Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 12:22:00 -0500 Subject: [PATCH 05/10] more refactor --- .../coordinator-dynamic-config-dialog.tsx | 152 ++++++------------ .../coordinator-dynamic-config.tsx | 58 ++++++- 2 files changed, 107 insertions(+), 103 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 5371ea577cf3..7c664f014a5f 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 @@ -16,18 +16,14 @@ * limitations under the License. */ -import { Code, Intent } from '@blueprintjs/core'; +import { Intent } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import React, { useMemo, useState } from 'react'; import type { Field, FormJsonTabs } from '../../components'; import { AutoForm, ExternalLink, FormJsonSelector, JsonInput, Loader } from '../../components'; import type { CoordinatorDynamicConfig } from '../../druid-models'; -import { - cloneCountSummary, - COORDINATOR_DYNAMIC_CONFIG_FIELDS, - serverCountSummary, -} from '../../druid-models'; +import { COORDINATOR_DYNAMIC_CONFIG_FIELDS } from '../../druid-models'; import type { Capabilities } from '../../helpers'; import { useQueryManager } from '../../hooks'; import { getLink } from '../../links'; @@ -65,92 +61,54 @@ function buildTieredServers(rows: { server: string; tier: string }[]): TieredSer return { tiers, serversByTier, serverToTier, allServers }; } -function buildServerPickerFields( +function attachServerPickerDialogs( + fields: Field[], servers: TieredServers | undefined, ): Field[] { - return [ - { - name: 'decommissioningNodes', - type: 'custom', - emptyValue: [], - info: ( - <> - List of historical services to 'decommission'. Coordinator will not assign new - segments to 'decommissioning' services, and segments will be moved away from - them to be placed on non-decommissioning services at the maximum rate specified by{' '} - maxSegmentsToMove. - - ), - customSummary: serverCountSummary, - customDialog: ({ value, onValueChange, onClose }) => ( - onValueChange(v)} - onClose={onClose} - /> - ), - }, - { - name: 'turboLoadingNodes', - type: 'custom', - experimental: true, - info: ( - <> -

- List of Historical servers to place in turbo loading mode. These servers use a larger - thread-pool to load segments faster but at the cost of query performance. For servers - specified in turboLoadingNodes,{' '} - druid.coordinator.loadqueuepeon.http.batchSize is ignored and the - coordinator uses the value of the respective numLoadingThreads instead. -

-

- Please use this config with caution. All servers should eventually be removed from this - list once the segment loading on the respective historicals is finished. -

- - ), - customSummary: serverCountSummary, - customDialog: ({ value, onValueChange, onClose }) => ( - onValueChange(v)} - onClose={onClose} - /> - ), - }, - { - name: 'cloneServers', - type: 'custom', - experimental: true, - info: ( - <> -

- Map from target Historical server to source Historical server. The target clones all - segments from the source, becoming an exact copy. The target does not participate in - regular segment assignment or balancing, and its segments do not count towards replica - counts. -

-

- If the source server disappears, the target remains in the last known state of the - source until removed from this mapping. -

- - ), - customSummary: cloneCountSummary, - customDialog: ({ value, onValueChange, onClose }) => ( - onValueChange(v)} - onClose={onClose} - /> - ), - }, - ]; + 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} + /> + ), + }; + case 'cloneServers': + return { + ...field, + customDialog: ({ value, onValueChange, onClose }) => ( + onValueChange(v)} + onClose={onClose} + /> + ), + }; + default: + return field; + } + }); } export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDynamicConfigDialog( @@ -213,16 +171,10 @@ ORDER BY "tier", "server"`, }, }); - const fields = useMemo(() => { - const insertIndex = COORDINATOR_DYNAMIC_CONFIG_FIELDS.findIndex( - f => f.name === 'killDataSourceWhitelist', - ); - return [ - ...COORDINATOR_DYNAMIC_CONFIG_FIELDS.slice(0, insertIndex), - ...buildServerPickerFields(serversState.data), - ...COORDINATOR_DYNAMIC_CONFIG_FIELDS.slice(insertIndex), - ]; - }, [serversState.data]); + const fields = useMemo( + () => attachServerPickerDialogs(COORDINATOR_DYNAMIC_CONFIG_FIELDS, serversState.data), + [serversState.data], + ); async function saveConfig(comment: string) { try { 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 757dc9dea0e7..7560746d6042 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 @@ -157,9 +157,61 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[ // End "smart" segment loading section - // decommissioningNodes, turboLoadingNodes, and cloneServers are added dynamically - // in the dialog component with server picker integration - + { + name: 'decommissioningNodes', + type: 'custom', + emptyValue: [], + info: ( + <> + List of historical services to 'decommission'. Coordinator will not assign new + segments to 'decommissioning' services, and segments will be moved away from them + to be placed on non-decommissioning services at the maximum rate specified by{' '} + maxSegmentsToMove. + + ), + customSummary: serverCountSummary, + }, + { + name: 'turboLoadingNodes', + type: 'custom', + experimental: true, + info: ( + <> +

+ List of Historical servers to place in turbo loading mode. These servers use a larger + thread-pool to load segments faster but at the cost of query performance. For servers + specified in turboLoadingNodes,{' '} + druid.coordinator.loadqueuepeon.http.batchSize is ignored and the coordinator + uses the value of the respective numLoadingThreads instead. +

+

+ Please use this config with caution. All servers should eventually be removed from this + list once the segment loading on the respective historicals is finished. +

+ + ), + customSummary: serverCountSummary, + }, + { + name: 'cloneServers', + type: 'custom', + experimental: true, + info: ( + <> +

+ Map from target Historical server to source Historical server. The target clones all + segments from the source, becoming an exact copy. The target does not participate in + regular segment assignment or balancing, and its segments do not count towards replica + counts. +

+

+ If the source server disappears, the target remains in the last known state of the source + until removed from this mapping. +

+ + ), + customSummary: cloneCountSummary, + }, { name: 'killDataSourceWhitelist', label: 'Kill datasource whitelist', From 7613b521b156b52a55736a686eabd309db87fbee Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 12:51:36 -0500 Subject: [PATCH 06/10] tests --- .../clone-server-mapping-dialog.spec.tsx.snap | 522 ++++++++++++++++++ .../server-multi-select-dialog.spec.tsx.snap | 281 ++++++++++ .../clone-server-mapping-dialog.spec.tsx | 87 +++ .../coordinator-dynamic-config-dialog.tsx | 19 +- .../server-multi-select-dialog.spec.tsx | 77 +++ .../tiered-servers.spec.ts | 69 +++ .../tiered-servers.ts | 18 + .../coordinator-dynamic-config.spec.ts | 63 +++ 8 files changed, 1118 insertions(+), 18 deletions(-) create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/server-multi-select-dialog.spec.tsx.snap create mode 100644 web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.spec.tsx 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/tiered-servers.spec.ts create mode 100644 web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.spec.ts diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap new file mode 100644 index 000000000000..7d44d5fc53e2 --- /dev/null +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap @@ -0,0 +1,522 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CloneServerMappingDialog matches snapshot when servers are loading 1`] = ` + + + +`; + +exports[`CloneServerMappingDialog matches snapshot with existing mappings 1`] = ` + + +
+ + Each target server clones all segments from its source server. The target does not participate in regular segment assignment or balancing. + + + + + + + + + + + + + + + + +
+ Target + + + Source + +
+ + + clones + + + + +
+ +
+
+
+ + +
+
+
+
+`; + +exports[`CloneServerMappingDialog matches snapshot with no existing mappings 1`] = ` + + +
+ + Each target server clones all segments from its source server. The target does not participate in regular segment assignment or balancing. + + +
+
+
+ + +
+
+
+
+`; + +exports[`CloneServerMappingDialog renders self-clone mapping (filtered on save by component logic) 1`] = ` + + +
+ + Each target server clones all segments from its source server. The target does not participate in regular segment assignment or balancing. + + + + + + + + + + + + + + + + + + + + + + +
+ Target + + + Source + +
+ + + clones + + + + +
+ + + clones + + + + +
+ +
+
+
+ + +
+
+
+
+`; 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..b449a2303b80 --- /dev/null +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/server-multi-select-dialog.spec.tsx.snap @@ -0,0 +1,281 @@ +// 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/clone-server-mapping-dialog.spec.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.spec.tsx new file mode 100644 index 000000000000..44246febdc93 --- /dev/null +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.spec.tsx @@ -0,0 +1,87 @@ +/* + * 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 { shallow } from '../../utils/shallow-renderer'; + +import { CloneServerMappingDialog } from './clone-server-mapping-dialog'; +import type { TieredServers } from './tiered-servers'; + +const MOCK_SERVERS: TieredServers = { + tiers: ['_default_tier'], + serversByTier: { + _default_tier: ['host1:8083', 'host2:8083', 'host3:8083'], + }, + serverToTier: { + 'host1:8083': '_default_tier', + 'host2:8083': '_default_tier', + 'host3:8083': '_default_tier', + }, + allServers: ['host1:8083', 'host2:8083', 'host3:8083'], +}; + +describe('CloneServerMappingDialog', () => { + it('matches snapshot with no existing mappings', () => { + const dialog = shallow( + {}} + onClose={() => {}} + />, + ); + expect(dialog).toMatchSnapshot(); + }); + + it('matches snapshot with existing mappings', () => { + const dialog = shallow( + {}} + onClose={() => {}} + />, + ); + expect(dialog).toMatchSnapshot(); + }); + + it('matches snapshot when servers are loading', () => { + const dialog = shallow( + {}} + onClose={() => {}} + />, + ); + expect(dialog).toMatchSnapshot(); + }); + + it('renders self-clone mapping (filtered on save by component logic)', () => { + // Verify the dialog renders with a self-clone mapping present. + // The component's handleSave filters out target === source mappings. + const dialog = shallow( + {}} + onClose={() => {}} + />, + ); + expect(dialog).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 7c664f014a5f..75df1b13dac3 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 @@ -35,6 +35,7 @@ import { CloneServerMappingDialog } from './clone-server-mapping-dialog'; 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'; @@ -43,24 +44,6 @@ export interface CoordinatorDynamicConfigDialogProps { onClose(): void; } -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 }; -} - function attachServerPickerDialogs( fields: Field[], servers: TieredServers | undefined, 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 new file mode 100644 index 000000000000..2060b35be845 --- /dev/null +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/server-multi-select-dialog.spec.tsx @@ -0,0 +1,77 @@ +/* + * 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 { shallow } from '../../utils/shallow-renderer'; + +import { ServerMultiSelectDialog } from './server-multi-select-dialog'; +import type { TieredServers } from './tiered-servers'; + +const MOCK_SERVERS: TieredServers = { + tiers: ['hot', 'cold'], + serversByTier: { + hot: ['hot-host1:8083', 'hot-host2:8083'], + cold: ['cold-host1:8083'], + }, + serverToTier: { + 'hot-host1:8083': 'hot', + 'hot-host2:8083': 'hot', + 'cold-host1:8083': 'cold', + }, + allServers: ['hot-host1:8083', 'hot-host2:8083', 'cold-host1:8083'], +}; + +describe('ServerMultiSelectDialog', () => { + 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/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 index f03462d4a687..b8a5cab9a4f6 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/tiered-servers.ts @@ -22,3 +22,21 @@ export interface TieredServers { 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..fa4f5ac238e3 --- /dev/null +++ b/web-console/src/druid-models/coordinator-dynamic-config/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 c02db0db90db2cfaf1c8a25e6562f5b800a90ed8 Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 15:09:20 -0500 Subject: [PATCH 07/10] more refactoring based on learnings and review --- .../clone-server-mapping-dialog.tsx | 12 +- .../coordinator-dynamic-config-completions.ts | 28 --- .../coordinator-dynamic-config-dialog.tsx | 1 + .../coordinator-dynamic-config.tsx | 2 + .../src/views/services-view/services-view.tsx | 201 ++++++++++-------- 5 files changed, 121 insertions(+), 123 deletions(-) diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx index 5d819229636a..ec3094a5dbd4 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx @@ -60,15 +60,14 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi function handleSave() { const result: Record = {}; for (const m of mappings) { - if (m.target && m.source && m.target !== m.source) { - result[m.target] = m.source; - } + result[m.target] = m.source; } onSave(result); onClose(); } const usedTargets = new Set(mappings.map(m => m.target).filter(Boolean)); + const hasInvalidMapping = mappings.some(m => !m.target || !m.source || m.target === m.source); return (
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts index 6f03ccbdb423..52d29fc33b81 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts @@ -100,34 +100,6 @@ export const COORDINATOR_DYNAMIC_CONFIG_COMPLETIONS: JsonCompletionRule[] = [ }, ], }, - // Properties only available when smartSegmentLoading is false - { - path: '$', - isObject: true, - condition: obj => obj.smartSegmentLoading === false, - completions: [ - { - value: 'maxSegmentsToMove', - documentation: 'Maximum segments that can be moved in a Historical tier per run', - }, - { - value: 'maxSegmentsInNodeLoadingQueue', - documentation: 'Maximum segments allowed in any server load queue', - }, - { - value: 'useRoundRobinSegmentAssignment', - documentation: 'Use round robin for segment assignment', - }, - { - value: 'replicationThrottleLimit', - documentation: 'Maximum segment replicas assigned to a tier per run', - }, - { - value: 'replicantLifetime', - documentation: 'Maximum Coordinator runs a segment can wait in load queue before alert', - }, - ], - }, // Boolean values { path: '$.smartSegmentLoading', 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 75df1b13dac3..b29faa8a56d7 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 @@ -173,6 +173,7 @@ ORDER BY "tier", "server"`, intent: Intent.DANGER, message: `Could not save coordinator dynamic config: ${getDruidErrorMessage(e)}`, }); + return; } AppToaster.show({ 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 7560746d6042..976553fb571c 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 @@ -174,6 +174,7 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[ { name: 'turboLoadingNodes', type: 'custom', + emptyValue: [], experimental: true, info: ( <> @@ -195,6 +196,7 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: Field[ { name: 'cloneServers', type: 'custom', + emptyValue: {}, experimental: true, info: ( <> diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx index 80beac8a90dc..05f344a70bc3 100644 --- a/web-console/src/views/services-view/services-view.tsx +++ b/web-console/src/views/services-view/services-view.tsx @@ -243,6 +243,112 @@ function aggregateLoadQueueInfos(loadQueueInfos: LoadQueueInfo[]): LoadQueueInfo }; } +interface DetailCellProps { + original: ServiceResultRow; + workerInfoLookup: Record; +} + +function DetailCell({ original, workerInfoLookup }: DetailCellProps) { + const { service_type, service, is_leader } = original; + const loadQueueInfoContext = useContext(LoadQueueInfoContext); + const cloneStatusContext = useContext(CloneStatusContext); + const serverModeInfo = useContext(ServerModeContext); + + switch (service_type) { + case 'middle_manager': + case 'indexer': { + const workerInfo = workerInfoLookup[service]; + if (!workerInfo) return null; + + if (workerInfo.worker.version === '') return <>Disabled; + + const details: string[] = []; + if (workerInfo.lastCompletedTaskTime) { + details.push( + `Last completed task: ${formatDate(workerInfo.lastCompletedTaskTime)}`, + ); + } + if (workerInfo.blacklistedUntil) { + details.push(`Blacklisted until: ${formatDate(workerInfo.blacklistedUntil)}`); + } + return <>{details.join(' ') || null}; + } + + case 'coordinator': + case 'overlord': + return <>{is_leader === 1 ? 'Leader' : ''}; + + case 'historical': { + const loadQueueInfo = loadQueueInfoContext[service]; + const cloneInfo = cloneStatusContext[service]; + + const parts: string[] = []; + if (serverModeInfo.decommissioningNodes.has(service)) { + parts.push('DECOMMISSIONING'); + } + if (serverModeInfo.turboLoadingNodes.has(service)) { + parts.push('TURBO SEGMENT LOADING'); + } + if (loadQueueInfo) { + parts.push(formatLoadQueueInfo(loadQueueInfo)); + } + if (cloneInfo) { + if (cloneInfo.state === 'SOURCE_SERVER_MISSING') { + parts.push(`Clone of ${cloneInfo.sourceServer} (source missing)`); + } else if ( + cloneInfo.segmentLoadsRemaining > 0 || + cloneInfo.segmentDropsRemaining > 0 + ) { + const details: string[] = []; + if (cloneInfo.segmentLoadsRemaining > 0) { + details.push( + `${pluralIfNeeded( + cloneInfo.segmentLoadsRemaining, + 'segment', + )} to load (${formatBytesCompact(cloneInfo.bytesToLoad)})`, + ); + } + if (cloneInfo.segmentDropsRemaining > 0) { + details.push( + `${pluralIfNeeded(cloneInfo.segmentDropsRemaining, 'segment')} to drop`, + ); + } + parts.push(`Cloning from ${cloneInfo.sourceServer}: ${details.join(', ')}`); + } else { + parts.push(`Clone of ${cloneInfo.sourceServer} (synced)`); + } + } + return <>{parts.join('; ') || null}; + } + + default: + return null; + } +} + +interface AggregatedDetailCellProps { + subRows: { _original: ServiceResultRow }[]; +} + +function AggregatedDetailCell({ subRows }: AggregatedDetailCellProps) { + const loadQueueInfoContext = useContext(LoadQueueInfoContext); + const originalRows = subRows.map(r => r._original); + if (!originalRows.some(r => r.service_type === 'historical')) return <>{''}; + + const loadQueueInfos: LoadQueueInfo[] = filterMap( + originalRows, + r => loadQueueInfoContext[r.service], + ); + + return ( + <> + {loadQueueInfos.length + ? formatLoadQueueInfo(aggregateLoadQueueInfos(loadQueueInfos)) + : ''} + + ); +} + function defaultDisplayFn(value: any): string { if (value === undefined || value === null) return ''; return String(value); @@ -894,97 +1000,10 @@ ORDER BY filterable: false, className: 'padded wrapped', accessor: 'service', - Cell: ({ original }) => { - const { service_type, service, is_leader } = original; - const loadQueueInfoContext = useContext(LoadQueueInfoContext); - const cloneStatusContext = useContext(CloneStatusContext); - const serverModeInfo = useContext(ServerModeContext); - - switch (service_type) { - case 'middle_manager': - case 'indexer': { - const workerInfo = workerInfoLookup[service]; - if (!workerInfo) return null; - - if (workerInfo.worker.version === '') return 'Disabled'; - - const details: string[] = []; - if (workerInfo.lastCompletedTaskTime) { - details.push( - `Last completed task: ${formatDate(workerInfo.lastCompletedTaskTime)}`, - ); - } - if (workerInfo.blacklistedUntil) { - details.push(`Blacklisted until: ${formatDate(workerInfo.blacklistedUntil)}`); - } - return details.join(' ') || null; - } - - case 'coordinator': - case 'overlord': - return is_leader === 1 ? 'Leader' : ''; - - case 'historical': { - const loadQueueInfo = loadQueueInfoContext[service]; - const cloneInfo = cloneStatusContext[service]; - - const parts: string[] = []; - if (serverModeInfo.decommissioningNodes.has(service)) { - parts.push('DECOMMISSIONING'); - } - if (serverModeInfo.turboLoadingNodes.has(service)) { - parts.push('TURBO SEGMENT LOADING'); - } - if (loadQueueInfo) { - parts.push(formatLoadQueueInfo(loadQueueInfo)); - } - if (cloneInfo) { - if (cloneInfo.state === 'SOURCE_SERVER_MISSING') { - parts.push(`Clone of ${cloneInfo.sourceServer} (source missing)`); - } else if ( - cloneInfo.segmentLoadsRemaining > 0 || - cloneInfo.segmentDropsRemaining > 0 - ) { - const details: string[] = []; - if (cloneInfo.segmentLoadsRemaining > 0) { - details.push( - `${pluralIfNeeded( - cloneInfo.segmentLoadsRemaining, - 'segment', - )} to load (${formatBytesCompact(cloneInfo.bytesToLoad)})`, - ); - } - if (cloneInfo.segmentDropsRemaining > 0) { - details.push( - `${pluralIfNeeded(cloneInfo.segmentDropsRemaining, 'segment')} to drop`, - ); - } - parts.push(`Cloning from ${cloneInfo.sourceServer}: ${details.join(', ')}`); - } else { - parts.push(`Clone of ${cloneInfo.sourceServer} (synced)`); - } - } - return parts.join('; ') || null; - } - - default: - return null; - } - }, - Aggregated: ({ subRows }) => { - const loadQueueInfoContext = useContext(LoadQueueInfoContext); - const originalRows = subRows.map(r => r._original); - if (!originalRows.some(r => r.service_type === 'historical')) return ''; - - const loadQueueInfos: LoadQueueInfo[] = filterMap( - originalRows, - r => loadQueueInfoContext[r.service], - ); - - return loadQueueInfos.length - ? formatLoadQueueInfo(aggregateLoadQueueInfos(loadQueueInfos)) - : ''; - }, + Cell: ({ original }) => ( + + ), + Aggregated: ({ subRows }) => , }, { Header: ACTION_COLUMN_LABEL, From fe5a387ba090184d03e0352da7819480f3d004c2 Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 15:57:45 -0500 Subject: [PATCH 08/10] prevent creating bad clone mappings --- .../clone-server-mapping-dialog.spec.tsx.snap | 10 ++++++ .../server-multi-select-dialog.spec.tsx.snap | 32 ++++++++++++------- .../clone-server-mapping-dialog.tsx | 30 +++++++++++++---- .../server-multi-select-dialog.tsx | 13 +++++++- .../src/views/services-view/services-view.tsx | 24 ++++---------- 5 files changed, 72 insertions(+), 37 deletions(-) diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap index 7d44d5fc53e2..843fbc0257a6 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap @@ -73,6 +73,7 @@ exports[`CloneServerMappingDialog matches snapshot with existing mappings 1`] = disabledServers={ Set { "host2:8083", + "host1:8083", } } onChange={[Function]} @@ -115,9 +116,11 @@ exports[`CloneServerMappingDialog matches snapshot with existing mappings 1`] = - hot - + + + hot + + } onChange={[Function]} /> @@ -90,9 +92,11 @@ exports[`ServerMultiSelectDialog matches snapshot with existing selection 1`] = checked={false} indeterminate={false} labelElement={ - - cold - + + + cold + + } onChange={[Function]} /> @@ -188,9 +192,11 @@ exports[`ServerMultiSelectDialog matches snapshot with no selection 1`] = ` checked={false} indeterminate={false} labelElement={ - - hot - + + + hot + + } onChange={[Function]} /> @@ -224,9 +230,11 @@ exports[`ServerMultiSelectDialog matches snapshot with no selection 1`] = ` checked={false} indeterminate={false} labelElement={ - - cold - + + + cold + + } onChange={[Function]} /> diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx index ec3094a5dbd4..c56b2143f35b 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx @@ -26,10 +26,13 @@ import { Loader } from '../../components'; import type { TieredServers } from './tiered-servers'; interface CloneMapping { + id: number; target: string; source: string; } +let nextMappingId = 0; + export interface CloneServerMappingDialogProps { servers: TieredServers | undefined; cloneServers: Record; @@ -42,7 +45,11 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi ) { const { servers, cloneServers: initialMapping, onSave, onClose } = props; const [mappings, setMappings] = useState(() => - Object.entries(initialMapping || {}).map(([target, source]) => ({ target, source })), + Object.entries(initialMapping || {}).map(([target, source]) => ({ + id: nextMappingId++, + target, + source, + })), ); function updateMapping(index: number, field: 'target' | 'source', value: string) { @@ -54,7 +61,7 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi } function addMapping() { - setMappings(prev => [...prev, { target: '', source: '' }]); + setMappings(prev => [...prev, { id: nextMappingId++, target: '', source: '' }]); } function handleSave() { @@ -67,7 +74,17 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi } const usedTargets = new Set(mappings.map(m => m.target).filter(Boolean)); - const hasInvalidMapping = mappings.some(m => !m.target || !m.source || m.target === m.source); + const usedSources = new Set(mappings.map(m => m.source).filter(Boolean)); + const disabledTargets = new Set([...usedTargets, ...usedSources]); + const disabledSources = new Set([...usedTargets, ...usedSources]); + const hasInvalidMapping = mappings.some( + m => + !m.target || + !m.source || + m.target === m.source || + usedSources.has(m.target) || + usedTargets.has(m.source), + ); return ( {mappings.map((mapping, i) => ( - + updateMapping(i, 'target', v)} /> @@ -115,7 +132,8 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi updateMapping(i, 'source', v)} /> 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 index 1fa5e5314d86..8b9ca8872b47 100644 --- 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 @@ -114,6 +114,7 @@ export const ServerMultiSelectDialog = React.memo(function ServerMultiSelectDial 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)); @@ -122,7 +123,17 @@ export const ServerMultiSelectDialog = React.memo(function ServerMultiSelectDial {tier}} + labelElement={ + <> + {tier} + {hiddenCount > 0 && ( + + {' '} + ({hiddenCount} hidden by filter) + + )} + + } onChange={() => toggleTier(tier, filteredServers)} />
diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx index 05f344a70bc3..c5dda97fb03a 100644 --- a/web-console/src/views/services-view/services-view.tsx +++ b/web-console/src/views/services-view/services-view.tsx @@ -264,9 +264,7 @@ function DetailCell({ original, workerInfoLookup }: DetailCellProps) { const details: string[] = []; if (workerInfo.lastCompletedTaskTime) { - details.push( - `Last completed task: ${formatDate(workerInfo.lastCompletedTaskTime)}`, - ); + details.push(`Last completed task: ${formatDate(workerInfo.lastCompletedTaskTime)}`); } if (workerInfo.blacklistedUntil) { details.push(`Blacklisted until: ${formatDate(workerInfo.blacklistedUntil)}`); @@ -295,10 +293,7 @@ function DetailCell({ original, workerInfoLookup }: DetailCellProps) { if (cloneInfo) { if (cloneInfo.state === 'SOURCE_SERVER_MISSING') { parts.push(`Clone of ${cloneInfo.sourceServer} (source missing)`); - } else if ( - cloneInfo.segmentLoadsRemaining > 0 || - cloneInfo.segmentDropsRemaining > 0 - ) { + } else if (cloneInfo.segmentLoadsRemaining > 0 || cloneInfo.segmentDropsRemaining > 0) { const details: string[] = []; if (cloneInfo.segmentLoadsRemaining > 0) { details.push( @@ -309,9 +304,7 @@ function DetailCell({ original, workerInfoLookup }: DetailCellProps) { ); } if (cloneInfo.segmentDropsRemaining > 0) { - details.push( - `${pluralIfNeeded(cloneInfo.segmentDropsRemaining, 'segment')} to drop`, - ); + details.push(`${pluralIfNeeded(cloneInfo.segmentDropsRemaining, 'segment')} to drop`); } parts.push(`Cloning from ${cloneInfo.sourceServer}: ${details.join(', ')}`); } else { @@ -333,20 +326,15 @@ interface AggregatedDetailCellProps { function AggregatedDetailCell({ subRows }: AggregatedDetailCellProps) { const loadQueueInfoContext = useContext(LoadQueueInfoContext); const originalRows = subRows.map(r => r._original); - if (!originalRows.some(r => r.service_type === 'historical')) return <>{''}; + if (!originalRows.some(r => r.service_type === 'historical')) return null; const loadQueueInfos: LoadQueueInfo[] = filterMap( originalRows, r => loadQueueInfoContext[r.service], ); - return ( - <> - {loadQueueInfos.length - ? formatLoadQueueInfo(aggregateLoadQueueInfos(loadQueueInfos)) - : ''} - - ); + if (!loadQueueInfos.length) return null; + return <>{formatLoadQueueInfo(aggregateLoadQueueInfos(loadQueueInfos))}; } function defaultDisplayFn(value: any): string { From 2bb890810d1c65ffdd6b18a4477e244f0c5bfa34 Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 16:31:33 -0500 Subject: [PATCH 09/10] add some error messaging and a safety check when saving clone mappings --- .../clone-server-mapping-dialog.tsx | 26 +++++++++++++------ .../coordinator-dynamic-config-dialog.tsx | 2 +- .../src/views/services-view/services-view.tsx | 5 ++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx index c56b2143f35b..81eaed4d80bb 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/clone-server-mapping-dialog.tsx @@ -16,10 +16,19 @@ * limitations under the License. */ -import { Button, Callout, Classes, Dialog, Intent, MenuDivider, MenuItem } from '@blueprintjs/core'; +import { + Button, + Callout, + Classes, + Dialog, + Intent, + Menu, + MenuDivider, + MenuItem, +} from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Select } from '@blueprintjs/select'; -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Loader } from '../../components'; @@ -31,8 +40,6 @@ interface CloneMapping { source: string; } -let nextMappingId = 0; - export interface CloneServerMappingDialogProps { servers: TieredServers | undefined; cloneServers: Record; @@ -44,9 +51,10 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi props: CloneServerMappingDialogProps, ) { const { servers, cloneServers: initialMapping, onSave, onClose } = props; + const nextMappingId = useRef(0); const [mappings, setMappings] = useState(() => Object.entries(initialMapping || {}).map(([target, source]) => ({ - id: nextMappingId++, + id: nextMappingId.current++, target, source, })), @@ -61,13 +69,15 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi } function addMapping() { - setMappings(prev => [...prev, { id: nextMappingId++, target: '', source: '' }]); + setMappings(prev => [...prev, { id: nextMappingId.current++, target: '', source: '' }]); } function handleSave() { const result: Record = {}; for (const m of mappings) { - result[m.target] = m.source; + if (m.target && m.source && m.target !== m.source) { + result[m.target] = m.source; + } } onSave(result); onClose(); @@ -209,7 +219,7 @@ function ServerSelect(props: ServerSelectProps) { } elements.push(renderItem(item, i)); } - return <>{elements}; + return {elements}; }} itemRenderer={(item, { handleClick, handleFocus, modifiers }) => { if (!modifiers.matchesPredicate) return null; 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 b29faa8a56d7..5a357556651d 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 @@ -109,7 +109,7 @@ export const CoordinatorDynamicConfigDialog = React.memo(function CoordinatorDyn }, }); - useQueryManager>({ + useQueryManager({ initQuery: null, processQuery: async (_, signal) => { try { diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx index c5dda97fb03a..d23ac2922602 100644 --- a/web-console/src/views/services-view/services-view.tsx +++ b/web-console/src/views/services-view/services-view.tsx @@ -518,6 +518,11 @@ ORDER BY : {}), }; } catch { + AppToaster.show({ + icon: IconNames.ERROR, + intent: Intent.DANGER, + message: 'There was an error getting clone status and server mode info', + }); return servicesWithAuxiliaryInfo; } }); From fb62aad1b53fc62628734e862411bc5223fb1a30 Mon Sep 17 00:00:00 2001 From: Lucas Capistrant Date: Wed, 1 Apr 2026 17:10:13 -0500 Subject: [PATCH 10/10] More refactoring and improvement --- .../clone-server-mapping-dialog.spec.tsx.snap | 6 ----- .../clone-server-mapping-dialog.tsx | 18 +++++---------- .../server-multi-select-dialog.tsx | 23 ++++++++++++------- 3 files changed, 21 insertions(+), 26 deletions(-) diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap index 843fbc0257a6..09dac32e148b 100644 --- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap +++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/clone-server-mapping-dialog.spec.tsx.snap @@ -69,7 +69,6 @@ exports[`CloneServerMappingDialog matches snapshot with existing mappings 1`] = m.target).filter(Boolean)); const usedSources = new Set(mappings.map(m => m.source).filter(Boolean)); - const disabledTargets = new Set([...usedTargets, ...usedSources]); - const disabledSources = new Set([...usedTargets, ...usedSources]); + const disabledServers = new Set([...usedTargets, ...usedSources]); const hasInvalidMapping = mappings.some( m => !m.target || @@ -132,8 +131,7 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi updateMapping(i, 'target', v)} /> @@ -142,8 +140,7 @@ export const CloneServerMappingDialog = React.memo(function CloneServerMappingDi updateMapping(i, 'source', v)} /> @@ -193,12 +190,11 @@ interface ServerSelectProps { servers: TieredServers; value: string; disabledServers?: Set; - currentValue?: string; onChange(value: string): void; } function ServerSelect(props: ServerSelectProps) { - const { servers, value, disabledServers, currentValue, onChange } = props; + const { servers, value, disabledServers, onChange } = props; // Build a flat list of items with tier headers handled in the renderer const allItems = servers.allServers; @@ -212,7 +208,7 @@ function ServerSelect(props: ServerSelectProps) { let lastTier: string | undefined; for (let i = 0; i < filteredItems.length; i++) { const item = filteredItems[i]; - const tier = servers.serverToTier[item]; + const tier = servers.serverToTier[item] || ''; if (tier && tier !== lastTier) { elements.push(); lastTier = tier; @@ -223,9 +219,7 @@ function ServerSelect(props: ServerSelectProps) { }} itemRenderer={(item, { handleClick, handleFocus, modifiers }) => { if (!modifiers.matchesPredicate) return null; - const disabled = disabledServers - ? disabledServers.has(item) && item !== currentValue - : false; + const disabled = disabledServers ? disabledServers.has(item) && item !== value : false; return ( { + 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 ( @@ -92,7 +103,8 @@ export const ServerMultiSelectDialog = React.memo(function ServerMultiSelectDial {staleServers.length > 0 && ( {staleServers.length} selected server{staleServers.length > 1 ? 's are' : ' is'} no - longer in the cluster: {staleServers.join(', ')}.{' '} + longer in the cluster + {staleServers.length <= 5 ? `: ${staleServers.join(', ')}` : ''}.{' '} @@ -106,12 +118,7 @@ export const ServerMultiSelectDialog = React.memo(function ServerMultiSelectDial style={{ marginBottom: 10 }} />
- {servers.tiers.map(tier => { - const tierServers = servers.serversByTier[tier] || []; - const filteredServers = lowerSearch - ? tierServers.filter(s => s.toLowerCase().includes(lowerSearch)) - : tierServers; - + {filteredTiers.map(({ tier, tierServers, filtered: filteredServers }) => { if (filteredServers.length === 0) return null; const hiddenCount = tierServers.length - filteredServers.length;