diff --git a/changelog/unreleased/issue-12498.toml b/changelog/unreleased/issue-12498.toml new file mode 100644 index 000000000000..57a09018999d --- /dev/null +++ b/changelog/unreleased/issue-12498.toml @@ -0,0 +1,5 @@ +type = "fixed" +message = "We improved the index set overview page performance and added a title-based search feature." + +issues = ["12498"] +pulls = ["15155"] diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/IndexSetRegistry.java b/graylog2-server/src/main/java/org/graylog2/indexer/IndexSetRegistry.java index 070ee8477011..726df6b0a8fd 100644 --- a/graylog2-server/src/main/java/org/graylog2/indexer/IndexSetRegistry.java +++ b/graylog2-server/src/main/java/org/graylog2/indexer/IndexSetRegistry.java @@ -16,6 +16,7 @@ */ package org.graylog2.indexer; +import org.graylog2.indexer.indexset.IndexSetConfig; import org.graylog2.indexer.indices.TooManyAliasesException; import java.util.Collection; @@ -55,6 +56,14 @@ public interface IndexSetRegistry extends Iterable { */ Set getForIndices(Collection indices); + /** + * Returns the {@link IndexSet}s for the given indices. + * + * @param indexSetConfigs Collection of index configurations + * @return Set of index sets which relates to given configurations + */ + Set getFromIndexConfig(Collection indexSetConfigs); + /** * Returns the {@link IndexSet} that is marked as default. * diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/MongoIndexSetRegistry.java b/graylog2-server/src/main/java/org/graylog2/indexer/MongoIndexSetRegistry.java index 052739f898bd..023ef8b58112 100644 --- a/graylog2-server/src/main/java/org/graylog2/indexer/MongoIndexSetRegistry.java +++ b/graylog2-server/src/main/java/org/graylog2/indexer/MongoIndexSetRegistry.java @@ -101,7 +101,6 @@ private Set findAllMongoIndexSets() { } return mongoIndexSets.build(); } - @Override public Set getAll() { return ImmutableSet.copyOf(findAllMongoIndexSets()); @@ -110,10 +109,10 @@ public Set getAll() { @Override public Optional get(final String indexSetId) { return this.indexSetsCache.get() - .stream() - .filter(indexSet -> Objects.equals(indexSet.id(), indexSetId)) - .map(indexSetConfig -> (IndexSet)mongoIndexSetFactory.create(indexSetConfig)) - .findFirst(); + .stream() + .filter(indexSet -> Objects.equals(indexSet.id(), indexSetId)) + .map(indexSetConfig -> (IndexSet) mongoIndexSetFactory.create(indexSetConfig)) + .findFirst(); } @Override @@ -140,6 +139,16 @@ public Set getForIndices(Collection indices) { return resultBuilder.build(); } + @Override + public Set getFromIndexConfig(Collection indexSetConfigs) { + final ImmutableSet.Builder mongoIndexSets = ImmutableSet.builder(); + for (IndexSetConfig config : indexSetConfigs) { + final MongoIndexSet mongoIndexSet = mongoIndexSetFactory.create(config); + mongoIndexSets.add(mongoIndexSet); + } + return ImmutableSet.copyOf(mongoIndexSets.build()); + } + @Override public IndexSet getDefault() { return mongoIndexSetFactory.create(indexSetService.getDefault()); diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/indexset/IndexSetService.java b/graylog2-server/src/main/java/org/graylog2/indexer/indexset/IndexSetService.java index fc726b16865b..a6b0441f86a2 100644 --- a/graylog2-server/src/main/java/org/graylog2/indexer/indexset/IndexSetService.java +++ b/graylog2-server/src/main/java/org/graylog2/indexer/indexset/IndexSetService.java @@ -73,12 +73,14 @@ public interface IndexSetService { * Retrieve a paginated set of index set. * * @param indexSetIds List of inde set ids to return - * @param limit Maximum number of index sets - * @param skip Number of index sets to skip + * @param limit Maximum number of index sets + * @param skip Number of index sets to skip * @return Paginated index sets */ List findPaginated(Set indexSetIds, int limit, int skip); + List searchByTitle(String searchString); + /** * Save the given index set. * diff --git a/graylog2-server/src/main/java/org/graylog2/indexer/indexset/MongoIndexSetService.java b/graylog2-server/src/main/java/org/graylog2/indexer/indexset/MongoIndexSetService.java index 690ab1cdcbef..1b26a9f82b85 100644 --- a/graylog2-server/src/main/java/org/graylog2/indexer/indexset/MongoIndexSetService.java +++ b/graylog2-server/src/main/java/org/graylog2/indexer/indexset/MongoIndexSetService.java @@ -34,8 +34,10 @@ import javax.inject.Inject; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Optional; import java.util.Set; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static com.google.common.base.Preconditions.checkState; @@ -153,6 +155,16 @@ public List findPaginated(Set indexSetIds, int limit, in .toArray()); } + @Override + public List searchByTitle(String searchString) { + String formatedSearchString = String.format(Locale.getDefault(), ".*%s.*", searchString); + Pattern searchPattern = Pattern.compile(formatedSearchString, Pattern.CASE_INSENSITIVE); + DBQuery.Query query = DBQuery.regex("title", searchPattern); + return ImmutableList.copyOf(collection.find(query) + .sort(DBSort.asc("title")) + .toArray()); + } + /** * {@inheritDoc} */ diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndexSetsResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndexSetsResource.java index 79da0a393bd0..13dfe4335aed 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndexSetsResource.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/indexer/IndexSetsResource.java @@ -68,6 +68,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -78,7 +79,7 @@ import static org.graylog2.shared.rest.documentation.generator.Generator.CLOUD_VISIBLE; @RequiresAuthentication -@Api(value = "System/IndexSets", description = "Index sets", tags={CLOUD_VISIBLE}) +@Api(value = "System/IndexSets", description = "Index sets", tags = {CLOUD_VISIBLE}) @Path("/system/indices/index_sets") @Produces(MediaType.APPLICATION_JSON) public class IndexSetsResource extends RestResource { @@ -124,41 +125,64 @@ public IndexSetResponse list(@ApiParam(name = "skip", value = "The number of ele @QueryParam("limit") @DefaultValue("0") int limit, @ApiParam(name = "stats", value = "Include index set stats.") @QueryParam("stats") @DefaultValue("false") boolean computeStats) { + final IndexSetConfig defaultIndexSet = indexSetService.getDefault(); + List allowedConfigurations = indexSetService.findAll() + .stream() + .filter(indexSet -> isPermitted(RestPermissions.INDEXSETS_READ, indexSet.id())) + .toList(); - List indexSets; - int count; + return getPagedIndexSetResponse(skip, limit, computeStats, defaultIndexSet, allowedConfigurations); + } - if (limit > 0) { - // First collect all index set ids the user is allowed to see. - final Set allowedIds = indexSetService.findAll().stream() - .filter(indexSet -> isPermitted(RestPermissions.INDEXSETS_READ, indexSet.id())) - .map(IndexSetConfig::id) - .collect(Collectors.toSet()); + @GET + @Path("search") + @Timed + @ApiOperation(value = "Get a list of all index sets") + @ApiResponses(value = { + @ApiResponse(code = 403, message = "Unauthorized"), + }) + public IndexSetResponse search(@ApiParam(name = "searchTitle", value = "The number of elements to skip (offset).") + @QueryParam("searchTitle") String searchTitle, + @ApiParam(name = "skip", value = "The number of elements to skip (offset).", required = true) + @QueryParam("skip") @DefaultValue("0") int skip, + @ApiParam(name = "limit", value = "The maximum number of elements to return.", required = true) + @QueryParam("limit") @DefaultValue("0") int limit, + @ApiParam(name = "stats", value = "Include index set stats.") + @QueryParam("stats") @DefaultValue("false") boolean computeStats) { + final IndexSetConfig defaultIndexSet = indexSetService.getDefault(); + List allowedConfigurations = indexSetService.searchByTitle(searchTitle).stream() + .filter(indexSet -> isPermitted(RestPermissions.INDEXSETS_READ, indexSet.id())).toList(); - indexSets = indexSetService.findPaginated(allowedIds, limit, skip).stream() - .map(config -> IndexSetSummary.fromIndexSetConfig(config, config.equals(defaultIndexSet))) - .collect(Collectors.toList()); - count = allowedIds.size(); - } else { - indexSets = indexSetService.findAll().stream() - .filter(indexSetConfig -> isPermitted(RestPermissions.INDEXSETS_READ, indexSetConfig.id())) - .map(config -> IndexSetSummary.fromIndexSetConfig(config, config.equals(defaultIndexSet))) - .collect(Collectors.toList()); - count = indexSets.size(); - } + return getPagedIndexSetResponse(skip, limit, computeStats, defaultIndexSet, allowedConfigurations); + } + + private IndexSetResponse getPagedIndexSetResponse(int skip, int limit, boolean computeStats, IndexSetConfig defaultIndexSet, List allowedConfigurations) { + int calculatedLimit = limit > 0 ? limit : allowedConfigurations.size(); + Comparator titleComparator = Comparator.comparing(IndexSetConfig::title, String.CASE_INSENSITIVE_ORDER); + + List pagedConfigs = allowedConfigurations.stream() + .sorted(titleComparator) + .skip(skip) + .limit(calculatedLimit) + .toList(); + + List indexSets = pagedConfigs.stream() + .map(config -> IndexSetSummary.fromIndexSetConfig(config, config.equals(defaultIndexSet))) + .toList(); + + + Map stats = Collections.emptyMap(); - final Map stats; if (computeStats) { - stats = indexSetRegistry.getAll().stream() + stats = indexSetRegistry.getFromIndexConfig(pagedConfigs).stream() .collect(Collectors.toMap(indexSet -> indexSet.getConfig().id(), indexSetStatsCreator::getForIndexSet)); - } else { - stats = Collections.emptyMap(); } - return IndexSetResponse.create(count, indexSets, stats); + return IndexSetResponse.create(allowedConfigurations.size(), indexSets, stats); } + @GET @Path("stats") @Timed diff --git a/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/IndexSetsResourceTest.java b/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/IndexSetsResourceTest.java index b19455d89373..c1635d4e64d0 100644 --- a/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/IndexSetsResourceTest.java +++ b/graylog2-server/src/test/java/org/graylog2/rest/resources/system/indexer/IndexSetsResourceTest.java @@ -57,6 +57,7 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Collections; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -114,25 +115,7 @@ private void notPermitted() { @Test public void list() { - final IndexSetConfig indexSetConfig = IndexSetConfig.create( - "id", - "title", - "description", - true, true, - "prefix", - 1, - 0, - MessageCountRotationStrategy.class.getCanonicalName(), - MessageCountRotationStrategyConfig.create(1000), - NoopRetentionStrategy.class.getCanonicalName(), - NoopRetentionStrategyConfig.create(1), - ZonedDateTime.of(2016, 10, 10, 12, 0, 0, 0, ZoneOffset.UTC), - "standard", - "index-template", - null, - 1, - false - ); + final IndexSetConfig indexSetConfig = createTestConfig("id", "title"); when(indexSetService.findAll()).thenReturn(Collections.singletonList(indexSetConfig)); final IndexSetResponse list = indexSetsResource.list(0, 0, false); @@ -149,25 +132,7 @@ public void list() { public void listDenied() { notPermitted(); - final IndexSetConfig indexSetConfig = IndexSetConfig.create( - "id", - "title", - "description", - true, true, - "prefix", - 1, - 0, - MessageCountRotationStrategy.class.getCanonicalName(), - MessageCountRotationStrategyConfig.create(1000), - NoopRetentionStrategy.class.getCanonicalName(), - NoopRetentionStrategyConfig.create(1), - ZonedDateTime.of(2016, 10, 10, 12, 0, 0, 0, ZoneOffset.UTC), - "standard", - "index-template", - null, - 1, - false - ); + final IndexSetConfig indexSetConfig = createTestConfig("id", "title"); when(indexSetService.findAll()).thenReturn(Collections.singletonList(indexSetConfig)); final IndexSetResponse list = indexSetsResource.list(0, 0, false); @@ -196,25 +161,7 @@ public void list0() { @Test public void get() { - final IndexSetConfig indexSetConfig = IndexSetConfig.create( - "id", - "title", - "description", - true, true, - "prefix", - 1, - 0, - MessageCountRotationStrategy.class.getCanonicalName(), - MessageCountRotationStrategyConfig.create(1000), - NoopRetentionStrategy.class.getCanonicalName(), - NoopRetentionStrategyConfig.create(1), - ZonedDateTime.of(2016, 10, 10, 12, 0, 0, 0, ZoneOffset.UTC), - "standard", - "index-template", - null, - 1, - false - ); + final IndexSetConfig indexSetConfig = createTestConfig("id", "title"); when(indexSetService.get("id")).thenReturn(Optional.of(indexSetConfig)); final IndexSetSummary summary = indexSetsResource.get("id"); @@ -408,25 +355,7 @@ public void update() { @Test public void updateDenied() { notPermitted(); - final IndexSetConfig indexSetConfig = IndexSetConfig.create( - "id", - "title", - "description", - true, true, - "prefix", - 1, - 0, - MessageCountRotationStrategy.class.getCanonicalName(), - MessageCountRotationStrategyConfig.create(1000), - NoopRetentionStrategy.class.getCanonicalName(), - NoopRetentionStrategyConfig.create(1), - ZonedDateTime.of(2016, 10, 10, 12, 0, 0, 0, ZoneOffset.UTC), - "standard", - "index-template", - null, - 1, - false - ); + final IndexSetConfig indexSetConfig = createTestConfig("id", "title"); expectedException.expect(ForbiddenException.class); expectedException.expectMessage("Not authorized to access resource id "); @@ -765,6 +694,49 @@ public void setDefaultDoesNotDoAnythingIfIndexSetIsNotElegibleAsDefault() throws } } + @Test + public void testSearchIndexSets() { + final IndexSetConfig indexSetConfig = createTestConfig("id", "title"); + String searchTitle = "itle"; + when(indexSetService.searchByTitle(searchTitle)).thenReturn(Collections.singletonList(indexSetConfig)); + final IndexSetResponse firstPage = indexSetsResource.search(searchTitle, 0, 0, false); + + verify(indexSetService, times(1)).searchByTitle(searchTitle); + verify(indexSetService, times(1)).getDefault(); + verifyNoMoreInteractions(indexSetService); + + assertThat(firstPage.total()).isEqualTo(1); + assertThat(firstPage.indexSets()).containsExactly(IndexSetSummary.fromIndexSetConfig(indexSetConfig, false)); + + final IndexSetConfig indexSetConfig2 = createTestConfig("id2", "title2"); + when(indexSetService.searchByTitle(searchTitle)).thenReturn(List.of(indexSetConfig, indexSetConfig2)); + final IndexSetResponse secondPage = indexSetsResource.search(searchTitle, 1, 0, false); + + assertThat(secondPage.indexSets()).containsExactly(IndexSetSummary.fromIndexSetConfig(indexSetConfig2, false)); + } + + private IndexSetConfig createTestConfig(String id, String title) { + return IndexSetConfig.create( + id, + title, + "description", + true, true, + "prefix", + 1, + 0, + MessageCountRotationStrategy.class.getCanonicalName(), + MessageCountRotationStrategyConfig.create(1000), + NoopRetentionStrategy.class.getCanonicalName(), + NoopRetentionStrategyConfig.create(1), + ZonedDateTime.of(2016, 10, 10, 12, 0, 0, 0, ZoneOffset.UTC), + "standard", + "index-template", + null, + 1, + false + ); + } + private static class TestResource extends IndexSetsResource { private final Provider permitted; diff --git a/graylog2-web-interface/src/components/indices/IndexSetsComponent.jsx b/graylog2-web-interface/src/components/indices/IndexSetsComponent.jsx deleted file mode 100644 index cf19a19ee76e..000000000000 --- a/graylog2-web-interface/src/components/indices/IndexSetsComponent.jsx +++ /dev/null @@ -1,200 +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 PropTypes from 'prop-types'; -// eslint-disable-next-line no-restricted-imports -import createReactClass from 'create-react-class'; -import Reflux from 'reflux'; - -import ButtonToolbar from 'components/bootstrap/ButtonToolbar'; -import { Link, LinkContainer } from 'components/common/router'; -import { Button, Col, DropdownButton, Label, MenuItem } from 'components/bootstrap'; -import { EntityList, EntityListItem, PaginatedList, Spinner } from 'components/common'; -import Routes from 'routing/Routes'; -import StringUtils from 'util/StringUtils'; -import NumberUtils from 'util/NumberUtils'; -import { IndexSetDeletionForm, IndexSetDetails } from 'components/indices'; -import withPaginationQueryParameter from 'components/common/withPaginationQueryParameter'; -import { IndexSetsActions, IndexSetsStore } from 'stores/indices/IndexSetsStore'; -import withTelemetry from 'logic/telemetry/withTelemetry'; - -const IndexSetsComponent = createReactClass({ - // eslint-disable-next-line react/no-unused-class-component-methods - propTypes: { - paginationQueryParameter: PropTypes.object.isRequired, - sendTelemetry: PropTypes.func.isRequired, - }, - - mixins: [Reflux.connect(IndexSetsStore)], - - componentDidMount() { - this.loadData(this.props.paginationQueryParameter.page, this.PAGE_SIZE); - }, - - forms: {}, - - loadData(pageNo, limit) { - this.currentPageNo = pageNo; - this.currentPageSize = limit; - IndexSetsActions.listPaginated((pageNo - 1) * limit, limit, true); - IndexSetsActions.stats(); - }, - - // Stores the current page and page size to be able to reload the current page - currentPageNo: 1, - - currentPageSize: 10, - PAGE_SIZE: 10, - - _onChangePaginatedList(page, size) { - this.loadData(page, size); - }, - - _onSetDefault(indexSet) { - return () => { - this.props.sendTelemetry('click', { - appSection: 'index_sets', - eventElement: 'set-default-index-set', - }); - - IndexSetsActions.setDefault(indexSet).then(() => { - this.loadData(this.currentPageNo, this.currentPageSize); - }); - }; - }, - - _onDelete(indexSet) { - return () => { - this.forms[`index-set-deletion-form-${indexSet.id}`].open(); - }; - }, - - _deleteIndexSet(indexSet, deleteIndices) { - this.props.paginationQueryParameter.resetPage(); - - this.props.sendTelemetry('submit_form', { - appSection: 'index_sets', - eventElement: 'delete-index-set', - }); - - IndexSetsActions.delete(indexSet, deleteIndices).then(() => { - this.loadData(1, this.PAGE_SIZE); - }); - }, - // eslint-disable-next-line react/no-unstable-nested-components - _formatIndexSet(indexSet) { - const { indexSetStats } = this.state; - - const actions = ( - - - - - - Set as default - - - Delete - - - ); - - const content = ( - - - - { this.forms[`index-set-deletion-form-${indexSet.id}`] = elem; }} indexSet={indexSet} onDelete={this._deleteIndexSet} /> - - ); - - const indexSetTitle = ( - - {indexSet.title} - - ); - - const isDefault = indexSet.default ? : ''; - const isReadOnly = !indexSet.writable ? : ''; - let { description } = indexSet; - - if (indexSet.default) { - description += `${description.endsWith('.') ? '' : '.'} Graylog will use this index set by default.`; - } - - let statsString; - const stats = indexSetStats[indexSet.id]; - - if (stats) { - statsString = this._formatStatsString(stats); - } - - return ( - {statsString} {isDefault} {isReadOnly}} - description={description} - actions={actions} - contentRow={content} /> - ); - }, - - _formatStatsString(stats) { - if (!stats) { - return 'N/A'; - } - - const indices = `${NumberUtils.formatNumber(stats.indices)} ${StringUtils.pluralize(stats.indices, 'index', 'indices')}`; - const documents = `${NumberUtils.formatNumber(stats.documents)} ${StringUtils.pluralize(stats.documents, 'document', 'documents')}`; - const size = NumberUtils.formatBytes(stats.size); - - return `${indices}, ${documents}, ${size}`; - }, - - _isLoading() { - const { indexSets } = this.state; - - return !indexSets; - }, - - render() { - if (this._isLoading()) { - return ; - } - - const { globalIndexSetStats, indexSetsCount, indexSets } = this.state; - - return ( -
-

Total: {this._formatStatsString(globalIndexSetStats)}

- -
- - - this._formatIndexSet(indexSet))} /> - -
- ); - }, -}); - -export default withPaginationQueryParameter(withTelemetry(IndexSetsComponent)); diff --git a/graylog2-web-interface/src/components/indices/IndexSetsComponent.tsx b/graylog2-web-interface/src/components/indices/IndexSetsComponent.tsx new file mode 100644 index 000000000000..bdbe270593ad --- /dev/null +++ b/graylog2-web-interface/src/components/indices/IndexSetsComponent.tsx @@ -0,0 +1,247 @@ +/* + * 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, { useEffect, useState, useCallback, useRef } from 'react'; +import styled, { css } from 'styled-components'; +import type { DefaultTheme } from 'styled-components'; + +import type { PaginationQueryParameterResult } from 'hooks/usePaginationQueryParameter'; +import ButtonToolbar from 'components/bootstrap/ButtonToolbar'; +import { Link, LinkContainer } from 'components/common/router'; +import { Button, Col, DropdownButton, Label, MenuItem, Row } from 'components/bootstrap'; +import { EntityList, EntityListItem, PaginatedList, SearchForm, Spinner } from 'components/common'; +import Routes from 'routing/Routes'; +import StringUtils from 'util/StringUtils'; +import NumberUtils from 'util/NumberUtils'; +import { useStore } from 'stores/connect'; +import { IndexSetDeletionForm, IndexSetDetails } from 'components/indices'; +import usePaginationQueryParameter from 'hooks/usePaginationQueryParameter'; +import type { IndexSetsStoreState, IndexSet, IndexSetStats } from 'stores/indices/IndexSetsStore'; +import { IndexSetsActions, IndexSetsStore } from 'stores/indices/IndexSetsStore'; +import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; + +const IndexSetsComponent = () => { + const DEFAULT_PAGE_NUMBER = 1; + const DEFAULT_PAGE_SIZE = 10; + const SEARCH_MIN_TERM_LENGTH = 3; + const { indexSetsCount, indexSets, indexSetStats, globalIndexSetStats } = useStore(IndexSetsStore); + const { page, resetPage } : PaginationQueryParameterResult = usePaginationQueryParameter(); + const sendTelemetry = useSendTelemetry(); + + const [statsEnabled, setStatsEnabled] = useState(false); + const [searchTerm, setSearchTerm] = useState(undefined); + + const formsRef = useRef<{[key: string]: { open:() => void }}>(); + + const loadData = useCallback((pageNumber: number = DEFAULT_PAGE_NUMBER, limit: number = DEFAULT_PAGE_SIZE) => { + if (searchTerm) { + IndexSetsActions.searchPaginated(searchTerm, (pageNumber - 1) * limit, limit, statsEnabled); + } else { + IndexSetsActions.listPaginated((pageNumber - 1) * limit, limit, statsEnabled); + } + }, [statsEnabled, searchTerm]); + + useEffect(() => { + loadData(page); + }, [loadData, page]); + + useEffect(() => { + if (statsEnabled) { + IndexSetsActions.stats(); + } + }, [statsEnabled]); + + const onSearch = (query) => { + if (query && query.length >= SEARCH_MIN_TERM_LENGTH) { + setSearchTerm(query); + resetPage(); + } else if (!query || query.length === 0) { + setSearchTerm(query); + resetPage(); + } + }; + + const onSearchReset = () => { + setSearchTerm(undefined); resetPage(); + }; + + const onToggleStats = () => { setStatsEnabled(!statsEnabled); }; + + const onSetDefault = (indexSet: IndexSet) => { + return () => { + sendTelemetry('click', { + appSection: 'index_sets', + eventElement: 'set-default-index-set', + }); + + IndexSetsActions.setDefault(indexSet).then(() => loadData()); + }; + }; + + const onDelete = (indexSet: IndexSet) => { + return () => { + formsRef.current[`index-set-deletion-form-${indexSet.id}`].open(); + }; + }; + + const deleteIndexSet = (indexSet: IndexSet, deleteIndices: boolean) => { + sendTelemetry('submit_form', { + appSection: 'index_sets', + eventElement: 'delete-index-set', + }); + + IndexSetsActions.delete(indexSet, deleteIndices).then(() => { + resetPage(); + }); + }; + + const formatStatsString = (stats: IndexSetStats) => { + if (!stats) { + return 'N/A'; + } + + const indices = `${NumberUtils.formatNumber(stats.indices)} ${StringUtils.pluralize(stats.indices, 'index', 'indices')}`; + const documents = `${NumberUtils.formatNumber(stats.documents)} ${StringUtils.pluralize(stats.documents, 'document', 'documents')}`; + const size = NumberUtils.formatBytes(stats.size); + + return `${indices}, ${documents}, ${size}`; + }; + + const Toolbar = styled(Row)(({ theme }) => css` + border-bottom: 1px solid ${theme.colors.gray[90]}; + padding-bottom: 15px; + `); + + const GlobalStatsCol = styled(Col)` + display: flex; + align-items: center; + gap: 10px; + `; + + const GlobalStats = styled.p` + margin-bottom: 0; + `; + + const StatsInfoText = styled.span(({ theme }: {theme: DefaultTheme }) => css` + color: ${theme.colors.textAlt}; + font-style: italic; + `); + + const statsDisabledText = 'Stats are disabled by default'; + + const formatIndexSet = (indexSet: IndexSet) => { + const actions = ( + + + + + + Set as default + + + Delete + + + ); + + const content = ( + + + + { formsRef.current = { ...formsRef.current, [`index-set-deletion-form-${indexSet.id}`]: elem }; } +} + indexSet={indexSet} + onDelete={deleteIndexSet} /> + + ); + + const indexSetTitle = ( + + {indexSet.title} + + ); + + const isDefault = indexSet.default ? : ''; + const isReadOnly = !indexSet.writable ? : ''; + let { description } = indexSet; + + if (indexSet.default) { + description += `${description.endsWith('.') ? '' : '.'} Graylog will use this index set by default.`; + } + + let statsString; + const stats = indexSetStats[indexSet.id]; + + if (stats) { + statsString = formatStatsString(stats); + } + + return ( + {statsEnabled ? statsString : {statsDisabledText}} {isDefault} {isReadOnly}} + description={description} + actions={actions} + contentRow={content} /> + ); + }; + + const isLoading = !indexSets; + + return ( + <> + + + + + + + + Stats for all indices: {statsEnabled + ? formatStatsString(globalIndexSetStats) + : {statsDisabledText}} + + + + + + + + {isLoading + ? : ( + + + formatIndexSet(indexSet))} /> + + )} + + + + ); +}; + +export default IndexSetsComponent; diff --git a/graylog2-web-interface/src/pages/IndexSetConfigurationPage.jsx b/graylog2-web-interface/src/pages/IndexSetConfigurationPage.jsx index 59681f59e1c0..3a1950d8092d 100644 --- a/graylog2-web-interface/src/pages/IndexSetConfigurationPage.jsx +++ b/graylog2-web-interface/src/pages/IndexSetConfigurationPage.jsx @@ -87,7 +87,7 @@ class IndexSetConfigurationPage extends React.Component { - )}> + )}> Modify the current configuration for this index set, allowing you to customize the retention, sharding, and replication of messages coming from one or more streams. diff --git a/graylog2-web-interface/src/routing/ApiRoutes.ts b/graylog2-web-interface/src/routing/ApiRoutes.ts index f5aa4afc9da0..d0af6a59ac28 100644 --- a/graylog2-web-interface/src/routing/ApiRoutes.ts +++ b/graylog2-web-interface/src/routing/ApiRoutes.ts @@ -182,6 +182,7 @@ const ApiRoutes = { get: (indexSetId: string) => { return { url: `/system/indices/index_sets/${indexSetId}` }; }, create: () => { return { url: '/system/indices/index_sets' }; }, delete: (indexSetId: string, deleteIndices) => { return { url: `/system/indices/index_sets/${indexSetId}?delete_indices=${deleteIndices}` }; }, + searchPaginated: (searchTerm, skip, limit, stats) => { return { url: `/system/indices/index_sets/search?searchTitle=${searchTerm}&skip=${skip}&limit=${limit}&stats=${stats}` }; }, setDefault: (indexSetId: string) => { return { url: `/system/indices/index_sets/${indexSetId}/default` }; }, stats: () => { return { url: '/system/indices/index_sets/stats' }; }, }, diff --git a/graylog2-web-interface/src/stores/indices/IndexSetsStore.ts b/graylog2-web-interface/src/stores/indices/IndexSetsStore.ts index fb821e15c5ea..1612d40cb73c 100644 --- a/graylog2-web-interface/src/stores/indices/IndexSetsStore.ts +++ b/graylog2-web-interface/src/stores/indices/IndexSetsStore.ts @@ -47,6 +47,7 @@ export const IndexSetPropType = PropTypes.shape({ writable: PropTypes.bool.isRequired, default: PropTypes.bool.isRequired, }); + export type IndexSet = { can_be_default?: boolean, id?: string, @@ -69,34 +70,42 @@ export type IndexSet = { default?: boolean, }; -type IndexSetStats = { - [key: string]: { - documents: number, - indices: number, - size: number, - }, +export type IndexSetStats = { + documents: number, + indices: number, + size: number, +} + +type IndexSetsStats = { + [key: string]: IndexSetStats } + type IndexSetsResponseType = { total: number, index_sets: Array, - stats: IndexSetStats, + stats: IndexSetsStats, }; -type IndexSetsStoreState = { + +export type IndexSetsStoreState = { indexSetsCount: number, indexSets: Array, - indexSetStats: IndexSetStats, + indexSetStats: IndexSetsStats, indexSet: IndexSet, + globalIndexSetStats: IndexSetStats } + type IndexSetsActionsType = { list: (stats: boolean) => Promise, - listPaginated: () => Promise, + listPaginated: (skip: number, limit: number, stats: boolean) => Promise, get: (indexSetId: string) => Promise, update: (indexSet: IndexSet) => Promise, create: (indexSet: IndexSet) => Promise, - delete: () => Promise, - setDefault: () => Promise, + delete: (indexSet: IndexSet, deleteIndices: boolean) => Promise, + searchPaginated: (searchTerm: string, skip: number, limit: number, stats: boolean) => Promise, + setDefault: (indexSet: IndexSet) => Promise, stats: () => Promise, }; + export const IndexSetsActions = singletonActions( 'core.IndexSets', () => Reflux.createActions({ @@ -106,6 +115,7 @@ export const IndexSetsActions = singletonActions( update: { asyncResult: true }, create: { asyncResult: true }, delete: { asyncResult: true }, + searchPaginated: { asyncResult: true }, setDefault: { asyncResult: true }, stats: { asyncResult: true }, }), @@ -119,25 +129,41 @@ export const IndexSetsStore = singletonStore( indexSets: undefined, indexSetStats: undefined, indexSet: undefined, + globalIndexSetStats: undefined, getInitialState() { + return this.getState(); + }, + + getState() { return { indexSetsCount: this.indexSetsCount, indexSets: this.indexSets, indexSetStats: this.indexSetStats, + indexSet: this.indexSet, + globalIndexSetStats: this.globalIndexSetStats, }; }, + + propagateChanges() { + this.trigger(this.getState()); + }, + list(stats: boolean) { const url = qualifyUrl(ApiRoutes.IndexSetsApiController.list(stats).url); const promise = fetch('GET', url); promise .then( - (response: IndexSetsResponseType) => this.trigger({ - indexSetsCount: response.total, - indexSets: response.index_sets, - indexSetStats: response.stats, - }), + (response: IndexSetsResponseType) => { + this.indexSetsCount = response.total; + this.indexSets = response.index_sets; + this.indexSetStats = response.stats; + + this.propagateChanges(); + + return response; + }, (error) => { UserNotification.error(`Fetching index sets list failed: ${error.message}`, 'Could not retrieve index sets.'); @@ -153,11 +179,15 @@ export const IndexSetsStore = singletonStore( promise .then( - (response: IndexSetsResponseType) => this.trigger({ - indexSetsCount: response.total, - indexSets: response.index_sets, - indexSetStats: response.stats, - }), + (response: IndexSetsResponseType) => { + this.indexSetsCount = response.total; + this.indexSets = response.index_sets; + this.indexSetStats = response.stats; + + this.propagateChanges(); + + return response; + }, (error) => { UserNotification.error(`Fetching index sets list failed: ${this._errorMessage(error)}`, 'Could not retrieve index sets.'); @@ -167,13 +197,39 @@ export const IndexSetsStore = singletonStore( IndexSetsActions.listPaginated.promise(promise); }, + searchPaginated(searchTerm: string, skip: number, limit: number, stats: boolean) { + const url = qualifyUrl(ApiRoutes.IndexSetsApiController.searchPaginated(searchTerm, skip, limit, stats).url); + const promise = fetch('GET', url); + + promise + .then( + (response: IndexSetsResponseType) => { + this.indexSetsCount = response.total; + this.indexSets = response.index_sets; + this.indexSetStats = response.stats; + + this.propagateChanges(); + + return response; + }, + (error) => { + UserNotification.error(`Fetching index sets list failed: ${this._errorMessage(error)}`, + 'Could not retrieve index sets.'); + }, + ); + + IndexSetsActions.searchPaginated.promise(promise); + }, + get(indexSetId: string) { const url = qualifyUrl(ApiRoutes.IndexSetsApiController.get(indexSetId).url); const promise = fetch('GET', url); promise.then( (response: IndexSet) => { - this.trigger({ indexSet: response }); + this.indexSet = response; + + this.propagateChanges(); return response; }, @@ -192,7 +248,10 @@ export const IndexSetsStore = singletonStore( promise.then( (response: IndexSet) => { UserNotification.success(`Successfully updated index set '${indexSet.title}'`, 'Success'); - this.trigger({ indexSet: response }); + + this.indexSet = response; + + this.propagateChanges(); return response; }, @@ -211,7 +270,10 @@ export const IndexSetsStore = singletonStore( promise.then( (response: IndexSet) => { UserNotification.success(`Successfully created index set '${indexSet.title}'`, 'Success'); - this.trigger({ indexSet: response }); + + this.indexSet = response; + + this.propagateChanges(); return response; }, @@ -261,13 +323,17 @@ export const IndexSetsStore = singletonStore( promise .then( - (response) => this.trigger({ - globalIndexSetStats: { + (response) => { + this.globalIndexSetStats = { indices: response.indices, documents: response.documents, size: response.size, - }, - }), + }; + + this.propagateChanges(); + + return response; + }, (error) => { UserNotification.error(`Fetching global index stats failed: ${error.message}`, 'Could not retrieve global index stats.');