From 01c14012ff5ede6c6b077e01c2f64832596404a9 Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Wed, 29 Jul 2026 17:24:36 +0200 Subject: [PATCH 1/6] wire bulk actions frontend --- .../IncompatibleIndicesBulkActions.test.tsx | 212 ++++++++++++++++-- .../IncompatibleIndicesBulkActions.tsx | 176 +++++++++++---- .../IncompatibleIndicesTable.tsx | 2 + .../opensearch-upgrade/bulkIndexActions.ts | 157 +++++-------- .../datanode/opensearch-upgrade/constants.ts | 3 - .../hooks/useArchivedIndexNames.test.ts | 1 + .../src/components/indices/archive/types.ts | 1 + .../indices/hooks/useCanArchive.test.ts | 1 + 8 files changed, 388 insertions(+), 165 deletions(-) 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 index 111c9d8f0a9d..029fdf3d0ac6 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx @@ -18,10 +18,12 @@ 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 { SystemIndexerIndices, ClusterDeflector } from '@graylog/server-api'; import asMock from 'helpers/mocking/AsMock'; import useSelectedEntities from 'components/common/EntityDataTable/hooks/useSelectedEntities'; +import useIndexArchive from 'components/indices/archive/useIndexArchive'; +import type { IndexArchiveBinding } from 'components/indices/archive/types'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import UserNotification from 'util/UserNotification'; @@ -31,13 +33,25 @@ import type { IncompatibleIndexRow } from './fetchIncompatibleIndices'; jest.mock('@graylog/server-api', () => ({ SystemIndexerIndices: { bulkDeleteOutdated: jest.fn(), + bulkReindex: jest.fn(), + }, + ClusterDeflector: { + bulkcycle: jest.fn(), }, })); jest.mock('components/common/EntityDataTable/hooks/useSelectedEntities'); +jest.mock('components/indices/archive/useIndexArchive'); jest.mock('logic/telemetry/useSendTelemetry'); jest.mock('util/UserNotification', () => ({ success: jest.fn(), warning: jest.fn(), error: jest.fn() })); -const makeIndex = (indexName: string): IncompatibleIndexRow => ({ +const bulkCycleSuccess = (failures: Array<{ entity_id: string; failure_explanation: string }> = []) => ({ + success: true, + code: 200, + error_text: '', + entity: { successfully_performed: 0, failures, errors: [] }, +}); + +const makeIndex = (indexName: string, overrides: Partial = {}): IncompatibleIndexRow => ({ id: indexName, index_name: indexName, version: '7.10.2', @@ -47,21 +61,49 @@ const makeIndex = (indexName: string): IncompatibleIndexRow => ({ active_write_index: null, begin: null, end: null, + ...overrides, }); const indices = [makeIndex('legacy_0'), makeIndex('legacy_1')]; +const systemIndices = [ + makeIndex('gl-system-events_0', { system_index: true }), + makeIndex('gl-system-events_1', { system_index: true }), +]; +const writeIndices = [ + makeIndex('graylog_42', { active_write_index: 'index-set-a' }), + makeIndex('events_7', { active_write_index: 'index-set-b' }), +]; +const managedIndices = [ + makeIndex('graylog_0', { managed_index: true }), + makeIndex('graylog_1', { managed_index: true }), +]; + +const mockSelectedEntities = (selected: Array, setSelectedEntities: jest.Mock) => + asMock(useSelectedEntities).mockReturnValue({ + selectedEntities: selected, + setSelectedEntities, + selectEntity: jest.fn(), + deselectEntity: jest.fn(), + toggleEntitySelect: jest.fn(), + isSomeRowsSelected: false, + isAllRowsSelected: true, + }); describe('IncompatibleIndicesBulkActions', () => { const setSelectedEntities = jest.fn(); const refetch = jest.fn(); + const addArchiveDeleteAction = jest.fn(); + const refetchClusterJobs = jest.fn(); - const renderBulkActions = () => + const renderBulkActions = (rows: Array = indices, canArchive = false) => render( , ); @@ -74,15 +116,10 @@ describe('IncompatibleIndicesBulkActions', () => { beforeEach(() => { jest.clearAllMocks(); - asMock(useSelectedEntities).mockReturnValue({ - selectedEntities: indices.map(({ id }) => id), + mockSelectedEntities( + indices.map(({ id }) => id), setSelectedEntities, - selectEntity: jest.fn(), - deselectEntity: jest.fn(), - toggleEntitySelect: jest.fn(), - isSomeRowsSelected: false, - isAllRowsSelected: true, - }); + ); asMock(useSendTelemetry).mockReturnValue(jest.fn()); }); @@ -138,4 +175,153 @@ describe('IncompatibleIndicesBulkActions', () => { expect(setSelectedEntities).not.toHaveBeenCalled(); expect(refetch).not.toHaveBeenCalled(); }); + + describe('bulk reindex', () => { + const confirmBulkReindex = async () => { + await userEvent.click(screen.getByRole('button', { name: /bulk actions/i })); + await userEvent.click(await screen.findByRole('menuitem', { name: /reindex all \(2\)/i })); + await userEvent.click(await screen.findByRole('button', { name: /^reindex all$/i })); + }; + + beforeEach(() => { + mockSelectedEntities( + systemIndices.map(({ id }) => id), + setSelectedEntities, + ); + }); + + it('reindexes all selected system indices via the bulk endpoint, clears the selection, and refreshes', async () => { + asMock(SystemIndexerIndices.bulkReindex).mockResolvedValue(undefined); + renderBulkActions(systemIndices); + + await confirmBulkReindex(); + + await waitFor(() => { + expect(SystemIndexerIndices.bulkReindex).toHaveBeenCalledWith({ + indices: ['gl-system-events_0', 'gl-system-events_1'], + with_replication: true, + }); + expect(UserNotification.success).toHaveBeenCalledWith('Reindex started for 2 system indices.'); + expect(setSelectedEntities).toHaveBeenCalledWith([]); + expect(refetch).toHaveBeenCalledTimes(1); + }); + }); + + it('reports request failures without changing the selection or refreshing', async () => { + asMock(SystemIndexerIndices.bulkReindex).mockRejectedValue(new Error('Backend unavailable')); + renderBulkActions(systemIndices); + + await confirmBulkReindex(); + + await waitFor(() => + expect(UserNotification.error).toHaveBeenCalledWith('Backend unavailable', 'Could not reindex all.'), + ); + expect(setSelectedEntities).not.toHaveBeenCalled(); + expect(refetch).not.toHaveBeenCalled(); + }); + }); + + describe('bulk rotate', () => { + const confirmBulkRotate = async () => { + await userEvent.click(screen.getByRole('button', { name: /bulk actions/i })); + await userEvent.click(await screen.findByRole('menuitem', { name: /rotate all \(2\)/i })); + await userEvent.click(await screen.findByRole('button', { name: /^rotate all$/i })); + }; + + beforeEach(() => { + mockSelectedEntities( + writeIndices.map(({ id }) => id), + setSelectedEntities, + ); + }); + + it('rotates the index sets of the selected write indices, clears the selection, and refreshes', async () => { + asMock(ClusterDeflector.bulkcycle).mockResolvedValue(bulkCycleSuccess()); + renderBulkActions(writeIndices); + + await confirmBulkRotate(); + + await waitFor(() => { + expect(ClusterDeflector.bulkcycle).toHaveBeenCalledWith({ entity_ids: ['index-set-a', 'index-set-b'] }); + expect(UserNotification.success).toHaveBeenCalledWith('2 indices were rotated.'); + expect(setSelectedEntities).toHaveBeenCalledWith([]); + expect(refetch).toHaveBeenCalledTimes(1); + }); + }); + + it('maps failed index sets back to index names and keeps them selected', async () => { + asMock(ClusterDeflector.bulkcycle).mockResolvedValue( + bulkCycleSuccess([{ entity_id: 'index-set-b', failure_explanation: 'Too many aliases' }]), + ); + renderBulkActions(writeIndices); + + await confirmBulkRotate(); + + await waitFor(() => { + expect(UserNotification.warning).toHaveBeenCalledWith( + '1 succeeded, 1 failed.\nevents_7: Too many aliases', + 'Some indices could not be rotated', + ); + expect(setSelectedEntities).toHaveBeenCalledWith(['events_7']); + }); + }); + }); + + describe('bulk archive and delete', () => { + const setSelectedEntitiesArchive = jest.fn(); + + const archiveBinding = { + archiveAndDeleteIndices: jest.fn(), + isArchiveJobConflict: jest.fn().mockReturnValue(false), + } as unknown as IndexArchiveBinding; + + const confirmBulkArchive = async () => { + await userEvent.click(screen.getByRole('button', { name: /bulk actions/i })); + await userEvent.click(await screen.findByRole('menuitem', { name: /archive and delete all \(2\)/i })); + await userEvent.click(await screen.findByRole('button', { name: /^archive and delete all$/i })); + }; + + beforeEach(() => { + mockSelectedEntities( + managedIndices.map(({ id }) => id), + setSelectedEntitiesArchive, + ); + asMock(useIndexArchive).mockReturnValue(archiveBinding); + }); + + it('starts a bulk archive job, tracks each index, clears the selection, and polls jobs', async () => { + asMock(archiveBinding.archiveAndDeleteIndices).mockResolvedValue({ systemJobId: 'job-1' }); + renderBulkActions(managedIndices, true); + + await confirmBulkArchive(); + + await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)); + + expect(archiveBinding.archiveAndDeleteIndices).toHaveBeenCalledWith(['graylog_0', 'graylog_1']); + expect(addArchiveDeleteAction).toHaveBeenCalledWith({ indexName: 'graylog_0', systemJobId: 'job-1' }); + expect(addArchiveDeleteAction).toHaveBeenCalledWith({ indexName: 'graylog_1', systemJobId: 'job-1' }); + expect(UserNotification.success).toHaveBeenCalledWith('Archive and delete started for 2 indices.'); + expect(setSelectedEntitiesArchive).toHaveBeenCalledWith([]); + expect(refetchClusterJobs).toHaveBeenCalled(); + }); + + it('warns about a running archive job without changing the selection or refreshing', async () => { + asMock(archiveBinding.isArchiveJobConflict).mockReturnValue(true); + asMock(archiveBinding.archiveAndDeleteIndices).mockRejectedValue( + new Error('An archive job is already running!'), + ); + renderBulkActions(managedIndices, true); + + await confirmBulkArchive(); + + await waitFor(() => { + expect(UserNotification.warning).toHaveBeenCalledWith( + 'Another archive job is already running. New archive jobs can be started after it finishes.', + 'Archive job already running', + ); + }); + expect(setSelectedEntitiesArchive).not.toHaveBeenCalled(); + expect(refetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx index 70f66ce36bd4..6c2795eba30b 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx @@ -16,19 +16,23 @@ */ import React, { useState } from 'react'; -import { SystemIndexerIndices } from '@graylog/server-api'; +import { ClusterDeflector, 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 useIndexArchive from 'components/indices/archive/useIndexArchive'; +import type { IndexArchiveBinding } from 'components/indices/archive/types'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; +import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import type { TelemetryEventType } from 'logic/telemetry/TelemetryContext'; 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 { PendingArchiveTracking } from './incompatibleIndexActions'; +import { buildPartialBulkNotification, getBulkIndexActionCandidates } from './bulkIndexActions'; import type { BulkIndexActionCandidate, BulkIndexActionNotification } from './bulkIndexActions'; import type { IncompatibleIndexRow } from './fetchIncompatibleIndices'; import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions'; @@ -38,9 +42,18 @@ type Props = { canArchive: boolean; pendingIndexStatuses: Map; archivedIndexNames: ReadonlySet; + addArchiveDeleteAction: (tracking: PendingArchiveTracking) => void; + refetchClusterJobs?: () => void; refetch: () => void; }; +const BULK_ACTION_TELEMETRY: Record = { + delete: TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.INDEX_DELETE_CONFIRMED, + 'reindex-system-index': TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.SYSTEM_INDEX_REINDEX_CONFIRMED, + 'archive-delete': TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.INDEX_ARCHIVE_AND_DELETE_CONFIRMED, + rotate: TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.WRITE_INDEX_ROTATE_CONFIRMED, +}; + const showNotification = ({ type, message, title }: BulkIndexActionNotification) => { if (type === 'success') { UserNotification.success(message); @@ -54,40 +67,92 @@ const showNotification = ({ type, message, title }: BulkIndexActionNotification) 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; + const failedIds = new Set((failures ?? []).map(({ entity_id }) => entity_id)); + const succeeded = entityIds.length - failedIds.size; - 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' }, - ); - } + showNotification( + buildPartialBulkNotification({ + succeeded, + failures: (failures ?? []).map(({ entity_id, failure_explanation }) => ({ + name: entity_id, + explanation: failure_explanation, + })), + successMessage: `${succeeded} ${succeeded === 1 ? 'index was' : 'indices were'} deleted.`, + partialSuccessTitle: 'Some indices could not be deleted', + failureTitle: 'Could not delete indices', + }), + ); - return entityIds.filter((id) => !failedIds.includes(id)); + return entityIds.filter((id) => !failedIds.has(id)); }; const bulkReindexIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => { - const result = await runBulkIndexAction({ action: bulkAction.action, indices: bulkAction.targetIndices }); - showNotification(getBulkIndexActionNotification(bulkAction, result)); + const indexNames = bulkAction.targetIndices.map((index) => index.index_name); + await SystemIndexerIndices.bulkReindex({ indices: indexNames, with_replication: true }); + showNotification({ type: 'success', message: bulkAction.successMessage(indexNames.length) }); - return result.successes.map(({ index }) => index.index_name); + return indexNames; +}; + +// The deflector cycles a whole index set, so the request/failures are keyed by index-set id +// (the write index's `active_write_index`); we map those back to index names for the user. +const bulkRotateIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => { + const namesByIndexSet = new Map>(); + bulkAction.targetIndices.forEach((index) => { + const names = namesByIndexSet.get(index.active_write_index) ?? []; + names.push(index.index_name); + namesByIndexSet.set(index.active_write_index, names); + }); + + const { success, entity, error_text } = await ClusterDeflector.bulkcycle({ + entity_ids: Array.from(namesByIndexSet.keys()), + }); + + if (!success) { + throw new Error(error_text || 'Bulk rotation request failed.'); + } + + const explanationByIndexSet = new Map((entity?.failures ?? []).map((f) => [f.entity_id, f.failure_explanation])); + const succeededNames: Array = []; + const failures: Array<{ name: string; explanation: string }> = []; + namesByIndexSet.forEach((names, indexSetId) => { + const explanation = explanationByIndexSet.get(indexSetId); + + if (explanation !== undefined) { + names.forEach((name) => failures.push({ name, explanation })); + } else { + succeededNames.push(...names); + } + }); + + showNotification( + buildPartialBulkNotification({ + succeeded: succeededNames.length, + failures, + successMessage: `${succeededNames.length} ${succeededNames.length === 1 ? 'index was' : 'indices were'} rotated.`, + partialSuccessTitle: 'Some indices could not be rotated', + failureTitle: 'Could not rotate indices', + }), + ); + + return succeededNames; +}; + +const bulkArchiveDeleteIndices = async ( + bulkAction: BulkIndexActionCandidate, + archive: IndexArchiveBinding | undefined, + addArchiveDeleteAction: (tracking: PendingArchiveTracking) => void, +): Promise> => { + if (!archive) { + throw new Error('Archiving is not available.'); + } + + const indexNames = bulkAction.targetIndices.map((index) => index.index_name); + const { systemJobId } = await archive.archiveAndDeleteIndices(indexNames); + indexNames.forEach((indexName) => addArchiveDeleteAction({ indexName, systemJobId })); + showNotification({ type: 'success', message: bulkAction.successMessage(indexNames.length) }); + + return indexNames; }; const IncompatibleIndicesBulkActions = ({ @@ -95,16 +160,18 @@ const IncompatibleIndicesBulkActions = ({ canArchive, pendingIndexStatuses, archivedIndexNames, + addArchiveDeleteAction, + refetchClusterJobs = undefined, refetch, }: Props) => { const sendTelemetry = useSendTelemetry(); + const archive = useIndexArchive(); 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, + indices, canArchive, pendingIndexStatuses, archivedIndexNames, @@ -121,7 +188,7 @@ const IncompatibleIndicesBulkActions = ({ return; } - sendTelemetry(CORE_ACTION_DEFINITIONS[confirmedBulkAction.action].telemetryEventType, { + sendTelemetry(BULK_ACTION_TELEMETRY[confirmedBulkAction.action], { ...TELEMETRY_DEFAULTS, app_action_value: 'bulk', bulk_count: confirmedBulkAction.targetIndices.length, @@ -129,19 +196,44 @@ const IncompatibleIndicesBulkActions = ({ setIsSubmitting(true); try { - const succeededIds = - confirmedBulkAction.action === 'delete' - ? await bulkDeleteIndices(confirmedBulkAction) - : await bulkReindexIndices(confirmedBulkAction); + let succeededIds: Array; + + switch (confirmedBulkAction.action) { + case 'delete': + succeededIds = await bulkDeleteIndices(confirmedBulkAction); + break; + case 'reindex-system-index': + succeededIds = await bulkReindexIndices(confirmedBulkAction); + break; + case 'rotate': + succeededIds = await bulkRotateIndices(confirmedBulkAction); + break; + default: + succeededIds = await bulkArchiveDeleteIndices(confirmedBulkAction, archive, addArchiveDeleteAction); + break; + } setSelectedEntities(selectedEntities.filter((id) => !succeededIds.includes(id))); refetch(); + + if (confirmedBulkAction.action === 'archive-delete') { + refetchClusterJobs?.(); + } + setConfirmedBulkAction(undefined); } catch (errorThrown) { - UserNotification.error( - extractErrorMessage(errorThrown), - `Could not ${confirmedBulkAction.confirmText.toLowerCase()}.`, - ); + const errorMessage = extractErrorMessage(errorThrown); + + if (confirmedBulkAction.action === 'archive-delete' && archive?.isArchiveJobConflict(errorMessage)) { + UserNotification.warning( + 'Another archive job is already running. New archive jobs can be started after it finishes.', + 'Archive job already running', + ); + refetchClusterJobs?.(); + setConfirmedBulkAction(undefined); + } else { + UserNotification.error(errorMessage, `Could not ${confirmedBulkAction.confirmText.toLowerCase()}.`); + } } finally { setIsSubmitting(false); } 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 8cd1fed4d815..3a677be24d63 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx @@ -116,6 +116,8 @@ const IncompatibleIndicesTable = () => { canArchive={archiveActionsAvailable} pendingIndexStatuses={pendingIndexStatuses} archivedIndexNames={archivedIndexNames} + addArchiveDeleteAction={addArchiveDeleteAction} + refetchClusterJobs={refetchClusterJobs} refetch={refetch} /> ), diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts index 8b2e053407f2..2bd0a83a82cd 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts @@ -15,14 +15,11 @@ * . */ import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices'; -import extractErrorMessage from 'util/extractErrorMessage'; -import { BULK_INDEX_ACTION_CONCURRENCY } from './constants'; import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions'; -import { CORE_ACTION_DEFINITIONS, getAvailableActions } from './incompatibleIndexActions'; +import { getAvailableActions } from './incompatibleIndexActions'; -// Deliberately no 'archive-delete' (bulk would race the single-concurrency backend job) or 'rotate' (row-only). -const BULK_ACTION_ORDER = ['delete', 'reindex-system-index'] as const; +const BULK_ACTION_ORDER = ['reindex-system-index', 'archive-delete', 'delete', 'rotate'] as const; type BulkCapableIndexAction = (typeof BULK_ACTION_ORDER)[number]; @@ -32,8 +29,6 @@ type BulkActionCopy = { confirmText: string; targetVerb: string; successMessage: (count: number) => string; - partialSuccessTitle: string; - failureTitle: string; }; export type BulkIndexActionCandidate = BulkActionCopy & { @@ -41,21 +36,6 @@ export type BulkIndexActionCandidate = BulkActionCopy & { targetIndices: Array; }; -export type BulkIndexActionSuccess = { - index: IncompatibleIndex; - response: unknown; -}; - -export type BulkIndexActionFailure = { - index: IncompatibleIndex; - error: unknown; -}; - -export type BulkIndexActionResult = { - successes: Array; - failures: Array; -}; - export type BulkIndexActionNotification = { type: 'success' | 'warning' | 'error'; message: string; @@ -69,20 +49,63 @@ const BULK_ACTION_COPY: Record = { confirmText: 'Delete all', targetVerb: 'delete', successMessage: (count) => `${count} incompatible ${count === 1 ? 'index was' : 'indices were'} deleted.`, - partialSuccessTitle: 'Some indices could not be deleted', - failureTitle: 'Could not delete indices', }, 'reindex-system-index': { buttonLabel: 'Reindex all', confirmTitle: 'Reindex system indices', confirmText: 'Reindex all', targetVerb: 'reindex', - successMessage: (count) => `${count} system ${count === 1 ? 'index was' : 'indices were'} reindexed.`, - partialSuccessTitle: 'Some system indices could not be reindexed', - failureTitle: 'Could not reindex system indices', + successMessage: (count) => `Reindex started for ${count} system ${count === 1 ? 'index' : 'indices'}.`, + }, + 'archive-delete': { + buttonLabel: 'Archive and delete all', + confirmTitle: 'Archive and delete indices', + confirmText: 'Archive and delete all', + targetVerb: 'archive and delete', + successMessage: (count) => `Archive and delete started for ${count} ${count === 1 ? 'index' : 'indices'}.`, + }, + rotate: { + buttonLabel: 'Rotate all', + confirmTitle: 'Rotate active write indices', + confirmText: 'Rotate all', + targetVerb: 'rotate', + successMessage: (count) => `${count} ${count === 1 ? 'index was' : 'indices were'} rotated.`, }, }; +// Shared by the bulk actions whose backend reports per-entity failures (delete, rotate). +export const buildPartialBulkNotification = ({ + succeeded, + failures, + successMessage, + partialSuccessTitle, + failureTitle, +}: { + succeeded: number; + failures: Array<{ name: string; explanation: string }>; + successMessage: string; + partialSuccessTitle: string; + failureTitle: string; +}): BulkIndexActionNotification => { + if (failures.length === 0) { + return { type: 'success', message: successMessage }; + } + + const details = failures + .slice(0, 3) + .map(({ name, explanation }) => `${name}: ${explanation}`) + .join('\n'); + const more = failures.length > 3 ? `\n...and ${failures.length - 3} more.` : ''; + const message = + succeeded > 0 + ? `${succeeded} succeeded, ${failures.length} failed.\n${details}${more}` + : `${failures.length} ${failures.length === 1 ? 'index' : 'indices'} failed.\n${details}${more}`; + + return succeeded > 0 + ? { type: 'warning', message, title: partialSuccessTitle } + : { type: 'error', message, title: failureTitle }; +}; + // Deleting mid-archive is racy; an already-archived index stays deletable. const isArchiveInProgress = (pendingStatus: PendingIndexStatus | undefined) => pendingStatus?.state === 'archiving'; @@ -110,83 +133,3 @@ export const getBulkIndexActionCandidates = ({ ...BULK_ACTION_COPY[action], }; }).filter((candidate) => candidate.targetIndices.length > 0); - -// A 403 would trigger FetchProvider's global redirect mid-batch, but the only 403 case (an active -// write index) never reaches a batch — getAvailableActions offers it rotate only. -export const runBulkIndexAction = async ({ - action, - indices, -}: { - action: BulkCapableIndexAction; - indices: Array; -}): Promise => { - const actionDefinition = CORE_ACTION_DEFINITIONS[action]; - const successes: Array = []; - const failures: Array = []; - let nextIndex = 0; - - const runNext = (): Promise => { - const index = indices[nextIndex]; - nextIndex += 1; - - if (!index) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => actionDefinition.run(index)) - .then((response) => { - successes.push({ index, response }); - }) - .catch((error) => { - failures.push({ index, error }); - }) - .then(runNext); - }; - - const workerCount = Math.min(BULK_INDEX_ACTION_CONCURRENCY, indices.length); - - await Promise.all(Array.from({ length: workerCount }, runNext)); - - return { successes, failures }; -}; - -const failureSummary = ({ failures }: Pick) => - failures - .slice(0, 3) - .map(({ index, error }) => `${index.index_name}: ${extractErrorMessage(error)}`) - .join('\n'); - -export const getBulkIndexActionNotification = ( - bulkAction: BulkIndexActionCandidate, - result: BulkIndexActionResult, -): BulkIndexActionNotification => { - const successCount = result.successes.length; - const failureCount = result.failures.length; - - if (failureCount === 0) { - return { - type: 'success', - message: bulkAction.successMessage(successCount), - }; - } - - const failureDetails = failureSummary(result); - const omittedFailures = failureCount > 3 ? `\n...and ${failureCount - 3} more.` : ''; - const message = - successCount > 0 - ? `${successCount} succeeded, ${failureCount} failed.\n${failureDetails}${omittedFailures}` - : `${failureCount} ${failureCount === 1 ? 'index' : 'indices'} failed.\n${failureDetails}${omittedFailures}`; - - return successCount > 0 - ? { - type: 'warning', - message, - title: bulkAction.partialSuccessTitle, - } - : { - type: 'error', - message, - title: bulkAction.failureTitle, - }; -}; diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/constants.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/constants.ts index 2e80827db4df..6d65c552a6ed 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/constants.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/constants.ts @@ -16,6 +16,3 @@ */ export const ARCHIVE_POLL_INTERVAL_MS = 5000; - -// Keep frontend-orchestrated bulk requests small until the backend exposes dedicated bulk endpoints. -export const BULK_INDEX_ACTION_CONCURRENCY = 3; diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts index da27f760d6e2..df0f4d106b12 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts @@ -29,6 +29,7 @@ const archivePlugin = new PluginManifest( useCanArchive: () => true, useArchivedIndexNames: useArchivedIndexNamesBinding, archiveAndDeleteIndex: () => Promise.resolve({}), + archiveAndDeleteIndices: () => Promise.resolve({}), isArchiveJobConflict: () => false, archiveSystemJobName: 'archive-job', }, diff --git a/graylog2-web-interface/src/components/indices/archive/types.ts b/graylog2-web-interface/src/components/indices/archive/types.ts index f16adbf3d008..49f666827f76 100644 --- a/graylog2-web-interface/src/components/indices/archive/types.ts +++ b/graylog2-web-interface/src/components/indices/archive/types.ts @@ -19,6 +19,7 @@ export type IndexArchiveBinding = { useCanArchive: () => boolean; useArchivedIndexNames: (indexNames: Array, enabled: boolean) => ReadonlySet; archiveAndDeleteIndex: (indexName: string) => Promise<{ systemJobId?: string }>; + archiveAndDeleteIndices: (indexNames: Array) => Promise<{ systemJobId?: string }>; isArchiveJobConflict: (errorMessage: string) => boolean; archiveSystemJobName: string; }; diff --git a/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts b/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts index c1e7fefa7fcc..b6a065320d64 100644 --- a/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts +++ b/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts @@ -27,6 +27,7 @@ const archivePlugin = new PluginManifest( useCanArchive: () => true, useArchivedIndexNames: () => new Set(), archiveAndDeleteIndex: () => Promise.resolve({}), + archiveAndDeleteIndices: () => Promise.resolve({}), isArchiveJobConflict: () => false, archiveSystemJobName: 'archive-job', }, From 2c25baef3a3a4ae6b3108f3d4faa4e9bf70add9c Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Thu, 30 Jul 2026 12:21:07 +0200 Subject: [PATCH 2/6] fix archiving issues --- .../IncompatibleIndexTableActions.tsx | 70 ++++++++++--------- .../IncompatibleIndicesBulkActions.test.tsx | 21 +++--- .../IncompatibleIndicesBulkActions.tsx | 24 ++----- .../IncompatibleIndicesColumnRenderers.tsx | 43 +++++------- .../IncompatibleIndicesContext.tsx | 43 ++++++++++++ .../IncompatibleIndicesTable.test.tsx | 31 +++++++- .../IncompatibleIndicesTable.tsx | 45 ++++-------- .../opensearch-upgrade/bulkIndexActions.ts | 10 +-- .../incompatibleIndexActions.tsx | 9 +++ 9 files changed, 172 insertions(+), 124 deletions(-) create mode 100644 graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx index 3369840ead4c..3daef16ae18e 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx @@ -26,61 +26,65 @@ 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'; +import { useIncompatibleIndicesContext } from './IncompatibleIndicesContext'; +import { getAvailableActions, isIndexArchived, useIncompatibleIndexActionDefinitions } from './incompatibleIndexActions'; +import type { IndexAction } from './incompatibleIndexActions'; const ActionsToolbar = styled(ButtonToolbar)` justify-content: flex-end; `; -const ArchiveProgressBar = styled(ProgressBar)` +const ArchivingIndicator = styled.div` display: inline-flex; + align-items: center; + justify-content: center; width: 120px; + white-space: nowrap; +`; + +const ArchiveProgressBar = styled(ProgressBar)` + display: inline-flex; + width: 100%; 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 IncompatibleIndexTableActions = ({ index }: Props) => { + const { archiveActionsAvailable, archivedIndexNames, pendingIndexStatuses, addArchiveDeleteAction, refetchClusterJobs, refetch } = + useIncompatibleIndicesContext(); const actionDefinitions = useIncompatibleIndexActionDefinitions(); const sendTelemetry = useSendTelemetry(); const { deselectEntity } = useSelectedEntities(); const [confirmedAction, setConfirmedAction] = useState(); const [isSubmitting, setIsSubmitting] = useState(false); + const canArchive = archiveActionsAvailable; + const pendingStatus = pendingIndexStatuses.get(index.index_name); + const isArchived = isIndexArchived(index.index_name, pendingStatus, archivedIndexNames); + if (pendingStatus?.state === 'archiving') { - return pendingStatus.percent > 0 ? ( - - ) : ( - + return ( + + {pendingStatus.percent > 0 ? ( + + ) : ( + + )} + ); } 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 index 029fdf3d0ac6..8d7180ff55aa 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx @@ -28,6 +28,7 @@ import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import UserNotification from 'util/UserNotification'; import IncompatibleIndicesBulkActions from './IncompatibleIndicesBulkActions'; +import IncompatibleIndicesContext from './IncompatibleIndicesContext'; import type { IncompatibleIndexRow } from './fetchIncompatibleIndices'; jest.mock('@graylog/server-api', () => ({ @@ -97,15 +98,17 @@ describe('IncompatibleIndicesBulkActions', () => { const renderBulkActions = (rows: Array = indices, canArchive = false) => render( - , + (), + pendingIndexStatuses: new Map(), + addArchiveDeleteAction, + refetchClusterJobs, + refetch, + }}> + + , ); const confirmBulkDelete = async () => { diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx index 6c2795eba30b..3a2fb630f85b 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx @@ -32,19 +32,13 @@ import UserNotification from 'util/UserNotification'; import { TELEMETRY_DEFAULTS } from './telemetry'; import BulkIndexActionConfirmDialog from './BulkIndexActionConfirmDialog'; import type { PendingArchiveTracking } from './incompatibleIndexActions'; +import { useIncompatibleIndicesContext } from './IncompatibleIndicesContext'; import { buildPartialBulkNotification, getBulkIndexActionCandidates } 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; - addArchiveDeleteAction: (tracking: PendingArchiveTracking) => void; - refetchClusterJobs?: () => void; - refetch: () => void; }; const BULK_ACTION_TELEMETRY: Record = { @@ -94,8 +88,6 @@ const bulkReindexIndices = async (bulkAction: BulkIndexActionCandidate): Promise return indexNames; }; -// The deflector cycles a whole index set, so the request/failures are keyed by index-set id -// (the write index's `active_write_index`); we map those back to index names for the user. const bulkRotateIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => { const namesByIndexSet = new Map>(); bulkAction.targetIndices.forEach((index) => { @@ -155,15 +147,9 @@ const bulkArchiveDeleteIndices = async ( return indexNames; }; -const IncompatibleIndicesBulkActions = ({ - indices, - canArchive, - pendingIndexStatuses, - archivedIndexNames, - addArchiveDeleteAction, - refetchClusterJobs = undefined, - refetch, -}: Props) => { +const IncompatibleIndicesBulkActions = ({ indices }: Props) => { + const { archiveActionsAvailable, archivedIndexNames, pendingIndexStatuses, addArchiveDeleteAction, refetchClusterJobs, refetch } = + useIncompatibleIndicesContext(); const sendTelemetry = useSendTelemetry(); const archive = useIndexArchive(); const { selectedEntities, setSelectedEntities } = useSelectedEntities(); @@ -172,7 +158,7 @@ const IncompatibleIndicesBulkActions = ({ const candidates = getBulkIndexActionCandidates({ indices, - canArchive, + canArchive: archiveActionsAvailable, pendingIndexStatuses, archivedIndexNames, }); diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx index 7a297753324d..9ca1837b7bde 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesColumnRenderers.tsx @@ -23,7 +23,8 @@ 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'; +import { useIncompatibleIndicesContext } from './IncompatibleIndicesContext'; +import { isIndexArchived } from './incompatibleIndexActions'; export const DEFAULT_DISPLAYED_COLUMNS = ['index_name', 'category', 'version', 'begin', 'end']; @@ -42,12 +43,6 @@ const typeBadges = (index: IncompatibleIndex): Array => [ ...(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 }] : []), @@ -65,28 +60,24 @@ const renderBadges = (badges: Array) => const renderRange = (value: unknown) => (value ? : ); -export const createColumnRenderers = ( - pendingIndexStatuses: Map, - archivedIndexNames: ReadonlySet, -): ColumnRenderers => ({ +const IndexNameCell = ({ index }: { index: IncompatibleIndexRow }) => { + const { archivedIndexNames, pendingIndexStatuses } = useIncompatibleIndicesContext(); + const isArchived = isIndexArchived(index.index_name, pendingIndexStatuses.get(index.index_name), archivedIndexNames); + + return ( + + {index.index_name} +   + {renderBadges(statusBadges(index, isArchived))} + + ); +}; + +export const createColumnRenderers = (): 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))} - - ); - }, + renderCell: (_value, index) => , }, category: { staticWidth: 200, diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx new file mode 100644 index 000000000000..30c3b2c4b880 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx @@ -0,0 +1,43 @@ +/* + * 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 { createContext, useContext } from 'react'; + +import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions'; +import type { PendingArchiveTracking } from './incompatibleIndexActions'; + +export type IncompatibleIndicesContextValue = { + archiveActionsAvailable: boolean; + archivedIndexNames: ReadonlySet; + pendingIndexStatuses: Map; + addArchiveDeleteAction: (tracking: PendingArchiveTracking) => void; + refetchClusterJobs?: () => void; + refetch: () => void; +}; + +const IncompatibleIndicesContext = createContext(undefined); + +export const useIncompatibleIndicesContext = (): IncompatibleIndicesContextValue => { + const context = useContext(IncompatibleIndicesContext); + + if (!context) { + throw new Error('useIncompatibleIndicesContext must be used within an IncompatibleIndicesContext.Provider'); + } + + return context; +}; + +export default IncompatibleIndicesContext; 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 28bae56b3fa0..02e88ce5352d 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 @@ -27,6 +27,8 @@ import type { SearchParams } from 'stores/PaginationTypes'; import IncompatibleIndicesTable from './IncompatibleIndicesTable'; import { createColumnRenderers } from './IncompatibleIndicesColumnRenderers'; +import IncompatibleIndicesContext from './IncompatibleIndicesContext'; +import type { IncompatibleIndicesContextValue } from './IncompatibleIndicesContext'; import { fetchIncompatibleIndices, incompatibleIndicesKeyFn } from './fetchIncompatibleIndices'; import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from './fetchIncompatibleIndices'; import useArchivedIndexNames from './hooks/useArchivedIndexNames'; @@ -200,7 +202,24 @@ describe('fetchIncompatibleIndices', () => { }); describe('createColumnRenderers', () => { - const renderers = createColumnRenderers(new Map(), new Set()); + const renderers = createColumnRenderers(); + + const contextValue = (overrides: Partial = {}): IncompatibleIndicesContextValue => ({ + archiveActionsAvailable: false, + archivedIndexNames: new Set(), + pendingIndexStatuses: new Map(), + addArchiveDeleteAction: jest.fn(), + refetchClusterJobs: jest.fn(), + refetch: jest.fn(), + ...overrides, + }); + + const renderIndexNameCell = (index: IncompatibleIndexRow, ctx: Partial = {}) => + render( + +
{renderers.attributes.index_name.renderCell(index.index_name, index, undefined)}
+
, + ); it('falls back to "Unknown" for a missing version', () => { expect(renderers.attributes.version.renderCell(undefined, makeIndex({ version: '' }), undefined)).toBe('Unknown'); @@ -209,13 +228,21 @@ describe('createColumnRenderers', () => { 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' }); - render(
{renderers.attributes.index_name.renderCell(index.index_name, index, undefined)}
); + renderIndexNameCell(index); expect(screen.getByText('graylog_2')).toBeInTheDocument(); expect(screen.getByText('active write index')).toBeInTheDocument(); expect(screen.queryByText('Warm')).not.toBeInTheDocument(); }); + it('shows the "archived already" badge based on the context archived set', () => { + const index = makeIndex({ index_name: 'graylog_2' }); + + renderIndexNameCell(index, { archivedIndexNames: new Set(['graylog_2']) }); + + expect(screen.getByText('archived already')).toBeInTheDocument(); + }); + it('renders the primary type as a single badge in the category column', () => { const cases: Array<[Partial, string]> = [ [{ managed_index: true }, 'Graylog'], 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 3a677be24d63..5b8366ff0d3d 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx @@ -32,6 +32,7 @@ import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from './fetchI import { createColumnRenderers, DEFAULT_DISPLAYED_COLUMNS } from './IncompatibleIndicesColumnRenderers'; import IncompatibleIndexTableActions from './IncompatibleIndexTableActions'; import IncompatibleIndicesBulkActions from './IncompatibleIndicesBulkActions'; +import IncompatibleIndicesContext from './IncompatibleIndicesContext'; import useArchivedIndexNames from './hooks/useArchivedIndexNames'; import usePendingIncompatibleIndexActions from './hooks/usePendingIncompatibleIndexActions'; @@ -78,27 +79,19 @@ const IncompatibleIndicesTable = () => { setHasLoaded(true); }; - const columnRenderers = createColumnRenderers(pendingIndexStatuses, archivedIndexNames); + const columnRenderers = createColumnRenderers(); - const renderActions = (index: IncompatibleIndexRow) => { - const pendingStatus = pendingIndexStatuses.get(index.index_name); - const isArchived = - pendingStatus?.state !== 'archiving' && - (archivedIndexNames.has(index.index_name) || pendingStatus?.state === 'archived'); - - return ( - - ); + const contextValue = { + archiveActionsAvailable, + archivedIndexNames, + pendingIndexStatuses, + addArchiveDeleteAction, + refetchClusterJobs, + refetch, }; + const renderActions = (index: IncompatibleIndexRow) => ; + const bulkSelection = { onChangeSelection: (selectedItemsIds: Array, list: Readonly>) => { setSelectedIndicesData((cur) => { @@ -110,21 +103,11 @@ const IncompatibleIndicesTable = () => { return { ...selectedCurrentItems, ...currentEntriesById }; }); }, - actions: ( - - ), + actions: , }; return ( - <> + Incompatible indices humanName="incompatible indices" @@ -137,7 +120,7 @@ const IncompatibleIndicesTable = () => { onDataLoaded={handleDataLoaded} entityAttributesAreCamelCase={false} /> - + ); }; diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts index 2bd0a83a82cd..2c5f1f71c2af 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts @@ -17,7 +17,7 @@ import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices'; import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions'; -import { getAvailableActions } from './incompatibleIndexActions'; +import { getAvailableActions, isIndexArchived } from './incompatibleIndexActions'; const BULK_ACTION_ORDER = ['reindex-system-index', 'archive-delete', 'delete', 'rotate'] as const; @@ -73,7 +73,6 @@ const BULK_ACTION_COPY: Record = { }, }; -// Shared by the bulk actions whose backend reports per-entity failures (delete, rotate). export const buildPartialBulkNotification = ({ succeeded, failures, @@ -123,8 +122,11 @@ export const getBulkIndexActionCandidates = ({ BULK_ACTION_ORDER.map((action) => { const targetIndices = indices.filter( (index) => - getAvailableActions(index, canArchive, archivedIndexNames.has(index.index_name)).includes(action) && - !isArchiveInProgress(pendingIndexStatuses.get(index.index_name)), + getAvailableActions( + index, + canArchive, + isIndexArchived(index.index_name, pendingIndexStatuses.get(index.index_name), archivedIndexNames), + ).includes(action) && !isArchiveInProgress(pendingIndexStatuses.get(index.index_name)), ); return { 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 9e3f452e6249..f5295c6a80a8 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx @@ -25,6 +25,8 @@ import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatible import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import type { TelemetryEventType } from 'logic/telemetry/TelemetryContext'; +import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions'; + export type IndexAction = 'delete' | 'archive-delete' | 'reindex-system-index' | 'rotate'; export type ConfirmedAction = { @@ -141,6 +143,13 @@ export const useIncompatibleIndexActionDefinitions = (): Record, +): boolean => + pendingStatus?.state !== 'archiving' && (archivedIndexNames.has(indexName) || pendingStatus?.state === 'archived'); + export const getAvailableActions = ( index: IncompatibleIndex, canArchive: boolean, From c9373bb8290927e8c1c1a090b2657dcdb10aa2ea Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Thu, 30 Jul 2026 13:07:53 +0200 Subject: [PATCH 3/6] fix rindexing --- .../IncompatibleIndexTableActions.tsx | 22 +++++-- .../IncompatibleIndicesBulkActions.test.tsx | 19 +++--- .../IncompatibleIndicesBulkActions.tsx | 10 ++- .../IncompatibleIndicesContext.tsx | 1 + .../IncompatibleIndicesTable.test.tsx | 27 ++++++++ .../IncompatibleIndicesTable.tsx | 8 ++- .../usePendingIncompatibleIndexActions.ts | 61 +++++++++++++++---- 7 files changed, 117 insertions(+), 31 deletions(-) diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx index 3daef16ae18e..70aa7ba9bd4b 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx @@ -34,7 +34,7 @@ const ActionsToolbar = styled(ButtonToolbar)` justify-content: flex-end; `; -const ArchivingIndicator = styled.div` +const PendingIndicator = styled.div` display: inline-flex; align-items: center; justify-content: center; @@ -54,7 +54,7 @@ type Props = { }; const IncompatibleIndexTableActions = ({ index }: Props) => { - const { archiveActionsAvailable, archivedIndexNames, pendingIndexStatuses, addArchiveDeleteAction, refetchClusterJobs, refetch } = + const { archiveActionsAvailable, archivedIndexNames, pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, refetchClusterJobs, refetch } = useIncompatibleIndicesContext(); const actionDefinitions = useIncompatibleIndexActionDefinitions(); const sendTelemetry = useSendTelemetry(); @@ -68,7 +68,7 @@ const IncompatibleIndexTableActions = ({ index }: Props) => { if (pendingStatus?.state === 'archiving') { return ( - + {pendingStatus.percent > 0 ? ( { ) : ( )} - + + ); + } + + if (pendingStatus?.state === 'reindexing') { + return ( + + + ); } @@ -109,6 +117,10 @@ const IncompatibleIndexTableActions = ({ index }: Props) => { UserNotification.success(actionDefinition.successMessage(index)); + if (confirmedAction === 'reindex-system-index') { + addReindexAction({ indexName: index.index_name }); + } + if (confirmedAction === 'delete') { deselectEntity(index.id); } @@ -139,7 +151,7 @@ const IncompatibleIndexTableActions = ({ index }: Props) => { {pendingStatus?.state === 'failed' && ( )} {actions.map((action) => { 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 index 8d7180ff55aa..d8d48d8e60f1 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx @@ -94,6 +94,7 @@ describe('IncompatibleIndicesBulkActions', () => { const setSelectedEntities = jest.fn(); const refetch = jest.fn(); const addArchiveDeleteAction = jest.fn(); + const addReindexAction = jest.fn(); const refetchClusterJobs = jest.fn(); const renderBulkActions = (rows: Array = indices, canArchive = false) => @@ -104,6 +105,7 @@ describe('IncompatibleIndicesBulkActions', () => { archivedIndexNames: new Set(), pendingIndexStatuses: new Map(), addArchiveDeleteAction, + addReindexAction, refetchClusterJobs, refetch, }}> @@ -199,15 +201,16 @@ describe('IncompatibleIndicesBulkActions', () => { await confirmBulkReindex(); - await waitFor(() => { - expect(SystemIndexerIndices.bulkReindex).toHaveBeenCalledWith({ - indices: ['gl-system-events_0', 'gl-system-events_1'], - with_replication: true, - }); - expect(UserNotification.success).toHaveBeenCalledWith('Reindex started for 2 system indices.'); - expect(setSelectedEntities).toHaveBeenCalledWith([]); - expect(refetch).toHaveBeenCalledTimes(1); + await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)); + + expect(SystemIndexerIndices.bulkReindex).toHaveBeenCalledWith({ + indices: ['gl-system-events_0', 'gl-system-events_1'], + with_replication: true, }); + expect(addReindexAction).toHaveBeenCalledWith({ indexName: 'gl-system-events_0' }); + expect(addReindexAction).toHaveBeenCalledWith({ indexName: 'gl-system-events_1' }); + expect(UserNotification.success).toHaveBeenCalledWith('Reindex started for 2 system indices.'); + expect(setSelectedEntities).toHaveBeenCalledWith([]); }); it('reports request failures without changing the selection or refreshing', async () => { diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx index 3a2fb630f85b..f7580ef94711 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.tsx @@ -80,9 +80,13 @@ const bulkDeleteIndices = async (bulkAction: BulkIndexActionCandidate): Promise< return entityIds.filter((id) => !failedIds.has(id)); }; -const bulkReindexIndices = async (bulkAction: BulkIndexActionCandidate): Promise> => { +const bulkReindexIndices = async ( + bulkAction: BulkIndexActionCandidate, + addReindexAction: (tracking: { indexName: string }) => void, +): Promise> => { const indexNames = bulkAction.targetIndices.map((index) => index.index_name); await SystemIndexerIndices.bulkReindex({ indices: indexNames, with_replication: true }); + indexNames.forEach((indexName) => addReindexAction({ indexName })); showNotification({ type: 'success', message: bulkAction.successMessage(indexNames.length) }); return indexNames; @@ -148,7 +152,7 @@ const bulkArchiveDeleteIndices = async ( }; const IncompatibleIndicesBulkActions = ({ indices }: Props) => { - const { archiveActionsAvailable, archivedIndexNames, pendingIndexStatuses, addArchiveDeleteAction, refetchClusterJobs, refetch } = + const { archiveActionsAvailable, archivedIndexNames, pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, refetchClusterJobs, refetch } = useIncompatibleIndicesContext(); const sendTelemetry = useSendTelemetry(); const archive = useIndexArchive(); @@ -189,7 +193,7 @@ const IncompatibleIndicesBulkActions = ({ indices }: Props) => { succeededIds = await bulkDeleteIndices(confirmedBulkAction); break; case 'reindex-system-index': - succeededIds = await bulkReindexIndices(confirmedBulkAction); + succeededIds = await bulkReindexIndices(confirmedBulkAction, addReindexAction); break; case 'rotate': succeededIds = await bulkRotateIndices(confirmedBulkAction); diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx index 30c3b2c4b880..4ec5c375e7ba 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesContext.tsx @@ -24,6 +24,7 @@ export type IncompatibleIndicesContextValue = { archivedIndexNames: ReadonlySet; pendingIndexStatuses: Map; addArchiveDeleteAction: (tracking: PendingArchiveTracking) => void; + addReindexAction: (tracking: { indexName: string }) => void; refetchClusterJobs?: () => void; refetch: () => void; }; 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 02e88ce5352d..027ef7d0f7fa 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 @@ -87,6 +87,7 @@ describe('IncompatibleIndicesTable', () => { asMock(usePendingIncompatibleIndexActions).mockReturnValue({ pendingIndexStatuses: new Map(), addArchiveDeleteAction: jest.fn(), + addReindexAction: jest.fn(), isArchiveJobRunning: false, refetchClusterJobs: jest.fn(), }); @@ -172,6 +173,31 @@ describe('IncompatibleIndicesTable', () => { expect.objectContaining({ incompatibleIndices: [secondPageIndex] }), ); }); + + it('feeds bulk actions the freshly fetched attributes of selected indices, not the selection snapshot', () => { + const { default: PaginatedEntityTable } = jest.requireMock('components/common/PaginatedEntityTable'); + const mockPaginatedEntityTable = asMock(PaginatedEntityTable); + const asWriteIndex = makeIndex({ id: 'graylog_5', index_name: 'graylog_5', active_write_index: 'set-1' }); + const afterRotation = makeIndex({ id: 'graylog_5', index_name: 'graylog_5', active_write_index: null }); + + render(); + + act(() => { + latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([asWriteIndex])); + }); + act(() => { + latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.(['graylog_5'], [asWriteIndex]); + }); + act(() => { + latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([afterRotation])); + }); + + const bulkActions = latestTableProps(mockPaginatedEntityTable).bulkSelection.actions as React.ReactElement<{ + indices: Array; + }>; + + expect(bulkActions.props.indices).toEqual([afterRotation]); + }); }); describe('fetchIncompatibleIndices', () => { @@ -209,6 +235,7 @@ describe('createColumnRenderers', () => { archivedIndexNames: new Set(), pendingIndexStatuses: new Map(), addArchiveDeleteAction: jest.fn(), + addReindexAction: jest.fn(), refetchClusterJobs: jest.fn(), refetch: jest.fn(), ...overrides, 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 5b8366ff0d3d..12425d675872 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx @@ -59,11 +59,12 @@ const IncompatibleIndicesTable = () => { 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 loadedIndicesById = keyBy(loadedIndices, 'id'); + const selectedIndices = Object.keys(selectedIndicesData).map((id) => loadedIndicesById[id] ?? selectedIndicesData[id]); + const trackedIndices = Object.values({ ...selectedIndicesData, ...loadedIndicesById }); const incompatibleIndexNames = trackedIndices.map((index) => index.index_name); const archivedIndexNames = useArchivedIndexNames(incompatibleIndexNames, canArchive); - const { pendingIndexStatuses, addArchiveDeleteAction, isArchiveJobRunning, refetchClusterJobs } = + const { pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, isArchiveJobRunning, refetchClusterJobs } = usePendingIncompatibleIndexActions({ incompatibleIndices: trackedIndices, isLoading: !hasLoaded, @@ -86,6 +87,7 @@ const IncompatibleIndicesTable = () => { archivedIndexNames, pendingIndexStatuses, addArchiveDeleteAction, + addReindexAction, refetchClusterJobs, refetch, }; diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts index 77ae2506e6f1..19a1aab22cc9 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts @@ -27,8 +27,10 @@ import { ARCHIVE_POLL_INTERVAL_MS } from '../constants'; export const PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY = 'datanode-pending-incompatible-index-actions'; +const REINDEX_JOB_TYPE = 'reindex-outdated-index-v1'; + export type PendingIncompatibleIndexAction = { - action: 'archive-delete'; + action: 'archive-delete' | 'reindex'; indexName: string; startedAt: string; systemJobId?: string; @@ -38,12 +40,14 @@ export type PendingIncompatibleIndexAction = { export type PendingIndexStatus = | { state: 'archiving'; percent: number } | { state: 'archived' } - | { state: 'failed'; message: string }; + | { state: 'reindexing' } + | { state: 'failed'; message: string; label: string }; type ActionResolution = | { kind: 'archiving'; percent: number } | { kind: 'archived' } - | { kind: 'failed'; message: string } + | { kind: 'reindexing' } + | { kind: 'failed'; message: string; label: string } | { kind: 'done' }; type Params = { @@ -65,7 +69,7 @@ const isValidStoredAction = (value: unknown): value is PendingIncompatibleIndexA const candidate = value as Record; return ( - candidate.action === 'archive-delete' && + (candidate.action === 'archive-delete' || candidate.action === 'reindex') && typeof candidate.indexName === 'string' && typeof candidate.startedAt === 'string' && !Number.isNaN(Date.parse(candidate.startedAt)) && @@ -88,7 +92,7 @@ const storeActions = (actions: Array) => { } }; -const resolveAction = ( +const resolveArchiveAction = ( action: PendingIncompatibleIndexAction, { jobsById, jobsUpdatedAt }: ClusterJobsResult, ): ActionResolution => { @@ -100,7 +104,7 @@ const resolveAction = ( if (job?.job_status === 'error') { // `||` (not `??`) on purpose: an empty `info` should fall back to the description. - return { kind: 'failed', message: job.info || job.description }; + return { kind: 'failed', message: job.info || job.description, label: 'Archive failed' }; } if (job?.job_status === 'complete') { @@ -119,6 +123,28 @@ const resolveAction = ( return { kind: 'archiving', percent: job?.percent_complete ?? 0 }; }; +const resolveReindexAction = ( + action: PendingIncompatibleIndexAction, + { jobsById, jobsUpdatedAt }: ClusterJobsResult, +): ActionResolution => { + const job = Array.from(jobsById.values()).find( + (candidate) => candidate.name === REINDEX_JOB_TYPE && !!candidate.info?.includes(`<${action.indexName}>`), + ); + + if (job?.job_status === 'error') { + return { kind: 'failed', message: job.info || job.description, label: 'Reindex failed' }; + } + + if (!job && jobsUpdatedAt > Date.parse(action.startedAt)) { + return { kind: 'done' }; + } + + return { kind: 'reindexing' }; +}; + +const resolveAction = (action: PendingIncompatibleIndexAction, jobs: ClusterJobsResult): ActionResolution => + action.action === 'reindex' ? resolveReindexAction(action, jobs) : resolveArchiveAction(action, jobs); + const reconcileActions = ( current: Array, incompatibleIndexNames: Set, @@ -180,17 +206,28 @@ const usePendingIncompatibleIndexActions = ({ pendingIndexStatuses.set(pendingAction.indexName, { state: 'archiving', percent: resolution.percent }); } else if (resolution.kind === 'archived') { pendingIndexStatuses.set(pendingAction.indexName, { state: 'archived' }); + } else if (resolution.kind === 'reindexing') { + pendingIndexStatuses.set(pendingAction.indexName, { state: 'reindexing' }); } else if (resolution.kind === 'failed') { - pendingIndexStatuses.set(pendingAction.indexName, { state: 'failed', message: resolution.message }); + pendingIndexStatuses.set(pendingAction.indexName, { + state: 'failed', + message: resolution.message, + label: resolution.label, + }); } }); - const addArchiveDeleteAction = ({ indexName, systemJobId }: { indexName: string; systemJobId?: string }) => { + const addPendingAction = (action: PendingIncompatibleIndexAction) => setPendingActions((current) => [ - ...current.filter((pendingAction) => pendingAction.indexName !== indexName), - { action: 'archive-delete', indexName, systemJobId, startedAt: new Date().toISOString() }, + ...current.filter((pendingAction) => pendingAction.indexName !== action.indexName), + action, ]); - }; + + const addArchiveDeleteAction = ({ indexName, systemJobId }: { indexName: string; systemJobId?: string }) => + addPendingAction({ action: 'archive-delete', indexName, systemJobId, startedAt: new Date().toISOString() }); + + const addReindexAction = ({ indexName }: { indexName: string }) => + addPendingAction({ action: 'reindex', indexName, startedAt: new Date().toISOString() }); // Guarded state adjustment during render instead of an effect: // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes @@ -219,7 +256,7 @@ const usePendingIncompatibleIndexActions = ({ return () => window.clearInterval(polling); }, [hasActiveTrackedActions, refetch]); - return { pendingIndexStatuses, addArchiveDeleteAction, isArchiveJobRunning, refetchClusterJobs }; + return { pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, isArchiveJobRunning, refetchClusterJobs }; }; export default usePendingIncompatibleIndexActions; From 3ac7301e8f01c658fa26ff82d080ebad20b45b56 Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Thu, 30 Jul 2026 13:27:32 +0200 Subject: [PATCH 4/6] cleanup --- .../datanode/opensearch-upgrade/bulkIndexActions.ts | 1 - .../hooks/usePendingIncompatibleIndexActions.ts | 5 ----- 2 files changed, 6 deletions(-) diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts index 2c5f1f71c2af..7460e2c0c2f4 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts @@ -105,7 +105,6 @@ export const buildPartialBulkNotification = ({ : { type: 'error', message, title: failureTitle }; }; -// Deleting mid-archive is racy; an already-archived index stays deletable. const isArchiveInProgress = (pendingStatus: PendingIndexStatus | undefined) => pendingStatus?.state === 'archiving'; export const getBulkIndexActionCandidates = ({ diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts index 19a1aab22cc9..d1d2c87b668e 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts @@ -103,7 +103,6 @@ const resolveArchiveAction = ( const job = action.systemJobId ? jobsById.get(action.systemJobId) : undefined; if (job?.job_status === 'error') { - // `||` (not `??`) on purpose: an empty `info` should fall back to the description. return { kind: 'failed', message: job.info || job.description, label: 'Archive failed' }; } @@ -115,7 +114,6 @@ const resolveArchiveAction = ( return { kind: 'done' }; } - // A jobs list fetched before the action started cannot prove the job is gone — keep waiting. if (action.systemJobId && !job && jobsUpdatedAt > Date.parse(action.startedAt)) { return { kind: 'archived' }; } @@ -229,8 +227,6 @@ const usePendingIncompatibleIndexActions = ({ const addReindexAction = ({ indexName }: { indexName: string }) => addPendingAction({ action: 'reindex', indexName, startedAt: new Date().toISOString() }); - // Guarded state adjustment during render instead of an effect: - // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes if (!isLoading && !isError) { const reconciled = reconcileActions(pendingActions, incompatibleIndexNames, { jobsById, jobsUpdatedAt }); @@ -243,7 +239,6 @@ const usePendingIncompatibleIndexActions = ({ storeActions(pendingActions); }, [pendingActions]); - // Plain interval: react-query's refetchInterval would need a state round-trip for a flag derived here. useEffect(() => { if (!hasActiveTrackedActions) { return undefined; From d7dfc114440ae4baba0fb3f1fd0e967d46d0cadc Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Thu, 30 Jul 2026 17:31:35 +0200 Subject: [PATCH 5/6] cleanup --- .../IncompatibleIndexTableActions.tsx | 2 +- .../IncompatibleIndicesBulkActions.test.tsx | 17 ++- .../IncompatibleIndicesTable.test.tsx | 126 +++--------------- .../IncompatibleIndicesTable.tsx | 78 ++--------- .../opensearch-upgrade/bulkIndexActions.ts | 5 +- .../fetchIncompatibleIndices.ts | 4 +- .../hooks/useArchivedIndexNames.test.ts | 4 +- .../useIncompatibleIndexActionState.test.ts | 84 ++++++++++++ .../hooks/useIncompatibleIndexActionState.ts | 56 ++++++++ ...usePendingIncompatibleIndexActions.test.ts | 69 ++++++++++ .../usePendingIncompatibleIndexActions.ts | 33 ++--- .../useTrackedIncompatibleIndices.test.ts | 98 ++++++++++++++ .../hooks/useTrackedIncompatibleIndices.ts | 61 +++++++++ .../incompatibleIndexActions.tsx | 7 +- .../src/components/indices/archive/types.ts | 4 +- .../indices/hooks/useCanArchive.test.ts | 4 +- .../indices/hooks/useIncompatibleIndices.ts | 3 +- 17 files changed, 447 insertions(+), 208 deletions(-) create mode 100644 graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.test.ts create mode 100644 graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.ts create mode 100644 graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts create mode 100644 graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.test.ts create mode 100644 graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.ts diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx index 70aa7ba9bd4b..a9ef183ff944 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndexTableActions.tsx @@ -121,7 +121,7 @@ const IncompatibleIndexTableActions = ({ index }: Props) => { addReindexAction({ indexName: index.index_name }); } - if (confirmedAction === 'delete') { + if (confirmedAction !== 'rotate') { deselectEntity(index.id); } 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 index d8d48d8e60f1..29206999b613 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesBulkActions.test.tsx @@ -30,6 +30,7 @@ import UserNotification from 'util/UserNotification'; import IncompatibleIndicesBulkActions from './IncompatibleIndicesBulkActions'; import IncompatibleIndicesContext from './IncompatibleIndicesContext'; import type { IncompatibleIndexRow } from './fetchIncompatibleIndices'; +import type { PendingIndexStatus } from './hooks/usePendingIncompatibleIndexActions'; jest.mock('@graylog/server-api', () => ({ SystemIndexerIndices: { @@ -97,13 +98,17 @@ describe('IncompatibleIndicesBulkActions', () => { const addReindexAction = jest.fn(); const refetchClusterJobs = jest.fn(); - const renderBulkActions = (rows: Array = indices, canArchive = false) => + const renderBulkActions = ( + rows: Array = indices, + canArchive = false, + pendingIndexStatuses: Map = new Map(), + ) => render( (), - pendingIndexStatuses: new Map(), + pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, refetchClusterJobs, @@ -225,6 +230,14 @@ describe('IncompatibleIndicesBulkActions', () => { expect(setSelectedEntities).not.toHaveBeenCalled(); expect(refetch).not.toHaveBeenCalled(); }); + + it('excludes an index that is already reindexing from the bulk candidates', async () => { + renderBulkActions(systemIndices, false, new Map([['gl-system-events_0', { state: 'reindexing' }]])); + + await userEvent.click(screen.getByRole('button', { name: /bulk actions/i })); + + expect(await screen.findByRole('menuitem', { name: /reindex all \(1\)/i })).toBeInTheDocument(); + }); }); describe('bulk rotate', () => { 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 027ef7d0f7fa..06a7de2a652c 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 @@ -16,13 +16,11 @@ */ import * as React from 'react'; import { render, screen } from 'wrappedTestingLibrary'; -import { act } from 'react'; import { SystemIndexerIndices } from '@graylog/server-api'; import asMock from 'helpers/mocking/AsMock'; import type { PaginatedEntityTableProps } from 'components/common/PaginatedEntityTable/PaginatedEntityTable'; -import useCanArchive from 'components/indices/hooks/useCanArchive'; import type { SearchParams } from 'stores/PaginationTypes'; import IncompatibleIndicesTable from './IncompatibleIndicesTable'; @@ -30,9 +28,8 @@ import { createColumnRenderers } from './IncompatibleIndicesColumnRenderers'; import IncompatibleIndicesContext from './IncompatibleIndicesContext'; import type { IncompatibleIndicesContextValue } from './IncompatibleIndicesContext'; import { fetchIncompatibleIndices, incompatibleIndicesKeyFn } from './fetchIncompatibleIndices'; -import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from './fetchIncompatibleIndices'; -import useArchivedIndexNames from './hooks/useArchivedIndexNames'; -import usePendingIncompatibleIndexActions from './hooks/usePendingIncompatibleIndexActions'; +import type { IncompatibleIndexRow } from './fetchIncompatibleIndices'; +import useIncompatibleIndexActionState from './hooks/useIncompatibleIndexActionState'; jest.mock('components/common/PaginatedEntityTable', () => ({ __esModule: true, @@ -43,9 +40,7 @@ jest.mock('components/common/PaginatedEntityTable', () => ({ jest.mock('@graylog/server-api', () => ({ SystemIndexerIndices: { listOutdatedIndices: jest.fn() }, })); -jest.mock('components/indices/hooks/useCanArchive'); -jest.mock('./hooks/useArchivedIndexNames'); -jest.mock('./hooks/usePendingIncompatibleIndexActions'); +jest.mock('./hooks/useIncompatibleIndexActionState'); const makeIndex = (overrides: Partial): IncompatibleIndexRow => ({ id: 'index', @@ -68,28 +63,17 @@ const searchParams: SearchParams = { filters: undefined, }; -const makeResponse = (list: Array): IncompatibleIndicesResponse => ({ - list, - pagination: { total: list.length }, - attributes: [], -}); - -const latestTableProps = ( - mockPaginatedEntityTable: jest.Mock, -): PaginatedEntityTableProps => - mockPaginatedEntityTable.mock.calls[mockPaginatedEntityTable.mock.calls.length - 1][0]; - describe('IncompatibleIndicesTable', () => { beforeEach(() => { jest.clearAllMocks(); - asMock(useCanArchive).mockReturnValue(true); - asMock(useArchivedIndexNames).mockReturnValue(new Set()); - asMock(usePendingIncompatibleIndexActions).mockReturnValue({ + asMock(useIncompatibleIndexActionState).mockReturnValue({ + archiveActionsAvailable: true, + archivedIndexNames: new Set(), pendingIndexStatuses: new Map(), addArchiveDeleteAction: jest.fn(), addReindexAction: jest.fn(), - isArchiveJobRunning: false, refetchClusterJobs: jest.fn(), + refetch: jest.fn(), }); }); @@ -114,90 +98,6 @@ describe('IncompatibleIndicesTable', () => { expect(callProps.bulkSelection.actions).toBeTruthy(); expect(callProps.columnRenderers.attributes).toHaveProperty('index_name'); }); - - 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' }); - - render(); - - act(() => { - latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([firstPageIndex])); - }); - - act(() => { - latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.( - [firstPageIndex.id], - [firstPageIndex], - ); - }); - - act(() => { - latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([secondPageIndex])); - }); - - act(() => { - latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.( - [firstPageIndex.id, secondPageIndex.id], - [secondPageIndex], - ); - }); - - const bulkActions = latestTableProps(mockPaginatedEntityTable).bulkSelection.actions as React.ReactElement<{ - indices: Array; - }>; - - 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] }), - ); - - act(() => { - latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.( - [secondPageIndex.id], - [secondPageIndex], - ); - }); - - const actionsAfterDeselection = latestTableProps(mockPaginatedEntityTable).bulkSelection - .actions as React.ReactElement<{ - indices: Array; - }>; - - expect(actionsAfterDeselection.props.indices).toEqual([secondPageIndex]); - expect(useArchivedIndexNames).toHaveBeenLastCalledWith(['legacy_1'], true); - expect(usePendingIncompatibleIndexActions).toHaveBeenLastCalledWith( - expect.objectContaining({ incompatibleIndices: [secondPageIndex] }), - ); - }); - - it('feeds bulk actions the freshly fetched attributes of selected indices, not the selection snapshot', () => { - const { default: PaginatedEntityTable } = jest.requireMock('components/common/PaginatedEntityTable'); - const mockPaginatedEntityTable = asMock(PaginatedEntityTable); - const asWriteIndex = makeIndex({ id: 'graylog_5', index_name: 'graylog_5', active_write_index: 'set-1' }); - const afterRotation = makeIndex({ id: 'graylog_5', index_name: 'graylog_5', active_write_index: null }); - - render(); - - act(() => { - latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([asWriteIndex])); - }); - act(() => { - latestTableProps(mockPaginatedEntityTable).bulkSelection.onChangeSelection?.(['graylog_5'], [asWriteIndex]); - }); - act(() => { - latestTableProps(mockPaginatedEntityTable).onDataLoaded?.(makeResponse([afterRotation])); - }); - - const bulkActions = latestTableProps(mockPaginatedEntityTable).bulkSelection.actions as React.ReactElement<{ - indices: Array; - }>; - - expect(bulkActions.props.indices).toEqual([afterRotation]); - }); }); describe('fetchIncompatibleIndices', () => { @@ -221,7 +121,9 @@ describe('fetchIncompatibleIndices', () => { const result = await fetchIncompatibleIndices(searchParams); - expect(listOutdatedIndices).toHaveBeenCalledWith('index_name', 2, 20, '', 'asc'); + expect(listOutdatedIndices).toHaveBeenCalledWith('index_name', 2, 20, '', 'asc', { + requestShouldExtendSession: false, + }); expect(result.list).toEqual([{ ...index, id: 'legacy-index' }]); expect(result.pagination).toEqual({ total: 42 }); }); @@ -270,6 +172,14 @@ describe('createColumnRenderers', () => { expect(screen.getByText('archived already')).toBeInTheDocument(); }); + it('does not show the "archived already" badge from a transient pending status alone', () => { + const index = makeIndex({ index_name: 'graylog_2' }); + + renderIndexNameCell(index, { pendingIndexStatuses: new Map([['graylog_2', { state: 'archived' }]]) }); + + expect(screen.queryByText('archived already')).not.toBeInTheDocument(); + }); + it('renders the primary type as a single badge in the category column', () => { const cases: Array<[Partial, string]> = [ [{ managed_index: true }, 'Graylog'], 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 12425d675872..e687b994573b 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/IncompatibleIndicesTable.tsx @@ -14,27 +14,19 @@ * along with this program. If not, see * . */ -import React, { useState } from 'react'; +import React 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 { PaginatedEntityTable } from 'components/common'; -import useCanArchive from 'components/indices/hooks/useCanArchive'; -import { - fetchIncompatibleIndices, - incompatibleIndicesKeyFn, - INCOMPATIBLE_INDICES_QUERY_KEY, -} from './fetchIncompatibleIndices'; -import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from './fetchIncompatibleIndices'; +import { fetchIncompatibleIndices, incompatibleIndicesKeyFn } from './fetchIncompatibleIndices'; +import type { IncompatibleIndexRow } from './fetchIncompatibleIndices'; import { createColumnRenderers, DEFAULT_DISPLAYED_COLUMNS } from './IncompatibleIndicesColumnRenderers'; import IncompatibleIndexTableActions from './IncompatibleIndexTableActions'; import IncompatibleIndicesBulkActions from './IncompatibleIndicesBulkActions'; import IncompatibleIndicesContext from './IncompatibleIndicesContext'; -import useArchivedIndexNames from './hooks/useArchivedIndexNames'; -import usePendingIncompatibleIndexActions from './hooks/usePendingIncompatibleIndexActions'; +import useIncompatibleIndexActionState from './hooks/useIncompatibleIndexActionState'; +import useTrackedIncompatibleIndices from './hooks/useTrackedIncompatibleIndices'; const Heading = styled.h4( ({ theme }) => css` @@ -52,62 +44,12 @@ const TABLE_LAYOUT = { }; const IncompatibleIndicesTable = () => { - const queryClient = useQueryClient(); - const canArchive = useCanArchive(); - const [loadedIndices, setLoadedIndices] = useState>([]); - const [selectedIndicesData, setSelectedIndicesData] = useState>({}); - const [hasLoaded, setHasLoaded] = useState(false); - - const refetch = () => queryClient.invalidateQueries({ queryKey: INCOMPATIBLE_INDICES_QUERY_KEY }); - const loadedIndicesById = keyBy(loadedIndices, 'id'); - const selectedIndices = Object.keys(selectedIndicesData).map((id) => loadedIndicesById[id] ?? selectedIndicesData[id]); - const trackedIndices = Object.values({ ...selectedIndicesData, ...loadedIndicesById }); - const incompatibleIndexNames = trackedIndices.map((index) => index.index_name); - const archivedIndexNames = useArchivedIndexNames(incompatibleIndexNames, canArchive); - const { pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, isArchiveJobRunning, refetchClusterJobs } = - usePendingIncompatibleIndexActions({ - incompatibleIndices: trackedIndices, - isLoading: !hasLoaded, - isError: false, - refetch, - canArchive, - }); - - const archiveActionsAvailable = canArchive && !isArchiveJobRunning; - - const handleDataLoaded = (data: IncompatibleIndicesResponse) => { - setLoadedIndices(data.list); - setHasLoaded(true); - }; - + const { selectedIndices, trackedIndices, hasLoaded, onDataLoaded, onChangeSelection } = + useTrackedIncompatibleIndices(); + const contextValue = useIncompatibleIndexActionState({ trackedIndices, isLoading: !hasLoaded }); const columnRenderers = createColumnRenderers(); - - const contextValue = { - archiveActionsAvailable, - archivedIndexNames, - pendingIndexStatuses, - addArchiveDeleteAction, - addReindexAction, - refetchClusterJobs, - refetch, - }; - const renderActions = (index: IncompatibleIndexRow) => ; - 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'); - - return { ...selectedCurrentItems, ...currentEntriesById }; - }); - }, - actions: , - }; - return ( Incompatible indices @@ -118,8 +60,8 @@ const IncompatibleIndicesTable = () => { keyFn={incompatibleIndicesKeyFn} columnRenderers={columnRenderers} entityActions={renderActions} - bulkSelection={bulkSelection} - onDataLoaded={handleDataLoaded} + bulkSelection={{ onChangeSelection, actions: }} + onDataLoaded={onDataLoaded} entityAttributesAreCamelCase={false} /> diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts index 7460e2c0c2f4..ef5576a98c36 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/bulkIndexActions.ts @@ -105,7 +105,8 @@ export const buildPartialBulkNotification = ({ : { type: 'error', message, title: failureTitle }; }; -const isArchiveInProgress = (pendingStatus: PendingIndexStatus | undefined) => pendingStatus?.state === 'archiving'; +const isActionInProgress = (pendingStatus: PendingIndexStatus | undefined) => + pendingStatus?.state === 'archiving' || pendingStatus?.state === 'reindexing'; export const getBulkIndexActionCandidates = ({ indices, @@ -125,7 +126,7 @@ export const getBulkIndexActionCandidates = ({ index, canArchive, isIndexArchived(index.index_name, pendingIndexStatuses.get(index.index_name), archivedIndexNames), - ).includes(action) && !isArchiveInProgress(pendingIndexStatuses.get(index.index_name)), + ).includes(action) && !isActionInProgress(pendingIndexStatuses.get(index.index_name)), ); return { diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts index 64868e7dade2..1721db7478a4 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/fetchIncompatibleIndices.ts @@ -38,7 +38,9 @@ export const fetchIncompatibleIndices = (searchParams: SearchParams): Promise ({ list: elements.map((element) => ({ ...element, id: element.index_name })) as Array, pagination: { total: pagination.total }, diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts index df0f4d106b12..22e546e94029 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useArchivedIndexNames.test.ts @@ -28,8 +28,8 @@ const archivePlugin = new PluginManifest( { useCanArchive: () => true, useArchivedIndexNames: useArchivedIndexNamesBinding, - archiveAndDeleteIndex: () => Promise.resolve({}), - archiveAndDeleteIndices: () => Promise.resolve({}), + archiveAndDeleteIndex: () => Promise.resolve({ systemJobId: 'job-id' }), + archiveAndDeleteIndices: () => Promise.resolve({ systemJobId: 'job-id' }), isArchiveJobConflict: () => false, archiveSystemJobName: 'archive-job', }, diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.test.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.test.ts new file mode 100644 index 000000000000..2c5290ae5f2e --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { renderHook } from 'wrappedTestingLibrary/hooks'; + +import asMock from 'helpers/mocking/AsMock'; +import useCanArchive from 'components/indices/hooks/useCanArchive'; + +import useIncompatibleIndexActionState from './useIncompatibleIndexActionState'; +import useArchivedIndexNames from './useArchivedIndexNames'; +import usePendingIncompatibleIndexActions from './usePendingIncompatibleIndexActions'; + +import type { IncompatibleIndexRow } from '../fetchIncompatibleIndices'; + +jest.mock('components/indices/hooks/useCanArchive'); +jest.mock('./useArchivedIndexNames'); +jest.mock('./usePendingIncompatibleIndexActions'); + +const makeIndex = (indexName: string): IncompatibleIndexRow => ({ + id: indexName, + index_name: indexName, + version: '1.3.20', + warm_index: false, + managed_index: true, + system_index: false, + active_write_index: null, + begin: null, + end: null, +}); + +const pendingReturn = { + pendingIndexStatuses: new Map(), + addArchiveDeleteAction: jest.fn(), + addReindexAction: jest.fn(), + isArchiveJobRunning: false, + refetchClusterJobs: jest.fn(), +}; + +const renderState = (trackedIndices: Array = [], isLoading = false) => + renderHook(() => useIncompatibleIndexActionState({ trackedIndices, isLoading })); + +describe('useIncompatibleIndexActionState', () => { + beforeEach(() => { + jest.clearAllMocks(); + asMock(useCanArchive).mockReturnValue(true); + asMock(useArchivedIndexNames).mockReturnValue(new Set(['already_archived'])); + asMock(usePendingIncompatibleIndexActions).mockReturnValue(pendingReturn); + }); + + it('feeds the tracked indices and their names to the underlying hooks', () => { + const index = makeIndex('graylog_0'); + + renderState([index], true); + + expect(usePendingIncompatibleIndexActions).toHaveBeenCalledWith( + expect.objectContaining({ incompatibleIndices: [index], isLoading: true, isError: false }), + ); + expect(useArchivedIndexNames).toHaveBeenCalledWith(['graylog_0'], true); + }); + + it('is archivable only when licensed and no archive job is running', () => { + expect(renderState().result.current.archiveActionsAvailable).toBe(true); + + asMock(usePendingIncompatibleIndexActions).mockReturnValue({ ...pendingReturn, isArchiveJobRunning: true }); + expect(renderState().result.current.archiveActionsAvailable).toBe(false); + + asMock(usePendingIncompatibleIndexActions).mockReturnValue(pendingReturn); + asMock(useCanArchive).mockReturnValue(false); + expect(renderState().result.current.archiveActionsAvailable).toBe(false); + }); +}); diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.ts new file mode 100644 index 000000000000..399a7233d467 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useIncompatibleIndexActionState.ts @@ -0,0 +1,56 @@ +/* + * 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 { useQueryClient } from '@tanstack/react-query'; + +import useCanArchive from 'components/indices/hooks/useCanArchive'; + +import useArchivedIndexNames from './useArchivedIndexNames'; +import usePendingIncompatibleIndexActions from './usePendingIncompatibleIndexActions'; + +import { INCOMPATIBLE_INDICES_QUERY_KEY } from '../fetchIncompatibleIndices'; +import type { IncompatibleIndexRow } from '../fetchIncompatibleIndices'; +import type { IncompatibleIndicesContextValue } from '../IncompatibleIndicesContext'; + +type Params = { + trackedIndices: Array; + isLoading: boolean; +}; + +const useIncompatibleIndexActionState = ({ trackedIndices, isLoading }: Params): IncompatibleIndicesContextValue => { + const queryClient = useQueryClient(); + const canArchive = useCanArchive(); + const refetch = () => queryClient.invalidateQueries({ queryKey: INCOMPATIBLE_INDICES_QUERY_KEY }); + + const archivedIndexNames = useArchivedIndexNames( + trackedIndices.map((index) => index.index_name), + canArchive, + ); + const { pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, isArchiveJobRunning, refetchClusterJobs } = + usePendingIncompatibleIndexActions({ incompatibleIndices: trackedIndices, isLoading, isError: false, refetch, canArchive }); + + return { + archiveActionsAvailable: canArchive && !isArchiveJobRunning, + archivedIndexNames, + pendingIndexStatuses, + addArchiveDeleteAction, + addReindexAction, + refetchClusterJobs, + refetch, + }; +}; + +export default useIncompatibleIndexActionState; diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts new file mode 100644 index 000000000000..ac56bfb80b82 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { renderHook } from 'wrappedTestingLibrary/hooks'; + +import asMock from 'helpers/mocking/AsMock'; +import Store from 'logic/local-storage/Store'; +import useIndexArchive from 'components/indices/archive/useIndexArchive'; +import type { IncompatibleIndex } from 'components/indices/hooks/useIncompatibleIndices'; + +import usePendingIncompatibleIndexActions from './usePendingIncompatibleIndexActions'; +import useClusterJobs from './useClusterJobs'; + +jest.mock('logic/local-storage/Store'); +jest.mock('components/indices/archive/useIndexArchive'); +jest.mock('./useClusterJobs'); + +const renderPendingActions = (incompatibleIndices: Array = []) => + renderHook(() => + usePendingIncompatibleIndexActions({ + incompatibleIndices, + isLoading: false, + isError: false, + refetch: jest.fn().mockResolvedValue(undefined), + canArchive: false, + }), + ); + +describe('usePendingIncompatibleIndexActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + asMock(useIndexArchive).mockReturnValue(undefined); + asMock(useClusterJobs).mockReturnValue({ jobsById: new Map(), jobsUpdatedAt: 0, refetch: jest.fn() }); + asMock(Store.get).mockReturnValue([]); + }); + + it('does not crash when reading pending actions from blocked storage', () => { + asMock(Store.get).mockImplementation(() => { + throw new Error('storage blocked'); + }); + + const { result } = renderPendingActions(); + + expect(result.current.pendingIndexStatuses.size).toBe(0); + }); + + it('keeps an in-flight action whose index is not in the current view', () => { + const action = { action: 'reindex', indexName: 'off_page_index', startedAt: '2026-01-01T00:00:00.000Z' }; + asMock(Store.get).mockReturnValue([action]); + + renderPendingActions([]); + + expect(asMock(Store.set).mock.calls.at(-1)?.[1]).toEqual([action]); + expect(useClusterJobs).toHaveBeenCalledWith(expect.objectContaining({ poll: true })); + }); +}); diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts index d1d2c87b668e..6eff6cf36bf9 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts @@ -79,16 +79,20 @@ const isValidStoredAction = (value: unknown): value is PendingIncompatibleIndexA }; const readStoredActions = (): Array => { - const stored = Store.get(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY); + try { + const stored = Store.get(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY); - return Array.isArray(stored) ? stored.filter(isValidStoredAction) : []; + return Array.isArray(stored) ? stored.filter(isValidStoredAction) : []; + } catch { + return []; + } }; const storeActions = (actions: Array) => { try { Store.set(PENDING_INCOMPATIBLE_INDEX_ACTIONS_STORAGE_KEY, actions); } catch { - // Ignore write failures (e.g. storage full / disabled) — tracking degrades to this session only. + return; } }; @@ -149,18 +153,18 @@ const reconcileActions = ( jobs: Pick, ): Array => { const next = current.flatMap((pendingAction): Array => { - if (!incompatibleIndexNames.has(pendingAction.indexName)) { - return []; - } - const resolution = resolveAction(pendingAction, jobs); if (resolution.kind === 'done') { return []; } - if (resolution.kind === 'archived' && pendingAction.state !== 'archived') { - return [{ ...pendingAction, state: 'archived' }]; + if (resolution.kind === 'archived') { + if (!incompatibleIndexNames.has(pendingAction.indexName)) { + return []; + } + + return pendingAction.state === 'archived' ? [pendingAction] : [{ ...pendingAction, state: 'archived' }]; } return [pendingAction]; @@ -184,13 +188,12 @@ const usePendingIncompatibleIndexActions = ({ const incompatibleIndexNames = new Set(incompatibleIndices.map((index) => index.index_name)); const trackedActions = pendingActions.filter((pendingAction) => incompatibleIndexNames.has(pendingAction.indexName)); - const activeTrackedActions = trackedActions.filter((pendingAction) => pendingAction.state !== 'archived'); - const hasActiveTrackedActions = activeTrackedActions.length > 0; + const hasActiveActions = pendingActions.some((pendingAction) => pendingAction.state !== 'archived'); const { jobsById, jobsUpdatedAt, refetch: refetchClusterJobs, - } = useClusterJobs({ enabled: canArchive || hasActiveTrackedActions, poll: hasActiveTrackedActions }); + } = useClusterJobs({ enabled: canArchive || hasActiveActions, poll: hasActiveActions }); const isArchiveJobRunning = !!archive && @@ -221,7 +224,7 @@ const usePendingIncompatibleIndexActions = ({ action, ]); - const addArchiveDeleteAction = ({ indexName, systemJobId }: { indexName: string; systemJobId?: string }) => + const addArchiveDeleteAction = ({ indexName, systemJobId }: { indexName: string; systemJobId: string }) => addPendingAction({ action: 'archive-delete', indexName, systemJobId, startedAt: new Date().toISOString() }); const addReindexAction = ({ indexName }: { indexName: string }) => @@ -240,7 +243,7 @@ const usePendingIncompatibleIndexActions = ({ }, [pendingActions]); useEffect(() => { - if (!hasActiveTrackedActions) { + if (!hasActiveActions) { return undefined; } @@ -249,7 +252,7 @@ const usePendingIncompatibleIndexActions = ({ }, ARCHIVE_POLL_INTERVAL_MS); return () => window.clearInterval(polling); - }, [hasActiveTrackedActions, refetch]); + }, [hasActiveActions, refetch]); return { pendingIndexStatuses, addArchiveDeleteAction, addReindexAction, isArchiveJobRunning, refetchClusterJobs }; }; diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.test.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.test.ts new file mode 100644 index 000000000000..4621ecfad901 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { renderHook } from 'wrappedTestingLibrary/hooks'; +import { act } from 'react'; + +import useTrackedIncompatibleIndices from './useTrackedIncompatibleIndices'; + +import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from '../fetchIncompatibleIndices'; + +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 makeResponse = (list: Array): IncompatibleIndicesResponse => ({ + list, + pagination: { total: list.length }, + attributes: [], +}); + +describe('useTrackedIncompatibleIndices', () => { + it('exposes loaded rows as trackedIndices and flags when data has loaded', () => { + const { result } = renderHook(() => useTrackedIncompatibleIndices()); + const loaded = makeIndex({ id: 'graylog_0', index_name: 'graylog_0' }); + + expect(result.current.hasLoaded).toBe(false); + + act(() => result.current.onDataLoaded(makeResponse([loaded]))); + + expect(result.current.trackedIndices).toEqual([loaded]); + expect(result.current.hasLoaded).toBe(true); + }); + + it('keeps a selected index across page changes and refreshes its attributes', () => { + const { result } = renderHook(() => useTrackedIncompatibleIndices()); + const asWriteIndex = makeIndex({ id: 'graylog_5', index_name: 'graylog_5', active_write_index: 'set-1' }); + const afterRotation = makeIndex({ id: 'graylog_5', index_name: 'graylog_5', active_write_index: null }); + + act(() => result.current.onDataLoaded(makeResponse([asWriteIndex]))); + act(() => result.current.onChangeSelection(['graylog_5'], [asWriteIndex])); + act(() => result.current.onDataLoaded(makeResponse([afterRotation]))); + + expect(result.current.selectedIndices).toEqual([afterRotation]); + + act(() => result.current.onDataLoaded(makeResponse([makeIndex({ id: 'other', index_name: 'other' })]))); + + expect(result.current.selectedIndices).toEqual([afterRotation]); + }); + + it('drops deselected indices', () => { + const { result } = renderHook(() => useTrackedIncompatibleIndices()); + const first = makeIndex({ id: 'legacy_0', index_name: 'legacy_0' }); + const second = makeIndex({ id: 'legacy_1', index_name: 'legacy_1' }); + + act(() => result.current.onDataLoaded(makeResponse([first, second]))); + act(() => result.current.onChangeSelection(['legacy_0', 'legacy_1'], [first, second])); + + expect(result.current.selectedIndices.map(({ id }) => id)).toEqual(['legacy_0', 'legacy_1']); + + act(() => result.current.onChangeSelection(['legacy_1'], [second])); + + expect(result.current.selectedIndices).toEqual([second]); + }); + + it('does not merge an unselected index whose name looks like a property path', () => { + const { result } = renderHook(() => useTrackedIncompatibleIndices()); + const selected = makeIndex({ id: 'graylog_0', index_name: 'graylog_0' }); + const dotted = makeIndex({ id: 'graylog_0.id', index_name: 'graylog_0.id' }); + + act(() => result.current.onDataLoaded(makeResponse([selected]))); + act(() => result.current.onChangeSelection(['graylog_0'], [selected])); + act(() => result.current.onDataLoaded(makeResponse([dotted]))); + + expect(result.current.selectedIndices.map(({ id }) => id)).toEqual(['graylog_0']); + }); +}); diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.ts new file mode 100644 index 000000000000..1a864b5d0b2a --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/useTrackedIncompatibleIndices.ts @@ -0,0 +1,61 @@ +/* + * 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 { useState } from 'react'; +import keyBy from 'lodash/keyBy'; +import pickBy from 'lodash/pickBy'; + +import type { IncompatibleIndexRow, IncompatibleIndicesResponse } from '../fetchIncompatibleIndices'; + +type TrackedIncompatibleIndices = { + selectedIndices: Array; + trackedIndices: Array; + hasLoaded: boolean; + onDataLoaded: (data: IncompatibleIndicesResponse) => void; + onChangeSelection: (selectedItemsIds: Array, list: Readonly>) => void; +}; + +const useTrackedIncompatibleIndices = (): TrackedIncompatibleIndices => { + const [loadedIndices, setLoadedIndices] = useState>([]); + const [selectedIndicesData, setSelectedIndicesData] = useState>({}); + const [hasLoaded, setHasLoaded] = useState(false); + + const selectedIndices = Object.values(selectedIndicesData); + const trackedIndices = Object.values({ ...selectedIndicesData, ...keyBy(loadedIndices, 'id') }); + + const onDataLoaded = (data: IncompatibleIndicesResponse) => { + setLoadedIndices(data.list); + setHasLoaded(true); + setSelectedIndicesData((cur) => ({ ...cur, ...keyBy(data.list.filter(({ id }) => Object.hasOwn(cur, id)), 'id') })); + }; + + const onChangeSelection = (selectedItemsIds: Array, list: Readonly>) => { + setSelectedIndicesData((cur) => { + const selectedItemsIdsSet = new Set(selectedItemsIds); + const selectedCurrentItems = pickBy(cur, (_, indexId) => selectedItemsIdsSet.has(indexId)); + const currentEntriesById = keyBy( + list.filter(({ id }) => selectedItemsIdsSet.has(id)), + 'id', + ); + + return { ...selectedCurrentItems, ...currentEntriesById }; + }); + }; + + return { selectedIndices, trackedIndices, hasLoaded, onDataLoaded, onChangeSelection }; +}; + +export default useTrackedIncompatibleIndices; 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 f5295c6a80a8..4ac55fbe1140 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/incompatibleIndexActions.tsx @@ -36,7 +36,7 @@ export type ConfirmedAction = { export type PendingArchiveTracking = { indexName: string; - systemJobId?: string; + systemJobId: string; }; type ActionDefinition = { @@ -129,7 +129,7 @@ const archiveDeleteDefinition = (archive: IndexArchiveBinding | undefined): Acti telemetryEventType: TELEMETRY_EVENT_TYPE.DATANODE_OPENSEARCH_UPGRADE.INDEX_ARCHIVE_AND_DELETE_CONFIRMED, getPendingArchiveTracking: (index, response) => ({ indexName: index.index_name, - systemJobId: (response as { systemJobId?: string })?.systemJobId, + systemJobId: (response as { systemJobId: string }).systemJobId, }), isArchiveJobConflict: (errorMessage) => archive?.isArchiveJobConflict(errorMessage) ?? false, }); @@ -147,8 +147,7 @@ export const isIndexArchived = ( indexName: string, pendingStatus: PendingIndexStatus | undefined, archivedIndexNames: ReadonlySet, -): boolean => - pendingStatus?.state !== 'archiving' && (archivedIndexNames.has(indexName) || pendingStatus?.state === 'archived'); +): boolean => pendingStatus?.state !== 'archiving' && archivedIndexNames.has(indexName); export const getAvailableActions = ( index: IncompatibleIndex, diff --git a/graylog2-web-interface/src/components/indices/archive/types.ts b/graylog2-web-interface/src/components/indices/archive/types.ts index 49f666827f76..eff98de804fb 100644 --- a/graylog2-web-interface/src/components/indices/archive/types.ts +++ b/graylog2-web-interface/src/components/indices/archive/types.ts @@ -18,8 +18,8 @@ export type IndexArchiveBinding = { useCanArchive: () => boolean; useArchivedIndexNames: (indexNames: Array, enabled: boolean) => ReadonlySet; - archiveAndDeleteIndex: (indexName: string) => Promise<{ systemJobId?: string }>; - archiveAndDeleteIndices: (indexNames: Array) => Promise<{ systemJobId?: string }>; + archiveAndDeleteIndex: (indexName: string) => Promise<{ systemJobId: string }>; + archiveAndDeleteIndices: (indexNames: Array) => Promise<{ systemJobId: string }>; isArchiveJobConflict: (errorMessage: string) => boolean; archiveSystemJobName: string; }; diff --git a/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts b/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts index b6a065320d64..7d9abd2b78a3 100644 --- a/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts +++ b/graylog2-web-interface/src/components/indices/hooks/useCanArchive.test.ts @@ -26,8 +26,8 @@ const archivePlugin = new PluginManifest( { useCanArchive: () => true, useArchivedIndexNames: () => new Set(), - archiveAndDeleteIndex: () => Promise.resolve({}), - archiveAndDeleteIndices: () => Promise.resolve({}), + archiveAndDeleteIndex: () => Promise.resolve({ systemJobId: 'job-id' }), + archiveAndDeleteIndices: () => Promise.resolve({ systemJobId: 'job-id' }), isArchiveJobConflict: () => false, archiveSystemJobName: 'archive-job', }, diff --git a/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts b/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts index 9874d97f3020..6f96ea57dd6e 100644 --- a/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts +++ b/graylog2-web-interface/src/components/indices/hooks/useIncompatibleIndices.ts @@ -39,7 +39,8 @@ const useIncompatibleIndices = () => { refetch, } = useQuery({ queryKey: ['incompatibleIndices'], - queryFn: () => SystemIndexerIndices.getOutdatedIndices() as Promise>, + queryFn: () => + SystemIndexerIndices.getOutdatedIndices({ requestShouldExtendSession: false }) as Promise>, retry: 2, refetchInterval: (query) => (query.state.status === 'error' ? ERROR_REFETCH_INTERVAL_MS : false), }); From 1f2121ff2179a54800b1ae5277cdb87013999103 Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Fri, 31 Jul 2026 09:10:54 +0200 Subject: [PATCH 6/6] fix copilot review comment --- .../IncompatibleIndicesTable.test.tsx | 8 ---- ...usePendingIncompatibleIndexActions.test.ts | 38 ++++++++++++++++++- .../usePendingIncompatibleIndexActions.ts | 28 +++----------- 3 files changed, 42 insertions(+), 32 deletions(-) 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 06a7de2a652c..ea2ad629c04d 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 @@ -172,14 +172,6 @@ describe('createColumnRenderers', () => { expect(screen.getByText('archived already')).toBeInTheDocument(); }); - it('does not show the "archived already" badge from a transient pending status alone', () => { - const index = makeIndex({ index_name: 'graylog_2' }); - - renderIndexNameCell(index, { pendingIndexStatuses: new Map([['graylog_2', { state: 'archived' }]]) }); - - expect(screen.queryByText('archived already')).not.toBeInTheDocument(); - }); - it('renders the primary type as a single badge in the category column', () => { const cases: Array<[Partial, string]> = [ [{ managed_index: true }, 'Graylog'], diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts index ac56bfb80b82..991f6f3db2f4 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.test.ts @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -import { renderHook } from 'wrappedTestingLibrary/hooks'; +import { renderHook, waitFor } from 'wrappedTestingLibrary/hooks'; import asMock from 'helpers/mocking/AsMock'; import Store from 'logic/local-storage/Store'; @@ -66,4 +66,40 @@ describe('usePendingIncompatibleIndexActions', () => { expect(asMock(Store.set).mock.calls.at(-1)?.[1]).toEqual([action]); expect(useClusterJobs).toHaveBeenCalledWith(expect.objectContaining({ poll: true })); }); + + it('discards a persisted action with the removed archived state', async () => { + asMock(Store.get).mockReturnValue([ + { + action: 'archive-delete', + indexName: 'graylog_0', + startedAt: '2026-01-01T00:00:00.000Z', + systemJobId: 'job-1', + state: 'archived', + }, + ]); + + renderPendingActions(); + + await waitFor(() => expect(asMock(Store.set).mock.calls.at(-1)?.[1]).toEqual([])); + expect(useClusterJobs).toHaveBeenCalledWith(expect.objectContaining({ poll: false })); + }); + + it('clears terminal archive tracking from storage', async () => { + const action = { + action: 'archive-delete', + indexName: 'graylog_0', + startedAt: '2026-01-01T00:00:00.000Z', + systemJobId: 'job-1', + }; + asMock(Store.get).mockReturnValue([action]); + asMock(useClusterJobs).mockReturnValue({ + jobsById: new Map(), + jobsUpdatedAt: Date.parse(action.startedAt) + 1, + refetch: jest.fn(), + }); + + renderPendingActions(); + + await waitFor(() => expect(asMock(Store.set).mock.calls.at(-1)?.[1]).toEqual([])); + }); }); diff --git a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts index 6eff6cf36bf9..6e84fcb4eaa5 100644 --- a/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts +++ b/graylog2-web-interface/src/components/datanode/opensearch-upgrade/hooks/usePendingIncompatibleIndexActions.ts @@ -34,18 +34,15 @@ export type PendingIncompatibleIndexAction = { indexName: string; startedAt: string; systemJobId?: string; - state?: 'archived'; }; export type PendingIndexStatus = | { state: 'archiving'; percent: number } - | { state: 'archived' } | { state: 'reindexing' } | { state: 'failed'; message: string; label: string }; type ActionResolution = | { kind: 'archiving'; percent: number } - | { kind: 'archived' } | { kind: 'reindexing' } | { kind: 'failed'; message: string; label: string } | { kind: 'done' }; @@ -74,7 +71,7 @@ const isValidStoredAction = (value: unknown): value is PendingIncompatibleIndexA typeof candidate.startedAt === 'string' && !Number.isNaN(Date.parse(candidate.startedAt)) && (candidate.systemJobId === undefined || typeof candidate.systemJobId === 'string') && - (candidate.state === undefined || candidate.state === 'archived') + candidate.state === undefined ); }; @@ -100,10 +97,6 @@ const resolveArchiveAction = ( action: PendingIncompatibleIndexAction, { jobsById, jobsUpdatedAt }: ClusterJobsResult, ): ActionResolution => { - if (action.state === 'archived') { - return { kind: 'archived' }; - } - const job = action.systemJobId ? jobsById.get(action.systemJobId) : undefined; if (job?.job_status === 'error') { @@ -111,7 +104,7 @@ const resolveArchiveAction = ( } if (job?.job_status === 'complete') { - return { kind: 'archived' }; + return { kind: 'done' }; } if (job?.job_status === 'cancelled') { @@ -119,7 +112,7 @@ const resolveArchiveAction = ( } if (action.systemJobId && !job && jobsUpdatedAt > Date.parse(action.startedAt)) { - return { kind: 'archived' }; + return { kind: 'done' }; } return { kind: 'archiving', percent: job?.percent_complete ?? 0 }; @@ -149,7 +142,6 @@ const resolveAction = (action: PendingIncompatibleIndexAction, jobs: ClusterJobs const reconcileActions = ( current: Array, - incompatibleIndexNames: Set, jobs: Pick, ): Array => { const next = current.flatMap((pendingAction): Array => { @@ -159,14 +151,6 @@ const reconcileActions = ( return []; } - if (resolution.kind === 'archived') { - if (!incompatibleIndexNames.has(pendingAction.indexName)) { - return []; - } - - return pendingAction.state === 'archived' ? [pendingAction] : [{ ...pendingAction, state: 'archived' }]; - } - return [pendingAction]; }); @@ -188,7 +172,7 @@ const usePendingIncompatibleIndexActions = ({ const incompatibleIndexNames = new Set(incompatibleIndices.map((index) => index.index_name)); const trackedActions = pendingActions.filter((pendingAction) => incompatibleIndexNames.has(pendingAction.indexName)); - const hasActiveActions = pendingActions.some((pendingAction) => pendingAction.state !== 'archived'); + const hasActiveActions = pendingActions.length > 0; const { jobsById, jobsUpdatedAt, @@ -205,8 +189,6 @@ const usePendingIncompatibleIndexActions = ({ if (resolution.kind === 'archiving') { pendingIndexStatuses.set(pendingAction.indexName, { state: 'archiving', percent: resolution.percent }); - } else if (resolution.kind === 'archived') { - pendingIndexStatuses.set(pendingAction.indexName, { state: 'archived' }); } else if (resolution.kind === 'reindexing') { pendingIndexStatuses.set(pendingAction.indexName, { state: 'reindexing' }); } else if (resolution.kind === 'failed') { @@ -231,7 +213,7 @@ const usePendingIncompatibleIndexActions = ({ addPendingAction({ action: 'reindex', indexName, startedAt: new Date().toISOString() }); if (!isLoading && !isError) { - const reconciled = reconcileActions(pendingActions, incompatibleIndexNames, { jobsById, jobsUpdatedAt }); + const reconciled = reconcileActions(pendingActions, { jobsById, jobsUpdatedAt }); if (reconciled !== pendingActions) { setPendingActions(reconciled);