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 5a172e7f5639..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);
+ outdatedIndexService = new OutdatedIndexService(indicesAdapter, null, null, null, null);
}
@FullBackendTest
diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndex.java b/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndex.java
index 148d57cc5a70..38e92a061224 100644
--- a/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndex.java
+++ b/graylog2-server/src/main/java/org/graylog2/indexer/indices/OutdatedIndex.java
@@ -18,24 +18,99 @@
package org.graylog2.indexer.indices;
-public record OutdatedIndex(String indexName, String version, boolean warmIndex,
- boolean managedIndex, String activeWriteIndex) implements Comparable {
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.graylog2.indexer.ranges.IndexRange;
+import org.graylog2.utilities.lucene.InMemorySearchableEntity;
+import org.graylog2.utilities.lucene.LuceneDocBuilder;
+import org.joda.time.DateTime;
+
+public record OutdatedIndex(@JsonProperty(FIELD_INDEX_NAME) String indexName,
+ @JsonProperty(FIELD_VERSION) String version,
+ @JsonProperty(FIELD_WARM_INDEX) boolean warmIndex,
+ @JsonProperty(FIELD_MANAGED_INDEX) boolean managedIndex,
+ @JsonProperty(FIELD_ACTIVE_WRITE_INDEX) String activeWriteIndex,
+ @JsonProperty(FIELD_BEGIN) DateTime begin,
+ @JsonProperty(FIELD_END) DateTime end)
+ implements Comparable, InMemorySearchableEntity {
+
+ public static final String FIELD_INDEX_NAME = "index_name";
+ public static final String FIELD_VERSION = "version";
+ public static final String FIELD_WARM_INDEX = "warm_index";
+ public static final String FIELD_MANAGED_INDEX = "managed_index";
+ public static final String FIELD_ACTIVE_WRITE_INDEX = "active_write_index";
+ public static final String FIELD_SYSTEM_INDEX = "system_index";
+ public static final String FIELD_BEGIN = "begin";
+ public static final String FIELD_END = "end";
+ // Derived, multi-valued classification used for the single "type" filter column.
+ public static final String FIELD_CATEGORY = "category";
+ public static final String CATEGORY_GRAYLOG = "graylog";
+ public static final String CATEGORY_SYSTEM = "system";
+ public static final String CATEGORY_FOREIGN = "foreign";
+ public static final String CATEGORY_WARM = "warm";
+
public OutdatedIndex(String indexName, String version, boolean warmIndex) {
- this(indexName, version, warmIndex, false, null);
+ this(indexName, version, warmIndex, false, null, null, null);
+ }
+
+ public OutdatedIndex(String indexName, String version, boolean warmIndex, boolean managedIndex, String activeWriteIndex) {
+ this(indexName, version, warmIndex, managedIndex, activeWriteIndex, null, null);
}
public OutdatedIndex asManaged(boolean managed) {
- return new OutdatedIndex(indexName, version, warmIndex, managed, activeWriteIndex);
+ return new OutdatedIndex(indexName, version, warmIndex, managed, activeWriteIndex, begin, end);
}
public OutdatedIndex asActiveWriteIndex(String isActiveWriteIndex) {
- return new OutdatedIndex(indexName, version, warmIndex, managedIndex, isActiveWriteIndex);
+ return new OutdatedIndex(indexName, version, warmIndex, managedIndex, isActiveWriteIndex, begin, end);
+ }
+
+ public OutdatedIndex withRange(IndexRange range) {
+ if (range == null || isUnknownRange(range)) {
+ return new OutdatedIndex(indexName, version, warmIndex, managedIndex, activeWriteIndex, null, null);
+ }
+ return new OutdatedIndex(indexName, version, warmIndex, managedIndex, activeWriteIndex, range.begin(), range.end());
}
+ private static boolean isUnknownRange(IndexRange range) {
+ return range.begin().getMillis() == 0L && range.end().getMillis() == 0L;
+ }
+
+ @JsonProperty(FIELD_SYSTEM_INDEX)
public boolean isSystemIndex() {
return indexName.startsWith(".");
}
+ /**
+ * The mutually exclusive primary classification of this index. System indices take precedence over the
+ * managed/foreign distinction, mirroring the badges shown in the UI.
+ */
+ private String primaryCategory() {
+ if (isSystemIndex()) {
+ return CATEGORY_SYSTEM;
+ }
+ return managedIndex ? CATEGORY_GRAYLOG : CATEGORY_FOREIGN;
+ }
+
+ @JsonIgnore
+ @Override
+ public void buildLuceneDoc(LuceneDocBuilder builder) {
+ builder.stringVal(FIELD_INDEX_NAME, indexName);
+ builder.stringVal(FIELD_VERSION, version);
+ // category is multi-valued: the primary classification plus an optional "warm" token, matching the badges.
+ builder.searchableVal(FIELD_CATEGORY, primaryCategory());
+ if (warmIndex) {
+ builder.searchableVal(FIELD_CATEGORY, CATEGORY_WARM);
+ }
+ // dateVal is not null-safe, so only add the range fields when present.
+ if (begin != null) {
+ builder.dateVal(FIELD_BEGIN, begin.toDate());
+ }
+ if (end != null) {
+ builder.dateVal(FIELD_END, end.toDate());
+ }
+ }
+
@Override
public int compareTo(OutdatedIndex other) {
return this.indexName.compareTo(other.indexName);
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 ad165d3c7c27..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,16 @@
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;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -35,6 +39,11 @@
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static com.mongodb.client.model.Filters.in;
@Singleton
public class OutdatedIndexService {
@@ -43,12 +52,17 @@ 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, Cluster cluster) {
+ public OutdatedIndexService(@IndexerAdminCert IndicesAdapter indicesAdapter, IndexSetRegistry indexSetRegistry,
+ IndexRangeService indexRangeService, EventBus eventBus, Cluster cluster) {
this.indicesAdapter = indicesAdapter;
this.indexSetRegistry = indexSetRegistry;
+ this.indexRangeService = indexRangeService;
+ this.eventBus = eventBus;
this.cluster = cluster;
}
@@ -61,12 +75,25 @@ public List getOutdatedIndices() {
throw new IllegalStateException("Cluster version cannot be determined: " + version);
}
}).orElseThrow(() -> new IllegalStateException("Cluster version cannot be determined: null"));
- return indicesAdapter.getOutdatedIndices(currentMajorVersion).stream()
+ final List outdatedIndices = indicesAdapter.getOutdatedIndices(currentMajorVersion).stream()
.map(index -> index.asManaged(indexSetRegistry.isManagedIndex(index.indexName())))
.map(index -> index.asActiveWriteIndex(isActiveWriteIndexForSet(index.indexName())))
+ .toList();
+ final Map ranges = findRanges(outdatedIndices);
+ return outdatedIndices.stream()
+ .map(index -> index.withRange(ranges.get(index.indexName())))
.sorted().toList();
}
+ private Map findRanges(List indices) {
+ final Set indexNames = indices.stream().map(OutdatedIndex::indexName).collect(Collectors.toSet());
+ if (indexNames.isEmpty()) {
+ return Map.of();
+ }
+ return indexRangeService.find(in(IndexRange.FIELD_INDEX_NAME, indexNames)).stream()
+ .collect(Collectors.toMap(IndexRange::indexName, Function.identity(), (existing, replacement) -> existing));
+ }
+
public String isActiveWriteIndexForSet(String index) {
return indexSetRegistry.getForIndex(index)
.filter(indexSet -> Objects.equals(indexSet.getActiveWriteIndex(), index))
@@ -160,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/RestResourcesModule.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/RestResourcesModule.java
index d0c18c737350..b9f27a6a68ef 100644
--- a/graylog2-server/src/main/java/org/graylog2/rest/resources/RestResourcesModule.java
+++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/RestResourcesModule.java
@@ -97,6 +97,7 @@
import org.graylog2.rest.resources.system.indexer.IndexerClusterResource;
import org.graylog2.rest.resources.system.indexer.IndexerOverviewResource;
import org.graylog2.rest.resources.system.indexer.IndicesResource;
+import org.graylog2.rest.resources.system.indexer.OutdatedIndexResource;
import org.graylog2.rest.resources.system.indices.RetentionStrategyResource;
import org.graylog2.rest.resources.system.indices.RotationStrategyResource;
import org.graylog2.rest.resources.system.inputs.ExtractorsResource;
@@ -231,6 +232,7 @@ private void addIndexingResources() {
addSystemRestResource(IndexSetDefaultsResource.class);
addSystemRestResource(IndexTemplatesResource.class);
addSystemRestResource(IndicesResource.class);
+ addSystemRestResource(OutdatedIndexResource.class);
addSystemRestResource(IndexRangesResource.class);
addSystemRestResource(RetentionStrategyResource.class);
addSystemRestResource(RotationStrategyResource.class);
diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndicesResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndicesResource.java
index 6cb65c492caa..7b09c9bb2828 100644
--- a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndicesResource.java
+++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndicesResource.java
@@ -27,7 +27,6 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.DELETE;
-import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
@@ -35,7 +34,6 @@
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
-import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresPermissions;
@@ -47,7 +45,6 @@
import org.graylog2.indexer.indexset.index.IndexPattern;
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.indexer.indices.TooManyAliasesException;
import org.graylog2.indexer.indices.stats.IndexStatistics;
@@ -315,44 +312,6 @@ public ClosedIndices indexSetReopened(@Parameter(name = "indexSetId") @PathParam
return ClosedIndices.create(reopenedIndices, reopenedIndices.size());
}
- @GET
- @Path("/outdated")
- @Operation(summary = "Get a list of indices that were created in a OpenSearch version prior to the recent one")
- @RequiresPermissions(RestPermissions.INDICES_READ)
- @Produces(MediaType.APPLICATION_JSON)
- public List getOutdatedIndices() {
- return outdatedIndexService.getOutdatedIndices();
- }
-
- @POST
- @Path("/outdated/{index}/reindex")
- @Operation(summary = "Reindexes an outdated index to make it compatible with the next major version of OpenSearch")
- @RequiresPermissions(RestPermissions.INDICES_REINDEX)
- @Produces(MediaType.APPLICATION_JSON)
- @AuditEvent(type = AuditEventTypes.ES_INDEX_REINDEX)
- public void reindex(@Parameter(name = "index") @PathParam("index") @NotNull String index,
- @Parameter(name = "withReplication") @QueryParam("withReplication") @DefaultValue("true") boolean withReplication) {
- OutdatedIndex outdatedIndex = getOutdatedIndices().stream()
- .filter(OutdatedIndex::isSystemIndex)
- .filter(i -> i.indexName().equals(index))
- .findAny().orElseThrow(() -> new NotFoundException("Index " + index + " not found or is no system index"));
- outdatedIndexService.reindex(outdatedIndex.indexName(), withReplication);
- }
-
- @DELETE
- @Path("/outdated/{index}")
- @Operation(summary = "Deletes an outdated, non-Graylog managed index")
- @RequiresPermissions(RestPermissions.INDICES_DELETE)
- @Produces(MediaType.APPLICATION_JSON)
- @AuditEvent(type = AuditEventTypes.ES_INDEX_DELETE)
- public void deleteOutdated(@Parameter(name = "index") @PathParam("index") @NotNull String index) {
- OutdatedIndex outdatedIndex = getOutdatedIndices().stream()
- .filter(i -> !i.managedIndex())
- .filter(i -> i.indexName().equals(index))
- .findAny().orElseThrow(() -> new NotFoundException("Index " + index + " not found or is an index managed by Graylog"));
- outdatedIndexService.delete(outdatedIndex.indexName());
- }
-
private OpenIndicesInfo getOpenIndicesInfo(Set indicesStatistics) {
final List indexInfos = new LinkedList<>();
final Set indices = indicesStatistics.stream()
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
new file mode 100644
index 000000000000..36499c34c37f
--- /dev/null
+++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/OutdatedIndexResource.java
@@ -0,0 +1,246 @@
+/*
+ * 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
+ * .
+ */
+package org.graylog2.rest.resources.system.indexer;
+
+import com.codahale.metrics.annotation.Timed;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+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;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+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;
+import org.graylog2.rest.resources.entities.EntityDefaults;
+import org.graylog2.rest.resources.entities.FilterOption;
+import org.graylog2.rest.resources.entities.Sorting;
+import org.graylog2.search.SearchQuery;
+import org.graylog2.search.SearchQueryField;
+import org.graylog2.search.SearchQueryParser;
+import org.graylog2.shared.rest.resources.RestResource;
+import org.graylog2.shared.security.RestPermissions;
+import org.graylog2.utilities.lucene.InMemorySearchEngine;
+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
+@Path("/system/indexer/indices/outdated")
+@Produces(MediaType.APPLICATION_JSON)
+public class OutdatedIndexResource extends RestResource {
+
+ private static final String DEFAULT_SORT_FIELD = OutdatedIndex.FIELD_INDEX_NAME;
+ private static final String DEFAULT_SORT_DIRECTION = "asc";
+ private static final List attributes = List.of(
+ EntityAttribute.builder().id(OutdatedIndex.FIELD_INDEX_NAME).title("Index Name").type(SearchQueryField.Type.STRING).sortable(true).searchable(true).build(),
+ EntityAttribute.builder().id(OutdatedIndex.FIELD_CATEGORY).title("Type").type(SearchQueryField.Type.STRING).sortable(false).filterable(true).filterOptions(categoryFilterOptions()).build(),
+ EntityAttribute.builder().id(OutdatedIndex.FIELD_VERSION).title("Version").type(SearchQueryField.Type.STRING).sortable(true).searchable(true).build(),
+ EntityAttribute.builder().id(OutdatedIndex.FIELD_BEGIN).title("Message Range Begin").type(SearchQueryField.Type.DATE).sortable(true).build(),
+ EntityAttribute.builder().id(OutdatedIndex.FIELD_END).title("Message Range End").type(SearchQueryField.Type.DATE).sortable(true).build()
+ );
+
+ private static Set categoryFilterOptions() {
+ return Set.of(
+ FilterOption.create(OutdatedIndex.CATEGORY_GRAYLOG, "Graylog"),
+ FilterOption.create(OutdatedIndex.CATEGORY_SYSTEM, "System"),
+ FilterOption.create(OutdatedIndex.CATEGORY_FOREIGN, "Foreign"),
+ FilterOption.create(OutdatedIndex.CATEGORY_WARM, "Warm")
+ );
+ }
+
+ private static final EntityDefaults settings = EntityDefaults.builder()
+ .sort(Sorting.create(DEFAULT_SORT_FIELD, Sorting.Direction.valueOf(DEFAULT_SORT_DIRECTION.toUpperCase(Locale.ROOT))))
+ .build();
+
+ private final OutdatedIndexService outdatedIndexService;
+ private final AuditEventSender auditEventSender;
+ private final InMemorySearchEngine outdatedIndexSearchService;
+ private final SearchQueryParser searchQueryParser;
+
+ @Inject
+ public OutdatedIndexResource(OutdatedIndexService outdatedIndexService, AuditEventSender auditEventSender) {
+ this.outdatedIndexService = outdatedIndexService;
+ this.auditEventSender = auditEventSender;
+ final Supplier> cachingSupplier = Suppliers.memoizeWithExpiration(
+ outdatedIndexService::getOutdatedIndices,
+ 10,
+ TimeUnit.SECONDS
+ );
+ this.outdatedIndexSearchService = new LuceneInMemorySearchEngine<>(attributes, cachingSupplier);
+ this.searchQueryParser = new SearchQueryParser(DEFAULT_SORT_FIELD, attributes);
+ }
+
+ @GET
+ @Timed
+ @RequiresPermissions(RestPermissions.INDICES_READ)
+ @Operation(summary = "Get a list of indices that were created in an OpenSearch version prior to the recent one")
+ public List getOutdatedIndices() {
+ return outdatedIndexService.getOutdatedIndices();
+ }
+
+ @GET
+ @Timed
+ @RequiresPermissions(RestPermissions.INDICES_READ)
+ @Operation(summary = "Get a paginated list of indices that were created in an OpenSearch version prior to the recent one")
+ @Path("/paginated")
+ public PageListResponse listOutdatedIndices(
+ @Parameter(name = "page") @QueryParam("page") @DefaultValue("1") int page,
+ @Parameter(name = "per_page") @QueryParam("per_page") @DefaultValue("50") int perPage,
+ @Parameter(name = "query") @QueryParam("query") @DefaultValue("") String query,
+ @Parameter(name = "sort",
+ description = "The field to sort the result on",
+ required = true,
+ schema = @Schema(allowableValues = {
+ OutdatedIndex.FIELD_INDEX_NAME,
+ OutdatedIndex.FIELD_VERSION,
+ OutdatedIndex.FIELD_BEGIN,
+ OutdatedIndex.FIELD_END
+ }))
+ @DefaultValue(DEFAULT_SORT_FIELD) @QueryParam("sort") String sort,
+ @Parameter(name = "order", description = "The sort direction",
+ schema = @Schema(allowableValues = {"asc", "desc"}))
+ @DefaultValue(DEFAULT_SORT_DIRECTION) @QueryParam("order") SortOrder order
+ ) throws QueryNodeException, IOException {
+ final SearchQuery parsedQuery = searchQueryParser.parse(query);
+ final PaginatedList result = outdatedIndexSearchService.search(parsedQuery, sort, order, page, perPage);
+ return PageListResponse.create(query, result.pagination(),
+ result.grandTotal().orElse(0L), sort, order, result.stream().toList(), attributes, settings);
+ }
+
+ @POST
+ @Path("/{index}/reindex")
+ @Operation(summary = "Reindexes an outdated index to make it compatible with the next major version of OpenSearch")
+ @RequiresPermissions(RestPermissions.INDICES_REINDEX)
+ @Produces(MediaType.APPLICATION_JSON)
+ @AuditEvent(type = AuditEventTypes.ES_INDEX_REINDEX)
+ public void reindex(@Parameter(name = "index") @PathParam("index") @NotNull String index,
+ @Parameter(name = "withReplication") @QueryParam("withReplication") @DefaultValue("true") boolean withReplication) {
+ OutdatedIndex outdatedIndex = getOutdatedIndices().stream()
+ .filter(OutdatedIndex::isSystemIndex)
+ .filter(i -> i.indexName().equals(index))
+ .findAny().orElseThrow(() -> new NotFoundException("Index " + index + " not found or is no system index"));
+ outdatedIndexService.reindex(outdatedIndex.indexName(), withReplication);
+ }
+
+ @DELETE
+ @Path("/{index}")
+ @Operation(summary = "Deletes an outdated, non-Graylog managed index")
+ @RequiresPermissions(RestPermissions.INDICES_DELETE)
+ @Produces(MediaType.APPLICATION_JSON)
+ @AuditEvent(type = AuditEventTypes.ES_INDEX_DELETE)
+ public void deleteOutdated(@Parameter(name = "index") @PathParam("index") @NotNull String index) {
+ OutdatedIndex outdatedIndex = getOutdatedIndices().stream()
+ .filter(i -> !i.managedIndex())
+ .filter(i -> i.indexName().equals(index))
+ .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/main/java/org/graylog2/utilities/lucene/LuceneDocBuilder.java b/graylog2-server/src/main/java/org/graylog2/utilities/lucene/LuceneDocBuilder.java
index 1d8f51fe4d2f..de66a85c7d76 100644
--- a/graylog2-server/src/main/java/org/graylog2/utilities/lucene/LuceneDocBuilder.java
+++ b/graylog2-server/src/main/java/org/graylog2/utilities/lucene/LuceneDocBuilder.java
@@ -41,6 +41,17 @@ public LuceneDocBuilder stringVal(String key, String value) {
return this;
}
+ /**
+ * Adds a searchable (but not sortable) string value. Unlike {@link #stringVal(String, String)} this does not add a
+ * {@link SortedDocValuesField}, so it may be called multiple times for the same key to index a multi-valued field.
+ */
+ public LuceneDocBuilder searchableVal(String key, String value) {
+ if (value != null) {
+ doc.add(new TextField(key, value, Field.Store.NO));
+ }
+ return this;
+ }
+
public LuceneDocBuilder dateVal(String key, Date value) {
return longVal(key, value.getTime());
}
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 6adf74129d8c..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,11 +17,15 @@
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;
import org.graylog2.indexer.indexset.IndexSet;
import org.graylog2.indexer.indexset.IndexSetConfig;
import org.graylog2.indexer.indexset.registry.IndexSetRegistry;
+import org.graylog2.indexer.ranges.IndexRange;
+import org.graylog2.indexer.ranges.IndexRangeService;
import org.graylog2.system.stats.elasticsearch.ElasticsearchStats;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +36,7 @@
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -59,6 +64,12 @@ class OutdatedIndexServiceTest {
@Mock
IndexSetRegistry indexSetRegistry;
+ @Mock
+ IndexRangeService indexRangeService;
+
+ @Mock
+ EventBus eventBus;
+
@InjectMocks
OutdatedIndexService outdatedIndexService;
@@ -95,6 +106,7 @@ void getOutdatedIndicesSucceeds() {
when(noWriteIndex.getActiveWriteIndex()).thenReturn("another_index");
when(indexSetRegistry.getForIndex("outdated2")).thenReturn(Optional.of(noWriteIndex));
when(indicesAdapter.getOutdatedIndices(2)).thenReturn(outdatedIndices);
+ when(indexRangeService.find(any(Bson.class))).thenReturn(Collections.emptySortedSet());
assertThat(outdatedIndexService.getOutdatedIndices()).isEqualTo(List.of(
new OutdatedIndex("outdated1", "1.3.0", false, true, "id1"),
new OutdatedIndex("outdated2", "1.3.0", true, false, null)
diff --git a/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/IndicesResourceTest.java b/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/IndicesResourceTest.java
deleted file mode 100644
index 7fa508f13164..000000000000
--- a/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/IndicesResourceTest.java
+++ /dev/null
@@ -1,93 +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
- * .
- */
-
-package org.graylog2.rest.resources.system.indexer;
-
-import jakarta.ws.rs.ForbiddenException;
-import org.assertj.core.api.Assertions;
-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.security.WithAuthorization;
-import org.graylog2.security.WithAuthorizationExtension;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-@ExtendWith(MockitoExtension.class)
-@ExtendWith(WithAuthorizationExtension.class)
-class IndicesResourceTest {
-
- @Mock
- Indices indices;
-
- @Mock
- NodeInfoCache nodeInfoCache;
-
- @Mock
- IndexSetRegistry indexSetRegistry;
-
- @Mock
- OutdatedIndexService outdatedIndexService;
-
- @InjectMocks
- IndicesResource indicesResource;
-
- @Test
- @WithAuthorization(permissions = {"something:else"})
- void getOutdatedIndicesFailsIfNotPermitted() {
- Assertions.assertThatThrownBy(() -> indicesResource.getOutdatedIndices()).isInstanceOf(ForbiddenException.class);
- }
-
- @Test
- @WithAuthorization(permissions = {"indices:read"})
- void getOutdatedIndicesSucceeds() {
- List outdatedIndices = List.of(
- new OutdatedIndex("outdated1", "1.3.0", false, false, null),
- new OutdatedIndex("outdated2", "1.3.0", true, true, "id1")
- );
- when(outdatedIndexService.getOutdatedIndices()).thenReturn(outdatedIndices);
- assertThat(indicesResource.getOutdatedIndices()).isEqualTo(outdatedIndices);
- }
-
- @Test
- @WithAuthorization(permissions = {"something:else"})
- void reindexFailsIfNotPermitted() {
- Assertions.assertThatThrownBy(() -> indicesResource.reindex("outdated", false))
- .isInstanceOf(ForbiddenException.class);
- }
-
- @Test
- @WithAuthorization(permissions = {"indices:read", "indices:reindex"})
- void reindexSucceeds() {
- when(outdatedIndexService.getOutdatedIndices()).thenReturn(List.of(new OutdatedIndex(".outdated1", "1.3.0", false, false, null)));
- indicesResource.reindex(".outdated1", true);
- verify(outdatedIndexService, times(1)).reindex(".outdated1", true);
- }
-
-}
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
new file mode 100644
index 000000000000..d396185208de
--- /dev/null
+++ b/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/OutdatedIndicesResourceTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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
+ * .
+ */
+
+package org.graylog2.rest.resources.system.indexer;
+
+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;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+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;
+
+@ExtendWith(MockitoExtension.class)
+@ExtendWith(WithAuthorizationExtension.class)
+class OutdatedIndicesResourceTest {
+
+ @Mock
+ Indices indices;
+
+ @Mock
+ NodeInfoCache nodeInfoCache;
+
+ @Mock
+ IndexSetRegistry indexSetRegistry;
+
+ @Mock
+ OutdatedIndexService outdatedIndexService;
+
+ @Mock
+ AuditEventSender auditEventSender;
+
+ @InjectMocks
+ OutdatedIndexResource outdatedIndexResource;
+
+ @Test
+ @WithAuthorization(permissions = {"something:else"})
+ void getOutdatedIndicesFailsIfNotPermitted() {
+ Assertions.assertThatThrownBy(() -> outdatedIndexResource.getOutdatedIndices()).isInstanceOf(ForbiddenException.class);
+ }
+
+ @Test
+ @WithAuthorization(permissions = {"indices:read"})
+ void getOutdatedIndicesSucceeds() {
+ List outdatedIndices = List.of(
+ new OutdatedIndex("outdated1", "1.3.0", false, false, null),
+ new OutdatedIndex("outdated2", "1.3.0", true, true, "id1")
+ );
+ when(outdatedIndexService.getOutdatedIndices()).thenReturn(outdatedIndices);
+ assertThat(outdatedIndexResource.getOutdatedIndices()).isEqualTo(outdatedIndices);
+ }
+
+ @Test
+ @WithAuthorization(permissions = {"something:else"})
+ void reindexFailsIfNotPermitted() {
+ Assertions.assertThatThrownBy(() -> outdatedIndexResource.reindex("outdated", false))
+ .isInstanceOf(ForbiddenException.class);
+ }
+
+ @Test
+ @WithAuthorization(permissions = {"indices:read", "indices:reindex"})
+ void reindexSucceeds() {
+ when(outdatedIndexService.getOutdatedIndices()).thenReturn(List.of(new OutdatedIndex(".outdated1", "1.3.0", false, false, null)));
+ outdatedIndexResource.reindex(".outdated1", true);
+ 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/IncompatibleIndexTableActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx
new file mode 100644
index 000000000000..3369840ead4c
--- /dev/null
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx
@@ -0,0 +1,171 @@
+/*
+ * 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 React, { useState } from 'react';
+import styled from 'styled-components';
+
+import { Button, ButtonToolbar, Label } from 'components/bootstrap';
+import { ConfirmDialog, ProgressBar } from 'components/common';
+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 type { IncompatibleIndexRow } from './fetchIncompatibleIndices';
+import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions';
+import { getAvailableActions, useIncompatibleIndexActionDefinitions } from './incompatibleIndexActions';
+import type { IndexAction, PendingArchiveTracking } from './incompatibleIndexActions';
+
+const ActionsToolbar = styled(ButtonToolbar)`
+ justify-content: flex-end;
+`;
+
+const ArchiveProgressBar = styled(ProgressBar)`
+ display: inline-flex;
+ width: 120px;
+ margin-bottom: 0;
+ vertical-align: middle;
+`;
+
+type Props = {
+ index: IncompatibleIndexRow;
+ canArchive: boolean;
+ pendingStatus: PendingIndexStatus | undefined;
+ isArchived: boolean;
+ addArchiveDeleteAction: (tracking: PendingArchiveTracking) => void;
+ refetchClusterJobs?: () => void;
+ refetch: () => void;
+};
+
+const IncompatibleIndexTableActions = ({
+ index,
+ canArchive,
+ pendingStatus,
+ isArchived,
+ addArchiveDeleteAction,
+ refetchClusterJobs = undefined,
+ refetch,
+}: Props) => {
+ const actionDefinitions = useIncompatibleIndexActionDefinitions();
+ const sendTelemetry = useSendTelemetry();
+ const { deselectEntity } = useSelectedEntities();
+ const [confirmedAction, setConfirmedAction] = useState();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ if (pendingStatus?.state === 'archiving') {
+ return pendingStatus.percent > 0 ? (
+
+ ) : (
+ Archiving...
+ );
+ }
+
+ const actions = getAvailableActions(index, canArchive, isArchived);
+
+ const handleConfirm = async () => {
+ if (!confirmedAction) {
+ return;
+ }
+
+ const actionDefinition = actionDefinitions[confirmedAction];
+ sendTelemetry(actionDefinition.telemetryEventType, { ...TELEMETRY_DEFAULTS });
+ setIsSubmitting(true);
+
+ try {
+ const actionResponse = await actionDefinition.run(index);
+ const pendingArchive = actionDefinition.getPendingArchiveTracking?.(index, actionResponse);
+
+ if (pendingArchive) {
+ addArchiveDeleteAction(pendingArchive);
+ }
+
+ UserNotification.success(actionDefinition.successMessage(index));
+
+ if (confirmedAction === 'delete') {
+ deselectEntity(index.id);
+ }
+
+ refetch();
+ setConfirmedAction(undefined);
+ } catch (errorThrown) {
+ const errorMessage = extractErrorMessage(errorThrown);
+
+ if (actionDefinition.isArchiveJobConflict?.(errorMessage)) {
+ UserNotification.warning(
+ 'Another archive job is already running. New archive jobs can be started after it finishes.',
+ 'Archive job already running',
+ );
+ refetchClusterJobs?.();
+ setConfirmedAction(undefined);
+ } else {
+ UserNotification.error(errorMessage, `Could not ${actionDefinition.confirmText.toLowerCase()}.`);
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const confirmDefinition = confirmedAction ? actionDefinitions[confirmedAction] : undefined;
+
+ return (
+
+ {pendingStatus?.state === 'failed' && (
+
+ Archive failed
+
+ )}
+ {actions.map((action) => {
+ const actionDefinition = actionDefinitions[action];
+
+ return (
+ setConfirmedAction(action)}>
+ {actionDefinition.buttonLabel}
+
+ );
+ })}
+ {confirmDefinition && (
+ setConfirmedAction(undefined)}
+ onConfirm={handleConfirm}
+ submitLoadingText="Working...">
+ {confirmDefinition.confirmationBody(index)}
+
+ )}
+
+ );
+};
+
+export default IncompatibleIndexTableActions;
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx
new file mode 100644
index 000000000000..111c9d8f0a9d
--- /dev/null
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx
@@ -0,0 +1,141 @@
+/*
+ * 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 * 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
new file mode 100644
index 000000000000..70f66ce36bd4
--- /dev/null
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx
@@ -0,0 +1,171 @@
+/*
+ * 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 React, { 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';
+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 type { BulkIndexActionCandidate, BulkIndexActionNotification } from './bulkIndexActions';
+import type { IncompatibleIndexRow } from './fetchIncompatibleIndices';
+import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions';
+
+type Props = {
+ indices: Array;
+ canArchive: boolean;
+ pendingIndexStatuses: Map;
+ archivedIndexNames: ReadonlySet;
+ 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);
+ }
+};
+
+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));
+};
+
+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,
+ pendingIndexStatuses,
+ archivedIndexNames,
+ refetch,
+}: Props) => {
+ const sendTelemetry = useSendTelemetry();
+ const { selectedEntities, setSelectedEntities } = useSelectedEntities();
+ const [confirmedBulkAction, setConfirmedBulkAction] = useState();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const selectedIndices = indices.filter((index) => selectedEntities.includes(index.id));
+ const candidates = getBulkIndexActionCandidates({
+ indices: selectedIndices,
+ canArchive,
+ pendingIndexStatuses,
+ archivedIndexNames,
+ });
+
+ const handleCancel = () => {
+ if (!isSubmitting) {
+ setConfirmedBulkAction(undefined);
+ }
+ };
+
+ const handleConfirm = async () => {
+ if (!confirmedBulkAction || isSubmitting) {
+ return;
+ }
+
+ sendTelemetry(CORE_ACTION_DEFINITIONS[confirmedBulkAction.action].telemetryEventType, {
+ ...TELEMETRY_DEFAULTS,
+ app_action_value: 'bulk',
+ bulk_count: confirmedBulkAction.targetIndices.length,
+ });
+ setIsSubmitting(true);
+
+ try {
+ const succeededIds =
+ confirmedBulkAction.action === 'delete'
+ ? await bulkDeleteIndices(confirmedBulkAction)
+ : await bulkReindexIndices(confirmedBulkAction);
+
+ setSelectedEntities(selectedEntities.filter((id) => !succeededIds.includes(id)));
+ refetch();
+ setConfirmedBulkAction(undefined);
+ } catch (errorThrown) {
+ UserNotification.error(
+ extractErrorMessage(errorThrown),
+ `Could not ${confirmedBulkAction.confirmText.toLowerCase()}.`,
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <>
+
+ {candidates.map((candidate) => (
+ setConfirmedBulkAction(candidate)}>
+ {candidate.buttonLabel} ({candidate.targetIndices.length})
+
+ ))}
+
+ {confirmedBulkAction && (
+
+ )}
+ >
+ );
+};
+
+export default IncompatibleIndicesBulkActions;
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx
new file mode 100644
index 000000000000..7a297753324d
--- /dev/null
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx
@@ -0,0 +1,101 @@
+/*
+ * 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 * as React from 'react';
+import type { ColorVariant } from '@graylog/sawmill';
+
+import { Label } from 'components/bootstrap';
+import { Timestamp } from 'components/common';
+import type { ColumnRenderers } from 'components/common/EntityDataTable';
+import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices';
+
+import type { IncompatibleIndexRow } from './fetchIncompatibleIndices';
+import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions';
+
+export const DEFAULT_DISPLAYED_COLUMNS = ['index_name', 'category', 'version', 'begin', 'end'];
+
+type Badge = { text: string; style: ColorVariant };
+
+const primaryTypeBadge = (index: IncompatibleIndex): Badge => {
+ if (index.system_index) {
+ return { text: 'System', style: 'info' };
+ }
+
+ return index.managed_index ? { text: 'Graylog', style: 'success' } : { text: 'Foreign', style: 'warning' };
+};
+
+const typeBadges = (index: IncompatibleIndex): Array => [
+ primaryTypeBadge(index),
+ ...(index.warm_index ? [{ text: 'Warm', style: 'default' as const }] : []),
+];
+
+const isIndexArchived = (
+ indexName: string,
+ pendingStatus: PendingIndexStatus | undefined,
+ archivedIndexNames: ReadonlySet,
+) => pendingStatus?.state !== 'archiving' && (archivedIndexNames.has(indexName) || pendingStatus?.state === 'archived');
+
+const statusBadges = (index: IncompatibleIndex, isArchived: boolean): Array => [
+ ...(index.active_write_index ? [{ text: 'active write index', style: 'default' as const }] : []),
+ ...(isArchived ? [{ text: 'archived already', style: 'success' as const }] : []),
+];
+
+const renderBadges = (badges: Array) =>
+ badges.map(({ text, style }) => (
+
+
+ {text}
+
+
+
+ ));
+
+const renderRange = (value: unknown) => (value ? : — );
+
+export const createColumnRenderers = (
+ pendingIndexStatuses: Map,
+ archivedIndexNames: ReadonlySet,
+): ColumnRenderers => ({
+ attributes: {
+ index_name: {
+ minWidth: 300,
+ renderCell: (_value, index) => {
+ const isArchived = isIndexArchived(
+ index.index_name,
+ pendingIndexStatuses.get(index.index_name),
+ archivedIndexNames,
+ );
+
+ return (
+
+ {index.index_name}
+
+ {renderBadges(statusBadges(index, isArchived))}
+
+ );
+ },
+ },
+ category: {
+ staticWidth: 200,
+ renderCell: (_value, index) => {renderBadges(typeBadges(index))} ,
+ },
+ version: {
+ renderCell: (_value, index) => index.version || 'Unknown',
+ },
+ begin: { renderCell: renderRange },
+ end: { renderCell: renderRange },
+ },
+});
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 6c257768f1b4..28bae56b3fa0 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
@@ -15,498 +15,236 @@
* .
*/
import * as React from 'react';
-import { render, screen, waitFor, within } from 'wrappedTestingLibrary';
-import userEvent from '@testing-library/user-event';
+import { render, screen } from 'wrappedTestingLibrary';
+import { act } from 'react';
-import { ClusterDeflector, IndexerIndices } from '@graylog/server-api';
+import { SystemIndexerIndices } from '@graylog/server-api';
import asMock from 'helpers/mocking/AsMock';
-import type { IndexArchiveBinding } from 'components/indices/archive/types';
-import useIndexArchive from 'components/indices/archive/useIndexArchive';
-import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices';
-import useIncompatibleIndices from 'components/indices/hooks/useIncompatibleIndices';
+import type { PaginatedEntityTableProps } from 'components/common/PaginatedEntityTable/PaginatedEntityTable';
import useCanArchive from 'components/indices/hooks/useCanArchive';
-import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
-import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants';
-import UserNotification from 'util/UserNotification';
+import type { SearchParams } from 'stores/PaginationTypes';
import IncompatibleIndicesTable from './IncompatibleIndicesTable';
-import { PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY } from './hooks/usePendingIncompatibleIndexActions';
-import useClusterJobs from './hooks/useClusterJobs';
-import type { SystemJobSummary } from './hooks/useClusterJobs';
+import { createColumnRenderers } from './IncompatibleIndicesColumnRenderers';
+import { fetchIncompatibleIndices, incompatibleIndicesKeyFn } from './fetchIncompatibleIndices';
+import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from './fetchIncompatibleIndices';
import useArchivedIndexNames from './hooks/useArchivedIndexNames';
+import usePendingIncompatibleIndexActions from './hooks/usePendingIncompatibleIndexActions';
+
+jest.mock('components/common/PaginatedEntityTable', () => ({
+ __esModule: true,
+ default: jest.fn(({ humanName }) => Paginated {humanName}
),
+ useTableFetchContext: jest.fn(),
+}));
-jest.mock('components/indices/archive/useIndexArchive');
-jest.mock('components/indices/hooks/useIncompatibleIndices');
-jest.mock('components/indices/hooks/useCanArchive');
-jest.mock('logic/telemetry/useSendTelemetry');
-jest.mock('./hooks/useClusterJobs');
-jest.mock('./hooks/useArchivedIndexNames');
-jest.mock('logic/rest/FetchProvider', () => {
- class Builder {
- setHeader() {
- return this;
- }
-
- setHeaders() {
- return this;
- }
-
- json() {
- return this;
- }
-
- text() {
- return this;
- }
-
- raw() {
- return this;
- }
-
- build() {
- return Promise.resolve(this);
- }
- }
-
- return {
- __esModule: true,
- default: jest.fn(() => Promise.resolve({ system_job: { id: 'archive-job-id' } })),
- Builder,
- };
-});
jest.mock('@graylog/server-api', () => ({
- IndexerIndices: {
- deleteOutdated: jest.fn(() => Promise.resolve()),
- remove: jest.fn(() => Promise.resolve()),
- reindex: jest.fn(() => Promise.resolve()),
- },
- ClusterDeflector: {
- cycleByindexSetId: jest.fn(() => Promise.resolve()),
- },
+ SystemIndexerIndices: { listOutdatedIndices: jest.fn() },
}));
-jest.mock('util/UserNotification', () => ({ success: jest.fn(), warning: jest.fn(), error: jest.fn() }));
+jest.mock('components/indices/hooks/useCanArchive');
+jest.mock('./hooks/useArchivedIndexNames');
+jest.mock('./hooks/usePendingIncompatibleIndexActions');
-const makeIndex = (overrides: Partial): IncompatibleIndex => ({
+const makeIndex = (overrides: Partial): IncompatibleIndexRow => ({
+ id: 'index',
index_name: 'index',
version: '7.10.2',
warm_index: false,
managed_index: false,
system_index: false,
active_write_index: null,
+ begin: null,
+ end: null,
...overrides,
});
-const graylogIndex = makeIndex({ index_name: 'graylog_0', managed_index: true });
-const secondGraylogIndex = makeIndex({ index_name: 'graylog_1', managed_index: true });
-const systemIndex = makeIndex({ index_name: '.system-index', system_index: true });
-const foreignIndex = makeIndex({ index_name: 'legacy-index' });
-const writeIndex = makeIndex({ index_name: 'graylog_2', managed_index: true, active_write_index: 'index-set-id' });
-
-const mockIncompatibleIndices = (overrides: Partial>) => {
- asMock(useIncompatibleIndices).mockReturnValue({
- data: [],
- isError: false,
- isLoading: false,
- refetch: jest.fn(() => Promise.resolve({ data: [] })),
- ...overrides,
- } as ReturnType);
+const searchParams: SearchParams = {
+ page: 2,
+ pageSize: 20,
+ query: '',
+ sort: { attributeId: 'index_name', direction: 'asc' },
+ filters: undefined,
};
-const ACTION_STARTED_AT = '2026-07-02T08:00:00.000Z';
-const JOBS_POLLED_AFTER_ACTION = Date.parse('2026-07-02T09:00:00.000Z');
-const JOBS_POLLED_BEFORE_ACTION = Date.parse('2026-07-02T07:00:00.000Z');
-
-const clusterJob = (overrides: Partial): SystemJobSummary =>
- ({
- id: 'job-1',
- name: 'archive-job',
- description: 'Archiving index',
- info: '',
- job_status: 'running',
- percent_complete: 0,
- provides_progress: true,
- is_cancelable: true,
- execution_duration: 'PT1S',
- started_at: ACTION_STARTED_AT,
- node_id: 'node-1',
- ...overrides,
- }) as SystemJobSummary;
-
-const archiveBinding = (): IndexArchiveBinding => ({
- useCanArchive: () => true,
- useArchivedIndexNames: () => new Set(),
- archiveAndDeleteIndex: jest.fn(() => Promise.resolve({ systemJobId: 'archive-job-id' })),
- isArchiveJobConflict: (errorMessage) => errorMessage.includes('already running'),
- archiveSystemJobName: 'org.graylog.plugins.archive.job.ArchiveCreateSystemJob',
+const makeResponse = (list: Array): IncompatibleIndicesResponse => ({
+ list,
+ pagination: { total: list.length },
+ attributes: [],
});
-const storePendingArchive = (indexName: string, systemJobId?: string) => {
- window.localStorage.setItem(
- PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY,
- JSON.stringify([{ action: 'archive-delete', indexName, systemJobId, startedAt: ACTION_STARTED_AT }]),
- );
-};
+const latestTableProps = (
+ mockPaginatedEntityTable: jest.Mock,
+): PaginatedEntityTableProps =>
+ mockPaginatedEntityTable.mock.calls[mockPaginatedEntityTable.mock.calls.length - 1][0];
describe('IncompatibleIndicesTable', () => {
beforeEach(() => {
jest.clearAllMocks();
- window.localStorage.removeItem(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY);
asMock(useCanArchive).mockReturnValue(true);
- asMock(useIndexArchive).mockReturnValue(archiveBinding());
- asMock(useSendTelemetry).mockReturnValue(jest.fn());
- asMock(useClusterJobs).mockReturnValue({ jobsById: new Map(), jobsUpdatedAt: 0 });
asMock(useArchivedIndexNames).mockReturnValue(new Set());
- mockIncompatibleIndices({});
- });
-
- it('shows a spinner while loading', async () => {
- mockIncompatibleIndices({ isLoading: true });
- render( );
-
- expect(await screen.findByText(/loading incompatible indices/i)).toBeInTheDocument();
- });
-
- it('shows an error alert when loading fails', () => {
- mockIncompatibleIndices({ isError: true });
- render( );
-
- expect(screen.getByText(/could not load incompatible indices/i)).toBeInTheDocument();
- });
-
- it('retries loading incompatible indices on demand from the error state', async () => {
- const refetch = jest.fn(() => Promise.resolve({ data: [] })) as unknown as ReturnType<
- typeof useIncompatibleIndices
- >['refetch'];
- mockIncompatibleIndices({ isError: true, refetch });
- render( );
-
- await userEvent.click(screen.getByRole('button', { name: /retry now/i }));
-
- expect(refetch).toHaveBeenCalled();
- });
-
- it('shows a success message when there are no incompatible indices', () => {
- mockIncompatibleIndices({ data: [] });
- render( );
-
- expect(screen.getByText(/no incompatible indices found/i)).toBeInTheDocument();
- });
-
- it('renders the group counts and the default group rows', () => {
- mockIncompatibleIndices({ data: [graylogIndex, systemIndex] });
- render( );
-
- expect(screen.getByText('Graylog (1)')).toBeInTheDocument();
- expect(screen.getByText('System (1)')).toBeInTheDocument();
- expect(screen.getByText('Foreign (0)')).toBeInTheDocument();
- expect(screen.getByText('graylog_0')).toBeInTheDocument();
- expect(
- within(screen.getByRole('columnheader', { name: 'Actions' })).getByRole('button', { name: /^delete all/i }),
- ).toBeInTheDocument();
- });
-
- it('offers archive-and-delete for managed indices only when archiving is available', () => {
- mockIncompatibleIndices({ data: [graylogIndex] });
- const { rerender } = render( );
-
- expect(screen.getByRole('button', { name: /^archive and delete$/i })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /archive and delete all/i })).not.toBeInTheDocument();
-
- asMock(useCanArchive).mockReturnValue(false);
- rerender( );
-
- expect(screen.queryByRole('button', { name: /archive and delete/i })).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
- });
-
- it('only offers reindex for system indices', () => {
- mockIncompatibleIndices({ data: [systemIndex] });
- render( );
-
- expect(screen.getByRole('button', { name: /^reindex$/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /reindex all/i })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
- });
-
- it('only offers delete for foreign indices', () => {
- mockIncompatibleIndices({ data: [foreignIndex] });
- render( );
-
- expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /archive/i })).not.toBeInTheDocument();
+ asMock(usePendingIncompatibleIndexActions).mockReturnValue({
+ pendingIndexStatuses: new Map(),
+ addArchiveDeleteAction: jest.fn(),
+ isArchiveJobRunning: false,
+ refetchClusterJobs: jest.fn(),
+ });
});
- it('only offers rotate for the active write index', () => {
- mockIncompatibleIndices({ data: [writeIndex] });
- render( );
+ it('wires the paginated entity table to the outdated indices endpoint', () => {
+ const { default: PaginatedEntityTable } = jest.requireMock('components/common/PaginatedEntityTable');
+ const mockPaginatedEntityTable = asMock(PaginatedEntityTable);
- expect(screen.getByText('active write index')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^rotate$/i })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /archive/i })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /reindex/i })).not.toBeInTheDocument();
- });
-
- it('rotates the active write index via its index set and refreshes the list', async () => {
- const sendTelemetry = jest.fn();
- asMock(useSendTelemetry).mockReturnValue(sendTelemetry);
- const refetch = jest.fn(() =>
- Promise.resolve({ data: [makeIndex({ ...writeIndex, active_write_index: null })] }),
- ) as unknown as ReturnType['refetch'];
- mockIncompatibleIndices({ data: [writeIndex], refetch });
render( );
- await userEvent.click(screen.getByRole('button', { name: /^rotate$/i }));
-
- const dialog = await screen.findByRole('dialog');
- expect(within(dialog).getByText(/is the active write index of its index set/i)).toBeInTheDocument();
-
- await userEvent.click(within(dialog).getByRole('button', { name: /^rotate$/i }));
+ expect(screen.getByText('Paginated incompatible indices')).toBeInTheDocument();
+ expect(mockPaginatedEntityTable).toHaveBeenCalledTimes(1);
- await waitFor(() => expect(ClusterDeflector.cycleByindexSetId).toHaveBeenCalledWith('index-set-id'));
- expect(sendTelemetry).toHaveBeenCalledWith(
- TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.WRITE_INDEX_ROTATE_CONFIRMED,
- expect.objectContaining({ app_section: 'opensearch-upgrade' }),
- );
- expect(UserNotification.success).toHaveBeenCalledWith(expect.stringContaining('graylog_2'));
- expect(refetch).toHaveBeenCalled();
+ 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);
+ expect(callProps.tableLayout.entityTableId).toBe('incompatible_indices');
+ expect(typeof callProps.entityActions).toBe('function');
+ expect(callProps.bulkSelection.actions).toBeTruthy();
+ expect(callProps.columnRenderers.attributes).toHaveProperty('index_name');
});
- it('excludes the active write index from bulk delete', () => {
- mockIncompatibleIndices({ data: [graylogIndex, writeIndex] });
- render( );
-
- expect(screen.getByRole('button', { name: /delete all \(1\)/i })).toBeInTheDocument();
- });
-
- it('uses the outdated delete endpoint for foreign indices', async () => {
- const sendTelemetry = jest.fn();
- asMock(useSendTelemetry).mockReturnValue(sendTelemetry);
- mockIncompatibleIndices({ data: [foreignIndex] });
- render( );
-
- await userEvent.click(screen.getByRole('button', { name: /^delete$/i }));
+ it('retains selected index data across pages and removes deselected indices', () => {
+ const { default: PaginatedEntityTable } = jest.requireMock('components/common/PaginatedEntityTable');
+ const mockPaginatedEntityTable = asMock(PaginatedEntityTable);
+ const firstPageIndex = makeIndex({ id: 'legacy_0', index_name: 'legacy_0' });
+ const secondPageIndex = makeIndex({ id: 'legacy_1', index_name: 'legacy_1' });
- const dialog = await screen.findByRole('dialog');
- expect(within(dialog).getByText(/this will permanently delete/i)).toBeInTheDocument();
-
- await userEvent.click(within(dialog).getByRole('button', { name: /^delete$/i }));
-
- await waitFor(() => expect(IndexerIndices.deleteOutdated).toHaveBeenCalledWith('legacy-index'));
- expect(sendTelemetry).toHaveBeenCalledWith(
- TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.INDEX_DELETE_CONFIRMED,
- expect.objectContaining({ app_section: 'opensearch-upgrade' }),
- );
- expect(UserNotification.success).toHaveBeenCalled();
- });
-
- it('uses the generic delete endpoint for Graylog-managed indices', async () => {
- mockIncompatibleIndices({ data: [graylogIndex] });
render( );
- await userEvent.click(screen.getByRole('button', { name: /^delete$/i }));
-
- const dialog = await screen.findByRole('dialog');
- await userEvent.click(within(dialog).getByRole('button', { name: /^delete$/i }));
-
- await waitFor(() => expect(IndexerIndices.remove).toHaveBeenCalledWith('graylog_0'));
- });
-
- it('shows archive progress from the system job and hides row actions for a pending index', () => {
- storePendingArchive('graylog_0', 'job-1');
- asMock(useClusterJobs).mockReturnValue({
- jobsById: new Map([['job-1', clusterJob({ job_status: 'running', percent_complete: 42 })]]),
- jobsUpdatedAt: JOBS_POLLED_AFTER_ACTION,
+ act(() => {
+ latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([firstPageIndex]));
});
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
- expect(screen.getByText('42%')).toBeInTheDocument();
- expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
- expect(screen.queryByRole('button', { name: /archive and delete/i })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /^delete$/i })).not.toBeInTheDocument();
- });
-
- it('shows an "Archiving..." label without a bar until there is real progress', () => {
- storePendingArchive('graylog_0', 'job-1');
- asMock(useClusterJobs).mockReturnValue({
- jobsById: new Map([['job-1', clusterJob({ job_status: 'running', percent_complete: 0 })]]),
- jobsUpdatedAt: JOBS_POLLED_AFTER_ACTION,
+ act(() => {
+ latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.(
+ [firstPageIndex.id],
+ [firstPageIndex],
+ );
});
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
-
- expect(screen.getByText(/archiving\.\.\./i)).toBeInTheDocument();
- expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
- });
- it('shows a failed state and keeps row actions when the archive job errored', () => {
- storePendingArchive('graylog_0', 'job-1');
- asMock(useClusterJobs).mockReturnValue({
- jobsById: new Map([['job-1', clusterJob({ job_status: 'error', info: 'Backend unreachable' })]]),
- jobsUpdatedAt: JOBS_POLLED_AFTER_ACTION,
+ act(() => {
+ latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([secondPageIndex]));
});
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
- expect(screen.getByText(/archive failed/i)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^archive and delete$/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
- });
+ act(() => {
+ latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.(
+ [firstPageIndex.id, secondPageIndex.id],
+ [secondPageIndex],
+ );
+ });
- it('shows an archived-already badge when the job finished but the index is still incompatible', async () => {
- storePendingArchive('graylog_0', 'job-1');
- asMock(useClusterJobs).mockReturnValue({ jobsById: new Map(), jobsUpdatedAt: JOBS_POLLED_AFTER_ACTION });
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
+ const bulkActions = latestTableProps(mockPaginatedEntityTable).bulkSelection.actions as React.ReactElement<{
+ indices: Array;
+ }>;
- expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
- expect(screen.getByText(/archived already/i)).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /^archive and delete$/i })).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /delete all \(1\)/i })).toBeInTheDocument();
- await waitFor(() =>
- expect(JSON.parse(window.localStorage.getItem(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY))).toEqual([
- expect.objectContaining({ action: 'archive-delete', indexName: 'graylog_0', state: 'archived' }),
- ]),
+ expect(bulkActions.props.indices.map(({ id }) => id)).toEqual(['legacy_0', 'legacy_1']);
+ expect(useArchivedIndexNames).toHaveBeenLastCalledWith(['legacy_0', 'legacy_1'], true);
+ expect(usePendingIncompatibleIndexActions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ incompatibleIndices: [firstPageIndex, secondPageIndex] }),
);
- });
-
- it('flags an index the archive catalog already knows about, even without a local pending action', () => {
- asMock(useArchivedIndexNames).mockReturnValue(new Set(['graylog_0']));
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
-
- expect(screen.getByText(/archived already/i)).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /^archive and delete$/i })).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
- });
- it('still counts an already-archived index as a bulk delete candidate', () => {
- asMock(useArchivedIndexNames).mockReturnValue(new Set(['graylog_0']));
- mockIncompatibleIndices({ data: [graylogIndex, secondGraylogIndex] });
- render( );
-
- expect(screen.getByRole('button', { name: /delete all \(2\)/i })).toBeInTheDocument();
- });
+ act(() => {
+ latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.(
+ [secondPageIndex.id],
+ [secondPageIndex],
+ );
+ });
- it('keeps tracking when the jobs list predates the action (stale cache is not "job gone")', () => {
- storePendingArchive('graylog_0', 'job-1');
- asMock(useClusterJobs).mockReturnValue({ jobsById: new Map(), jobsUpdatedAt: JOBS_POLLED_BEFORE_ACTION });
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
+ const actionsAfterDeselection = latestTableProps(mockPaginatedEntityTable).bulkSelection
+ .actions as React.ReactElement<{
+ indices: Array;
+ }>;
- expect(screen.getByText(/archiving\.\.\./i)).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /archive and delete/i })).not.toBeInTheDocument();
- expect(window.localStorage.getItem(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY)).not.toBe('[]');
+ expect(actionsAfterDeselection.props.indices).toEqual([secondPageIndex]);
+ expect(useArchivedIndexNames).toHaveBeenLastCalledWith(['legacy_1'], true);
+ expect(usePendingIncompatibleIndexActions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ incompatibleIndices: [secondPageIndex] }),
+ );
});
+});
- it('drops pending actions for indices that are no longer incompatible', async () => {
- storePendingArchive('graylog_0', 'job-1');
- mockIncompatibleIndices({ data: [foreignIndex] });
- render( );
-
- expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
- await waitFor(() => expect(window.localStorage.getItem(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY)).toBe('[]'));
+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 });
});
+});
- it('keeps tracking an action without a system job id until its index disappears', () => {
- storePendingArchive('graylog_0');
- asMock(useClusterJobs).mockReturnValue({ jobsById: new Map(), jobsUpdatedAt: JOBS_POLLED_AFTER_ACTION });
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
+describe('createColumnRenderers', () => {
+ const renderers = createColumnRenderers(new Map(), new Set());
- expect(screen.getByText(/archiving\.\.\./i)).toBeInTheDocument();
- expect(window.localStorage.getItem(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY)).not.toBe('[]');
+ it('falls back to "Unknown" for a missing version', () => {
+ expect(renderers.attributes.version.renderCell(undefined, makeIndex({ version: '' }), undefined)).toBe('Unknown');
});
- it('ignores malformed localStorage without crashing', () => {
- window.localStorage.setItem(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY, JSON.stringify({ not: 'an array' }));
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
+ it('renders the index name with status badges (not type badges)', () => {
+ const index = makeIndex({ index_name: 'graylog_2', warm_index: true, active_write_index: 'index-set-id' });
- expect(screen.getByRole('button', { name: /^archive and delete$/i })).toBeInTheDocument();
- expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
- });
+ render({renderers.attributes.index_name.renderCell(index.index_name, index, undefined)}
);
- it('drops invalid stored entries', () => {
- window.localStorage.setItem(
- PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY,
- JSON.stringify([{ action: 'archive-delete' }, null, 'nonsense']),
- );
- mockIncompatibleIndices({ data: [graylogIndex] });
- render( );
-
- expect(screen.getByRole('button', { name: /^archive and delete$/i })).toBeInTheDocument();
- expect(screen.queryByText(/archiving\.\.\./i)).not.toBeInTheDocument();
+ expect(screen.getByText('graylog_2')).toBeInTheDocument();
+ expect(screen.getByText('active write index')).toBeInTheDocument();
+ expect(screen.queryByText('Warm')).not.toBeInTheDocument();
});
- it('runs bulk delete for eligible group indices and reports partial failures', async () => {
- const refetch = jest.fn(() =>
- Promise.resolve({ data: [secondGraylogIndex] }),
- ) as unknown as ReturnType['refetch'];
- asMock(IndexerIndices.remove).mockImplementation((indexName: string) =>
- indexName === 'graylog_1' ? Promise.reject(new Error('Delete failed')) : Promise.resolve(),
- );
- mockIncompatibleIndices({ data: [graylogIndex, secondGraylogIndex], refetch });
- render( );
-
- await userEvent.click(screen.getByRole('button', { name: /^delete all/i }));
+ it('renders the primary type as a single badge in the category column', () => {
+ const cases: Array<[Partial, string]> = [
+ [{ managed_index: true }, 'Graylog'],
+ [{ system_index: true }, 'System'],
+ [{}, 'Foreign'],
+ ];
- const dialog = await screen.findByRole('dialog');
- expect(within(dialog).getByText(/this will delete 2 incompatible indices/i)).toBeInTheDocument();
+ cases.forEach(([overrides, expected]) => {
+ const { unmount } = render(
+ {renderers.attributes.category.renderCell(undefined, makeIndex(overrides), undefined)}
,
+ );
- await userEvent.click(within(dialog).getByRole('button', { name: /delete all/i }));
-
- await waitFor(() => expect(IndexerIndices.remove).toHaveBeenCalledWith('graylog_0'));
- expect(IndexerIndices.remove).toHaveBeenCalledWith('graylog_1');
- expect(UserNotification.warning).toHaveBeenCalledWith(
- expect.stringContaining('1 succeeded, 1 failed'),
- 'Some indices could not be deleted',
- );
- expect(refetch).toHaveBeenCalled();
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ unmount();
+ });
});
- it('tracks a successful archive-and-delete job as a pending action', async () => {
- const binding = archiveBinding();
- asMock(useIndexArchive).mockReturnValue(binding);
- mockIncompatibleIndices({ data: [graylogIndex, secondGraylogIndex] });
- render( );
-
- await userEvent.click(screen.getAllByRole('button', { name: /^archive and delete$/i })[0]);
+ it('adds a Warm badge alongside the primary type', () => {
+ const index = makeIndex({ managed_index: true, warm_index: true });
- const dialog = await screen.findByRole('dialog');
- await userEvent.click(within(dialog).getByRole('button', { name: /^archive and delete$/i }));
+ render({renderers.attributes.category.renderCell(undefined, index, undefined)}
);
- await waitFor(() => expect(binding.archiveAndDeleteIndex).toHaveBeenCalledWith('graylog_0'));
-
- const storedActions = JSON.parse(window.localStorage.getItem(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY));
-
- expect(storedActions).toEqual([
- expect.objectContaining({ action: 'archive-delete', indexName: 'graylog_0', systemJobId: 'archive-job-id' }),
- ]);
- expect(UserNotification.success).toHaveBeenCalledWith('Archive and delete job for "graylog_0" was started.');
+ expect(screen.getByText('Graylog')).toBeInTheDocument();
+ expect(screen.getByText('Warm')).toBeInTheDocument();
});
- it('skips in-progress archive actions when running a bulk action', async () => {
- storePendingArchive('graylog_0', 'job-1');
- asMock(useClusterJobs).mockReturnValue({
- jobsById: new Map([['job-1', clusterJob({ job_status: 'running', percent_complete: 42 })]]),
- jobsUpdatedAt: JOBS_POLLED_AFTER_ACTION,
- });
- mockIncompatibleIndices({ data: [graylogIndex, secondGraylogIndex] });
- render( );
-
- await userEvent.click(screen.getByRole('button', { name: /^delete all/i }));
-
- const dialog = await screen.findByRole('dialog');
- expect(within(dialog).getByText(/this will delete 1 incompatible index/i)).toBeInTheDocument();
-
- await userEvent.click(within(dialog).getByRole('button', { name: /delete all/i }));
+ it('renders an em dash for an unknown message range', () => {
+ render({renderers.attributes.begin.renderCell(null, makeIndex({}), undefined)}
);
- await waitFor(() => expect(IndexerIndices.remove).toHaveBeenCalledTimes(1));
- expect(IndexerIndices.remove).toHaveBeenCalledWith('graylog_1');
- expect(IndexerIndices.remove).not.toHaveBeenCalledWith('graylog_0');
+ expect(screen.getByText('—')).toBeInTheDocument();
});
});
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 25599d33df9c..8cd1fed4d815 100644
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx
@@ -16,39 +16,24 @@
*/
import React, { useState } from 'react';
import styled, { css } from 'styled-components';
+import { useQueryClient } from '@tanstack/react-query';
+import keyBy from 'lodash/keyBy';
+import pickBy from 'lodash/pickBy';
-import { Alert, Button, SegmentedControl } from 'components/bootstrap';
-import { ConfirmDialog, Spinner } from 'components/common';
+import { PaginatedEntityTable } from 'components/common';
import useCanArchive from 'components/indices/hooks/useCanArchive';
-import useIncompatibleIndices from 'components/indices/hooks/useIncompatibleIndices';
-import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
-import extractErrorMessage from 'util/extractErrorMessage';
-import UserNotification from 'util/UserNotification';
-import BulkIndexActionConfirmDialog from './BulkIndexActionConfirmDialog';
-import { getBulkIndexActionCandidates, getBulkIndexActionNotification, runBulkIndexAction } from './bulkIndexActions';
-import type { BulkIndexActionCandidate, BulkIndexActionNotification } from './bulkIndexActions';
-import IndicesGroupTable from './IndicesGroupTable';
+import {
+ fetchIncompatibleIndices,
+ incompatibleIndicesKeyFn,
+ INCOMPATIBLE_INDICES_QUERY_KEY,
+} from './fetchIncompatibleIndices';
+import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from './fetchIncompatibleIndices';
+import { createColumnRenderers, DEFAULT_DISPLAYED_COLUMNS } from './IncompatibleIndicesColumnRenderers';
+import IncompatibleIndexTableActions from './IncompatibleIndexTableActions';
+import IncompatibleIndicesBulkActions from './IncompatibleIndicesBulkActions';
import useArchivedIndexNames from './hooks/useArchivedIndexNames';
import usePendingIncompatibleIndexActions from './hooks/usePendingIncompatibleIndexActions';
-import { useIncompatibleIndexActionDefinitions } from './incompatibleIndexActions';
-import type { ConfirmedAction } from './incompatibleIndexActions';
-import { getFirstGroupWithIndices, getSelectedGroup, groupIncompatibleIndices } from './incompatibleIndexGroups';
-
-const TELEMETRY_DEFAULTS = { app_pathname: 'datanode', app_section: 'opensearch-upgrade' } as const;
-
-const showBulkNotification = ({ type, message, title }: BulkIndexActionNotification) => {
- const notify = (notification: (notificationMessage: string, notificationTitle?: string) => void) =>
- title ? notification(message, title) : notification(message);
-
- if (type === 'success') {
- notify(UserNotification.success);
- } else if (type === 'warning') {
- notify(UserNotification.warning);
- } else {
- notify(UserNotification.error);
- }
-};
const Heading = styled.h4(
({ theme }) => css`
@@ -57,221 +42,99 @@ const Heading = styled.h4(
`,
);
-const ActionConfirmDialog = ({
- confirmedAction,
- isSubmitting,
- onCancel,
- onConfirm,
-}: {
- confirmedAction: ConfirmedAction;
- isSubmitting: boolean;
- onCancel: () => void;
- onConfirm: () => void;
-}) => {
- const actionDefinitions = useIncompatibleIndexActionDefinitions();
- const actionDefinition = actionDefinitions[confirmedAction.action];
-
- return (
-
- {actionDefinition.confirmationBody(confirmedAction.index)}
-
- );
+const TABLE_LAYOUT = {
+ entityTableId: 'incompatible_indices',
+ defaultSort: { attributeId: 'index_name', direction: 'asc' as const },
+ defaultDisplayedAttributes: DEFAULT_DISPLAYED_COLUMNS,
+ defaultPageSize: 10,
+ defaultColumnOrder: ['index_name', 'category', 'version', 'begin', 'end'],
};
const IncompatibleIndicesTable = () => {
- const { data: incompatibleIndices, isError, isLoading, refetch } = useIncompatibleIndices();
+ const queryClient = useQueryClient();
const canArchive = useCanArchive();
- const actionDefinitions = useIncompatibleIndexActionDefinitions();
- const sendTelemetry = useSendTelemetry();
+ const [loadedIndices, setLoadedIndices] = useState>([]);
+ const [selectedIndicesData, setSelectedIndicesData] = useState>({});
+ const [hasLoaded, setHasLoaded] = useState(false);
+
+ const refetch = () => queryClient.invalidateQueries({ queryKey: INCOMPATIBLE_INDICES_QUERY_KEY });
+ const selectedIndices = Object.values(selectedIndicesData);
+ const trackedIndices = Object.values({ ...selectedIndicesData, ...keyBy(loadedIndices, 'id') });
+ const incompatibleIndexNames = trackedIndices.map((index) => index.index_name);
+ const archivedIndexNames = useArchivedIndexNames(incompatibleIndexNames, canArchive);
const { pendingIndexStatuses, addArchiveDeleteAction, isArchiveJobRunning, refetchClusterJobs } =
usePendingIncompatibleIndexActions({
- incompatibleIndices,
- isLoading,
- isError,
+ incompatibleIndices: trackedIndices,
+ isLoading: !hasLoaded,
+ isError: false,
refetch,
canArchive,
});
- const incompatibleIndexNames = incompatibleIndices.map((index) => index.index_name);
- const archivedIndexNames = useArchivedIndexNames(incompatibleIndexNames, canArchive);
- const [confirmedAction, setConfirmedAction] = useState();
- const [confirmedBulkAction, setConfirmedBulkAction] = useState();
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [isBulkSubmitting, setIsBulkSubmitting] = useState(false);
- const [selectedGroupId, setSelectedGroupId] = useState();
- const indicesGroups = groupIncompatibleIndices(incompatibleIndices);
- const firstGroupWithIndices = getFirstGroupWithIndices(indicesGroups);
- const activeGroupId = selectedGroupId ?? firstGroupWithIndices;
- const selectedGroup = getSelectedGroup(indicesGroups, activeGroupId);
const archiveActionsAvailable = canArchive && !isArchiveJobRunning;
- const segments = indicesGroups.map((group) => ({
- value: group.id,
- label: `${group.shortLabel} (${group.indices.length})`,
- }));
- const bulkActions = getBulkIndexActionCandidates({
- indices: selectedGroup.indices,
- canArchive: archiveActionsAvailable,
- pendingIndexStatuses,
- archivedIndexNames,
- });
- const closeConfirmDialog = () => setConfirmedAction(undefined);
- const closeBulkConfirmDialog = () => setConfirmedBulkAction(undefined);
- const cancelBulkConfirmDialog = () => {
- if (!isBulkSubmitting) {
- closeBulkConfirmDialog();
- }
+ const handleDataLoaded = (data: IncompatibleIndicesResponse) => {
+ setLoadedIndices(data.list);
+ setHasLoaded(true);
};
- const finalizeAfterActions = async () => {
- const { data: updatedIncompatibleIndices = [] } = await refetch();
- const updatedGroups = groupIncompatibleIndices(updatedIncompatibleIndices);
- const updatedSelectedGroup = getSelectedGroup(updatedGroups, activeGroupId);
+ const columnRenderers = createColumnRenderers(pendingIndexStatuses, archivedIndexNames);
- if (updatedSelectedGroup.indices.length === 0) {
- setSelectedGroupId(getFirstGroupWithIndices(updatedGroups));
- }
- };
+ const renderActions = (index: IncompatibleIndexRow) => {
+ const pendingStatus = pendingIndexStatuses.get(index.index_name);
+ const isArchived =
+ pendingStatus?.state !== 'archiving' &&
+ (archivedIndexNames.has(index.index_name) || pendingStatus?.state === 'archived');
- const showArchiveJobConflictWarning = () => {
- UserNotification.warning(
- 'Another archive job is already running. New archive jobs can be started after it finishes.',
- 'Archive job already running',
+ return (
+
);
- refetchClusterJobs?.();
- };
-
- const handleConfirm = async () => {
- if (!confirmedAction) {
- return;
- }
-
- const { action, index } = confirmedAction;
- const actionDefinition = actionDefinitions[action];
-
- sendTelemetry(actionDefinition.telemetryEventType, { ...TELEMETRY_DEFAULTS });
- setIsSubmitting(true);
-
- try {
- const actionResponse = await actionDefinition.run(index);
- const pendingArchive = actionDefinition.getPendingArchiveTracking?.(index, actionResponse);
-
- if (pendingArchive) {
- addArchiveDeleteAction(pendingArchive);
- }
-
- UserNotification.success(actionDefinition.successMessage(index));
- await finalizeAfterActions();
- closeConfirmDialog();
- } catch (errorThrown) {
- const errorMessage = extractErrorMessage(errorThrown);
-
- if (actionDefinition.isArchiveJobConflict?.(errorMessage)) {
- showArchiveJobConflictWarning();
- closeConfirmDialog();
- } else {
- UserNotification.error(errorMessage, `Could not ${actionDefinition.confirmText.toLowerCase()}.`);
- }
- } finally {
- setIsSubmitting(false);
- }
};
- const handleBulkConfirm = async () => {
- if (!confirmedBulkAction || isBulkSubmitting) {
- return;
- }
+ const bulkSelection = {
+ onChangeSelection: (selectedItemsIds: Array, list: Readonly>) => {
+ setSelectedIndicesData((cur) => {
+ const selectedItemsIdsSet = new Set(selectedItemsIds);
+ const selectedCurrentItems = pickBy(cur, (_, indexId) => selectedItemsIdsSet.has(indexId));
+ const selectedCurrentEntries = list.filter(({ id }) => selectedItemsIdsSet.has(id));
+ const currentEntriesById = keyBy(selectedCurrentEntries, 'id');
- sendTelemetry(actionDefinitions[confirmedBulkAction.action].telemetryEventType, {
- ...TELEMETRY_DEFAULTS,
- app_action_value: 'bulk',
- bulk_count: confirmedBulkAction.targetIndices.length,
- });
- setIsBulkSubmitting(true);
-
- try {
- const result = await runBulkIndexAction({
- action: confirmedBulkAction.action,
- indices: confirmedBulkAction.targetIndices,
+ return { ...selectedCurrentItems, ...currentEntriesById };
});
-
- showBulkNotification(getBulkIndexActionNotification(confirmedBulkAction, result));
- await finalizeAfterActions();
- closeBulkConfirmDialog();
- } catch (errorThrown) {
- UserNotification.error(
- extractErrorMessage(errorThrown),
- `Could not ${confirmedBulkAction.confirmText.toLowerCase()}.`,
- );
- } finally {
- setIsBulkSubmitting(false);
- }
+ },
+ actions: (
+
+ ),
};
- if (isLoading) {
- return ;
- }
-
- if (isError) {
- return (
-
- Could not load incompatible indices — retrying automatically.{' '}
- refetch()}>
- Retry now
-
-
- );
- }
-
- if (!incompatibleIndices.length) {
- return No incompatible indices found. ;
- }
-
return (
<>
Incompatible indices
-
+ humanName="incompatible indices"
+ tableLayout={TABLE_LAYOUT}
+ fetchEntities={fetchIncompatibleIndices}
+ keyFn={incompatibleIndicesKeyFn}
+ columnRenderers={columnRenderers}
+ entityActions={renderActions}
+ bulkSelection={bulkSelection}
+ onDataLoaded={handleDataLoaded}
+ entityAttributesAreCamelCase={false}
/>
-
-
- {confirmedAction && (
-
- )}
- {confirmedBulkAction && (
-
- )}
>
);
};
diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IndicesGroupTable.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IndicesGroupTable.tsx
deleted file mode 100644
index a240ffa29edf..000000000000
--- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IndicesGroupTable.tsx
+++ /dev/null
@@ -1,252 +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 React from 'react';
-import styled, { css } from 'styled-components';
-
-import { Alert, Button, ButtonToolbar, Label, Table } from 'components/bootstrap';
-import { ProgressBar } from 'components/common';
-import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices';
-
-import type { BulkIndexActionCandidate } from './bulkIndexActions';
-import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions';
-import { CORE_ACTION_DEFINITIONS, getAvailableActions, useIncompatibleIndexActionDefinitions } from './incompatibleIndexActions';
-import type { ConfirmedAction } from './incompatibleIndexActions';
-import type { IndicesGroup } from './incompatibleIndexGroups';
-
-const indexNameBadges = (index: IncompatibleIndex, isArchived: boolean) =>
- [
- { text: 'warm', style: 'default', show: index.warm_index },
- { text: 'active write index', style: 'default', show: !!index.active_write_index },
- { text: 'archived already', style: 'success', show: isArchived },
- ] as const;
-
-const ActionsToolbar = styled(ButtonToolbar)`
- justify-content: flex-end;
-`;
-
-const BulkActionsToolbar = styled(ButtonToolbar)(
- () => css`
- justify-content: flex-end;
- margin: 0;
- `,
-);
-
-const ArchiveProgressBar = styled(ProgressBar)`
- display: inline-flex;
- width: 120px;
- margin-bottom: 0;
- vertical-align: middle;
-`;
-
-const ScrollableTableWrapper = styled.div(
- ({ theme }) => css`
- margin-top: ${theme.spacings.md};
- margin-bottom: ${theme.spacings.md};
-
- & > table {
- margin-bottom: 0;
- table-layout: fixed;
- }
-
- & thead,
- & tbody {
- display: block;
- }
-
- & thead tr,
- & tbody tr {
- display: table;
- width: 100%;
- table-layout: fixed;
- }
-
- & tbody {
- max-height: 300px;
- overflow-y: auto;
- scrollbar-gutter: stable;
- }
-
- & thead {
- scrollbar-gutter: stable;
- overflow-y: hidden;
- }
-
- & thead th {
- background-color: ${theme.colors.table.head.background};
- }
-
- & tr > *:nth-child(1) {
- width: 40%;
- text-align: left;
- }
-
- & tr > *:nth-child(2) {
- width: 30%;
- text-align: left;
- }
-
- & tr > *:nth-child(3) {
- width: 30%;
- text-align: right;
- }
- `,
-);
-
-const IncompatibleIndexActions = ({
- index,
- onAction,
- canArchive,
- pendingStatus,
- isArchived,
-}: {
- index: IncompatibleIndex;
- onAction: (action: ConfirmedAction) => void;
- canArchive: boolean;
- pendingStatus: PendingIndexStatus | undefined;
- isArchived: boolean;
-}) => {
- const actionDefinitions = useIncompatibleIndexActionDefinitions();
-
- if (pendingStatus?.state === 'archiving') {
- // No empty 0% bar flash for jobs that finish almost instantly.
- return pendingStatus.percent > 0 ? (
-
- ) : (
- Archiving...
- );
- }
-
- const actions = getAvailableActions(index, canArchive, isArchived);
-
- return (
-
- {pendingStatus?.state === 'failed' && (
-
- Archive failed
-
- )}
- {actions.map((action) => {
- const actionDefinition = actionDefinitions[action];
-
- return (
- onAction({ action, index })}>
- {actionDefinition.buttonLabel}
-
- );
- })}
-
- );
-};
-
-const IndicesGroupTable = ({
- group,
- onAction,
- onBulkAction,
- canArchive,
- pendingIndexStatuses,
- archivedIndexNames,
- bulkActions,
- isBulkActionSubmitting,
-}: {
- group: IndicesGroup;
- onAction: (action: ConfirmedAction) => void;
- onBulkAction: (bulkAction: BulkIndexActionCandidate) => void;
- canArchive: boolean;
- pendingIndexStatuses: Map;
- archivedIndexNames: ReadonlySet;
- bulkActions: Array;
- isBulkActionSubmitting: boolean;
-}) => {
- if (group.indices.length === 0) {
- return No incompatible {group.shortLabel} indices. ;
- }
-
- return (
-
-
-
-
- {group.indexLabel}
- OpenSearch version
-
- {bulkActions.length > 0 && (
-
- {bulkActions.map((bulkAction) => (
- onBulkAction(bulkAction)}>
- {bulkAction.buttonLabel} ({bulkAction.targetIndices.length})
-
- ))}
-
- )}
-
-
-
-
- {group.indices.map((index) => {
- const pendingStatus = pendingIndexStatuses.get(index.index_name);
- const isArchived =
- pendingStatus?.state !== 'archiving' &&
- (archivedIndexNames.has(index.index_name) || pendingStatus?.state === 'archived');
-
- return (
-
-
- {index.index_name}
- {indexNameBadges(index, isArchived)
- .filter((badge) => badge.show)
- .map(({ text, style }) => (
-
-
-
- {text}
-
-
- ))}
-
- {index.version || 'Unknown'}
-
-
-
-
- );
- })}
-
-
-
- );
-};
-
-export default IndicesGroupTable;
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
new file mode 100644
index 000000000000..64868e7dade2
--- /dev/null
+++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts
@@ -0,0 +1,54 @@
+/*
+ * 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';
+
+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 => {
+ 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, pagination }) => ({
+ list: elements.map((element) => ({ ...element, id: element.index_name })) as Array,
+ pagination: { total: 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..9e3f452e6249 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);
@@ -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.
+
),
run: rotateWriteIndex,
@@ -118,7 +120,9 @@ const archiveDeleteDefinition = (archive: IndexArchiveBinding | undefined): Acti
),
run: (index) =>
- archive ? archive.archiveAndDeleteIndex(index.index_name) : Promise.reject(new Error('Archiving is not available.')),
+ archive
+ ? archive.archiveAndDeleteIndex(index.index_name)
+ : Promise.reject(new Error('Archiving is not available.')),
successMessage: (index) => `Archive and delete job for "${index.index_name}" was started.`,
telemetryEventType: TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.INDEX_ARCHIVE_AND_DELETE_CONFIRMED,
getPendingArchiveTracking: (index, response) => ({
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..9874d97f3020 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;
@@ -24,8 +24,9 @@ export type IncompatibleIndex = {
warm_index: boolean;
managed_index: boolean;
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;
+ begin?: string | null;
+ end?: string | null;
};
const ERROR_REFETCH_INTERVAL_MS = 30000;
@@ -38,8 +39,7 @@ const useIncompatibleIndices = () => {
refetch,
} = 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),
});