-
- );
-};
-
-export default IndicesGroupTable;
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts
new file mode 100644
index 000000000000..563e5347ea03
--- /dev/null
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2020 Graylog, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Server Side Public License, version 1,
+ * as published by MongoDB, Inc.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Server Side Public License for more details.
+ *
+ * You should have received a copy of the Server Side Public License
+ * along with this program. If not, see
+ * .
+ */
+import { SystemIndexerIndices } from '@graylog/server-api';
+
+import FiltersForQueryParams from 'components/common/EntityFilters/FiltersForQueryParams';
+import type { Attribute, SearchParams } from 'stores/PaginationTypes';
+import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices';
+
+// Entity tables require an `id` on every row; the index name is a stable natural key.
+export type IncompatibleIndexRow = IncompatibleIndex & { id: string };
+
+export type IncompatibleIndicesResponse = {
+ list: Array;
+ pagination: { total: number };
+ attributes: Array;
+};
+
+export const INCOMPATIBLE_INDICES_QUERY_KEY = ['incompatibleIndices'] as const;
+
+type ListOutdatedIndicesSort = Parameters[0];
+type ListOutdatedIndicesOrder = Parameters[4];
+
+export const fetchIncompatibleIndices = (searchParams: SearchParams): Promise => {
+ // This endpoint is backed by an in-memory Lucene search that only understands the `query` string
+ // (there is no separate `filters` param), so fold the active filter chips into the query.
+ const query = [searchParams.query, ...FiltersForQueryParams(searchParams.filters)].filter(Boolean).join(' ');
+ const sort = (searchParams.sort?.attributeId ?? 'index_name') as ListOutdatedIndicesSort;
+ const order = (searchParams.sort?.direction ?? 'asc') as ListOutdatedIndicesOrder;
+
+ return SystemIndexerIndices.listOutdatedIndices(sort, searchParams.page, searchParams.pageSize, query, order).then(
+ ({ elements, attributes, total }) => ({
+ list: elements.map((element) => ({ ...element, id: element.index_name })) as Array,
+ pagination: { total },
+ attributes,
+ }),
+ );
+};
+
+export const incompatibleIndicesKeyFn = (searchParams?: SearchParams) => [
+ ...INCOMPATIBLE_INDICES_QUERY_KEY,
+ 'paginated',
+ ...(searchParams ? [searchParams] : []),
+];
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx
index 576449acc88a..e8a2a553276b 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx
@@ -16,7 +16,7 @@
*/
import React from 'react';
-import { ClusterDeflector, IndexerIndices } from '@graylog/server-api';
+import { ClusterDeflector, IndexerIndices, SystemIndexerIndices } from '@graylog/server-api';
import type { StyleProps } from 'components/bootstrap/Button';
import type { IndexArchiveBinding } from 'components/indices/archive/types';
@@ -51,9 +51,9 @@ type ActionDefinition = {
};
const deleteIncompatibleIndex = (index: IncompatibleIndex) =>
- index.managed_index ? IndexerIndices.remove(index.index_name) : IndexerIndices.deleteOutdated(index.index_name);
+ index.managed_index ? IndexerIndices.remove(index.index_name) : SystemIndexerIndices.deleteOutdated(index.index_name);
-const reindexSystemIndex = (index: IncompatibleIndex) => IndexerIndices.reindex(index.index_name);
+const reindexSystemIndex = (index: IncompatibleIndex) => SystemIndexerIndices.reindex(index.index_name);
const rotateWriteIndex = (index: IncompatibleIndex) => ClusterDeflector.cycleByindexSetId(index.active_write_index);
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexGroups.test.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexGroups.test.ts
deleted file mode 100644
index f437de5579f5..000000000000
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexGroups.test.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Copyright (C) 2020 Graylog, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the Server Side Public License, version 1,
- * as published by MongoDB, Inc.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * Server Side Public License for more details.
- *
- * You should have received a copy of the Server Side Public License
- * along with this program. If not, see
- * .
- */
-import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices';
-
-import { groupIncompatibleIndices, getFirstGroupWithIndices, getSelectedGroup } from './incompatibleIndexGroups';
-
-const makeIndex = (overrides: Partial): IncompatibleIndex => ({
- index_name: 'index',
- version: '2.0.0',
- warm_index: false,
- managed_index: false,
- system_index: false,
- active_write_index: null,
- ...overrides,
-});
-
-const graylogIndex = makeIndex({ index_name: 'graylog_0', managed_index: true });
-const systemIndex = makeIndex({ index_name: '.system', system_index: true });
-const foreignIndex = makeIndex({ index_name: 'legacy' });
-
-describe('incompatibleIndexGroups', () => {
- describe('groupIncompatibleIndices', () => {
- it('returns the three groups in a stable order', () => {
- expect(groupIncompatibleIndices([]).map((group) => group.id)).toEqual(['graylog', 'system', 'foreign']);
- });
-
- it('classifies each index into its matching group', () => {
- const [graylog, system, foreign] = groupIncompatibleIndices([graylogIndex, systemIndex, foreignIndex]);
-
- expect(graylog.indices).toEqual([graylogIndex]);
- expect(system.indices).toEqual([systemIndex]);
- expect(foreign.indices).toEqual([foreignIndex]);
- });
-
- it('treats a managed system index as a system index', () => {
- const managedSystemIndex = makeIndex({ index_name: '.managed-system', managed_index: true, system_index: true });
- const [graylog, system] = groupIncompatibleIndices([managedSystemIndex]);
-
- expect(graylog.indices).toEqual([]);
- expect(system.indices).toEqual([managedSystemIndex]);
- });
- });
-
- describe('getFirstGroupWithIndices', () => {
- it('returns the id of the first non-empty group', () => {
- const groups = groupIncompatibleIndices([systemIndex]);
-
- expect(getFirstGroupWithIndices(groups)).toBe('system');
- });
-
- it('falls back to the default group when every group is empty', () => {
- const groups = groupIncompatibleIndices([]);
-
- expect(getFirstGroupWithIndices(groups)).toBe('graylog');
- });
- });
-
- describe('getSelectedGroup', () => {
- it('returns the group matching the given id', () => {
- const groups = groupIncompatibleIndices([foreignIndex]);
-
- expect(getSelectedGroup(groups, 'foreign').id).toBe('foreign');
- });
-
- it('falls back to the first group for an unknown id', () => {
- const groups = groupIncompatibleIndices([]);
-
- expect(getSelectedGroup(groups, 'does-not-exist').id).toBe('graylog');
- });
- });
-});
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexGroups.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexGroups.ts
deleted file mode 100644
index 10f3aa34b0cc..000000000000
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexGroups.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright (C) 2020 Graylog, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the Server Side Public License, version 1,
- * as published by MongoDB, Inc.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * Server Side Public License for more details.
- *
- * You should have received a copy of the Server Side Public License
- * along with this program. If not, see
- * .
- */
-import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices';
-
-type IndexGroupId = 'graylog' | 'system' | 'foreign';
-
-type IndexGroupDefinition = {
- id: IndexGroupId;
- shortLabel: string;
- indexLabel: string;
- matches: (index: IncompatibleIndex) => boolean;
-};
-
-export type IndicesGroup = Omit & {
- indices: Array;
-};
-
-const INDEX_GROUPS: Array = [
- {
- id: 'graylog',
- shortLabel: 'Graylog',
- indexLabel: 'Graylog index',
- matches: (index) => index.managed_index && !index.system_index,
- },
- {
- id: 'system',
- shortLabel: 'System',
- indexLabel: 'System index',
- matches: (index) => index.system_index,
- },
- {
- id: 'foreign',
- shortLabel: 'Foreign',
- indexLabel: 'Foreign index',
- matches: (index) => !index.managed_index && !index.system_index,
- },
-];
-
-const DEFAULT_GROUP_ID = INDEX_GROUPS[0].id;
-
-export const groupIncompatibleIndices = (indices: Array): Array =>
- INDEX_GROUPS.map(({ id, shortLabel, indexLabel, matches }) => ({
- id,
- shortLabel,
- indexLabel,
- indices: indices.filter(matches),
- }));
-
-export const getFirstGroupWithIndices = (groups: Array) =>
- groups.find((group) => group.indices.length > 0)?.id ?? DEFAULT_GROUP_ID;
-
-export const getSelectedGroup = (groups: Array, selectedGroupId: string) =>
- groups.find((group) => group.id === selectedGroupId) ?? groups[0];
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/telemetry.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/telemetry.ts
new file mode 100644
index 000000000000..abdb47016860
--- /dev/null
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/telemetry.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright (C) 2020 Graylog, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the Server Side Public License, version 1,
+ * as published by MongoDB, Inc.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * Server Side Public License for more details.
+ *
+ * You should have received a copy of the Server Side Public License
+ * along with this program. If not, see
+ * .
+ */
+export const TELEMETRY_DEFAULTS = { app_pathname: 'datanode', app_section: 'opensearch-upgrade' } as const;
diff --git a/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts b/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts
index 78347410781d..05f1e2a81c88 100644
--- a/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts
+++ b/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts
@@ -16,7 +16,7 @@
*/
import { useQuery } from '@tanstack/react-query';
-import { IndexerIndices } from '@graylog/server-api';
+import { SystemIndexerIndices } from '@graylog/server-api';
export type IncompatibleIndex = {
index_name: string;
@@ -26,6 +26,10 @@ export type IncompatibleIndex = {
system_index: boolean;
/** Id of the index set this index is the active write index of, `null` for all other indices. */
active_write_index: string | null;
+ /** Start of the message time range stored in the index, `null` when unknown / not calculated. */
+ begin?: string | null;
+ /** End of the message time range stored in the index, `null` when unknown / not calculated. */
+ end?: string | null;
};
const ERROR_REFETCH_INTERVAL_MS = 30000;
@@ -39,7 +43,7 @@ const useIncompatibleIndices = () => {
} = useQuery({
queryKey: ['incompatibleIndices'],
// No error toast: the panels render a persistent error state, and background retries would spam toasts.
- queryFn: () => IndexerIndices.getOutdatedIndices() as Promise>,
+ queryFn: () => SystemIndexerIndices.getOutdatedIndices() as Promise>,
retry: 2,
refetchInterval: (query) => (query.state.status === 'error' ? ERROR_REFETCH_INTERVAL_MS : false),
});
From e772629e5b4b5fdefae28db02af530de8368786a Mon Sep 17 00:00:00 2001
From: Matthias Oesterheld
Date: Fri, 17 Jul 2026 13:14:41 +0200
Subject: [PATCH 6/9] add bulk delete functionality
---
.../indexer/indices/OutdatedIndexService.java | 8 +-
.../system/indexer/OutdatedIndexResource.java | 72 +++++++++++++++++-
.../indices/OutdatedIndexServiceTest.java | 4 +
.../indexer/OutdatedIndicesResourceTest.java | 54 +++++++++++++
.../IncompatibleIndicesBulkActions.tsx | 75 ++++++++++++++-----
5 files changed, 193 insertions(+), 20 deletions(-)
diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndexService.java b/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndexService.java
index 92166fc85b29..b47b2f785e59 100644
--- a/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndexService.java
+++ b/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndexService.java
@@ -19,12 +19,14 @@
import com.github.zafarkhaja.semver.ParseException;
import com.github.zafarkhaja.semver.Version;
+import com.google.common.eventbus.EventBus;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.validation.constraints.NotNull;
import org.graylog2.indexer.ElasticsearchException;
import org.graylog2.indexer.cluster.Cluster;
import org.graylog2.indexer.indexset.registry.IndexSetRegistry;
+import org.graylog2.indexer.indices.events.IndicesDeletedEvent;
import org.graylog2.indexer.ranges.IndexRange;
import org.graylog2.indexer.ranges.IndexRangeService;
import org.graylog2.indexer.security.IndexerAdminCert;
@@ -51,14 +53,16 @@ public class OutdatedIndexService {
private final IndicesAdapter indicesAdapter;
private final IndexSetRegistry indexSetRegistry;
private final IndexRangeService indexRangeService;
+ private final EventBus eventBus;
private final Cluster cluster;
@Inject
public OutdatedIndexService(@IndexerAdminCert IndicesAdapter indicesAdapter, IndexSetRegistry indexSetRegistry,
- IndexRangeService indexRangeService, Cluster cluster) {
+ IndexRangeService indexRangeService, EventBus eventBus, Cluster cluster) {
this.indicesAdapter = indicesAdapter;
this.indexSetRegistry = indexSetRegistry;
this.indexRangeService = indexRangeService;
+ this.eventBus = eventBus;
this.cluster = cluster;
}
@@ -183,5 +187,7 @@ private Map cleanIndexSettings(Map settings, boo
public void delete(@NotNull String index) {
indicesAdapter.delete(index);
+ // Mirror Indices#delete so listeners clean up index ranges and cached field types for managed indices.
+ eventBus.post(IndicesDeletedEvent.create(index));
}
}
diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/OutdatedIndexResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/OutdatedIndexResource.java
index e57a32e45e4e..36499c34c37f 100644
--- a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/OutdatedIndexResource.java
+++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/OutdatedIndexResource.java
@@ -25,6 +25,8 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Inject;
import jakarta.validation.constraints.NotNull;
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
@@ -38,11 +40,17 @@
import org.apache.lucene.queryparser.flexible.core.QueryNodeException;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.graylog2.audit.AuditActor;
+import org.graylog2.audit.AuditEventSender;
import org.graylog2.audit.AuditEventTypes;
import org.graylog2.audit.jersey.AuditEvent;
+import org.graylog2.audit.jersey.NoAuditEvent;
import org.graylog2.database.PaginatedList;
import org.graylog2.indexer.indices.OutdatedIndex;
import org.graylog2.indexer.indices.OutdatedIndexService;
+import org.graylog2.rest.bulk.model.BulkOperationFailure;
+import org.graylog2.rest.bulk.model.BulkOperationRequest;
+import org.graylog2.rest.bulk.model.BulkOperationResponse;
import org.graylog2.rest.models.SortOrder;
import org.graylog2.rest.models.tools.responses.PageListResponse;
import org.graylog2.rest.resources.entities.EntityAttribute;
@@ -58,10 +66,14 @@
import org.graylog2.utilities.lucene.LuceneInMemorySearchEngine;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import java.util.stream.Collectors;
@Tag(name = "System/Indexer/Indices", description = "Outdated index discovery")
@RequiresAuthentication
@@ -93,12 +105,14 @@ private static Set categoryFilterOptions() {
.build();
private final OutdatedIndexService outdatedIndexService;
+ private final AuditEventSender auditEventSender;
private final InMemorySearchEngine outdatedIndexSearchService;
private final SearchQueryParser searchQueryParser;
@Inject
- public OutdatedIndexResource(OutdatedIndexService outdatedIndexService) {
+ public OutdatedIndexResource(OutdatedIndexService outdatedIndexService, AuditEventSender auditEventSender) {
this.outdatedIndexService = outdatedIndexService;
+ this.auditEventSender = auditEventSender;
final Supplier> cachingSupplier = Suppliers.memoizeWithExpiration(
outdatedIndexService::getOutdatedIndices,
10,
@@ -173,4 +187,60 @@ public void deleteOutdated(@Parameter(name = "index") @PathParam("index") @NotNu
.findAny().orElseThrow(() -> new NotFoundException("Index " + index + " not found or is an index managed by Graylog"));
outdatedIndexService.delete(outdatedIndex.indexName());
}
+
+ @POST
+ @Path("/bulk_delete")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Timed
+ @Operation(summary = "Delete a bulk of outdated indices")
+ @NoAuditEvent("Each deleted index is audited individually below")
+ public BulkOperationResponse bulkDeleteOutdated(@Parameter(name = "Entities to remove", required = true) BulkOperationRequest request) {
+ if (request == null || request.entityIds() == null || request.entityIds().isEmpty()) {
+ throw new BadRequestException("No index names provided");
+ }
+
+ final Map outdatedByName = getOutdatedIndices().stream()
+ .collect(Collectors.toMap(OutdatedIndex::indexName, Function.identity(), (existing, replacement) -> existing));
+
+ final List failures = new ArrayList<>();
+ int deleted = 0;
+ for (String index : request.entityIds()) {
+ final String failure = deleteSingleOutdated(index, outdatedByName.get(index));
+ if (failure != null) {
+ failures.add(new BulkOperationFailure(index, failure));
+ } else {
+ deleted++;
+ }
+ }
+ return new BulkOperationResponse(deleted, failures);
+ }
+
+ private String deleteSingleOutdated(String index, OutdatedIndex outdatedIndex) {
+ if (outdatedIndex == null) {
+ return "Index " + index + " not found or is not an outdated index";
+ }
+ if (outdatedIndex.activeWriteIndex() != null) {
+ return "Index " + index + " is the active write index and cannot be deleted";
+ }
+ // Managed indices are gated by a per-index permission (see IndicesResource#delete); non-managed/foreign
+ // indices have no per-index grant and are only gated by the general permission (see #deleteOutdated).
+ final boolean permitted = outdatedIndex.managedIndex()
+ ? isPermitted(RestPermissions.INDICES_DELETE, index)
+ : isPermitted(RestPermissions.INDICES_DELETE);
+ if (!permitted) {
+ return "Not authorized to delete index " + index;
+ }
+ try {
+ outdatedIndexService.delete(index);
+ auditEventSender.success(auditActor(), AuditEventTypes.ES_INDEX_DELETE, Map.of("index_name", index));
+ return null;
+ } catch (Exception e) {
+ auditEventSender.failure(auditActor(), AuditEventTypes.ES_INDEX_DELETE, Map.of("index_name", index));
+ return e.getMessage();
+ }
+ }
+
+ private AuditActor auditActor() {
+ return AuditActor.user(getSubject().getPrincipal().toString());
+ }
}
diff --git a/graylog2-server/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceTest.java b/graylog2-server/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceTest.java
index 75bd5fa84f4a..53e19343d68b 100644
--- a/graylog2-server/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceTest.java
+++ b/graylog2-server/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceTest.java
@@ -17,6 +17,7 @@
package org.graylog2.indexer.indices;
+import com.google.common.eventbus.EventBus;
import org.assertj.core.api.Assertions;
import org.bson.conversions.Bson;
import org.graylog2.indexer.cluster.Cluster;
@@ -66,6 +67,9 @@ class OutdatedIndexServiceTest {
@Mock
IndexRangeService indexRangeService;
+ @Mock
+ EventBus eventBus;
+
@InjectMocks
OutdatedIndexService outdatedIndexService;
diff --git a/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/OutdatedIndicesResourceTest.java b/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/OutdatedIndicesResourceTest.java
index 9ec443423248..d396185208de 100644
--- a/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/OutdatedIndicesResourceTest.java
+++ b/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/OutdatedIndicesResourceTest.java
@@ -19,11 +19,15 @@
import jakarta.ws.rs.ForbiddenException;
import org.assertj.core.api.Assertions;
+import org.graylog2.audit.AuditEventSender;
import org.graylog2.indexer.NodeInfoCache;
import org.graylog2.indexer.indexset.registry.IndexSetRegistry;
import org.graylog2.indexer.indices.Indices;
import org.graylog2.indexer.indices.OutdatedIndex;
import org.graylog2.indexer.indices.OutdatedIndexService;
+import org.graylog2.rest.bulk.model.BulkOperationFailure;
+import org.graylog2.rest.bulk.model.BulkOperationRequest;
+import org.graylog2.rest.bulk.model.BulkOperationResponse;
import org.graylog2.security.WithAuthorization;
import org.graylog2.security.WithAuthorizationExtension;
import org.junit.jupiter.api.Test;
@@ -35,6 +39,7 @@
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -55,6 +60,9 @@ class OutdatedIndicesResourceTest {
@Mock
OutdatedIndexService outdatedIndexService;
+ @Mock
+ AuditEventSender auditEventSender;
+
@InjectMocks
OutdatedIndexResource outdatedIndexResource;
@@ -90,4 +98,50 @@ void reindexSucceeds() {
verify(outdatedIndexService, times(1)).reindex(".outdated1", true);
}
+ @Test
+ @WithAuthorization(permissions = {"indices:read", "indices:delete"})
+ void bulkDeleteOutdatedDeletesEligibleIndicesAndReportsFailures() {
+ when(outdatedIndexService.getOutdatedIndices()).thenReturn(List.of(
+ new OutdatedIndex("foreign1", "1.3.0", false, false, null),
+ new OutdatedIndex("managed1", "1.3.0", false, true, null),
+ new OutdatedIndex("writeindex", "1.3.0", false, true, "id1")
+ ));
+
+ final BulkOperationResponse response = outdatedIndexResource.bulkDeleteOutdated(
+ new BulkOperationRequest(List.of("foreign1", "managed1", "writeindex", "missing")));
+
+ verify(outdatedIndexService).delete("foreign1");
+ verify(outdatedIndexService).delete("managed1");
+ verify(outdatedIndexService, never()).delete("writeindex");
+ verify(outdatedIndexService, never()).delete("missing");
+ assertThat(response.successfullyPerformed()).isEqualTo(2);
+ assertThat(response.failures()).extracting(BulkOperationFailure::entityId)
+ .containsExactlyInAnyOrder("writeindex", "missing");
+ }
+
+ @Test
+ @WithAuthorization(permissions = {"indices:read", "indices:delete:managed1"})
+ void bulkDeleteOutdatedUsesPerIndexPermissionForManagedAndGeneralForForeign() {
+ when(outdatedIndexService.getOutdatedIndices()).thenReturn(List.of(
+ new OutdatedIndex("managed1", "1.3.0", false, true, null),
+ new OutdatedIndex("foreign1", "1.3.0", false, false, null)
+ ));
+
+ final BulkOperationResponse response = outdatedIndexResource.bulkDeleteOutdated(
+ new BulkOperationRequest(List.of("managed1", "foreign1")));
+
+ // The instance grant covers the managed index, but the foreign index needs the general indices:delete.
+ verify(outdatedIndexService).delete("managed1");
+ verify(outdatedIndexService, never()).delete("foreign1");
+ assertThat(response.successfullyPerformed()).isEqualTo(1);
+ assertThat(response.failures()).extracting(BulkOperationFailure::entityId).containsExactly("foreign1");
+ }
+
+ @Test
+ @WithAuthorization(permissions = {"indices:read", "indices:delete"})
+ void bulkDeleteOutdatedRejectsEmptyRequest() {
+ Assertions.assertThatThrownBy(() -> outdatedIndexResource.bulkDeleteOutdated(new BulkOperationRequest(List.of())))
+ .isInstanceOf(jakarta.ws.rs.BadRequestException.class);
+ }
+
}
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx
index 081642fcd6d7..b1c09715e9c0 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx
@@ -16,6 +16,8 @@
*/
import React, { useMemo, useState } from 'react';
+import { SystemIndexerIndices } from '@graylog/server-api';
+
import { MenuItem } from 'components/bootstrap';
import BulkActionsDropdown from 'components/common/EntityDataTable/BulkActionsDropdown';
import useSelectedEntities from 'components/common/EntityDataTable/hooks/useSelectedEntities';
@@ -30,7 +32,7 @@ import {
getBulkIndexActionNotification,
runBulkIndexAction,
} from './bulkIndexActions';
-import type { BulkIndexActionCandidate } from './bulkIndexActions';
+import type { BulkIndexActionCandidate, BulkIndexActionNotification } from './bulkIndexActions';
import type { IncompatibleIndexRow } from './fetchIncompatibleIndices';
import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions';
@@ -42,6 +44,54 @@ type Props = {
refetch: () => void;
};
+const showNotification = ({ type, message, title }: BulkIndexActionNotification) => {
+ if (type === 'success') {
+ UserNotification.success(message);
+ } else if (type === 'warning') {
+ UserNotification.warning(message, title);
+ } else {
+ UserNotification.error(message, title);
+ }
+};
+
+// Deletes are handled by a single server-side bulk endpoint. Returns the names of the indices that were deleted.
+const bulkDeleteIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => {
+ const entityIds = bulkAction.targetIndices.map((index) => index.index_name);
+ const { failures } = await SystemIndexerIndices.bulkDeleteOutdated({ entity_ids: entityIds });
+ const failedIds = (failures ?? []).map(({ entity_id }) => entity_id);
+ const succeeded = entityIds.length - failedIds.length;
+
+ if (failedIds.length === 0) {
+ showNotification({ type: 'success', message: `${succeeded} ${succeeded === 1 ? 'index was' : 'indices were'} deleted.` });
+ } else {
+ const details = (failures ?? [])
+ .slice(0, 3)
+ .map(({ entity_id, failure_explanation }) => `${entity_id}: ${failure_explanation}`)
+ .join('\n');
+ const more = failedIds.length > 3 ? `\n...and ${failedIds.length - 3} more.` : '';
+ const message =
+ succeeded > 0
+ ? `${succeeded} succeeded, ${failedIds.length} failed.\n${details}${more}`
+ : `${failedIds.length} ${failedIds.length === 1 ? 'index' : 'indices'} failed.\n${details}${more}`;
+
+ showNotification(
+ succeeded > 0
+ ? { type: 'warning', message, title: 'Some indices could not be deleted' }
+ : { type: 'error', message, title: 'Could not delete indices' },
+ );
+ }
+
+ return entityIds.filter((id) => !failedIds.includes(id));
+};
+
+// Reindex has no bulk endpoint, so it runs client-side. Returns the names of the indices that were reindexed.
+const bulkReindexIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => {
+ const result = await runBulkIndexAction({ action: bulkAction.action, indices: bulkAction.targetIndices });
+ showNotification(getBulkIndexActionNotification(bulkAction, result));
+
+ return result.successes.map(({ index }) => index.index_name);
+};
+
const IncompatibleIndicesBulkActions = ({
indices,
canArchive,
@@ -77,23 +127,12 @@ const IncompatibleIndicesBulkActions = ({
setIsSubmitting(true);
try {
- const result = await runBulkIndexAction({
- action: confirmedBulkAction.action,
- indices: confirmedBulkAction.targetIndices,
- });
-
- const notification = getBulkIndexActionNotification(confirmedBulkAction, result);
-
- if (notification.type === 'success') {
- UserNotification.success(notification.message);
- } else if (notification.type === 'warning') {
- UserNotification.warning(notification.message, notification.title);
- } else {
- UserNotification.error(notification.message, notification.title);
- }
-
- const succeededIds = new Set(result.successes.map(({ index }) => index.index_name));
- setSelectedEntities(selectedEntities.filter((id) => !succeededIds.has(id)));
+ const succeededIds =
+ confirmedBulkAction.action === 'delete'
+ ? await bulkDeleteIndices(confirmedBulkAction)
+ : await bulkReindexIndices(confirmedBulkAction);
+
+ setSelectedEntities(selectedEntities.filter((id) => !succeededIds.includes(id)));
refetch();
setConfirmedBulkAction(undefined);
} finally {
From d23ae810f8c25c15cf1d66466103ecfe1104c0c6 Mon Sep 17 00:00:00 2001
From: Matthias Oesterheld
Date: Fri, 17 Jul 2026 13:30:56 +0200
Subject: [PATCH 7/9] fix test
---
.../org/graylog2/indexer/indices/OutdatedIndexServiceIT.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/full-backend-tests/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceIT.java b/full-backend-tests/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceIT.java
index a29ef64696ef..d710cd8f2ab6 100644
--- a/full-backend-tests/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceIT.java
+++ b/full-backend-tests/src/test/java/org/graylog2/indexer/indices/OutdatedIndexServiceIT.java
@@ -43,7 +43,7 @@ public class OutdatedIndexServiceIT extends SearchServerBaseTest {
public void setUp() throws Exception {
indicesAdapter = searchServer().adapters().indicesAdapter();
countsAdapter = searchServer().adapters().countsAdapter();
- outdatedIndexService = new OutdatedIndexService(indicesAdapter, null, null, null);
+ outdatedIndexService = new OutdatedIndexService(indicesAdapter, null, null, null, null);
}
@FullBackendTest
From a8b605198d8c77ff5f217d95ff5d671877bf46e3 Mon Sep 17 00:00:00 2001
From: Mohamed Ould Hocine
Date: Fri, 17 Jul 2026 16:29:17 +0200
Subject: [PATCH 8/9] cleanup + ux improvements
---
.../IncompatibleIndexTableActions.tsx | 3 +-
.../IncompatibleIndicesBulkActions.test.tsx | 141 ++++++++++++++++++
.../IncompatibleIndicesBulkActions.tsx | 30 ++--
.../IncompatibleIndicesColumnRenderers.tsx | 8 +-
.../IncompatibleIndicesTable.test.tsx | 46 +++++-
.../IncompatibleIndicesTable.tsx | 12 +-
.../OpenSearchUpgradeSection.tsx | 10 +-
.../fetchIncompatibleIndices.ts | 9 +-
.../incompatibleIndexActions.tsx | 8 +-
.../indices/hooks/useIncompatibleIndices.ts | 4 -
10 files changed, 238 insertions(+), 33 deletions(-)
create mode 100644 graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx
index 333fe9240b3c..3369840ead4c 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx
@@ -57,7 +57,7 @@ const IncompatibleIndexTableActions = ({
pendingStatus,
isArchived,
addArchiveDeleteAction,
- refetchClusterJobs,
+ refetchClusterJobs = undefined,
refetch,
}: Props) => {
const actionDefinitions = useIncompatibleIndexActionDefinitions();
@@ -67,7 +67,6 @@ const IncompatibleIndexTableActions = ({
const [isSubmitting, setIsSubmitting] = useState(false);
if (pendingStatus?.state === 'archiving') {
- // No empty 0% bar flash for jobs that finish almost instantly.
return pendingStatus.percent > 0 ? (
.
+ */
+import * as React from 'react';
+import { render, screen, waitFor } from 'wrappedTestingLibrary';
+import userEvent from '@testing-library/user-event';
+
+import { SystemIndexerIndices } from '@graylog/server-api';
+
+import asMock from 'helpers/mocking/AsMock';
+import useSelectedEntities from 'components/common/EntityDataTable/hooks/useSelectedEntities';
+import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
+import UserNotification from 'util/UserNotification';
+
+import IncompatibleIndicesBulkActions from './IncompatibleIndicesBulkActions';
+import type { IncompatibleIndexRow } from './fetchIncompatibleIndices';
+
+jest.mock('@graylog/server-api', () => ({
+ SystemIndexerIndices: {
+ bulkDeleteOutdated: jest.fn(),
+ },
+}));
+jest.mock('components/common/EntityDataTable/hooks/useSelectedEntities');
+jest.mock('logic/telemetry/useSendTelemetry');
+jest.mock('util/UserNotification', () => ({ success: jest.fn(), warning: jest.fn(), error: jest.fn() }));
+
+const makeIndex = (indexName: string): IncompatibleIndexRow => ({
+ id: indexName,
+ index_name: indexName,
+ version: '7.10.2',
+ warm_index: false,
+ managed_index: false,
+ system_index: false,
+ active_write_index: null,
+ begin: null,
+ end: null,
+});
+
+const indices = [makeIndex('legacy_0'), makeIndex('legacy_1')];
+
+describe('IncompatibleIndicesBulkActions', () => {
+ const setSelectedEntities = jest.fn();
+ const refetch = jest.fn();
+
+ const renderBulkActions = () =>
+ render(
+ ,
+ );
+
+ const confirmBulkDelete = async () => {
+ await userEvent.click(screen.getByRole('button', { name: /bulk actions/i }));
+ await userEvent.click(await screen.findByRole('menuitem', { name: /delete all \(2\)/i }));
+ await userEvent.click(await screen.findByRole('button', { name: /^delete all$/i }));
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ asMock(useSelectedEntities).mockReturnValue({
+ selectedEntities: indices.map(({ id }) => id),
+ setSelectedEntities,
+ selectEntity: jest.fn(),
+ deselectEntity: jest.fn(),
+ toggleEntitySelect: jest.fn(),
+ isSomeRowsSelected: false,
+ isAllRowsSelected: true,
+ });
+ asMock(useSendTelemetry).mockReturnValue(jest.fn());
+ });
+
+ it('deletes all selected indices, clears the selection, and refreshes the table', async () => {
+ asMock(SystemIndexerIndices.bulkDeleteOutdated).mockResolvedValue({
+ successfully_performed: 2,
+ failures: [],
+ errors: [],
+ });
+ renderBulkActions();
+
+ await confirmBulkDelete();
+
+ await waitFor(() => {
+ expect(SystemIndexerIndices.bulkDeleteOutdated).toHaveBeenCalledWith({
+ entity_ids: ['legacy_0', 'legacy_1'],
+ });
+ expect(UserNotification.success).toHaveBeenCalledWith('2 indices were deleted.');
+ expect(setSelectedEntities).toHaveBeenCalledWith([]);
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('keeps failed indices selected and warns with the failure details', async () => {
+ asMock(SystemIndexerIndices.bulkDeleteOutdated).mockResolvedValue({
+ successfully_performed: 1,
+ failures: [{ entity_id: 'legacy_1', failure_explanation: 'Delete failed' }],
+ errors: [],
+ });
+ renderBulkActions();
+
+ await confirmBulkDelete();
+
+ await waitFor(() => {
+ expect(UserNotification.warning).toHaveBeenCalledWith(
+ '1 succeeded, 1 failed.\nlegacy_1: Delete failed',
+ 'Some indices could not be deleted',
+ );
+ expect(setSelectedEntities).toHaveBeenCalledWith(['legacy_1']);
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('reports request failures without changing the selection or refreshing', async () => {
+ asMock(SystemIndexerIndices.bulkDeleteOutdated).mockRejectedValue(new Error('Backend unavailable'));
+ renderBulkActions();
+
+ await confirmBulkDelete();
+
+ await waitFor(() =>
+ expect(UserNotification.error).toHaveBeenCalledWith('Backend unavailable', 'Could not delete all.'),
+ );
+ expect(setSelectedEntities).not.toHaveBeenCalled();
+ expect(refetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx
index b1c09715e9c0..079879a592ce 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx
@@ -22,16 +22,13 @@ import { MenuItem } from 'components/bootstrap';
import BulkActionsDropdown from 'components/common/EntityDataTable/BulkActionsDropdown';
import useSelectedEntities from 'components/common/EntityDataTable/hooks/useSelectedEntities';
import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
+import extractErrorMessage from 'util/extractErrorMessage';
import UserNotification from 'util/UserNotification';
import { TELEMETRY_DEFAULTS } from './telemetry';
import BulkIndexActionConfirmDialog from './BulkIndexActionConfirmDialog';
import { CORE_ACTION_DEFINITIONS } from './incompatibleIndexActions';
-import {
- getBulkIndexActionCandidates,
- getBulkIndexActionNotification,
- runBulkIndexAction,
-} from './bulkIndexActions';
+import { getBulkIndexActionCandidates, getBulkIndexActionNotification, runBulkIndexAction } from './bulkIndexActions';
import type { BulkIndexActionCandidate, BulkIndexActionNotification } from './bulkIndexActions';
import type { IncompatibleIndexRow } from './fetchIncompatibleIndices';
import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions';
@@ -54,7 +51,6 @@ const showNotification = ({ type, message, title }: BulkIndexActionNotification)
}
};
-// Deletes are handled by a single server-side bulk endpoint. Returns the names of the indices that were deleted.
const bulkDeleteIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => {
const entityIds = bulkAction.targetIndices.map((index) => index.index_name);
const { failures } = await SystemIndexerIndices.bulkDeleteOutdated({ entity_ids: entityIds });
@@ -62,7 +58,10 @@ const bulkDeleteIndices = async (bulkAction: BulkIndexActionCandidate): Promise<
const succeeded = entityIds.length - failedIds.length;
if (failedIds.length === 0) {
- showNotification({ type: 'success', message: `${succeeded} ${succeeded === 1 ? 'index was' : 'indices were'} deleted.` });
+ showNotification({
+ type: 'success',
+ message: `${succeeded} ${succeeded === 1 ? 'index was' : 'indices were'} deleted.`,
+ });
} else {
const details = (failures ?? [])
.slice(0, 3)
@@ -84,7 +83,6 @@ const bulkDeleteIndices = async (bulkAction: BulkIndexActionCandidate): Promise<
return entityIds.filter((id) => !failedIds.includes(id));
};
-// Reindex has no bulk endpoint, so it runs client-side. Returns the names of the indices that were reindexed.
const bulkReindexIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => {
const result = await runBulkIndexAction({ action: bulkAction.action, indices: bulkAction.targetIndices });
showNotification(getBulkIndexActionNotification(bulkAction, result));
@@ -110,10 +108,17 @@ const IncompatibleIndicesBulkActions = ({
);
const candidates = useMemo(
- () => getBulkIndexActionCandidates({ indices: selectedIndices, canArchive, pendingIndexStatuses, archivedIndexNames }),
+ () =>
+ getBulkIndexActionCandidates({ indices: selectedIndices, canArchive, pendingIndexStatuses, archivedIndexNames }),
[selectedIndices, canArchive, pendingIndexStatuses, archivedIndexNames],
);
+ const handleCancel = () => {
+ if (!isSubmitting) {
+ setConfirmedBulkAction(undefined);
+ }
+ };
+
const handleConfirm = async () => {
if (!confirmedBulkAction || isSubmitting) {
return;
@@ -135,6 +140,11 @@ const IncompatibleIndicesBulkActions = ({
setSelectedEntities(selectedEntities.filter((id) => !succeededIds.includes(id)));
refetch();
setConfirmedBulkAction(undefined);
+ } catch (errorThrown) {
+ UserNotification.error(
+ extractErrorMessage(errorThrown),
+ `Could not ${confirmedBulkAction.confirmText.toLowerCase()}.`,
+ );
} finally {
setIsSubmitting(false);
}
@@ -153,7 +163,7 @@ const IncompatibleIndicesBulkActions = ({
setConfirmedBulkAction(undefined)}
+ onCancel={handleCancel}
onConfirm={handleConfirm}
/>
)}
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx
index 65737575b4a4..7a297753324d 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx
@@ -15,7 +15,6 @@
* .
*/
import * as React from 'react';
-
import type { ColorVariant } from '@graylog/sawmill';
import { Label } from 'components/bootstrap';
@@ -30,7 +29,6 @@ export const DEFAULT_DISPLAYED_COLUMNS = ['index_name', 'category', 'version', '
type Badge = { text: string; style: ColorVariant };
-// The primary, mutually exclusive classification; matches the backend `category` field precedence.
const primaryTypeBadge = (index: IncompatibleIndex): Badge => {
if (index.system_index) {
return { text: 'System', style: 'info' };
@@ -75,7 +73,11 @@ export const createColumnRenderers = (
index_name: {
minWidth: 300,
renderCell: (_value, index) => {
- const isArchived = isIndexArchived(index.index_name, pendingIndexStatuses.get(index.index_name), archivedIndexNames);
+ const isArchived = isIndexArchived(
+ index.index_name,
+ pendingIndexStatuses.get(index.index_name),
+ archivedIndexNames,
+ );
return (
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.test.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.test.tsx
index 04657d66f5a7..c7be0ae56b31 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.test.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.test.tsx
@@ -17,9 +17,12 @@
import * as React from 'react';
import { render, screen } from 'wrappedTestingLibrary';
+import { SystemIndexerIndices } from '@graylog/server-api';
+
import asMock from 'helpers/mocking/AsMock';
import type { PaginatedEntityTableProps } from 'components/common/PaginatedEntityTable/PaginatedEntityTable';
import useCanArchive from 'components/indices/hooks/useCanArchive';
+import type { SearchParams } from 'stores/PaginationTypes';
import IncompatibleIndicesTable from './IncompatibleIndicesTable';
import { createColumnRenderers } from './IncompatibleIndicesColumnRenderers';
@@ -34,6 +37,9 @@ jest.mock('components/common/PaginatedEntityTable', () => ({
useTableFetchContext: jest.fn(),
}));
+jest.mock('@graylog/server-api', () => ({
+ SystemIndexerIndices: { listOutdatedIndices: jest.fn() },
+}));
jest.mock('components/indices/hooks/useCanArchive');
jest.mock('./hooks/useArchivedIndexNames');
jest.mock('./hooks/usePendingIncompatibleIndexActions');
@@ -51,6 +57,14 @@ const makeIndex = (overrides: Partial): IncompatibleIndexR
...overrides,
});
+const searchParams: SearchParams = {
+ page: 2,
+ pageSize: 20,
+ query: '',
+ sort: { attributeId: 'index_name', direction: 'asc' },
+ filters: undefined,
+};
+
describe('IncompatibleIndicesTable', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -73,7 +87,10 @@ describe('IncompatibleIndicesTable', () => {
expect(screen.getByText('Paginated incompatible indices')).toBeInTheDocument();
expect(mockPaginatedEntityTable).toHaveBeenCalledTimes(1);
- const callProps = mockPaginatedEntityTable.mock.calls[0][0] as PaginatedEntityTableProps;
+ const callProps = mockPaginatedEntityTable.mock.calls[0][0] as PaginatedEntityTableProps<
+ IncompatibleIndexRow,
+ unknown
+ >;
expect(callProps.fetchEntities).toBe(fetchIncompatibleIndices);
expect(callProps.keyFn).toBe(incompatibleIndicesKeyFn);
expect(callProps.entityAttributesAreCamelCase).toBe(false);
@@ -84,6 +101,33 @@ describe('IncompatibleIndicesTable', () => {
});
});
+describe('fetchIncompatibleIndices', () => {
+ it('maps the nested pagination total and adds the entity id', async () => {
+ const index = {
+ index_name: 'legacy-index',
+ version: '7.10.2',
+ warm_index: false,
+ managed_index: false,
+ system_index: false,
+ active_write_index: null,
+ begin: null,
+ end: null,
+ };
+ const listOutdatedIndices = asMock(SystemIndexerIndices.listOutdatedIndices).mockResolvedValue({
+ elements: [index],
+ attributes: [],
+ pagination: { total: 42 },
+ total: 0,
+ } as never);
+
+ const result = await fetchIncompatibleIndices(searchParams);
+
+ expect(listOutdatedIndices).toHaveBeenCalledWith('index_name', 2, 20, '', 'asc');
+ expect(result.list).toEqual([{ ...index, id: 'legacy-index' }]);
+ expect(result.pagination).toEqual({ total: 42 });
+ });
+});
+
describe('createColumnRenderers', () => {
const renderers = createColumnRenderers(new Map(), new Set());
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx
index c2bb793acff4..74aad3cdd53f 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx
@@ -44,7 +44,7 @@ const TABLE_LAYOUT = {
entityTableId: 'incompatible_indices',
defaultSort: { attributeId: 'index_name', direction: 'asc' as const },
defaultDisplayedAttributes: DEFAULT_DISPLAYED_COLUMNS,
- defaultPageSize: 20,
+ defaultPageSize: 10,
defaultColumnOrder: ['index_name', 'category', 'version', 'begin', 'end'],
};
@@ -70,7 +70,6 @@ const IncompatibleIndicesTable = () => {
canArchive,
});
- // Suppress archive actions while an archive job runs — the backend archive job is single-concurrency.
const archiveActionsAvailable = canArchive && !isArchiveJobRunning;
const handleDataLoaded = useCallback((data: IncompatibleIndicesResponse) => {
@@ -102,7 +101,14 @@ const IncompatibleIndicesTable = () => {
/>
);
},
- [pendingIndexStatuses, archivedIndexNames, archiveActionsAvailable, addArchiveDeleteAction, refetchClusterJobs, refetch],
+ [
+ pendingIndexStatuses,
+ archivedIndexNames,
+ archiveActionsAvailable,
+ addArchiveDeleteAction,
+ refetchClusterJobs,
+ refetch,
+ ],
);
const bulkSelection = useMemo(
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/OpenSearchUpgradeSection.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/OpenSearchUpgradeSection.tsx
index c30cb79a9c2b..9c1eb3fbd44b 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/OpenSearchUpgradeSection.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/OpenSearchUpgradeSection.tsx
@@ -49,6 +49,12 @@ const DisabledHint = styled.p(
`,
);
+const ActionsRow = styled(Row)(
+ ({ theme }) => css`
+ margin-top: ${theme.spacings.md};
+ `,
+);
+
const MIN_NODES_FOR_ROLLING_UPGRADE = 3;
const TELEMETRY_DEFAULTS = { app_pathname: 'datanode', app_section: 'opensearch-upgrade' } as const;
@@ -169,7 +175,7 @@ const OpenSearchUpgradeSection = () => {
)}
-
+
{showStartAction && (
@@ -197,7 +203,7 @@ const OpenSearchUpgradeSection = () => {
Data Nodes' embedded OpenSearch is already up to date.
)}
-
+
{showRollingUpgradeStatus && (
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts
index 563e5347ea03..64868e7dade2 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts
@@ -20,7 +20,6 @@ import FiltersForQueryParams from 'components/common/EntityFilters/FiltersForQue
import type { Attribute, SearchParams } from 'stores/PaginationTypes';
import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices';
-// Entity tables require an `id` on every row; the index name is a stable natural key.
export type IncompatibleIndexRow = IncompatibleIndex & { id: string };
export type IncompatibleIndicesResponse = {
@@ -35,16 +34,14 @@ type ListOutdatedIndicesSort = Parameters[4];
export const fetchIncompatibleIndices = (searchParams: SearchParams): Promise => {
- // This endpoint is backed by an in-memory Lucene search that only understands the `query` string
- // (there is no separate `filters` param), so fold the active filter chips into the query.
- const query = [searchParams.query, ...FiltersForQueryParams(searchParams.filters)].filter(Boolean).join(' ');
+ const query = [searchParams.query, ...(FiltersForQueryParams(searchParams.filters) ?? [])].filter(Boolean).join(' ');
const sort = (searchParams.sort?.attributeId ?? 'index_name') as ListOutdatedIndicesSort;
const order = (searchParams.sort?.direction ?? 'asc') as ListOutdatedIndicesOrder;
return SystemIndexerIndices.listOutdatedIndices(sort, searchParams.page, searchParams.pageSize, query, order).then(
- ({ elements, attributes, total }) => ({
+ ({ elements, attributes, pagination }) => ({
list: elements.map((element) => ({ ...element, id: element.index_name })) as Array,
- pagination: { total },
+ pagination: { total: pagination.total },
attributes,
}),
);
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx
index e8a2a553276b..9e3f452e6249 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx
@@ -97,7 +97,9 @@ export const CORE_ACTION_DEFINITIONS: Record{index.index_name} is the active write index of its index set and still receives new
messages. Rotating starts a new write index on the current OpenSearch version.
-
Afterwards, {index.index_name} can be archived or deleted.
+
+ Afterwards, {index.index_name} can be archived or deleted.
+