diff --git a/changelogs/fragments/11096.yml b/changelogs/fragments/11096.yml new file mode 100644 index 000000000000..1cc5d45d948c --- /dev/null +++ b/changelogs/fragments/11096.yml @@ -0,0 +1,2 @@ +feat: +- Dataset UI and observability workspace + Trace automation ([#11096](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/11096)) \ No newline at end of file diff --git a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/queries.spec.js b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/queries.spec.js index e6b5a6813cf6..f36da359a5a4 100644 --- a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/queries.spec.js +++ b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/queries.spec.js @@ -72,7 +72,14 @@ const queriesTestSuite = () => { // Query should persist across refresh cy.reload(); - cy.getElementByTestId(`discoverQueryElapsedMs`).should('be.visible'); + cy.osd.waitForLoader(true); + // Wait for dataset to be fully loaded after reload + cy.getElementByTestId('datasetSelectButton', { timeout: 30000 }) + .should('be.visible') + .should('not.be.disabled'); + // Wait for page to fully stabilize and query to execute + cy.osd.waitForLoader(true); + cy.getElementByTestId(`discoverQueryElapsedMs`, { timeout: 30000 }).should('be.visible'); // Verify the state again after reload verifyDiscoverPageState({ @@ -147,7 +154,8 @@ const queriesTestSuite = () => { cy.get('button[role="tab"]').contains('Logs').click(); cy.get('button[role="tab"][aria-selected="true"]').contains('Logs').should('be.visible'); - cy.explore.setTopNavDate(START_TIME, START_TIME); + // Change date range to trigger query re-execution (use valid range) + cy.explore.setTopNavDate('Jan 1, 2020 @ 00:00:00.000', 'Jan 2, 2020 @ 00:00:00.000'); cy.getElementByTestId('exploreQueryExecutionButton').click(); cy.osd.waitForLoader(true); diff --git a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/saved_explore.spec.js b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/saved_explore.spec.js index ed4a8eb9467d..5c142ca5f230 100644 --- a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/saved_explore.spec.js +++ b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/saved_explore.spec.js @@ -36,8 +36,11 @@ const runSavedExploreTests = () => { page: 'explore/logs', isEnhancement: true, }); - // ensure dataset is loaded - cy.wait(10000); + // Wait for dataset to be fully loaded after navigation + cy.getElementByTestId('datasetSelectButton', { timeout: 30000 }) + .should('be.visible') + .should('not.be.disabled'); + cy.getElementByTestId('discoverNewButton', { timeout: 30000 }).should('be.visible'); }); after(() => { @@ -48,7 +51,7 @@ const runSavedExploreTests = () => { }); beforeEach(() => { - cy.getElementByTestId('discoverNewButton').click(); + cy.getElementByTestId('discoverNewButton', { timeout: 30000 }).should('be.visible').click(); cy.osd.waitForLoader(true); }); diff --git a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/simple_dataset_select.spec.js b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/simple_dataset_select.spec.js index 78dfc5841ee4..5feb3c58cfe3 100644 --- a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/simple_dataset_select.spec.js +++ b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/01/simple_dataset_select.spec.js @@ -136,7 +136,7 @@ export const runSimpleDatasetSelectorTests = () => { for (let i = 1; i <= noIndexPatterns; i++) { validateItemsInSimpleDatasetSelectorDropDown( `${INDEX_PATTERN_WITH_TIME.slice(0, i)}`, - noIndexPatterns - i + 1 + noIndexPatterns ); } }); diff --git a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js index f2f371a30afb..4d9f296a7284 100644 --- a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js +++ b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js @@ -73,7 +73,7 @@ const cachingTestSuite = () => { cy.getElementByTestId('datasetSelectSelectable') .should('be.visible') .within(() => { - cy.get(`[title="${alternativeIndexPattern}Index Patterns"]`).should('exist'); + cy.getElementByTestId(`datasetSelectOption-${alternativeIndexPattern}`).should('exist'); }); }); }); diff --git a/cypress/utils/apps/query_enhancements/commands.js b/cypress/utils/apps/query_enhancements/commands.js index 860dd8ed4181..e949f1522e9d 100644 --- a/cypress/utils/apps/query_enhancements/commands.js +++ b/cypress/utils/apps/query_enhancements/commands.js @@ -156,38 +156,31 @@ Cypress.Commands.add( .should('not.be.disabled') .click(); cy.getElementByTestId(`datasetSelectorAdvancedButton`).should('be.visible').click(); - cy.get(`[title="Indexes"]`).click(); - cy.get(`[title="${dataSourceName}"]`).click(); - - // Ensure "Index name" mode is selected (not "Index wildcard") - cy.getElementByTestId('index-scope-selector') - .should('be.visible') - .find('[data-test-subj="comboBoxInput"]') - .click(); - // Select "Index name" if not already selected - cy.get(`[title="Index name"]`).should('be.visible').click({ force: true }); + // Click on "Indexes" dataset type if it's visible + cy.get('body').then(($body) => { + if ($body.find(`[title="Indexes"]`).length > 0) { + cy.get(`[title="Indexes"]`).click(); + } + }); - // Verify selection - cy.getElementByTestId('index-scope-selector') - .find('[data-test-subj="comboBoxInput"]') - .should('contain.text', 'Index name'); + cy.get(`[title="${dataSourceName}"]`).click(); - // Click the search field to open the popover (onFocus triggers isPopoverOpen = true) - cy.getElementByTestId('index-selector-search') + // Use the unified index selector - type to search and click from results + cy.getElementByTestId('unified-index-selector-search') .should('be.visible') - .click({ force: true }) // Use click instead of focus to ensure onFocus event fires + .click({ force: true }) .clear() .type(index); - // Wait for the popover to fully render - cy.getElementByTestId('index-selector-popover', { timeout: 10000 }).should('be.visible'); + // Wait for the dropdown to appear with results + cy.getElementByTestId('unified-index-selector-dropdown').should('be.visible'); - // Now look for the dataset-index-selector within the popover - cy.getElementByTestId('dataset-index-selector', { timeout: 5000 }) + // Click the matching index from the dropdown list + cy.getElementByTestId('unified-index-selector-list') .should('be.visible') .within(() => { - // Look for the index by title attribute in the popover + // Find and click the index by its label in the EuiSelectable cy.get(`[title="${index}"]`).should('be.visible').click({ force: true }); }); cy.getElementByTestId('datasetSelectorNext').should('be.visible').click(); @@ -223,6 +216,10 @@ Cypress.Commands.add( (indexPattern, dataSourceName, datasetEnabled = false) => { const title = datasetEnabled ? indexPattern : `${dataSourceName}::${indexPattern}`; cy.getElementByTestId('datasetSelectorButton').should('be.visible').click(); + + // Wait for dropdown list to appear + cy.get('.euiSelectableList').should('be.visible'); + cy.get(`[title="${title}"]`).should('be.visible').click(); // verify that it has been selected @@ -248,7 +245,13 @@ Cypress.Commands.add( (indexPattern, dataSourceName, language, finalAction = 'submit') => { cy.getElementByTestId('datasetSelectorButton').should('be.visible').click(); cy.getElementByTestId(`datasetSelectorAdvancedButton`).should('be.visible').click(); - cy.get(`[title="Index Patterns"]`).click(); + // Note: If only Index Patterns exist, the type selection will be hidden + // Try to click Index Patterns if it exists, otherwise continue + cy.get('body').then(($body) => { + if ($body.find(`[title="Index Patterns"]`).length > 0) { + cy.get(`[title="Index Patterns"]`).click(); + } + }); cy.getElementByTestId('datasetExplorerWindow') .find(`[title="${dataSourceName}::${indexPattern}"]`) diff --git a/cypress/utils/commands.explore.js b/cypress/utils/commands.explore.js index fe2bb81b3bdb..d5366b981ac3 100644 --- a/cypress/utils/commands.explore.js +++ b/cypress/utils/commands.explore.js @@ -59,28 +59,22 @@ const isEditorEmpty = () => { .then((text) => text.trim() === ''); }; -const selectIndexWildcardMode = (indexPattern, appendWildcard = true) => { - // Select "Index wildcard" from the scope selector - cy.getElementByTestId('index-scope-selector') - .should('be.visible') - .find('[data-test-subj="comboBoxSearchInput"]') - .click() - .clear() - .type('Index wildcard{enter}'); - - // Wait for the selection to take effect by verifying the text changed - cy.getElementByTestId('index-scope-selector') - .find('[data-test-subj="comboBoxInput"]') - .should('contain.text', 'Index wildcard') - .should('be.visible'); +const selectIndexWildcardMode = (indexPattern) => { + // UI now auto-appends wildcard when typing single character, so just use the pattern as-is + const pattern = indexPattern; - // Enter the pattern with optional wildcard appending - const pattern = appendWildcard ? `${indexPattern}*{enter}` : `${indexPattern}{enter}`; - cy.getElementByTestId('dataset-prefix-selector', { timeout: 10000 }) + // Type the pattern into the unified search field + cy.getElementByTestId('unified-index-selector-search') .should('be.visible') - .find('[data-test-subj="multiWildcardPatternInput"]') + .click({ force: true }) .clear() .type(pattern); + + // Click the "Add wildcard" button to add the pattern + cy.getElementByTestId('unified-index-selector-add-button') + .should('be.visible') + .should('not.be.disabled') + .click(); }; cy.explore.add('clearQueryEditor', () => { @@ -291,7 +285,9 @@ cy.explore.add( // The force is necessary as there is occasionally a popover that covers the button cy.getElementByTestId('savedQueryFormSaveButton').click({ force: true }); - cy.getElementByTestId('euiToastHeader').contains('was saved').should('be.visible'); + cy.getElementByTestId('euiToastHeader', { timeout: 30000 }) + .contains('was saved') + .should('be.visible'); } ); @@ -329,7 +325,9 @@ cy.explore.add( // The force is necessary as there is occasionally a popover that covers the button cy.getElementByTestId('savedQueryFormSaveButton').click({ force: true }); - cy.getElementByTestId('euiToastHeader').contains('was saved').should('be.visible'); + cy.getElementByTestId('euiToastHeader', { timeout: 30000 }) + .contains('was saved') + .should('be.visible'); cy.osd.waitForSync(); } ); @@ -337,7 +335,11 @@ cy.explore.add( cy.explore.add('loadSavedQuery', (name) => { cy.getElementByTestId('queryPanelFooterSaveQueryButton').click(); - cy.getElementByTestId('saved-query-management-open-button').click(); + // Wait for the popover to fully render before clicking the open button + cy.getElementByTestId('saved-query-management-open-button') + .should('be.visible') + .should('not.be.disabled') + .click(); cy.getElementByTestId('euiFlyoutCloseButton').parent().contains(name).should('exist').click(); // click button through popover @@ -355,7 +357,11 @@ cy.explore.add('clearSavedQuery', () => { cy.explore.add('deleteSavedQuery', (name) => { cy.getElementByTestId('queryPanelFooterSaveQueryButton').click(); - cy.getElementByTestId('saved-query-management-open-button').click(); + // Wait for the popover to fully render before clicking the open button + cy.getElementByTestId('saved-query-management-open-button') + .should('be.visible') + .should('not.be.disabled') + .click(); cy.getElementByTestId('euiFlyoutCloseButton') .parent() .contains(name) @@ -405,39 +411,24 @@ cy.explore.add( .should('be.visible') .should('not.be.disabled') .click(); - cy.getElementByTestId(`datasetSelectAdvancedButton`).should('be.visible').click(); - cy.get(`[title="Indexes"]`).click(); + cy.getElementByTestId(`datasetSelectorAdvancedButton`).should('be.visible').click(); cy.get(`[title="${dataSourceName}"]`).click(); - // Ensure "Index name" mode is selected (not "Index wildcard") - cy.getElementByTestId('index-scope-selector') - .should('be.visible') - .find('[data-test-subj="comboBoxInput"]') - .click(); - - // Select "Index name" if not already selected - cy.get(`[title="Index name"]`).should('be.visible').click({ force: true }); - - // Verify selection - cy.getElementByTestId('index-scope-selector') - .find('[data-test-subj="comboBoxInput"]') - .should('contain.text', 'Index name'); - - // Click the search field to open the popover (onFocus triggers isPopoverOpen = true) - cy.getElementByTestId('index-selector-search') + // Use the unified index selector - type to search and click from results + cy.getElementByTestId('unified-index-selector-search') .should('be.visible') - .click({ force: true }) // Use click instead of focus to ensure onFocus event fires + .click({ force: true }) .clear() .type(index); - // Wait for the popover to fully render - cy.getElementByTestId('index-selector-popover', { timeout: 10000 }).should('be.visible'); + // Wait for the dropdown to appear with results + cy.getElementByTestId('unified-index-selector-dropdown').should('be.visible'); - // Now look for the dataset-index-selector within the popover - cy.getElementByTestId('dataset-index-selector', { timeout: 5000 }) + // Click the matching index from the dropdown list + cy.getElementByTestId('unified-index-selector-list') .should('be.visible') .within(() => { - // Look for the index by title attribute in the popover + // Find and click the index by its label in the EuiSelectable cy.get(`[title="${index}"]`).should('be.visible').click({ force: true }); }); cy.getElementByTestId('datasetSelectorNext').should('be.visible').click(); @@ -505,18 +496,16 @@ cy.explore.add( .click(); // Step 3 - Click advanced selector button - cy.getElementByTestId(`datasetSelectAdvancedButton`).should('be.visible').click(); + cy.getElementByTestId(`datasetSelectorAdvancedButton`).should('be.visible').click(); - // Step 4 - Select Indexes - cy.get(`[title="Indexes"]`).should('be.visible'); - cy.get(`[title="Indexes"]`).click(); + // Step 4 - Indexes panel is now hidden when it's the only option, skip to data source selection // Step 5 - Select data source cy.get(`[title="${dataSourceName}"]`).should('be.visible'); cy.get(`[title="${dataSourceName}"]`).click(); // Step 6 & 7 - Select index scope (Index wildcard) and enter pattern - selectIndexWildcardMode(indexPattern, true); + selectIndexWildcardMode(indexPattern); // Step 8 - Click Next button cy.getElementByTestId('datasetSelectorNext').should('be.visible').click(); @@ -669,16 +658,14 @@ cy.explore.add( } } - // Step 4 - Select Indexes - cy.get(`[title="Indexes"]`).should('be.visible'); - cy.get(`[title="Indexes"]`).click(); + // Step 4 - Indexes panel is now hidden when it's the only option, skip to data source selection // Step 5 - Select data source cy.get(`[title="${dataSource}"]`).should('be.visible'); cy.get(`[title="${dataSource}"]`).click(); - // Step 6 & 7 - Select index scope (Index wildcard) and enter pattern (no wildcard appending) - selectIndexWildcardMode(indexPattern, false); + // Step 6 & 7 - Select index scope (Index wildcard) and enter pattern + selectIndexWildcardMode(indexPattern); // Step 8 - Click Next button cy.getElementByTestId('datasetSelectorNext') @@ -755,7 +742,7 @@ cy.explore.add( .click(); // Step 14 - Wait for dataset creation request and save ID - cy.wait('@createDatasetInterception', { timeout: 15000 }).then((interception) => { + cy.wait('@createDatasetInterception').then((interception) => { // Save the created index pattern ID as an alias cy.wrap(interception.response.body.id).as('INDEX_PATTERN_ID'); }); diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.scss b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.scss index 0123c00fb2c1..61ed2e0c1434 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.scss +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.scss @@ -4,6 +4,7 @@ */ @import "@elastic/eui/src/global_styling/variables/size"; +@import "@elastic/eui/src/global_styling/variables/colors"; .indexDataStructureCreator { padding: $euiSize; @@ -18,4 +19,109 @@ &__badgeContainer { display: flex; } + + &__selectedList { + height: 40vh; + display: flex; + flex-direction: column; + } + + &__emptyState { + height: 40vh; + display: flex; + align-items: center; + justify-content: center; + } + + &__selectedTable { + max-height: 400px; + overflow-y: auto; + } + + &__tableHeader { + padding: $euiSizeS $euiSize; + border-bottom: 1px solid $euiBorderColor; + background-color: $euiColorLightestShade; + position: sticky; + top: 0; + z-index: 1; + } + + &__tableRow { + padding: $euiSizeXS $euiSize; + + &:not(:last-child) { + border-bottom: 1px solid $euiBorderColor; + } + } + + &__columnStatus, + &__columnDocuments, + &__columnSize { + min-width: 100px; + } + + &__columnActions { + min-width: 60px; + } + + &__wildcardPopover { + width: 500px; + max-height: 500px; + display: flex; + flex-direction: column; + } + + &__wildcardPopoverHeader { + padding: $euiSizeS $euiSizeM; + border-bottom: 1px solid $euiBorderColor; + background-color: $euiColorEmptyShade; + flex-shrink: 0; + } + + &__wildcardPopoverContent { + flex: 1; + overflow-y: auto; + min-height: 0; + } + + &__wildcardPopoverTableHeader { + padding: $euiSizeS $euiSizeM; + border-bottom: 1px solid $euiBorderColor; + background-color: $euiColorLightestShade; + position: sticky; + top: 0; + z-index: 2; + } + + &__wildcardPopoverTableBody { + // Just a container for rows + } + + &__wildcardPopoverRow { + padding: $euiSizeXS $euiSizeM; + border-bottom: 1px solid $euiColorLightestShade; + } + + &__wildcardPopoverColumn { + min-width: 80px; + } + + &__wildcardPopoverFooter { + padding: $euiSizeS $euiSizeM; + border-top: 1px solid $euiBorderColor; + background-color: $euiColorEmptyShade; + flex-shrink: 0; + } + + &__healthPopover { + width: 200px; + padding: $euiSizeM; + } + + &__healthPopoverLoading { + width: 250px; + padding: $euiSizeM; + text-align: center; + } } diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.test.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.test.tsx index f767c2cd537b..a74909731718 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.test.tsx +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.test.tsx @@ -16,93 +16,74 @@ jest.mock('./use_index_fetcher', () => ({ }), })); -// Mock the child components -jest.mock('./mode_selection_row', () => ({ - ModeSelectionRow: ({ - onModeChange, - onWildcardPatternsChange, - onCurrentWildcardPatternChange, - onMultiIndexSelectionChange, - selectionMode, - wildcardPatterns, - selectedIndexIds, - }: any) => ( +// Mock the UnifiedIndexSelector component +jest.mock('./unified_index_selector', () => ({ + UnifiedIndexSelector: ({ selectedItems, onSelectionChange }: any) => (
-
{selectionMode}
+
Unified Index Selector
+
{selectedItems.length}
- - - - - -
{selectedIndexIds.join(',')}
-
{wildcardPatterns.join(',')}
-
- ), -})); - -jest.mock('./matching_indices_list', () => ({ - MatchingIndicesList: ({ matchingIndices, customPrefix, isLoading }: any) => ( -
-
{customPrefix}
-
{isLoading ? 'loading' : 'not-loading'}
- {matchingIndices.map((index: string) => ( -
- {index} -
- ))} +
+ {selectedItems.map((item: any) => ( +
+ {item.title} ({item.isWildcard ? 'wildcard' : 'single'}) +
+ ))} +
), })); const mockSelectDataStructure = jest.fn(); +const mockHttp = { + get: jest.fn().mockResolvedValue([ + { + health: 'green', + status: 'open', + index: 'test-index', + 'docs.count': '1000', + 'store.size': '1mb', + }, + ]), +}; const defaultProps = { path: [ { id: 'test', title: 'Test', - type: 'INDEX' as const, - children: [ - { id: 'index1', title: 'logs-2024', type: 'INDEX' as const }, - { id: 'index2', title: 'metrics-2024', type: 'INDEX' as const }, - ], + type: 'DATA_SOURCE' as const, }, ], index: 0, selectDataStructure: mockSelectDataStructure, - setPath: jest.fn(), - fetchDataStructure: jest.fn(), + services: { + http: mockHttp, + } as any, }; const renderComponent = (props = {}) => @@ -115,13 +96,7 @@ const renderComponent = (props = {}) => describe('IndexDataStructureCreator', () => { beforeEach(() => { jest.clearAllMocks(); - jest.useFakeTimers(); - mockFetchIndices.mockResolvedValue(['result1', 'result2']); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); + mockFetchIndices.mockResolvedValue(['index1', 'index2', 'logs-2024']); }); describe('Basic Rendering', () => { @@ -130,442 +105,244 @@ describe('IndexDataStructureCreator', () => { expect(container.querySelector('.indexDataStructureCreator')).toBeInTheDocument(); }); - it('renders mode selection row', () => { - const { getByText } = renderComponent(); - expect(getByText('Switch to Prefix Mode')).toBeInTheDocument(); + it('renders unified index selector', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId('unified-selector')).toBeInTheDocument(); }); - it('starts in single mode by default', () => { + it('starts with no selected items', () => { const { getByTestId } = renderComponent(); - expect(getByTestId('current-mode')).toHaveTextContent('single'); + expect(getByTestId('selected-count')).toHaveTextContent('0'); }); }); - describe('Mode Switching', () => { - it('switches to prefix mode and sets currentWildcardPattern to *', async () => { + describe('Single Index Selection', () => { + it('handles single index selection', async () => { const { getByTestId } = renderComponent(); - fireEvent.click(getByTestId('mode-change-to-prefix')); + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(getByTestId('current-mode')).toHaveTextContent('prefix'); + expect(getByTestId('selected-count')).toHaveTextContent('1'); + expect(getByTestId('selected-item-index1')).toHaveTextContent('index1 (single)'); }); - // Should trigger debounced fetch with '*' - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockFetchIndices).toHaveBeenCalledWith({ patterns: ['*'] }); - }); + expect(mockSelectDataStructure).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test::index1', + title: 'index1', + type: 'INDEX', + meta: expect.objectContaining({ + type: DATA_STRUCTURE_META_TYPES.CUSTOM, + isMultiIndex: true, + selectedIndices: ['test::index1'], + selectedTitles: ['index1'], + }), + }), + expect.any(Array) + ); }); - it('switches back to single mode and clears patterns', async () => { + it('handles multiple single index selections', async () => { const { getByTestId } = renderComponent(); - // Switch to prefix mode first - fireEvent.click(getByTestId('mode-change-to-prefix')); - await waitFor(() => { - expect(getByTestId('current-mode')).toHaveTextContent('prefix'); - }); - - // Switch back to single mode - fireEvent.click(getByTestId('mode-change-to-single')); + fireEvent.click(getByTestId('add-single-index')); + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(getByTestId('current-mode')).toHaveTextContent('single'); + expect(getByTestId('selected-count')).toHaveTextContent('2'); }); - - // MatchingIndicesList should not be rendered in single mode - expect(() => getByTestId('matching-list')).toThrow(); }); - it('clears wildcardPatterns and matchingIndices when switching modes', async () => { + it('clears selection', async () => { const { getByTestId } = renderComponent(); - // Switch to prefix and add patterns - fireEvent.click(getByTestId('mode-change-to-prefix')); - fireEvent.click(getByTestId('add-pattern')); - + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(getByTestId('wildcard-patterns')).toHaveTextContent('logs-*'); + expect(getByTestId('selected-count')).toHaveTextContent('1'); }); - // Switch back to single - fireEvent.click(getByTestId('mode-change-to-single')); + fireEvent.click(getByTestId('clear-selection')); await waitFor(() => { - expect(getByTestId('wildcard-patterns')).toHaveTextContent(''); + expect(getByTestId('selected-count')).toHaveTextContent('0'); }); + + expect(mockSelectDataStructure).toHaveBeenCalledWith(undefined, expect.any(Array)); }); }); - describe('Multi-Index Selection (Single Mode)', () => { - it('creates data structure when indices are selected', async () => { + describe('Wildcard Pattern Selection', () => { + it('handles wildcard pattern selection', async () => { const { getByTestId } = renderComponent(); - fireEvent.click(getByTestId('select-indices')); + fireEvent.click(getByTestId('add-wildcard')); await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalledWith( - { - id: 'local::logs-2024,metrics-2024', - title: 'logs-2024,metrics-2024', - type: 'INDEX', - meta: { - type: DATA_STRUCTURE_META_TYPES.CUSTOM, - isMultiIndex: true, - selectedIndices: ['index1', 'index2'], - selectedTitles: ['logs-2024', 'metrics-2024'], - }, - }, - [defaultProps.path[0]] - ); - }); - }); - - it('clears data structure when all indices are deselected', async () => { - const { getByTestId } = renderComponent(); - - // First select indices - fireEvent.click(getByTestId('select-indices')); - await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalled(); + expect(getByTestId('selected-count')).toHaveTextContent('1'); + expect(getByTestId('selected-item-logs-*')).toHaveTextContent('logs-* (wildcard)'); }); - mockSelectDataStructure.mockClear(); - - // Then clear selection - fireEvent.click(getByTestId('clear-indices')); - - await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalledWith(undefined, [defaultProps.path[0]]); - }); - }); - - it('uses data source ID from path when available', async () => { - const propsWithDataSource = { - ...defaultProps, - path: [ - { - id: 'my-datasource', - title: 'My DataSource', - type: 'DATA_SOURCE' as const, - }, - ...defaultProps.path, - ], - index: 1, - }; - - const { getByTestId } = renderComponent(propsWithDataSource); - - fireEvent.click(getByTestId('select-indices')); - - await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'my-datasource::logs-2024,metrics-2024', + expect(mockSelectDataStructure).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test::logs-*', + title: 'logs-*', + type: 'INDEX', + meta: expect.objectContaining({ + type: DATA_STRUCTURE_META_TYPES.CUSTOM, + isMultiWildcard: true, + wildcardPatterns: ['logs-*'], }), - expect.anything() - ); - }); - }); - - it('displays selected index badges', async () => { - const { getByTestId, getByText } = renderComponent(); - - fireEvent.click(getByTestId('select-indices')); - - await waitFor(() => { - expect(getByText('logs-2024')).toBeInTheDocument(); - expect(getByText('metrics-2024')).toBeInTheDocument(); - }); - }); - - it('removes individual index when badge cross is clicked', async () => { - const { getByTestId, getByLabelText } = renderComponent(); - - fireEvent.click(getByTestId('select-indices')); - - await waitFor(() => { - const removeButton = getByLabelText('Remove index logs-2024'); - expect(removeButton).toBeInTheDocument(); - }); - - mockSelectDataStructure.mockClear(); - - const removeButton = getByLabelText('Remove index logs-2024'); - fireEvent.click(removeButton); - - await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalled(); - const callArgs = mockSelectDataStructure.mock.calls[0]; - expect(callArgs[0].meta.selectedIndices).toEqual(['index2']); - expect(callArgs[0].meta.selectedTitles).toEqual(['metrics-2024']); - }); + }), + expect.any(Array) + ); }); }); - describe('Wildcard Pattern Selection (Prefix Mode)', () => { - it('creates data structure when wildcard patterns are added', async () => { + describe('Mixed Selection (Single + Wildcard)', () => { + it('handles mixed selection of single indices and wildcards', async () => { + const mockOnSelectionChange = jest.fn(); const { getByTestId } = renderComponent(); - // Switch to prefix mode - fireEvent.click(getByTestId('mode-change-to-prefix')); - jest.advanceTimersByTime(300); - await waitFor(() => { - expect(mockFetchIndices).toHaveBeenCalledWith({ patterns: ['*'] }); - }); - - mockSelectDataStructure.mockClear(); - - // Add pattern - fireEvent.click(getByTestId('add-pattern')); + // Add single index first + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalledWith( - { - id: 'local::logs-*', - title: 'logs-*', - type: 'INDEX', - meta: { - type: DATA_STRUCTURE_META_TYPES.CUSTOM, - isMultiWildcard: true, - wildcardPatterns: ['logs-*'], - matchingIndices: ['result1', 'result2'], - }, - }, - [defaultProps.path[0]] - ); + expect(getByTestId('selected-count')).toHaveTextContent('1'); }); - }); - - it('clears data structure when all patterns are removed', async () => { - const { getByTestId } = renderComponent(); - // Switch to prefix mode and add pattern - fireEvent.click(getByTestId('mode-change-to-prefix')); - fireEvent.click(getByTestId('add-pattern')); + // Add wildcard + fireEvent.click(getByTestId('add-wildcard')); await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalled(); + expect(getByTestId('selected-count')).toHaveTextContent('2'); }); - mockSelectDataStructure.mockClear(); - - // Clear patterns - fireEvent.click(getByTestId('clear-patterns')); - - await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalledWith(undefined, [defaultProps.path[0]]); - }); - }); - - it('displays wildcard pattern badges', async () => { - const { getByTestId, getAllByText } = renderComponent(); - - fireEvent.click(getByTestId('mode-change-to-prefix')); - fireEvent.click(getByTestId('add-pattern')); - - await waitFor(() => { - const badges = getAllByText('logs-*'); - // Should have at least one badge (in the actual EuiBadge component) - expect(badges.length).toBeGreaterThanOrEqual(1); - }); - }); - - it('removes individual pattern when badge cross is clicked', async () => { - const { getByTestId, getByLabelText } = renderComponent(); - - fireEvent.click(getByTestId('mode-change-to-prefix')); - fireEvent.click(getByTestId('add-pattern')); - - await waitFor(() => { - const removeButton = getByLabelText('Remove pattern logs-*'); - expect(removeButton).toBeInTheDocument(); - }); - - mockSelectDataStructure.mockClear(); - - const removeButton = getByLabelText('Remove pattern logs-*'); - fireEvent.click(removeButton); - - await waitFor(() => { - expect(mockSelectDataStructure).toHaveBeenCalledWith(undefined, [defaultProps.path[0]]); - }); + expect(mockSelectDataStructure).toHaveBeenLastCalledWith( + expect.objectContaining({ + title: 'index1,logs-*', + meta: expect.objectContaining({ + type: DATA_STRUCTURE_META_TYPES.CUSTOM, + isMultiWildcard: true, + wildcardPatterns: ['logs-*'], + selectedIndices: ['test::index1'], + selectedTitles: ['index1'], + }), + }), + expect.any(Array) + ); }); }); - describe('Debounced API Calls', () => { - it('debounces fetchMatchingIndices calls with 300ms delay', async () => { + describe('Health Data Fetching', () => { + it('fetches health data when exact index is selected', async () => { const { getByTestId } = renderComponent(); - fireEvent.click(getByTestId('mode-change-to-prefix')); - - // Should not call immediately - expect(mockFetchIndices).not.toHaveBeenCalled(); - - // Advance timer by 300ms - jest.advanceTimersByTime(300); + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(mockFetchIndices).toHaveBeenCalledWith({ patterns: ['*'] }); + expect(mockHttp.get).toHaveBeenCalledWith( + '/api/directquery/dsl/cat.indices/dataSourceMDSId=test', + expect.objectContaining({ + query: expect.objectContaining({ + format: 'json', + index: 'index1', + }), + }) + ); }); }); - it('only triggers one API call for multiple rapid changes', async () => { + it('handles health data fetch errors gracefully', async () => { + mockHttp.get.mockRejectedValueOnce(new Error('Network error')); const { getByTestId } = renderComponent(); - fireEvent.click(getByTestId('mode-change-to-prefix')); - - // Make multiple rapid changes - fireEvent.click(getByTestId('set-current-pattern')); - jest.advanceTimersByTime(100); - fireEvent.click(getByTestId('set-current-pattern')); - jest.advanceTimersByTime(100); - fireEvent.click(getByTestId('set-current-pattern')); - - // Only advance by remaining time - jest.advanceTimersByTime(100); - - await waitFor(() => { - // Should only be called once (not three times) - expect(mockFetchIndices).toHaveBeenCalledTimes(1); - }); - }); - - it('clears matchingIndices when switching away from prefix mode', async () => { - const { getByTestId, queryByTestId } = renderComponent(); - - // Switch to prefix mode - fireEvent.click(getByTestId('mode-change-to-prefix')); - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(getByTestId('matching-list')).toBeInTheDocument(); - }); - - // Switch back to single mode - fireEvent.click(getByTestId('mode-change-to-single')); + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(queryByTestId('matching-list')).not.toBeInTheDocument(); + expect(mockHttp.get).toHaveBeenCalled(); }); - }); - - it('shows all indices (*) when no patterns are entered', async () => { - const { getByTestId } = renderComponent(); - - fireEvent.click(getByTestId('mode-change-to-prefix')); - jest.advanceTimersByTime(300); - await waitFor(() => { - expect(mockFetchIndices).toHaveBeenCalledWith({ patterns: ['*'] }); - }); + // Component should still work after error + expect(getByTestId('selected-count')).toHaveTextContent('1'); }); - it('combines added patterns with current pattern for fetch', async () => { + it('includes dataSourceId in URL path when present', async () => { const { getByTestId } = renderComponent(); - fireEvent.click(getByTestId('mode-change-to-prefix')); - fireEvent.click(getByTestId('add-pattern')); // adds 'logs-*' - fireEvent.click(getByTestId('set-current-pattern')); // sets 'test-*' - - mockFetchIndices.mockClear(); - jest.advanceTimersByTime(300); + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(mockFetchIndices).toHaveBeenCalledWith({ patterns: ['logs-*', 'test-*'] }); + expect(mockHttp.get).toHaveBeenCalledWith( + '/api/directquery/dsl/cat.indices/dataSourceMDSId=test', + expect.objectContaining({ + query: expect.objectContaining({ + format: 'json', + index: 'index1', + }), + }) + ); }); }); }); - describe('MatchingIndicesList Integration', () => { - it('only renders MatchingIndicesList in prefix mode', async () => { - const { getByTestId, queryByTestId } = renderComponent(); - - // Should not be visible in single mode - expect(queryByTestId('matching-list')).not.toBeInTheDocument(); - - // Switch to prefix mode - fireEvent.click(getByTestId('mode-change-to-prefix')); - - await waitFor(() => { - expect(getByTestId('matching-list')).toBeInTheDocument(); - }); + describe('Empty State', () => { + it('shows empty state message when no items selected', () => { + const { getByText } = renderComponent(); + expect(getByText(/No indices or patterns selected yet/i)).toBeInTheDocument(); }); - it('passes matchingIndices to MatchingIndicesList', async () => { - const { getByTestId } = renderComponent(); + it('hides empty state when items are selected', async () => { + const { getByTestId, queryByText } = renderComponent(); - fireEvent.click(getByTestId('mode-change-to-prefix')); - jest.advanceTimersByTime(300); + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(getByTestId('matching-index-result1')).toBeInTheDocument(); - expect(getByTestId('matching-index-result2')).toBeInTheDocument(); + expect(queryByText(/No indices or patterns selected yet/i)).not.toBeInTheDocument(); }); }); + }); - it('passes currentWildcardPattern to MatchingIndicesList', async () => { - const { getByTestId } = renderComponent(); - - fireEvent.click(getByTestId('mode-change-to-prefix')); - - await waitFor(() => { - expect(getByTestId('matching-prefix')).toHaveTextContent('*'); + describe('Data Source Handling', () => { + it('uses local as default when no data source in path', async () => { + const { getByTestId } = renderComponent({ + path: [], }); - }); - - it('passes loading state to MatchingIndicesList', async () => { - const { getByTestId } = renderComponent(); - fireEvent.click(getByTestId('mode-change-to-prefix')); + fireEvent.click(getByTestId('add-single-index')); - // Should show loading before timer completes - expect(getByTestId('matching-loading')).toHaveTextContent('not-loading'); - - jest.advanceTimersByTime(300); - - // Wait for loading state to update await waitFor(() => { - expect(mockFetchIndices).toHaveBeenCalled(); + expect(mockSelectDataStructure).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'local::index1', + }), + expect.any(Array) + ); }); }); - }); - - describe('Error Handling', () => { - it('handles empty pattern gracefully', async () => { - mockFetchIndices.mockResolvedValue([]); + it('uses data source id from path when present', async () => { const { getByTestId } = renderComponent(); - fireEvent.click(getByTestId('mode-change-to-prefix')); - jest.advanceTimersByTime(300); + fireEvent.click(getByTestId('add-single-index')); await waitFor(() => { - expect(mockFetchIndices).toHaveBeenCalled(); + expect(mockSelectDataStructure).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test::index1', + }), + expect.any(Array) + ); }); - - // Should not crash and matchingIndices should be empty - const matchingList = getByTestId('matching-list'); - expect(matchingList).toBeInTheDocument(); }); + }); - it('handles fetchIndices errors gracefully', async () => { - // When fetch fails, it returns empty array (handled in the hook) - // This test verifies the component handles that gracefully - mockFetchIndices.mockResolvedValue([]); - - const { getByTestId } = renderComponent(); - - fireEvent.click(getByTestId('mode-change-to-prefix')); - jest.advanceTimersByTime(300); - - // Component should still render with empty results - await waitFor(() => { - expect(getByTestId('matching-list')).toBeInTheDocument(); - expect(mockFetchIndices).toHaveBeenCalled(); + describe('Services Prop', () => { + it('renders without services prop', () => { + const { container } = renderComponent({ + services: undefined, }); + expect(container.querySelector('.indexDataStructureCreator')).toBeInTheDocument(); }); }); }); diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.tsx index 3d975988c3cd..1aaeb0ef5bcb 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.tsx +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_data_structure_creator.tsx @@ -3,8 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { EuiSpacer, EuiText, EuiBadge, EuiBadgeGroup } from '@elastic/eui'; +import React, { useState, useEffect, useCallback } from 'react'; +import { + EuiSpacer, + EuiText, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiLoadingSpinner, + EuiButtonEmpty, + EuiPopover, + EuiToolTip, + EuiTablePagination, +} from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; import { @@ -13,290 +25,704 @@ import { DATA_STRUCTURE_META_TYPES, } from '../../../../../../common'; import { IDataPluginServices } from '../../../../../types'; -import { ModeSelectionRow } from './mode_selection_row'; -import { MatchingIndicesList } from './matching_indices_list'; +import { UnifiedIndexSelector } from './unified_index_selector'; import { useIndexFetcher } from './use_index_fetcher'; import './index_data_structure_creator.scss'; -type SelectionMode = 'single' | 'prefix'; - interface IndexDataStructureCreatorProps extends DataStructureCreatorProps { services?: IDataPluginServices; } +interface SelectedItem { + id: string; + title: string; + isWildcard: boolean; +} + +interface IndexInfo { + health: string; + status: string; + index: string; + 'docs.count': string; + 'store.size': string; +} + export const IndexDataStructureCreator: React.FC = ({ path, index, selectDataStructure, services, }) => { - const current = path[index]; + const [selectedItems, setSelectedItems] = useState([]); + const [openPopoverIndex, setOpenPopoverIndex] = useState(null); + const [indexInfoCache, setIndexInfoCache] = useState>({}); + const [loadingInfoForIndexSet, setLoadingInfoForIndexSet] = useState>(new Set()); + const [matchingIndicesCache, setMatchingIndicesCache] = useState>({}); + const [loadingMatchingIndicesSet, setLoadingMatchingIndicesSet] = useState>( + new Set() + ); - const [selectionMode, setSelectionMode] = useState('single'); - const [selectedIndexIds, setSelectedIndexIds] = useState([]); - const [wildcardPatterns, setWildcardPatterns] = useState([]); - const [currentWildcardPattern, setCurrentWildcardPattern] = useState(''); - const [matchingIndices, setMatchingIndices] = useState([]); - const [isLoadingMatches, setIsLoadingMatches] = useState(false); - const debounceTimerRef = useRef(null); + // Pagination state for wildcard popover + const [wildcardPopoverPage, setWildcardPopoverPage] = useState< + Record + >({}); // Use shared hook for fetching indices const { fetchIndices } = useIndexFetcher({ services, path }); - // Fetch indices matching a wildcard pattern using shared hook + // Batch fetch index info for one or more indices + const fetchBatchIndexInfo = useCallback( + async (indexNames: string[]) => { + if (!services?.http || indexNames.length === 0) return; + + // Mark all indices as loading + setLoadingInfoForIndexSet((prev) => { + const next = new Set(prev); + indexNames.forEach((name) => next.add(name)); + return next; + }); + + try { + const dataSourceId = path?.find((item) => item.type === 'DATA_SOURCE')?.id; + + const response = await services.http.get( + `/api/directquery/dsl/cat.indices/dataSourceMDSId=${dataSourceId || ''}`, + { + query: { + format: 'json', + index: indexNames.join(','), + }, + } + ); + + if (response && Array.isArray(response)) { + // Cache all results + const newCache: Record = {}; + response.forEach((infoData: IndexInfo) => { + newCache[infoData.index] = infoData; + }); + + // Mark any missing indices as null (not found) + indexNames.forEach((indexName) => { + if (!newCache[indexName]) { + newCache[indexName] = null; + } + }); + + setIndexInfoCache((prev) => ({ ...prev, ...newCache })); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching batch index info:', error); + // Mark all as null on error + const errorCache: Record = {}; + indexNames.forEach((indexName) => { + errorCache[indexName] = null; + }); + setIndexInfoCache((prev) => ({ ...prev, ...errorCache })); + } finally { + // Clear loading state for all indices + setLoadingInfoForIndexSet((prev) => { + const next = new Set(prev); + indexNames.forEach((name) => next.delete(name)); + return next; + }); + } + }, + [services, path] + ); + + // Fetch matching indices for wildcard pattern const fetchMatchingIndices = useCallback( async (pattern: string) => { - if (!pattern || pattern.trim() === '') { - setMatchingIndices([]); - return; + if (!pattern || !pattern.includes('*')) { + return []; } - setIsLoadingMatches(true); - + setLoadingMatchingIndicesSet((prev) => new Set(prev).add(pattern)); try { - // Check if pattern contains commas (multiple patterns) - const patterns = pattern - .split(',') - .map((p) => p.trim()) - .filter((p) => p); - - // Fetch indices using shared hook - const results = await fetchIndices({ patterns }); - setMatchingIndices(results); + const allIndices = await fetchIndices({ + patterns: [pattern], + limit: undefined, + }); + + // Cache the result + setMatchingIndicesCache((prev) => ({ ...prev, [pattern]: allIndices })); + + return allIndices; + } catch (error) { + setMatchingIndicesCache((prev) => ({ ...prev, [pattern]: [] })); + return []; } finally { - setIsLoadingMatches(false); + setLoadingMatchingIndicesSet((prev) => { + const next = new Set(prev); + next.delete(pattern); + return next; + }); } }, [fetchIndices] ); - // Debounced handler for wildcard pattern changes + // Auto-fetch health/matching data for selected items useEffect(() => { - if (selectionMode !== 'prefix') { - setMatchingIndices([]); - return; - } - - // Combine added patterns with current pattern - const allPatterns = [...wildcardPatterns]; - if (currentWildcardPattern && currentWildcardPattern.trim()) { - allPatterns.push(currentWildcardPattern); - } - - // If no patterns, show all indices (*) to help user see what's available - const patternToFetch = allPatterns.length === 0 ? '*' : allPatterns.join(', '); - - // Clear existing timer - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - // Set new timer - debounceTimerRef.current = setTimeout(() => { - fetchMatchingIndices(patternToFetch); - }, 300); - - // Cleanup - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, [currentWildcardPattern, wildcardPatterns, selectionMode, fetchMatchingIndices]); - - const handleCurrentWildcardPatternChange = useCallback((pattern: string) => { - setCurrentWildcardPattern(pattern); - }, []); - - // matchingIndices now comes from API calls via state - - const handleModeChange = (selectedOptions: Array<{ label: string; value?: string }>) => { - if (selectedOptions.length > 0 && selectedOptions[0].value) { - const newMode = selectedOptions[0].value as SelectionMode; - setSelectionMode(newMode); - - if (newMode === 'prefix') { - // Show all indices initially by querying for * - setCurrentWildcardPattern('*'); + selectedItems.forEach((item) => { + if (item.isWildcard) { + // Fetch matching indices for wildcards + if (!matchingIndicesCache[item.title] && !loadingMatchingIndicesSet.has(item.title)) { + fetchMatchingIndices(item.title); + } } else { - setCurrentWildcardPattern(''); + // Fetch info for exact indices + if (indexInfoCache[item.title] === undefined && !loadingInfoForIndexSet.has(item.title)) { + fetchBatchIndexInfo([item.title]); + } + } + }); + }, [ + selectedItems, + matchingIndicesCache, + indexInfoCache, + loadingMatchingIndicesSet, + loadingInfoForIndexSet, + fetchMatchingIndices, + fetchBatchIndexInfo, + ]); + + // Auto-fetch health data for visible indices in wildcard popover + useEffect(() => { + if (openPopoverIndex !== null) { + const item = selectedItems[openPopoverIndex]; + if (item?.isWildcard) { + const matchingIndices = matchingIndicesCache[item.title] || []; + const pagination = wildcardPopoverPage[item.title] || { pageIndex: 0, pageSize: 10 }; + const startIndex = pagination.pageIndex * pagination.pageSize; + const endIndex = Math.min(startIndex + pagination.pageSize, matchingIndices.length); + const visibleIndices = matchingIndices.slice(startIndex, endIndex); + + // Filter out already cached indices before batch fetching + const uncachedIndices = visibleIndices.filter((name) => indexInfoCache[name] === undefined); + if (uncachedIndices.length > 0) { + fetchBatchIndexInfo(uncachedIndices); + } } - - setWildcardPatterns([]); - setMatchingIndices([]); } - }; - - const handleMultiIndexSelectionChange = (selectedIds: string[]) => { - setSelectedIndexIds(selectedIds); + }, [ + openPopoverIndex, + selectedItems, + matchingIndicesCache, + wildcardPopoverPage, + indexInfoCache, + fetchBatchIndexInfo, + ]); + + // Handle selection changes from unified selector + const handleSelectionChange = useCallback( + (items: SelectedItem[]) => { + setSelectedItems(items); + + if (items.length === 0) { + // Clear selection when no items selected + selectDataStructure(undefined, path.slice(0, index + 1)); + return; + } - if (selectedIds.length > 0) { - // Create a combined data structure for multiple indices + // Create a combined data structure const dataSourceId = path.find((item) => item.type === 'DATA_SOURCE')?.id || 'local'; - const selectedTitles = selectedIds - .map((id) => current.children?.find((child) => child.id === id)?.title) - .filter(Boolean); + const titles = items.map((item) => item.title); + const hasWildcard = items.some((item) => item.isWildcard); + const exactIndices = items.filter((item) => !item.isWildcard); + const wildcardItems = items.filter((item) => item.isWildcard); const combinedDataStructure: DataStructure = { - id: `${dataSourceId}::${selectedTitles.join(',')}`, - title: selectedTitles.join(','), + id: `${dataSourceId}::${titles.join(',')}`, + title: titles.join(','), type: 'INDEX', meta: { type: DATA_STRUCTURE_META_TYPES.CUSTOM, - isMultiIndex: true, - selectedIndices: selectedIds, - selectedTitles, + ...(hasWildcard + ? { + isMultiWildcard: true, + wildcardPatterns: wildcardItems.map((item) => item.title), + // Include exact indices if there are any + ...(exactIndices.length > 0 && { + selectedIndices: exactIndices.map((item) => item.id), + selectedTitles: exactIndices.map((item) => item.title), + }), + } + : { + isMultiIndex: true, + selectedIndices: items.map((item) => item.id), + selectedTitles: titles, + }), }, }; selectDataStructure(combinedDataStructure, path.slice(0, index + 1)); + }, + [path, index, selectDataStructure] + ); + + const handleRemoveItem = (indexToRemove: number) => { + const newItems = selectedItems.filter((_, itemIndex) => itemIndex !== indexToRemove); + handleSelectionChange(newItems); + + // Close popover if the removed item had it open + if (openPopoverIndex === indexToRemove) { + setOpenPopoverIndex(null); + } else if (openPopoverIndex !== null && openPopoverIndex > indexToRemove) { + setOpenPopoverIndex(openPopoverIndex - 1); + } + }; + + // Handle info icon click + const handleInfoIconClick = async (itemIndex: number, item: SelectedItem) => { + if (openPopoverIndex === itemIndex) { + setOpenPopoverIndex(null); } else { - // Clear selection when no indices selected - selectDataStructure(undefined, path.slice(0, index + 1)); + setOpenPopoverIndex(itemIndex); + if (item.isWildcard) { + // Fetch matching indices for wildcard if not cached + if (!matchingIndicesCache[item.title]) { + await fetchMatchingIndices(item.title); + } + } else { + // Fetch info data for exact index if not cached + if (indexInfoCache[item.title] === undefined) { + await fetchBatchIndexInfo([item.title]); + } + } } }; - const handleWildcardPatternsChange = (patterns: string[]) => { - setWildcardPatterns(patterns); + // Render health popover content + const renderHealthPopover = (item: SelectedItem, itemIndex: number) => { + if (item.isWildcard) { + const matchingIndices = matchingIndicesCache[item.title] || []; + const isLoading = loadingMatchingIndicesSet.has(item.title); - if (patterns.length > 0) { - // Create a combined wildcard data structure - const dataSourceId = path.find((item) => item.type === 'DATA_SOURCE')?.id || 'local'; - const combinedPattern = patterns.join(','); + if (isLoading) { + return ( +
+ +
+ ); + } - // Use API-fetched matchingIndices instead of deriving from current.children - // matchingIndices is populated by fetchMatchingIndices via debounced API calls - const wildcardDataStructure: DataStructure = { - id: `${dataSourceId}::${combinedPattern}`, - title: combinedPattern, - type: 'INDEX', - meta: { - type: DATA_STRUCTURE_META_TYPES.CUSTOM, - isMultiWildcard: true, - wildcardPatterns: patterns, - matchingIndices, // Use API results, not client-side filtering - }, - }; + if (matchingIndices.length === 0) { + return ( +
+ + + +
+ ); + } - selectDataStructure(wildcardDataStructure, path.slice(0, index + 1)); - } else { - // Clear selection when no patterns - selectDataStructure(undefined, path.slice(0, index + 1)); + // Get or initialize pagination for this wildcard + const pagination = wildcardPopoverPage[item.title] || { pageIndex: 0, pageSize: 10 }; + const { pageIndex, pageSize } = pagination; + + // Calculate pagination + const pageCount = Math.ceil(matchingIndices.length / pageSize); + const startIndex = pageIndex * pageSize; + const endIndex = Math.min(startIndex + pageSize, matchingIndices.length); + const visibleIndices = matchingIndices.slice(startIndex, endIndex); + + return ( +
+
+ + + + + +
+ +
+ {/* Table header */} +
+ + + + Name + + + + + Documents + + + + + Size + + + +
+ + {/* Table rows */} +
+ {visibleIndices.map((indexName) => { + const indexInfo = indexInfoCache[indexName]; + const isLoadingIndexInfo = loadingInfoForIndexSet.has(indexName); + + return ( +
+ + + {indexName} + + + {isLoadingIndexInfo ? ( + + ) : indexInfo?.['docs.count'] ? ( + {indexInfo['docs.count']} + ) : ( + + — + + )} + + + {isLoadingIndexInfo ? ( + + ) : indexInfo?.['store.size'] ? ( + {indexInfo['store.size']} + ) : ( + + — + + )} + + +
+ ); + })} +
+
+ + {/* EUI-styled pagination */} +
+ { + setWildcardPopoverPage((prev) => ({ + ...prev, + [item.title]: { ...pagination, pageIndex: newPageIndex }, + })); + }} + itemsPerPage={pageSize} + onChangeItemsPerPage={(newPageSize) => { + setWildcardPopoverPage((prev) => ({ + ...prev, + [item.title]: { pageIndex: 0, pageSize: newPageSize }, + })); + }} + itemsPerPageOptions={[5, 10, 20]} + /> +
+
+ ); + } + + const infoData = indexInfoCache[item.title]; + const isLoading = loadingInfoForIndexSet.has(item.title); + + if (isLoading) { + return ( +
+ +
+ ); } + + if (!infoData) { + return ( +
+ + + +
+ ); + } + + return ( +
+ + + + Health: + + + + + {infoData.health} + + + + + Status: {infoData.status} + + + Documents: {infoData['docs.count'] || '0'} + + + Size: {infoData['store.size'] || 'N/A'} + +
+ ); }; return (
- - - - - - - - {selectionMode === 'single' && selectedIndexIds.length > 0 && ( - <> - - - {i18n.translate('data.datasetService.indexSelector.selectedIndicesLabel', { - defaultMessage: 'Selected Indices:', - })} - - - -
- {selectedIndexIds.map((indexId) => { - const indexTitle = - current.children?.find((child) => child.id === indexId)?.title || indexId; + {/* Selected items - full width */} + + + {i18n.translate('data.datasetService.unifiedSelector.selectedItemsLabel', { + defaultMessage: 'Selected:', + })} + + + +
+ {selectedItems.length === 0 ? ( + + + + + + ) : ( + + {/* Table header */} +
+ + + + + {i18n.translate('data.datasetService.unifiedSelector.nameHeader', { + defaultMessage: 'Name', + })} + + + + + + + {i18n.translate('data.datasetService.unifiedSelector.statusHeader', { + defaultMessage: 'Status', + })} + + + + + + + {i18n.translate('data.datasetService.unifiedSelector.documentsHeader', { + defaultMessage: 'Documents', + })} + + + + + + + {i18n.translate('data.datasetService.unifiedSelector.sizeHeader', { + defaultMessage: 'Size', + })} + + + + + + + {i18n.translate('data.datasetService.unifiedSelector.actionsHeader', { + defaultMessage: 'Actions', + })} + + + + +
+ {/* Table rows */} + {selectedItems.map((item, itemIndex) => { + const infoData = indexInfoCache[item.title]; + const matchingIndices = matchingIndicesCache[item.title]; + const isLoadingInfo = loadingInfoForIndexSet.has(item.title); + const isLoadingMatches = loadingMatchingIndicesSet.has(item.title); + return ( -
- { - const newSelectedIds = selectedIndexIds.filter((id) => id !== indexId); - handleMultiIndexSelectionChange(newSelectedIds); - }} - iconOnClickAriaLabel={i18n.translate( - 'data.datasetService.indexSelector.removeIndex', - { - defaultMessage: 'Remove index {indexTitle}', - values: { indexTitle }, - } - )} - > - {indexTitle} - +
+ + {/* Name Column */} + + {item.title} + + + {/* Status Column */} + + {isLoadingInfo || isLoadingMatches ? ( + + ) : item.isWildcard ? ( + matchingIndices && matchingIndices.length > 0 ? ( + + handleInfoIconClick(itemIndex, item)} + className="indexDataStructureCreator__wildcardButton" + > + + {matchingIndices.length}{' '} + {matchingIndices.length === 1 ? 'index' : 'indices'} + + + + } + isOpen={openPopoverIndex === itemIndex} + closePopover={() => setOpenPopoverIndex(null)} + anchorPosition="rightCenter" + > + {renderHealthPopover(item, itemIndex)} + + ) : ( + + — + + ) + ) : infoData ? ( + + {infoData.health} + + ) : ( + + — + + )} + + + {/* Documents Column */} + + {item.isWildcard ? ( + + — + + ) : infoData?.['docs.count'] ? ( + {infoData['docs.count']} + ) : ( + + — + + )} + + + {/* Size Column */} + + {item.isWildcard ? ( + + — + + ) : infoData?.['store.size'] ? ( + {infoData['store.size']} + ) : ( + + — + + )} + + + {/* Actions Column */} + + handleRemoveItem(itemIndex)} + aria-label={i18n.translate( + 'data.datasetService.unifiedSelector.removeItem', + { + defaultMessage: 'Remove {item}', + values: { item: item.title }, + } + )} + /> + +
); })} -
- - - )} - - {selectionMode === 'prefix' && wildcardPatterns.length > 0 && ( - <> - - - {i18n.translate('data.datasetService.multiWildcard.patternsLabel', { - defaultMessage: 'Wildcard Patterns:', - })} - - - - - {wildcardPatterns.map((pattern) => ( - { - const newPatterns = wildcardPatterns.filter((p) => p !== pattern); - // handleWildcardPatternsChange already calls setWildcardPatterns - handleWildcardPatternsChange(newPatterns); - }} - iconOnClickAriaLabel={i18n.translate( - 'data.datasetService.multiWildcard.removePattern', - { - defaultMessage: 'Remove pattern {pattern}', - values: { pattern }, - } - )} - > - {pattern} - - ))} - - - - )} - - {selectionMode === 'prefix' && ( - - )} +
+ )} +
); }; diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.scss b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.scss deleted file mode 100644 index b9ab29db9786..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.scss +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -@import "@elastic/eui/src/global_styling/variables/size"; - -.indexSelector { - position: relative; - - &__popover { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 1000; - background-color: $euiColorEmptyShade; - border: $euiBorderThin; - border-radius: $euiBorderRadius; - box-shadow: none; - margin-top: $euiSizeXS; - } -} - -.indexSelectorOption { - width: 100%; -} diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.test.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.test.tsx deleted file mode 100644 index 74067b8563fa..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.test.tsx +++ /dev/null @@ -1,339 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render, fireEvent, screen, waitFor } from '@testing-library/react'; -import { I18nProvider } from '@osd/i18n/react'; -import { IndexSelector } from './index_selector'; - -const mockHttpGet = jest.fn(); -const mockOnMultiSelectionChange = jest.fn(); - -const mockServices = { - http: { - get: mockHttpGet, - }, -}; - -const mockPath = [{ id: 'test-datasource', type: 'DATA_SOURCE', title: 'Test DataSource' }]; - -const defaultProps = { - selectedIndexIds: [], - onMultiSelectionChange: mockOnMultiSelectionChange, - services: mockServices as any, - path: mockPath as any, -}; - -const mockApiResponse = { - indices: [{ name: 'logs-2024' }, { name: 'metrics-2024' }, { name: 'otel-logs' }], - aliases: [{ name: 'logs-alias' }], - data_streams: [{ name: 'logs-stream' }], -}; - -const renderComponent = (props = {}) => - render( - - - - ); - -describe('IndexSelector', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - mockHttpGet.mockResolvedValue(mockApiResponse); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test('renders search field', () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - expect(searchField).toBeInTheDocument(); - }); - - test('loads initial results on mount', async () => { - renderComponent(); - - // Fast-forward timers to trigger initial load - jest.runAllTimers(); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*', - expect.objectContaining({ - query: expect.objectContaining({ - expand_wildcards: 'all', - }), - }) - ); - }); - }); - - test('opens popover on search field focus', () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - - expect(screen.queryByTestId('dataset-index-selector')).toBeInTheDocument(); - }); - - test('searches for indices when user types', async () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'otel' } }); - - // Fast-forward past debounce delay - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*otel*', - expect.objectContaining({ - query: expect.objectContaining({ - expand_wildcards: 'all', - }), - }) - ); - }); - }); - - test('debounces search input', async () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - // Initial load should fire on mount - jest.runAllTimers(); - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*', - expect.any(Object) - ); - }); - - // Clear mock and type rapidly - mockHttpGet.mockClear(); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'o' } }); - fireEvent.change(searchField, { target: { value: 'ot' } }); - fireEvent.change(searchField, { target: { value: 'ote' } }); - - // Should not call API immediately - expect(mockHttpGet).not.toHaveBeenCalled(); - - // Fast-forward past debounce delay - jest.advanceTimersByTime(300); - - await waitFor(() => { - // Should only call once with final value - expect(mockHttpGet).toHaveBeenCalledTimes(1); - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*ote*', - expect.any(Object) - ); - }); - }); - - test('includes data source in query when available', async () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'logs' } }); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*logs*', - expect.objectContaining({ - query: expect.objectContaining({ - expand_wildcards: 'all', - data_source: 'test-datasource', - }), - }) - ); - }); - }); - - test('handles API errors gracefully', async () => { - mockHttpGet.mockRejectedValueOnce(new Error('API Error')); - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'test' } }); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalled(); - }); - - // Should not throw error, just show empty results - expect(screen.getByTestId('dataset-index-selector')).toBeInTheDocument(); - }); - - test('fetches indices and makes them available for selection', async () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'logs' } }); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*logs*', - expect.any(Object) - ); - }); - - // Verify the EuiSelectable is present with the selector - await waitFor(() => { - expect(screen.getByTestId('dataset-index-selector')).toBeInTheDocument(); - }); - }); - - test('resets to initial results when search is cleared', async () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices') as HTMLInputElement; - - // First, do a search - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'logs' } }); - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - expect.stringContaining('*logs*'), - expect.any(Object) - ); - }); - - // Clear the search - fireEvent.change(searchField, { target: { value: '' } }); - jest.advanceTimersByTime(300); - - await waitFor(() => { - // Should query for * again (initial results) - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*', - expect.any(Object) - ); - }); - }); - - test('closes popover when clicking outside', () => { - const { container } = renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - // Open popover - fireEvent.focus(searchField); - expect(screen.queryByTestId('dataset-index-selector')).toBeInTheDocument(); - - // Click outside - fireEvent.mouseDown(document.body); - - // Popover should close - expect(screen.queryByTestId('dataset-index-selector')).not.toBeInTheDocument(); - }); - - test('displays limited results message when showing first 100 of many indices', async () => { - // Mock a response with more than 100 indices - const manyIndices = Array.from({ length: 150 }, (_, i) => ({ name: `index-${i}` })); - mockHttpGet.mockResolvedValue({ indices: manyIndices, aliases: [], data_streams: [] }); - - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - - // Initial load happens automatically - jest.runAllTimers(); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalled(); - }); - - // Should show limited results message - await waitFor(() => { - expect(screen.getByText(/Showing first 100 of 150 indices/)).toBeInTheDocument(); - }); - }); - - test('handles wildcard patterns in search', async () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'logs*' } }); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - // Should use the pattern as-is (not wrap with *) - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/logs*', - expect.any(Object) - ); - }); - }); - - test('combines indices, aliases, and data streams in results', async () => { - renderComponent(); - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'test' } }); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*test*', - expect.any(Object) - ); - }); - - // Should have EuiSelectable with all types combined (3 indices + 1 alias + 1 data_stream) - await waitFor(() => { - expect(screen.getByTestId('dataset-index-selector')).toBeInTheDocument(); - }); - }); - - test('preserves existing selections when searching', async () => { - const existingSelections = ['test-datasource::existing-index']; - renderComponent({ selectedIndexIds: existingSelections }); - - const searchField = screen.getByPlaceholderText('Search indices'); - - fireEvent.focus(searchField); - fireEvent.change(searchField, { target: { value: 'logs' } }); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockHttpGet).toHaveBeenCalledWith( - '/internal/index-pattern-management/resolve_index/*logs*', - expect.any(Object) - ); - }); - - // Verify component loaded with existing selections - // The actual selection preservation logic is tested through the onChange handler - // which filters selections to keep those not in displayOptions - await waitFor(() => { - expect(screen.getByTestId('dataset-index-selector')).toBeInTheDocument(); - }); - }); -}); diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.tsx deleted file mode 100644 index 5a125dd25e2a..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/index_selector.tsx +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { - EuiSelectable, - EuiSelectableOption, - EuiFlexGroup, - EuiFlexItem, - EuiFieldText, -} from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import { DataStructure } from '../../../../../../common'; -import { IDataPluginServices } from '../../../../../types'; -import { useIndexFetcher } from './use_index_fetcher'; -import { MAX_INITIAL_RESULTS } from './constants'; -import './index_selector.scss'; - -interface IndexSelectorProps { - selectedIndexIds: string[]; - onMultiSelectionChange: (selectedIds: string[]) => void; - services?: IDataPluginServices; - path?: DataStructure[]; -} - -export const IndexSelector: React.FC = ({ - selectedIndexIds, - onMultiSelectionChange, - services, - path, -}) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [searchValue, setSearchValue] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [totalCount, setTotalCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const containerRef = useRef(null); - const debounceTimerRef = useRef(null); - const hasLoadedInitial = useRef(false); - - // Use shared hook for fetching indices - const { fetchIndices: fetchIndicesFromHook } = useIndexFetcher({ services, path }); - - // Fetch indices matching search using shared hook - const fetchIndices = useCallback( - async (search: string, limit?: number) => { - if (!search || search.trim() === '') { - setSearchResults([]); - setTotalCount(0); - return; - } - - setIsLoading(true); - - try { - // Use wildcard pattern for search - const searchPattern = search.includes('*') ? search : `*${search}*`; - - // Fetch indices using shared hook - const allIndices = await fetchIndicesFromHook({ - patterns: [searchPattern], - limit: undefined, // Don't limit in hook, we need total count - }); - - // Set total count - setTotalCount(allIndices.length); - - // Apply limit if specified - if (limit && allIndices.length > limit) { - setSearchResults(allIndices.slice(0, limit)); - } else { - setSearchResults(allIndices); - } - } catch (error) { - // Error handling is done in the shared hook - setSearchResults([]); - setTotalCount(0); - } finally { - setIsLoading(false); - } - }, - [fetchIndicesFromHook] - ); - - // Load initial results on mount - useEffect(() => { - if (!hasLoadedInitial.current && services?.http) { - hasLoadedInitial.current = true; - fetchIndices('*', MAX_INITIAL_RESULTS); - } - }, [services, fetchIndices]); - - // Debounced search - useEffect(() => { - if (!searchValue || searchValue.trim() === '') { - // Reset to initial results when search is cleared - if (hasLoadedInitial.current) { - fetchIndices('*', MAX_INITIAL_RESULTS); - } - return; - } - - // Clear existing timer - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - // Set new timer - no limit for user searches - debounceTimerRef.current = setTimeout(() => { - fetchIndices(searchValue); - }, 300); - - // Cleanup - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, [searchValue, fetchIndices]); - - // Create options from search results - const displayOptions: EuiSelectableOption[] = searchResults.map((indexName) => { - const dataSourceId = path?.find((item) => item.type === 'DATA_SOURCE')?.id || 'local'; - const indexId = `${dataSourceId}::${indexName}`; - - return { - label: indexName, - key: indexId, - checked: selectedIndexIds.includes(indexId) ? 'on' : undefined, - }; - }); - - const onChange = (newOptions: EuiSelectableOption[]) => { - // Only update selections from the visible options, but preserve existing selections - const visibleSelectedIds = newOptions - .filter((option) => option.checked === 'on') - .map((option) => option.key!) - .filter(Boolean); - - // Keep existing selections that aren't in the current search results - const existingHiddenSelections = selectedIndexIds.filter( - (id) => !displayOptions.some((option) => option.key === id) - ); - - const finalSelectedIds = [...existingHiddenSelections, ...visibleSelectedIds]; - onMultiSelectionChange(finalSelectedIds); - }; - - const renderOption = (option: EuiSelectableOption) => { - return ( - - - {option.label} - - - ); - }; - - // Close popover when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsPopoverOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - return ( -
- {/* Always visible search field */} - setSearchValue(e.target.value)} - onFocus={() => setIsPopoverOpen(true)} - fullWidth - /> - - {/* Popover that appears over content */} - {isPopoverOpen && ( -
- {/* Show count message when results are limited */} - {!searchValue && totalCount > MAX_INITIAL_RESULTS && ( -
- {i18n.translate('data.datasetService.indexSelector.limitedResultsMessage', { - defaultMessage: - 'Showing first {displayed} of {total} indices. Type to search for more.', - values: { displayed: searchResults.length, total: totalCount }, - })} -
- )} - - {(list) => list} - -
- )} -
- ); -}; diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/matching_indices_list.scss b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/matching_indices_list.scss deleted file mode 100644 index d83bfa7b40b8..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/matching_indices_list.scss +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -.matchingIndicesList { - max-height: 300px; - - &__scrollable { - min-height: 0; - height: 100%; - overflow-y: auto; - } -} diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/matching_indices_list.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/matching_indices_list.tsx deleted file mode 100644 index 8d8a2385eeb5..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/matching_indices_list.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { - EuiTable, - EuiTableBody, - EuiTableRow, - EuiTableRowCell, - EuiText, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiLoadingSpinner, -} from '@elastic/eui'; -import { FormattedMessage } from '@osd/i18n/react'; -import './matching_indices_list.scss'; - -interface MatchingIndicesListProps { - matchingIndices: string[]; - customPrefix: string; - isLoading?: boolean; -} - -export const MatchingIndicesList: React.FC = ({ - matchingIndices, - customPrefix, - isLoading = false, -}) => { - const highlightIndexName = (indexName: string, queryPattern: string): React.ReactNode => { - // Remove wildcards from query for highlighting - const queryWithoutWildcard = queryPattern.replace(/\*/g, ''); - const queryIdx = indexName.indexOf(queryWithoutWildcard); - - if (!queryWithoutWildcard || queryIdx === -1) { - return indexName; - } - - const preStr = indexName.substring(0, queryIdx); - const postStr = indexName.substring(queryIdx + queryWithoutWildcard.length); - - return ( - - {preStr} - {queryWithoutWildcard} - {postStr} - - ); - }; - - // Show loading spinner when fetching indices - if (isLoading) { - return ( - - - - - - - - - - - - ); - } - - // Early return if no matching indices - if (matchingIndices.length === 0) { - return null; - } - - const rows = matchingIndices.map((indexName, key) => ( - - - {highlightIndexName(indexName, customPrefix)} - - - )); - - return ( - - - - - - {customPrefix === '*' ? ( - - ) : ( - - )} - - - - - - - {rows} - - - - ); -}; diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/mode_selection_row.scss b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/mode_selection_row.scss deleted file mode 100644 index ba68cf9c27ea..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/mode_selection_row.scss +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -.modeSelectionRow { - &__selectorColumn { - flex-basis: 20%; - min-width: 170px; - } -} diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/mode_selection_row.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/mode_selection_row.tsx deleted file mode 100644 index 129fb8520887..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/mode_selection_row.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiComboBox } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import { DataStructure } from '../../../../../../common'; -import { IDataPluginServices } from '../../../../../types'; -import './mode_selection_row.scss'; -import { IndexSelector } from './index_selector'; -import { MultiWildcardSelector } from './multi_wildcard_selector'; - -type SelectionMode = 'single' | 'prefix'; - -interface ModeSelectionRowProps { - selectionMode: SelectionMode; - onModeChange: (selectedOptions: Array<{ label: string; value?: string }>) => void; - // Props for multi-wildcard mode - wildcardPatterns: string[]; - onWildcardPatternsChange: (patterns: string[]) => void; - onCurrentWildcardPatternChange?: (pattern: string) => void; - // Props for index mode - selectedIndexIds: string[]; - onMultiIndexSelectionChange: (selectedIds: string[]) => void; - // Shared props - services?: IDataPluginServices; - path?: DataStructure[]; -} - -export const ModeSelectionRow: React.FC = ({ - selectionMode, - onModeChange, - wildcardPatterns, - onWildcardPatternsChange, - onCurrentWildcardPatternChange, - selectedIndexIds, - onMultiIndexSelectionChange, - services, - path, -}) => { - const modeOptions = [ - { - label: i18n.translate('data.datasetService.modeSelectionRow.indexNameOption', { - defaultMessage: 'Index name', - }), - value: 'single', - }, - { - label: i18n.translate('data.datasetService.modeSelectionRow.indexWildcardOption', { - defaultMessage: 'Index wildcard', - }), - value: 'prefix', - }, - ]; - - const selectedModeOption = modeOptions.find((option) => option.value === selectionMode); - - return ( - - - - - - - - - {selectionMode === 'prefix' ? ( - - ) : ( - - )} - - - - ); -}; diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/multi_wildcard_selector.test.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/multi_wildcard_selector.test.tsx deleted file mode 100644 index e9707f7a467b..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/multi_wildcard_selector.test.tsx +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; -import { I18nProvider } from '@osd/i18n/react'; -import { MultiWildcardSelector } from './multi_wildcard_selector'; - -const mockOnPatternsChange = jest.fn(); - -const defaultProps = { - patterns: [], - onPatternsChange: mockOnPatternsChange, -}; - -const renderComponent = (props = {}) => - render( - - - - ); - -describe('MultiWildcardSelector', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('renders with empty patterns', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - expect(input).toBeInTheDocument(); - expect(input).toHaveValue(''); - expect(addButton).toBeInTheDocument(); - expect(addButton).toBeDisabled(); - }); - - test('renders with existing patterns', () => { - renderComponent({ patterns: ['logs*', 'otel*'] }); - const input = screen.getByTestId('multiWildcardPatternInput'); - - expect(input).toBeInTheDocument(); - expect(input).toHaveValue(''); - }); - - test('enables Add button when input has value', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'test*' } }); - - expect(addButton).not.toBeDisabled(); - }); - - test('auto-appends wildcard for single character input', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput') as HTMLInputElement; - - fireEvent.change(input, { target: { value: 'o' } }); - - expect(input.value).toBe('o*'); - }); - - test('adds pattern when Add button is clicked', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'logs*' } }); - fireEvent.click(addButton); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['logs*']); - }); - - test('adds pattern when Enter key is pressed', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'otel*' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['otel*']); - }); - - test('prevents adding duplicate patterns', () => { - renderComponent({ patterns: ['logs*'] }); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'logs*' } }); - - expect(addButton).toBeDisabled(); - }); - - test('prevents adding duplicate patterns with Add button click', () => { - renderComponent({ patterns: ['logs*'] }); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'logs*' } }); - fireEvent.click(addButton); - - // Should not call onPatternsChange since it's a duplicate - expect(mockOnPatternsChange).not.toHaveBeenCalled(); - }); - - test('adds multiple unique patterns', () => { - renderComponent({ patterns: ['logs*'] }); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'otel*' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['logs*', 'otel*']); - }); - - test('clears input after adding pattern', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput') as HTMLInputElement; - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'metrics*' } }); - fireEvent.click(addButton); - - expect(input.value).toBe(''); - }); - - test('trims whitespace from patterns', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: ' spaces* ' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['spaces*']); - }); - - test('ignores non-Enter key presses', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'test*' } }); - fireEvent.keyDown(input, { key: 'Tab' }); - - expect(mockOnPatternsChange).not.toHaveBeenCalled(); - }); - - test('ignores Enter key press with empty input', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(mockOnPatternsChange).not.toHaveBeenCalled(); - }); - - test('handles clearing auto-appended wildcard', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput') as HTMLInputElement; - - // Type single character to trigger auto-wildcard - fireEvent.change(input, { target: { value: 'o' } }); - expect(input.value).toBe('o*'); - - // Clear to just wildcard should clear everything - fireEvent.change(input, { target: { value: '*' } }); - expect(input.value).toBe(''); - }); - - test('handles complex pattern input', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'application-logs-2024*' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['application-logs-2024*']); - }); - - describe('Comma-separated patterns', () => { - test('splits comma-separated patterns into multiple chips', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'otel*,logs*' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['otel*', 'logs*']); - }); - - test('handles comma-separated patterns with spaces', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'otel*, logs*, metrics*' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['otel*', 'logs*', 'metrics*']); - }); - - test('filters out duplicate patterns when splitting comma-separated input', () => { - renderComponent({ patterns: ['logs*'] }); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'otel*,logs*,metrics*' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - // Should only add otel* and metrics*, filtering out duplicate logs* - expect(mockOnPatternsChange).toHaveBeenCalledWith(['logs*', 'otel*', 'metrics*']); - }); - - test('handles comma-separated patterns with Add button click', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'app*,web*' } }); - fireEvent.click(addButton); - - expect(mockOnPatternsChange).toHaveBeenCalledWith(['app*', 'web*']); - }); - - test('ignores empty patterns in comma-separated input', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - - fireEvent.change(input, { target: { value: 'otel*,,logs*,' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - // Should ignore empty patterns, and trailing comma gets converted to wildcard - expect(mockOnPatternsChange).toHaveBeenCalledWith(['otel*', 'logs*', '*']); - }); - - test('disables Add button when all comma-separated patterns are duplicates', () => { - renderComponent({ patterns: ['otel*', 'logs*'] }); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'otel*,logs*' } }); - - expect(addButton).toBeDisabled(); - }); - - test('enables Add button when at least one comma-separated pattern is new', () => { - renderComponent({ patterns: ['logs*'] }); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - fireEvent.change(input, { target: { value: 'logs*,otel*' } }); - - expect(addButton).not.toBeDisabled(); - }); - - test('clears input after adding comma-separated patterns', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput') as HTMLInputElement; - - fireEvent.change(input, { target: { value: 'test1*,test2*' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(input.value).toBe(''); - }); - }); - - describe('Illegal characters validation', () => { - test('allows wildcard * and comma , as special characters', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - // Wildcard should be allowed - fireEvent.change(input, { target: { value: 'logs*' } }); - expect(addButton).not.toBeDisabled(); - - // Comma should be allowed (for separating patterns) - fireEvent.change(input, { target: { value: 'logs*,otel*' } }); - expect(addButton).not.toBeDisabled(); - }); - - test('rejects newly added illegal characters (colon, plus, hash)', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - // Colon should be rejected - fireEvent.change(input, { target: { value: 'logs:test' } }); - expect(addButton).toBeDisabled(); - - // Plus should be rejected - fireEvent.change(input, { target: { value: 'logs+test' } }); - expect(addButton).toBeDisabled(); - - // Hash should be rejected - fireEvent.change(input, { target: { value: 'logs#test' } }); - expect(addButton).toBeDisabled(); - }); - - test('rejects existing illegal characters', () => { - renderComponent(); - const input = screen.getByTestId('multiWildcardPatternInput'); - const addButton = screen.getByTestId('multiWildcardAddButton'); - - // Backslash - fireEvent.change(input, { target: { value: 'logs\\test' } }); - expect(addButton).toBeDisabled(); - - // Forward slash - fireEvent.change(input, { target: { value: 'logs/test' } }); - expect(addButton).toBeDisabled(); - - // Question mark - fireEvent.change(input, { target: { value: 'logs?test' } }); - expect(addButton).toBeDisabled(); - }); - }); -}); diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/multi_wildcard_selector.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/multi_wildcard_selector.tsx deleted file mode 100644 index 48e7fd24f7ce..000000000000 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/multi_wildcard_selector.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState } from 'react'; -import { EuiFieldText, EuiButton, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import { canAppendWildcard } from './index_data_structure_creator_utils'; - -// Note: * and , are NOT in this list because they are special characters -// * is used for wildcard matching, , is used to separate multiple patterns -const ILLEGAL_CHARACTERS_VISIBLE = ['\\', '/', '?', '"', '<', '>', '|', ':', '+', '#']; - -interface MultiWildcardSelectorProps { - patterns: string[]; - onPatternsChange: (patterns: string[]) => void; - onCurrentPatternChange?: (pattern: string) => void; -} - -export const MultiWildcardSelector: React.FC = ({ - patterns, - onPatternsChange, - onCurrentPatternChange, -}) => { - const [currentPattern, setCurrentPattern] = useState(''); - const [appendedWildcard, setAppendedWildcard] = useState(false); - const [previousValue, setPreviousValue] = useState(''); - const [validationErrors, setValidationErrors] = useState([]); - - // Validate pattern for illegal characters - const validatePattern = (inputPattern: string): string[] => { - // If the input contains commas, validate each pattern separately - if (inputPattern.includes(',')) { - const splitPatterns = inputPattern - .split(',') - .map((p) => p.trim()) - .filter((p) => p); - const allIllegalChars = new Set(); - - splitPatterns.forEach((pattern) => { - const illegalChars = ILLEGAL_CHARACTERS_VISIBLE.filter((char) => pattern.includes(char)); - illegalChars.forEach((char) => allIllegalChars.add(char)); - - // Check for spaces within the trimmed pattern (not leading/trailing) - if (pattern.includes(' ')) { - allIllegalChars.add(' '); - } - }); - - return Array.from(allIllegalChars); - } else { - // For single patterns, check for illegal characters - // Only flag spaces if they're within the pattern (not leading/trailing) - const trimmedPattern = inputPattern.trim(); - const illegalChars = ILLEGAL_CHARACTERS_VISIBLE.filter((char) => - trimmedPattern.includes(char) - ); - - // Check for internal spaces (spaces that remain after trimming) - if (trimmedPattern.includes(' ')) { - illegalChars.push(' '); - } - - return illegalChars; - } - }; - - // Get validation error message - const getValidationErrorMessage = (errors: string[]): string => { - if (errors.length === 0) return ''; - - const characterList = - ILLEGAL_CHARACTERS_VISIBLE.slice(0, ILLEGAL_CHARACTERS_VISIBLE.length - 1).join(', ') + - `, and ${ILLEGAL_CHARACTERS_VISIBLE[ILLEGAL_CHARACTERS_VISIBLE.length - 1]}`; - - return i18n.translate('data.datasetService.multiWildcard.illegalCharactersError', { - defaultMessage: 'Spaces and the characters {characterList} are not allowed.', - values: { characterList }, - }); - }; - - const handlePatternChange = (e: React.ChangeEvent) => { - const { target } = e; - let value = target.value; - const isAddingContent = value.length > previousValue.length; - - // Auto-append wildcard when user types a single alphanumeric character - // Places cursor before the wildcard for continued typing - if (value.length === 1 && canAppendWildcard(value)) { - value += '*'; - setAppendedWildcard(true); - setTimeout(() => target.setSelectionRange(1, 1)); - } else { - if (value === '*' && appendedWildcard) { - value = ''; - setAppendedWildcard(false); - } - } - - // Only apply transformations when user is adding content, not deleting - if (isAddingContent) { - // Transform "text,*" to "text*, *" in real-time as user types - // Look for this pattern at the end of the string, but allow for previous patterns - if (value.match(/([^,\s]+),\*$/)) { - const transformedValue = value.replace(/([^,\s]+),\*$/g, '$1*, *'); - value = transformedValue; - setCurrentPattern(value); - setPreviousValue(value); - // Position cursor between ", " and "*" for continued typing - const cursorPosition = value.length - 1; // Before the trailing asterisk - setTimeout(() => target.setSelectionRange(cursorPosition, cursorPosition)); - // Validate transformed value - setValidationErrors(validatePattern(value)); - // Notify parent of current pattern change - if (onCurrentPatternChange) { - onCurrentPatternChange(value); - } - return; - } - - // Transform "text*," to "text*, *" when user adds comma after existing wildcard - // Look for this pattern anywhere in the string - if (value.match(/([^,\s]+\*),$/)) { - const transformedValue = value.replace(/([^,\s]+\*),$/g, '$1, *'); - value = transformedValue; - setCurrentPattern(value); - setPreviousValue(value); - // Position cursor between ", " and "*" for continued typing - const cursorPosition = value.length - 1; // Before the trailing asterisk - setTimeout(() => target.setSelectionRange(cursorPosition, cursorPosition)); - // Validate transformed value - setValidationErrors(validatePattern(value)); - // Notify parent of current pattern change - if (onCurrentPatternChange) { - onCurrentPatternChange(value); - } - return; - } - } - - setPreviousValue(value); - setCurrentPattern(value); - // Validate current value - setValidationErrors(validatePattern(value)); - - // Notify parent of current pattern change for real-time matching - if (onCurrentPatternChange) { - onCurrentPatternChange(value); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && currentPattern.trim() && !shouldDisableAddButton(currentPattern)) { - addPattern(); - } - }; - - const addPattern = () => { - let processedPattern = currentPattern.trim(); - if (!processedPattern) return; - - // Handle special case: "text,*" should become "text*, *" - // Only apply this transformation if it matches the exact pattern - if (processedPattern.match(/([^,\s]+),\*$/)) { - processedPattern = processedPattern.replace(/([^,\s]+),\*$/g, '$1*, *'); - } - - // Check if the pattern contains commas (Kibana-style multi-pattern input) - if (processedPattern.includes(',')) { - // Split by comma and process each pattern individually - const splitPatterns = processedPattern - .split(',') - .map((pattern) => pattern.trim()) - .filter((pattern) => pattern && !patterns.includes(pattern)); - - if (splitPatterns.length > 0) { - const newPatterns = [...patterns, ...splitPatterns]; - onPatternsChange(newPatterns); - } - } else { - // Single pattern - original behavior - if (!patterns.includes(processedPattern)) { - const newPatterns = [...patterns, processedPattern]; - onPatternsChange(newPatterns); - } - } - - setCurrentPattern(''); - setAppendedWildcard(false); - setValidationErrors([]); - - // Notify parent that pattern was cleared - if (onCurrentPatternChange) { - onCurrentPatternChange(''); - } - }; - - // Helper function to check if should disable the add button - const shouldDisableAddButton = (input: string): boolean => { - const trimmed = input.trim(); - if (!trimmed) return true; - - // Disable if there are validation errors - if (validationErrors.length > 0) return true; - - if (trimmed.includes(',')) { - const splitPatterns = trimmed - .split(',') - .map((p) => p.trim()) - .filter((p) => p); - - // Disable if no valid patterns or ALL patterns are duplicates - return splitPatterns.length === 0 || splitPatterns.every((p) => patterns.includes(p)); - } else { - return patterns.includes(trimmed); - } - }; - - const hasValidationErrors = validationErrors.length > 0; - const errorMessage = getValidationErrorMessage(validationErrors); - - return ( - - - - - - - - {i18n.translate('data.datasetService.multiWildcard.addButton', { - defaultMessage: 'Add', - })} - - - - - ); -}; diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.scss b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.scss new file mode 100644 index 000000000000..5ef7fc3b6bb6 --- /dev/null +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.scss @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.unifiedIndexSelector { + position: relative; +} diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.test.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.test.tsx new file mode 100644 index 000000000000..224bedcfd5bb --- /dev/null +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.test.tsx @@ -0,0 +1,393 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, waitFor, fireEvent, screen } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; +import { UnifiedIndexSelector } from './unified_index_selector'; + +const mockFetchIndices = jest.fn(); +jest.mock('./use_index_fetcher', () => ({ + useIndexFetcher: () => ({ + fetchIndices: mockFetchIndices, + }), +})); + +const defaultProps = { + selectedItems: [], + onSelectionChange: jest.fn(), + services: { + http: {} as any, + } as any, + path: [ + { + id: 'test', + title: 'Test', + type: 'DATA_SOURCE' as const, + }, + ], +}; + +const renderComponent = (props = {}) => + render( + + + + ); + +describe('UnifiedIndexSelector', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockFetchIndices.mockResolvedValue(['index1', 'index2', 'logs-2024', 'metrics-2024']); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('Basic Rendering', () => { + it('renders without errors', () => { + const { container } = renderComponent(); + expect(container.querySelector('.unifiedIndexSelector')).toBeInTheDocument(); + }); + + it('renders search input', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId('unified-index-selector-search')).toBeInTheDocument(); + }); + + it('renders add wildcard button', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId('unified-index-selector-add-button')).toBeInTheDocument(); + }); + + it('add wildcard button is disabled by default', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId('unified-index-selector-add-button')).toBeDisabled(); + }); + + it('renders help text', () => { + renderComponent(); + expect(screen.getByText(/Click indices to add them, or enter wildcards/)).toBeInTheDocument(); + }); + }); + + describe('Search Functionality', () => { + it('loads initial results on mount', async () => { + renderComponent(); + + await waitFor(() => { + expect(mockFetchIndices).toHaveBeenCalledWith({ + patterns: ['*'], + limit: undefined, + }); + }); + }); + + it('searches indices when user types', async () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'log' } }); + + jest.advanceTimersByTime(300); + + await waitFor(() => { + expect(mockFetchIndices).toHaveBeenCalledWith({ + patterns: ['*log*'], + limit: undefined, + }); + }); + }); + + it('debounces search input', async () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + // Wait a moment for initial load + await waitFor(() => { + expect(input).toBeInTheDocument(); + }); + + // Clear mock to start counting from here + mockFetchIndices.mockClear(); + + fireEvent.change(input, { target: { value: 'l' } }); + fireEvent.change(input, { target: { value: 'lo' } }); + fireEvent.change(input, { target: { value: 'log' } }); + + // No calls should have been made yet (debounced) + expect(mockFetchIndices).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(300); + + await waitFor(() => { + expect(mockFetchIndices).toHaveBeenCalledTimes(1); // Only final debounced call + }); + }); + }); + + describe('Wildcard Auto-Append', () => { + it('auto-appends wildcard when typing single alphanumeric character', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'l' } }); + + expect(input.value).toBe('l*'); + }); + + it('removes auto-appended wildcard when backspacing', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'l' } }); + expect(input.value).toBe('l*'); + + fireEvent.change(input, { target: { value: '*' } }); + expect(input.value).toBe(''); + }); + + it('does not auto-append wildcard for special characters', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '*' } }); + expect(input.value).toBe('*'); + }); + }); + + describe('Pattern Validation', () => { + it('shows error for illegal characters', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'log/test' } }); + + expect(screen.getByText(/The characters.*are not allowed/)).toBeInTheDocument(); + }); + + it('shows error for spaces', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'log test' } }); + + expect(screen.getByText(/Spaces are not allowed/)).toBeInTheDocument(); + }); + + it('shows error for spaces and illegal characters', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'log test/' } }); + + expect(screen.getByText(/Spaces and the characters.*are not allowed/)).toBeInTheDocument(); + }); + + it('disables add button when validation errors exist', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'log/test' } }); + + expect(getByTestId('unified-index-selector-add-button')).toBeDisabled(); + }); + }); + + describe('Adding Patterns', () => { + it('enables add button when valid wildcard is entered', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'logs-*' } }); + + expect(getByTestId('unified-index-selector-add-button')).not.toBeDisabled(); + }); + + it('calls onSelectionChange when adding pattern', () => { + const mockOnSelectionChange = jest.fn(); + const { getByTestId } = renderComponent({ onSelectionChange: mockOnSelectionChange }); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'logs-*' } }); + fireEvent.click(getByTestId('unified-index-selector-add-button')); + + expect(mockOnSelectionChange).toHaveBeenCalledWith([ + { + id: 'test::logs-*', + title: 'logs-*', + isWildcard: true, + }, + ]); + }); + + it('clears search value after adding pattern', () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'logs-*' } }); + fireEvent.click(getByTestId('unified-index-selector-add-button')); + + expect(input.value).toBe(''); + }); + + it('adds pattern on Enter key press', () => { + const mockOnSelectionChange = jest.fn(); + const { getByTestId } = renderComponent({ onSelectionChange: mockOnSelectionChange }); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'logs-*' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(mockOnSelectionChange).toHaveBeenCalledWith([ + { + id: 'test::logs-*', + title: 'logs-*', + isWildcard: true, + }, + ]); + }); + + it('does not add duplicate patterns', () => { + const mockOnSelectionChange = jest.fn(); + const { getByTestId } = renderComponent({ + onSelectionChange: mockOnSelectionChange, + selectedItems: [{ id: 'test::logs-*', title: 'logs-*', isWildcard: true }], + }); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'logs-*' } }); + fireEvent.click(getByTestId('unified-index-selector-add-button')); + + expect(mockOnSelectionChange).not.toHaveBeenCalled(); + }); + }); + + describe('Popover Behavior', () => { + it('opens popover when input is focused', async () => { + const { getByTestId, queryByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search'); + + fireEvent.focus(input); + + await waitFor(() => { + expect(queryByTestId('unified-index-selector-dropdown')).toBeInTheDocument(); + }); + }); + + it('opens popover when user starts typing', async () => { + const { getByTestId, queryByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search'); + + fireEvent.change(input, { target: { value: 'log' } }); + + await waitFor(() => { + expect(queryByTestId('unified-index-selector-dropdown')).toBeInTheDocument(); + }); + }); + }); + + describe('Selecting from Dropdown', () => { + it('shows search results in dropdown', async () => { + mockFetchIndices.mockResolvedValue(['index1', 'index2']); + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'index' } }); + jest.advanceTimersByTime(300); + + await waitFor(() => { + expect(getByTestId('unified-index-selector-list')).toBeInTheDocument(); + }); + }); + + it('calls onSelectionChange when selecting from dropdown', async () => { + const mockOnSelectionChange = jest.fn(); + mockFetchIndices.mockResolvedValue(['index1']); + const { getByTestId } = renderComponent({ onSelectionChange: mockOnSelectionChange }); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'index' } }); + jest.advanceTimersByTime(300); + + await waitFor(() => { + expect(getByTestId('unified-index-selector-list')).toBeInTheDocument(); + }); + + // The actual selection interaction would happen within EuiSelectable + // For now we just verify the list is rendered + }); + + it('does not add duplicate indices from dropdown', () => { + const mockOnSelectionChange = jest.fn(); + const { getByTestId } = renderComponent({ + onSelectionChange: mockOnSelectionChange, + selectedItems: [{ id: 'test::index1', title: 'index1', isWildcard: false }], + }); + + // This test verifies the logic exists but actual interaction happens in EuiSelectable + expect(getByTestId('unified-index-selector-search')).toBeInTheDocument(); + }); + }); + + describe('Loading States', () => { + it('shows loading state while fetching indices', async () => { + const { getByTestId } = renderComponent(); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'log' } }); + jest.advanceTimersByTime(300); + + await waitFor(() => { + expect(getByTestId('unified-index-selector-list')).toBeInTheDocument(); + }); + }); + }); + + describe('Data Source Handling', () => { + it('uses local data source when no DATA_SOURCE in path', () => { + const mockOnSelectionChange = jest.fn(); + const { getByTestId } = renderComponent({ + onSelectionChange: mockOnSelectionChange, + path: [], + }); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'logs-*' } }); + fireEvent.click(getByTestId('unified-index-selector-add-button')); + + expect(mockOnSelectionChange).toHaveBeenCalledWith([ + { + id: 'local::logs-*', + title: 'logs-*', + isWildcard: true, + }, + ]); + }); + + it('uses data source id from path when available', () => { + const mockOnSelectionChange = jest.fn(); + const { getByTestId } = renderComponent({ onSelectionChange: mockOnSelectionChange }); + const input = getByTestId('unified-index-selector-search') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'logs-*' } }); + fireEvent.click(getByTestId('unified-index-selector-add-button')); + + expect(mockOnSelectionChange).toHaveBeenCalledWith([ + { + id: 'test::logs-*', + title: 'logs-*', + isWildcard: true, + }, + ]); + }); + }); +}); diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.tsx b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.tsx new file mode 100644 index 000000000000..4eac3a3d84e4 --- /dev/null +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_data_structure_creator/unified_index_selector.tsx @@ -0,0 +1,415 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { + EuiSelectable, + EuiSelectableOption, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiButton, + EuiFormRow, + EuiIcon, + EuiText, + EuiPopover, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { DataStructure } from '../../../../../../common'; +import { IDataPluginServices } from '../../../../../types'; +import { useIndexFetcher } from './use_index_fetcher'; +import { MAX_INITIAL_RESULTS } from './constants'; +import { canAppendWildcard } from './index_data_structure_creator_utils'; +import './unified_index_selector.scss'; + +interface UnifiedIndexSelectorProps { + selectedItems: Array<{ id: string; title: string; isWildcard: boolean }>; + onSelectionChange: (items: Array<{ id: string; title: string; isWildcard: boolean }>) => void; + services?: IDataPluginServices; + path?: DataStructure[]; +} + +// Note: * is NOT in this list because it's used for wildcards +const ILLEGAL_CHARACTERS_VISIBLE = ['\\', '/', '?', '"', '<', '>', '|', ':', '+', '#', ',']; + +export const UnifiedIndexSelector: React.FC = ({ + selectedItems, + onSelectionChange, + services, + path, +}) => { + const [searchValue, setSearchValue] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [appendedWildcard, setAppendedWildcard] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + const hasLoadedInitial = useRef(false); + const shouldRepositionCursor = useRef(false); + + // Use shared hook for fetching indices + const { fetchIndices: fetchIndicesFromHook } = useIndexFetcher({ services, path }); + + // Validate pattern for illegal characters + const validatePattern = (inputPattern: string): string[] => { + const illegalChars = ILLEGAL_CHARACTERS_VISIBLE.filter((char) => inputPattern.includes(char)); + + // Check for spaces + if (inputPattern.includes(' ')) { + illegalChars.push('space'); + } + + return illegalChars; + }; + + // Get validation error message + const getValidationErrorMessage = (errors: string[]): string => { + if (errors.length === 0) return ''; + + const hasSpace = errors.includes('space'); + const otherChars = errors.filter((e) => e !== 'space'); + + let message = ''; + + if (hasSpace && otherChars.length > 0) { + const characterList = otherChars.join(', '); + message = i18n.translate( + 'data.datasetService.unifiedSelector.illegalCharactersErrorWithSpace', + { + defaultMessage: 'Spaces and the characters {characterList} are not allowed.', + values: { characterList }, + } + ); + } else if (hasSpace) { + message = i18n.translate('data.datasetService.unifiedSelector.spacesNotAllowed', { + defaultMessage: 'Spaces are not allowed.', + }); + } else { + const characterList = otherChars.join(', '); + message = i18n.translate('data.datasetService.unifiedSelector.illegalCharactersError', { + defaultMessage: 'The characters {characterList} are not allowed.', + values: { characterList }, + }); + } + + return message; + }; + + // Fetch indices matching search using shared hook + const fetchIndices = useCallback( + async (search: string, limit?: number) => { + if (!search || search.trim() === '') { + setSearchResults([]); + setTotalCount(0); + return; + } + + setIsLoading(true); + + try { + // Use wildcard pattern for search + const searchPattern = search.includes('*') ? search : `*${search}*`; + + // Fetch indices using shared hook + const allIndices = await fetchIndicesFromHook({ + patterns: [searchPattern], + limit: undefined, + }); + + // Set total count + setTotalCount(allIndices.length); + + // Apply limit if specified + if (limit && allIndices.length > limit) { + setSearchResults(allIndices.slice(0, limit)); + } else { + setSearchResults(allIndices); + } + } catch (error) { + setSearchResults([]); + setTotalCount(0); + } finally { + setIsLoading(false); + } + }, + [fetchIndicesFromHook] + ); + + // Load initial results on mount + useEffect(() => { + if (!hasLoadedInitial.current && services?.http) { + hasLoadedInitial.current = true; + fetchIndices('*', MAX_INITIAL_RESULTS); + } + }, [services, fetchIndices]); + + // Debounced search + useEffect(() => { + if (!searchValue || searchValue.trim() === '') { + if (hasLoadedInitial.current) { + fetchIndices('*', MAX_INITIAL_RESULTS); + } + return; + } + + // Clear existing timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Set new timer + debounceTimerRef.current = setTimeout(() => { + fetchIndices(searchValue); + }, 300); + + // Cleanup + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchValue, fetchIndices]); + + // Auto-focus input on first mount to open dropdown + useEffect(() => { + if (inputRef.current) { + // Small delay to ensure component is fully mounted + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, []); + + // Reposition cursor after wildcard is auto-appended + useEffect(() => { + if (shouldRepositionCursor.current && inputRef.current) { + shouldRepositionCursor.current = false; + // Position cursor after the first character (before the wildcard) + inputRef.current.setSelectionRange(1, 1); + } + }, [searchValue]); + + const handleSearchChange = (e: React.ChangeEvent) => { + const { target } = e; + let value = target.value; + + // Auto-append wildcard when user types a single alphanumeric character + if (value.length === 1 && canAppendWildcard(value)) { + value += '*'; + setAppendedWildcard(true); + // Signal that cursor should be repositioned after state update + shouldRepositionCursor.current = true; + } else { + if (value === '*' && appendedWildcard) { + value = ''; + setAppendedWildcard(false); + } + } + + setSearchValue(value); + setValidationErrors(validatePattern(value)); + + // Open popover when typing + if (!isPopoverOpen) { + setIsPopoverOpen(true); + } + }; + + const handleAddPattern = () => { + const trimmed = searchValue.trim(); + if (!trimmed || validationErrors.length > 0) return; + + const dataSourceId = path?.find((item) => item.type === 'DATA_SOURCE')?.id || 'local'; + const itemId = `${dataSourceId}::${trimmed}`; + + // Check if already in list + if (selectedItems.some((item) => item.title === trimmed)) { + return; + } + + const newItem = { + id: itemId, + title: trimmed, + isWildcard: trimmed.includes('*'), + }; + + const newItems = [...selectedItems, newItem]; + onSelectionChange(newItems); + setSearchValue(''); + setAppendedWildcard(false); + setValidationErrors([]); + }; + + const handleSelectFromDropdown = (indexName: string) => { + const dataSourceId = path?.find((item) => item.type === 'DATA_SOURCE')?.id || 'local'; + const itemId = `${dataSourceId}::${indexName}`; + + // Check if already in list + if (selectedItems.some((item) => item.title === indexName)) { + return; + } + + const newItem = { + id: itemId, + title: indexName, + isWildcard: false, + }; + + const newItems = [...selectedItems, newItem]; + onSelectionChange(newItems); + // Keep popover open to allow multiple selections + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && searchValue.trim() && searchValue.includes('*')) { + e.preventDefault(); + handleAddPattern(); + setIsPopoverOpen(false); + inputRef.current?.blur(); + } + }; + + // Create options from search results + const displayOptions: EuiSelectableOption[] = searchResults.map((indexName) => { + const isSelected = selectedItems.some((item) => item.title === indexName); + return { + label: indexName, + key: indexName, + checked: undefined, + prepend: isSelected ? : undefined, + append: ( + + {i18n.translate('data.datasetService.unifiedSelector.addSingleIndex', { + defaultMessage: 'Add single index', + })} + + ), + }; + }); + + const onChange = (newOptions: EuiSelectableOption[]) => { + // Find newly selected option + const selectedOption = newOptions.find((option) => option.checked === 'on'); + if (selectedOption && selectedOption.label) { + handleSelectFromDropdown(selectedOption.label); + } + }; + + const hasWildcard = searchValue.includes('*'); + const canAddPattern = searchValue.trim() && hasWildcard && validationErrors.length === 0; + const hasValidationErrors = validationErrors.length > 0; + const errorMessage = getValidationErrorMessage(validationErrors); + + return ( +
+ + {i18n.translate('data.datasetService.unifiedSelector.helpText', { + defaultMessage: + 'Click indices to add them, or enter wildcards (e.g., otel*) and use Add wildcard button', + })} + + + + + + setIsPopoverOpen(true)} + isInvalid={hasValidationErrors} + fullWidth + /> + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downCenter" + display="block" + ownFocus={false} + > +
+ {!searchValue && totalCount > MAX_INITIAL_RESULTS && searchResults.length > 0 && ( +
+ {i18n.translate('data.datasetService.unifiedSelector.limitedResultsMessage', { + defaultMessage: + 'Showing first {displayed} of {total} indices. Type to search for more.', + values: { displayed: searchResults.length, total: totalCount }, + })} +
+ )} + + {(list) => list} + +
+
+
+ + + {i18n.translate('data.datasetService.unifiedSelector.addButton', { + defaultMessage: 'Add wildcard', + })} + + +
+
+
+ ); +}; diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.test.ts b/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.test.ts index 73ef12e048f0..54c9a831feec 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.test.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.test.ts @@ -80,6 +80,85 @@ describe('indexTypeConfig', () => { }); }); + test('toDataset handles multi-index selection with comma-separated titles', () => { + const mockPath: DataStructure[] = [ + { + id: 'datasource1', + title: 'DataSource 1', + type: 'DATA_SOURCE', + }, + { + id: 'datasource1::index1,index2,index3', + title: 'index1,index2,index3', + type: 'INDEX', + meta: { + type: DATA_STRUCTURE_META_TYPES.CUSTOM, + isMultiIndex: true, + selectedIndices: ['datasource1::index1', 'datasource1::index2', 'datasource1::index3'], + selectedTitles: ['index1', 'index2', 'index3'], + } as DataStructureCustomMeta, + }, + ]; + + const result = indexTypeConfig.toDataset(mockPath); + + expect(result.title).toBe('index1,index2,index3'); + expect(result.type).toBe('INDEXES'); + }); + + test('toDataset handles multi-wildcard selection', () => { + const mockPath: DataStructure[] = [ + { + id: 'datasource1', + title: 'DataSource 1', + type: 'DATA_SOURCE', + }, + { + id: 'datasource1::logs-*,metrics-*', + title: 'logs-*,metrics-*', + type: 'INDEX', + meta: { + type: DATA_STRUCTURE_META_TYPES.CUSTOM, + isMultiWildcard: true, + wildcardPatterns: ['logs-*', 'metrics-*'], + } as DataStructureCustomMeta, + }, + ]; + + const result = indexTypeConfig.toDataset(mockPath); + + expect(result.title).toBe('logs-*,metrics-*'); + expect(result.type).toBe('INDEXES'); + }); + + test('toDataset handles mixed wildcard and single index selection', () => { + const mockPath: DataStructure[] = [ + { + id: 'datasource1', + title: 'DataSource 1', + type: 'DATA_SOURCE', + }, + { + id: 'datasource1::logs-*,index1,index2', + title: 'logs-*,index1,index2', + type: 'INDEX', + meta: { + type: DATA_STRUCTURE_META_TYPES.CUSTOM, + isMultiWildcard: true, + wildcardPatterns: ['logs-*'], + selectedIndices: ['datasource1::index1', 'datasource1::index2'], + selectedTitles: ['index1', 'index2'], + } as DataStructureCustomMeta, + }, + ]; + + const result = indexTypeConfig.toDataset(mockPath); + + // Should contain wildcard patterns first, then exact indices + expect(result.title).toBe('logs-*,index1,index2'); + expect(result.type).toBe('INDEXES'); + }); + test('fetchFields returns fields from index', async () => { const mockFields = [ { name: 'field1', type: 'string' }, diff --git a/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.ts b/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.ts index 7b4c8ccd1dee..15bcee6f0010 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/lib/index_type.ts @@ -8,7 +8,6 @@ import { SavedObjectsClientContract, SimpleSavedObject, } from 'opensearch-dashboards/public'; -import { map } from 'rxjs/operators'; import { i18n } from '@osd/i18n'; import { DATA_STRUCTURE_META_TYPES, @@ -44,18 +43,25 @@ export const indexTypeConfig: DatasetTypeConfig = { const dataSource = path.find((ds) => ds.type === 'DATA_SOURCE'); const indexMeta = index.meta as DataStructureCustomMeta; - // Handle different types of multi-selections - preserve comma-separated format + // Build dataset title from multi-selections (wildcards and/or exact indices) let datasetTitle = index.title; - // Check if this is a multi-index selection - if (indexMeta?.isMultiIndex && indexMeta?.selectedTitles?.length) { - // Use the selected titles array to create comma-separated string - datasetTitle = indexMeta.selectedTitles.join(','); - } - // Check if this is a multi-wildcard selection - else if (indexMeta?.isMultiWildcard && indexMeta?.wildcardPatterns?.length) { - // Use the wildcard patterns to create comma-separated string - datasetTitle = indexMeta.wildcardPatterns.join(','); + if (indexMeta?.isMultiWildcard || indexMeta?.isMultiIndex) { + const titles: string[] = []; + + // Add wildcard patterns if present + if (indexMeta.wildcardPatterns?.length) { + titles.push(...indexMeta.wildcardPatterns); + } + + // Add exact indices if present + if (indexMeta.selectedTitles?.length) { + titles.push(...indexMeta.selectedTitles); + } + + if (titles.length > 0) { + datasetTitle = titles.join(','); + } } return { diff --git a/src/plugins/data/public/ui/dataset_select/_dataset_select.scss b/src/plugins/data/public/ui/dataset_select/_dataset_select.scss index 0439e44c56ec..400a48702d3f 100644 --- a/src/plugins/data/public/ui/dataset_select/_dataset_select.scss +++ b/src/plugins/data/public/ui/dataset_select/_dataset_select.scss @@ -100,5 +100,31 @@ // euiOverlayMask pushes the modal up due to having padding-bottom: 10vh max-height: calc(90vh - $euiSizeL); + + /* stylelint-disable @osd/stylelint/no_modifying_global_selectors */ + .euiModal__flex { + max-height: none; + } + + .osdOverlayMountWrapper { + display: flex; + flex-direction: column; + height: 100%; + } + + .euiModalBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + + .euiModalBody__overflow { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + /* stylelint-enable @osd/stylelint/no_modifying_global_selectors */ } } diff --git a/src/plugins/data/public/ui/dataset_select/dataset_select.test.tsx b/src/plugins/data/public/ui/dataset_select/dataset_select.test.tsx index 9c1632a5545e..6fbd6cb37d7b 100644 --- a/src/plugins/data/public/ui/dataset_select/dataset_select.test.tsx +++ b/src/plugins/data/public/ui/dataset_select/dataset_select.test.tsx @@ -165,19 +165,29 @@ describe('DatasetSelect', () => { fireEvent.click(button); await waitFor(() => { - const datasetOption = screen.getByTestId('datasetSelectOption-index-pattern-id'); - expect(datasetOption).toBeInTheDocument(); - fireEvent.click(datasetOption); + expect(screen.getByTestId('datasetSelectSelectable')).toBeInTheDocument(); }); - expect(mockOnSelect).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'index-pattern-id', - }) + // Find the option using findByTestId which waits for the element + const datasetOption = await screen.findByTestId( + 'datasetSelectOption-Test Index Pattern', + {}, + { timeout: 5000 } ); + expect(datasetOption).toBeInTheDocument(); + fireEvent.click(datasetOption); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'index-pattern-id', + title: 'Test Index Pattern', + }) + ); + }); }); - it('opens advanced selector when advanced button is clicked', async () => { + it('opens advanced selector when create dataset button is clicked', async () => { renderWithContext(); await waitFor(() => { @@ -188,9 +198,9 @@ describe('DatasetSelect', () => { fireEvent.click(button); await waitFor(() => { - const advancedButton = screen.getByTestId('datasetSelectAdvancedButton'); - expect(advancedButton).toBeInTheDocument(); - fireEvent.click(advancedButton); + const createButton = screen.getByTestId('datasetSelectorAdvancedButton'); + expect(createButton).toBeInTheDocument(); + fireEvent.click(createButton); }); expect(mockCore.overlays.openModal).toHaveBeenCalled(); @@ -198,7 +208,10 @@ describe('DatasetSelect', () => { it('selects default dataset if no current dataset', async () => { mockQueryService.queryString.getQuery = jest.fn().mockReturnValue({ dataset: null }); - renderWithContext(); + renderWithContext({ + ...defaultProps, + signalType: CORE_SIGNAL_TYPES.LOGS, + }); await waitFor(() => { expect(mockDataViews.getDefault).toHaveBeenCalled(); @@ -385,4 +398,360 @@ describe('DatasetSelect', () => { expect(screen.queryByText('Logs Dataset')).not.toBeInTheDocument(); }); + + it('ignores incompatible dataset changes and preserves selection', async () => { + // Setup: Start with a trace dataset selected on traces page + const traceDataset = { + id: 'trace-id', + title: 'trace-dataset', + type: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN, + signalType: CORE_SIGNAL_TYPES.TRACES, + }; + + const logDataset = { + id: 'log-id', + title: 'log-dataset', + type: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN, + signalType: CORE_SIGNAL_TYPES.LOGS, + }; + + // Mock getIds to return both datasets + mockDataViews.getIds = jest.fn().mockResolvedValue(['trace-id', 'log-id']); + + // Mock get to return the correct dataset based on ID + mockDataViews.get = jest.fn().mockImplementation((id) => { + if (id === 'trace-id') { + return Promise.resolve({ + id: 'trace-id', + title: 'trace-dataset', + displayName: 'Trace Dataset', + signalType: CORE_SIGNAL_TYPES.TRACES, + }); + } else if (id === 'log-id') { + return Promise.resolve({ + id: 'log-id', + title: 'log-dataset', + displayName: 'Log Dataset', + signalType: CORE_SIGNAL_TYPES.LOGS, + }); + } + return Promise.resolve(null); + }); + + mockDataViews.convertToDataset = jest.fn().mockImplementation((dataView) => { + return Promise.resolve({ + id: dataView.id, + title: dataView.title, + type: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN, + signalType: dataView.signalType, + }); + }); + + // Start with trace dataset selected + mockQueryService.queryString.getQuery = jest.fn().mockReturnValue({ + dataset: traceDataset, + }); + + const { rerender } = renderWithContext({ + ...defaultProps, + signalType: CORE_SIGNAL_TYPES.TRACES, + }); + + await waitFor(() => { + expect(mockDataViews.getIds).toHaveBeenCalled(); + expect(screen.getByText('Trace Dataset')).toBeInTheDocument(); + }); + + // Simulate flyout changing query to log dataset (e.g., querying related logs) + mockQueryService.queryString.getQuery = jest.fn().mockReturnValue({ + dataset: logDataset, + }); + + // Force re-render to trigger the effect + rerender( + + + + + + ); + + // Wait a bit for effects to run + await waitFor(() => { + expect(mockDataViews.get).toHaveBeenCalledWith('log-id', false); + }); + + // The UI should still show the trace dataset, not the incompatible log dataset + // It should NOT clear the selection or show "Select data" + expect(screen.getByText('Trace Dataset')).toBeInTheDocument(); + expect(screen.queryByText('Log Dataset')).not.toBeInTheDocument(); + expect(screen.queryByText('Select data')).not.toBeInTheDocument(); + }); + + it('handles errors when fetching datasets gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const localMockDataViews = { + ...mockDataViews, + getIds: jest.fn().mockRejectedValue(new Error('Failed to fetch')), + }; + + const localMockQueryService = { + ...mockQueryService, + queryString: { + ...mockQueryService.queryString, + getQuery: jest.fn().mockReturnValue({ dataset: null, language: 'kuery' }), + }, + }; + + const localServices = { + ...mockServices, + data: { + ...mockServices.data, + dataViews: localMockDataViews, + query: localMockQueryService, + }, + }; + + render( + + + + + + ); + + // Wait for getIds to be called and error handling to complete + await waitFor( + () => { + expect(localMockDataViews.getIds).toHaveBeenCalled(); + }, + { timeout: 3000 } + ); + + // Wait for loading to complete after error + await waitFor( + () => { + const button = screen.getByTestId('datasetSelectButton'); + expect(button).not.toHaveClass('euiButtonEmpty-isDisabled'); + }, + { timeout: 3000 } + ); + + // Component should render without crashing and show "Select data" text + expect(screen.getByTestId('datasetSelectButton')).toBeInTheDocument(); + expect(screen.getByText('Select data')).toBeInTheDocument(); + + consoleErrorSpy.mockRestore(); + }); + + it('shows loading state initially', () => { + renderWithContext(); + // Check for disabled state which indicates loading + const button = screen.getByTestId('datasetSelectButton'); + expect(button).toHaveClass('euiButtonEmpty-isDisabled'); + }); + + it('displays "Select data" when no dataset is selected', async () => { + mockQueryService.queryString.getQuery = jest.fn().mockReturnValue({ dataset: null }); + mockDataViews.getDefault = jest.fn().mockResolvedValue(null); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Select data')).toBeInTheDocument(); + }); + }); + + it('renders dataset information with time field', async () => { + const { getByTestId } = renderWithContext(); + + await waitFor(() => { + expect(mockDataViews.getIds).toHaveBeenCalled(); + expect(mockDataViews.get).toHaveBeenCalled(); + }); + + // Verify dataset was loaded and component rendered + expect(getByTestId('datasetSelectButton')).toBeInTheDocument(); + }); + + it('renders dataset with data source information', async () => { + const localMockDataViews = { + ...mockDataViews, + get: jest.fn().mockResolvedValue({ + ...mockDataViewData, + dataSourceRef: { + id: 'ds-id', + type: 'data-source', + }, + }), + convertToDataset: jest.fn().mockResolvedValue({ + id: mockDataViewData.id, + title: mockDataViewData.title, + type: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN, + dataSource: { + id: 'ds-id', + title: 'Test Data Source', + type: 'data-source', + }, + }), + }; + + const localServices = { + ...mockServices, + data: { + ...mockServices.data, + dataViews: localMockDataViews, + }, + }; + + render( + + + + + + ); + + await waitFor(() => { + expect(localMockDataViews.getIds).toHaveBeenCalled(); + expect(localMockDataViews.convertToDataset).toHaveBeenCalled(); + }); + + // Verify component rendered with data source + expect(screen.getByTestId('datasetSelectButton')).toBeInTheDocument(); + }); + + it('opens dataset selector popover', async () => { + const { getByTestId } = renderWithContext(); + + await waitFor(() => { + expect(mockDataViews.getIds).toHaveBeenCalled(); + }); + + const button = getByTestId('datasetSelectButton'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + // Verify the selectable component is visible + expect(screen.getByTestId('datasetSelectSelectable')).toBeInTheDocument(); + }); + + it('handles empty datasets list', async () => { + const localMockDataViews = { + ...mockDataViews, + getIds: jest.fn().mockResolvedValue([]), + }; + + const localQueryService = { + ...mockQueryService, + queryString: { + ...mockQueryService.queryString, + getQuery: jest.fn().mockReturnValue({ dataset: null }), + }, + }; + + const localMockDataViewsWithDefault = { + ...localMockDataViews, + getDefault: jest.fn().mockResolvedValue(null), + }; + + (getQueryService as jest.Mock).mockReturnValue(localQueryService); + + const localServices = { + ...mockServices, + data: { + ...mockServices.data, + dataViews: localMockDataViewsWithDefault, + }, + }; + + render( + + + + + + ); + + await waitFor(() => { + expect(localMockDataViewsWithDefault.getIds).toHaveBeenCalled(); + }); + + const button = screen.getByTestId('datasetSelectButton'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + // Should not crash with empty list + expect(button).toBeInTheDocument(); + }); + + it('searches datasets by title', async () => { + renderWithContext(); + + await waitFor(() => { + expect(mockDataViews.getIds).toHaveBeenCalled(); + }); + + const button = screen.getByTestId('datasetSelectButton'); + fireEvent.click(button); + + await waitFor(() => { + const searchInput = screen.getByPlaceholderText('Search'); + expect(searchInput).toBeInTheDocument(); + fireEvent.change(searchInput, { target: { value: 'Test' } }); + }); + + expect(screen.getByPlaceholderText('Search')).toHaveValue('Test'); + }); + + it('closes popover after dataset selection', async () => { + mockDataViews.getIds = jest.fn().mockResolvedValue(['index-pattern-id', 'new-id']); + mockDataViews.get = jest.fn().mockImplementation((id) => { + if (id === 'new-id') { + return Promise.resolve({ + id: 'new-id', + title: 'New Dataset', + displayName: 'New Dataset', + }); + } + return Promise.resolve(mockDataViewData); + }); + + renderWithContext(); + + await waitFor(() => { + expect(mockDataViews.getIds).toHaveBeenCalled(); + }); + + const button = screen.getByTestId('datasetSelectButton'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + it('handles dataset with description', async () => { + const { getByTestId } = renderWithContext(); + + await waitFor(() => { + expect(mockDataViews.getIds).toHaveBeenCalled(); + }); + + const button = getByTestId('datasetSelectButton'); + fireEvent.click(button); + + await waitFor(() => { + // Just verify the popover opened successfully + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + }); }); diff --git a/src/plugins/data/public/ui/dataset_select/dataset_select.tsx b/src/plugins/data/public/ui/dataset_select/dataset_select.tsx index f393cf7882b2..4ff7a38cef5b 100644 --- a/src/plugins/data/public/ui/dataset_select/dataset_select.tsx +++ b/src/plugins/data/public/ui/dataset_select/dataset_select.tsx @@ -22,6 +22,13 @@ import { EuiText, EuiPopoverTitle, EuiSplitPanel, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiBasicTable, + EuiFieldSearch, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -31,7 +38,7 @@ import { } from '../../../../opensearch_dashboards_react/public'; import { CORE_SIGNAL_TYPES, Dataset, DEFAULT_DATA, DataStructure, Query } from '../../../common'; import { IDataPluginServices } from '../../types'; -import { DatasetDetails, DatasetDetailsBody, DatasetDetailsHeader } from './dataset_details'; +import { DatasetDetails } from './dataset_details'; import { AdvancedSelector } from '../dataset_selector/advanced_selector'; import './_index.scss'; @@ -42,18 +49,195 @@ export interface DetailedDataset extends Dataset { } export interface DatasetSelectProps { - onSelect: (dataset: Dataset) => void; - appName: string; + onSelect: (dataset: Dataset | undefined) => void; supportedTypes?: string[]; signalType: string | null; } +interface ViewDatasetsModalProps { + datasets: DetailedDataset[]; + isLoading: boolean; + onClose: () => void; + services: IDataPluginServices; +} + +const isDatasetCompatibleWithSignalType = ( + dataset: DetailedDataset, + signalType: string | null +): boolean => { + if (!signalType) return true; + + if (signalType === CORE_SIGNAL_TYPES.TRACES) { + return dataset.signalType === CORE_SIGNAL_TYPES.TRACES; + } else if (signalType === CORE_SIGNAL_TYPES.LOGS) { + return dataset.signalType === CORE_SIGNAL_TYPES.LOGS || !dataset.signalType; + } else if (signalType === CORE_SIGNAL_TYPES.METRICS) { + return dataset.signalType === CORE_SIGNAL_TYPES.METRICS || !dataset.signalType; + } + return true; +}; + +const ViewDatasetsModal: React.FC = ({ + datasets, + isLoading, + onClose, + services, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const { data, application } = services; + const { queryString } = data.query; + const datasetService = queryString.getDatasetService(); + + const filteredDatasets = useMemo(() => { + if (!searchQuery) return datasets; + const lowerSearch = searchQuery.toLowerCase(); + return datasets.filter((dataset) => { + const displayName = (dataset.displayName || dataset.title).toLowerCase(); + const description = dataset.description?.toLowerCase() || ''; + const signalType = dataset.signalType?.toLowerCase() || ''; + const dataSourceName = dataset.dataSource?.title?.toLowerCase() || 'local cluster'; + const indexPattern = dataset.title.toLowerCase(); + + return ( + displayName.includes(lowerSearch) || + description.includes(lowerSearch) || + signalType.includes(lowerSearch) || + dataSourceName.includes(lowerSearch) || + indexPattern.includes(lowerSearch) + ); + }); + }, [datasets, searchQuery]); + + const handleDatasetClick = useCallback( + (dataset: DetailedDataset) => { + onClose(); + application.navigateToApp('datasets', { + path: `/patterns/${dataset.id}`, + }); + }, + [onClose, application] + ); + + const columns = [ + { + field: 'displayName', + name: i18n.translate('data.datasetSelect.viewModal.nameColumn', { + defaultMessage: 'Name', + }), + render: (displayName: string, dataset: DetailedDataset) => { + const typeConfig = datasetService.getType(dataset.type); + const iconType = typeConfig?.meta?.icon?.type || 'database'; + return ( + + + + + + + {displayName || dataset.title} + + + + ); + }, + sortable: true, + }, + { + field: 'signalType', + name: i18n.translate('data.datasetSelect.viewModal.typeColumn', { + defaultMessage: 'Type', + }), + render: (signalType: string | undefined) => { + if (!signalType) { + return '—'; + } + // Capitalize first letter for display + return signalType.charAt(0).toUpperCase() + signalType.slice(1).toLowerCase(); + }, + sortable: true, + }, + { + field: 'title', + name: i18n.translate('data.datasetSelect.viewModal.dataColumn', { + defaultMessage: 'Data', + }), + render: (title: string, dataset: DetailedDataset) => { + const dataSourceName = dataset.dataSource?.title || 'Local cluster'; + return ( +
+ + + + + + {dataSourceName} + + + + {title} + +
+ ); + }, + sortable: true, + }, + { + field: 'description', + name: i18n.translate('data.datasetSelect.viewModal.descriptionColumn', { + defaultMessage: 'Description', + }), + render: (description: string) => description || '—', + truncateText: true, + }, + ]; + + return ( + + + + + + + + setSearchQuery(e.target.value)} + isClearable + fullWidth + /> + + ({ + onClick: () => handleDatasetClick(dataset), + style: { cursor: 'pointer' }, + })} + pagination={{ + pageSize: 10, + pageSizeOptions: [10], + }} + /> + + + ); +}; + /** * @experimental This component is experimental and may change in future versions */ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, signalType }) => { const { services } = useOpenSearchDashboards(); const isMounted = useRef(true); + const hasCompletedInitialLoad = useRef(false); + const previousSignalType = useRef(signalType); const [isOpen, setIsOpen] = useState(false); const [datasets, setDatasets] = useState([]); const [selectedDataset, setSelectedDataset] = useState(); @@ -69,6 +253,17 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, const currentQuery = queryString.getQuery(); const currentDataset = currentQuery.dataset; + // Handle signal type changes (e.g., navigating from logs to traces) + useEffect(() => { + const signalTypeChanged = previousSignalType.current !== signalType; + if (signalTypeChanged) { + previousSignalType.current = signalType; + // Reset initial load flag AND clear datasets to force refetch with new signal type + hasCompletedInitialLoad.current = false; + setDatasets([]); + } + }, [signalType]); + useEffect(() => { const updateSelectedDataset = async () => { if (!currentDataset) { @@ -76,9 +271,23 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, return; } + // If datasets array is empty during a refetch, don't update selection + // This prevents clearing the dataset when fetchDatasets temporarily clears the array + if (datasets.length === 0 && hasCompletedInitialLoad.current) { + return; + } + const matchingDataset = datasets.find((d) => d.id === currentDataset.id); if (matchingDataset) { - setSelectedDataset(matchingDataset); + const isCompatible = isDatasetCompatibleWithSignalType(matchingDataset, signalType); + + if (isCompatible) { + setSelectedDataset(matchingDataset); + } else { + // Don't clear incompatible dataset - just ignore it + // This handles cases where flyouts temporarily change the query dataset + // Keep the current UI state - don't update + } return; } @@ -98,10 +307,27 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, signalType: dataView.signalType, } as DetailedDataset; - setSelectedDataset(detailedDataset); + const isCompatible = isDatasetCompatibleWithSignalType(detailedDataset, signalType); + + if (isCompatible) { + setSelectedDataset(detailedDataset); + } else { + // Don't clear incompatible dataset - just ignore it + // This handles cases where flyouts temporarily change the query dataset + // (e.g., trace flyout querying related logs changes dataset from traces to logs) + // Keep the current UI state - don't update selectedDataset + } }; updateSelectedDataset(); - }, [currentDataset, dataViews, datasets]); + }, [ + currentDataset, + dataViews, + datasets, + signalType, + queryString, + currentQuery.language, + onSelect, + ]); const datasetTypeConfig = datasetService.getType( selectedDataset?.sourceDatasetRef?.type || selectedDataset?.type || '' @@ -175,21 +401,51 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, const filteredDatasets = fetchedDatasets.filter(onFilter); - const defaultDataView = await dataViews.getDefault(); - if (defaultDataView) { - setDefaultDatasetId(defaultDataView.id); + // Deduplicate datasets by id to prevent duplicates from multiple sources + const deduplicatedDatasets = Array.from( + new Map(filteredDatasets.map((dataset) => [dataset.id, dataset])).values() + ); + + let defaultDataView; + try { + defaultDataView = await dataViews.getDefault(); + if (defaultDataView) { + setDefaultDatasetId(defaultDataView.id); + } + } catch (error) { + // Default dataset not found (stale reference), continue without it + // eslint-disable-next-line no-console + console.warn('[DatasetSelect] Default dataset not found, using first available:', error); } const defaultDataset = - filteredDatasets.find((d) => d.id === defaultDataView?.id) ?? filteredDatasets[0]; + deduplicatedDatasets.find((d) => d.id === defaultDataView?.id) ?? deduplicatedDatasets[0]; // Get fresh current dataset value at execution time const currentlySelectedDataset = queryString.getQuery().dataset; - if (defaultDataset && !currentlySelectedDataset) { + + // Only auto-select datasets on initial load when there's no dataset selected + // During refetches (e.g., when flyouts open), we don't want to trigger dataset changes + // IMPORTANT: Don't run auto-select when signalType is null (component is still mounting) + // IMPORTANT: Don't override a dataset that's already selected - it may have been set by + // a saved query, test, or manual selection, and may not be in the fetched list + if ( + !hasCompletedInitialLoad.current && + signalType !== null && + !currentlySelectedDataset && + defaultDataset + ) { + // No dataset selected but default is available - select it onSelect(defaultDataset); } - setDatasets(filteredDatasets); + if (isMounted.current) { + setDatasets(deduplicatedDatasets); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('[DatasetSelect] Error fetching datasets:', error); } finally { if (isMounted.current) { setIsLoading(false); + hasCompletedInitialLoad.current = true; } } }, [dataViews, signalType, onSelect, queryString, datasetService, services, supportedTypes]); @@ -208,16 +464,40 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, }, []); const options = datasets.map((dataset) => { - const { id, title, type, description, displayName } = dataset; + const { id, title, type, description, displayName, dataSource, timeFieldName } = dataset; const isSelected = id === selectedDataset?.id; const isDefault = id === defaultDatasetId; const typeConfig = datasetService.getType(type); const iconType = typeConfig?.meta?.icon?.type || 'database'; const label = displayName || title; + + // Build subtitle with data source and time field + const subtitleParts = []; + if (dataSource?.title) { + subtitleParts.push(dataSource.title); + } else { + // For local data sources (no dataSourceRef), show "Local cluster" + subtitleParts.push( + i18n.translate('data.datasetSelect.localCluster', { + defaultMessage: 'Local cluster', + }) + ); + } + if (timeFieldName) { + subtitleParts.push( + i18n.translate('data.datasetSelect.timeField', { + defaultMessage: 'Time field: {timeField}', + values: { timeField: timeFieldName }, + }) + ); + } + const subtitle = subtitleParts.length > 0 ? subtitleParts.join(' • ') : ''; + // Prepending the label to the searchable label to allow for better search, render will strip it out - const searchableLabel = `${label}${typeConfig?.title || DEFAULT_DATA.STRUCTURES.ROOT.title}${ - description && description.trim() !== '' ? ` - ${description}` : '' - }`.trim(); + // Adding a separator before subtitle if it exists so renderOption can parse it correctly + const searchableLabel = subtitle + ? `${label} ${subtitle}${description && description.trim() !== '' ? ` - ${description}` : ''}` + : `${label}${description && description.trim() !== '' ? ` - ${description}` : ''}`; return { label, @@ -303,7 +583,6 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, className="datasetSelect__contextMenu" hasBorder={false} hasShadow={false} - onFocus={() => {}} direction="column" > @@ -314,13 +593,35 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, - {datasetTypeConfig?.title || DEFAULT_DATA.STRUCTURES.ROOT.title} + {(() => { + const parts = []; + if (selectedDataset?.dataSource?.title) { + parts.push(selectedDataset.dataSource.title); + } else if (selectedDataset) { + // For local data sources (no dataSourceRef), show "Local cluster" + parts.push( + i18n.translate('data.datasetSelect.localCluster', { + defaultMessage: 'Local cluster', + }) + ); + } + if (selectedDataset?.timeFieldName) { + parts.push( + i18n.translate('data.datasetSelect.timeField', { + defaultMessage: 'Time field: {timeField}', + values: { timeField: selectedDataset.timeFieldName }, + }) + ); + } + return parts.length > 0 + ? parts.join(' • ') + : datasetTypeConfig?.title || DEFAULT_DATA.STRUCTURES.ROOT.title; + })()} ), - panel: 1, icon: , }, { @@ -408,23 +709,6 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, }, ], }, - { - id: 1, - title: ( - - ), - content: ( - - ), - }, ]} /> @@ -434,17 +718,17 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, justifyContent="spaceBetween" alignItems="center" responsive={false} - gutterSize="none" + gutterSize="s" className="datasetSelect__footer" > { closePopover(); const overlay = overlays?.openModal( @@ -510,8 +794,37 @@ const DatasetSelect: React.FC = ({ onSelect, supportedTypes, }} > + + + + { + closePopover(); + const overlay = overlays?.openModal( + toMountPoint( + overlay?.close()} + services={services} + /> + ), + { + maxWidth: '800px', + className: 'datasetSelect__viewDatasetsModal', + } + ); + }} + > + diff --git a/src/plugins/data/public/ui/dataset_selector/_dataset_selector.scss b/src/plugins/data/public/ui/dataset_selector/_dataset_selector.scss index 2f92480a4413..8360f8d0647d 100644 --- a/src/plugins/data/public/ui/dataset_selector/_dataset_selector.scss +++ b/src/plugins/data/public/ui/dataset_selector/_dataset_selector.scss @@ -23,9 +23,31 @@ // euiOverlayMask pushes the modal up due to having padding-bottom: 10vh max-height: calc(90vh - $euiSizeL); + /* stylelint-disable @osd/stylelint/no_modifying_global_selectors */ .euiModal__flex { max-height: none; } + + .osdOverlayMountWrapper { + display: flex; + flex-direction: column; + height: 100%; + } + + .euiModalBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + + .euiModalBody__overflow { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + /* stylelint-enable @osd/stylelint/no_modifying_global_selectors */ } &__checkbox { diff --git a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx index e2a914b059f8..3a8c3f71bbe0 100644 --- a/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx +++ b/src/plugins/data/public/ui/dataset_selector/dataset_explorer.tsx @@ -51,39 +51,77 @@ export const DatasetExplorer = ({ const uiSettings = services.uiSettings; const [explorerDataset, setExplorerDataset] = useState(undefined); const [loading, setLoading] = useState(false); + const [autoSelectionDone, setAutoSelectionDone] = useState>(new Set()); const datasetService = queryString.getDatasetService(); - const fetchNextDataStructure = async ( - nextPath: DataStructure[], - dataType: string, - options?: DataStructureFetchOptions - ) => datasetService.fetchOptions(services, nextPath, dataType, options); + const fetchNextDataStructure = React.useCallback( + async (nextPath: DataStructure[], dataType: string, options?: DataStructureFetchOptions) => + datasetService.fetchOptions(services, nextPath, dataType, options), + [datasetService, services] + ); + + const selectDataStructure = React.useCallback( + async (item: DataStructure | undefined, newPath: DataStructure[]) => { + if (!item) { + setExplorerDataset(undefined); + return; + } + const lastPathItem = newPath[newPath.length - 1]; + const nextPath = [...newPath, item]; + + const typeConfig = datasetService.getType(nextPath[1].id); + if (!typeConfig) return; - const selectDataStructure = async (item: DataStructure | undefined, newPath: DataStructure[]) => { - if (!item) { - setExplorerDataset(undefined); + if (!lastPathItem.hasNext) { + const dataset = typeConfig!.toDataset(nextPath); + setExplorerDataset(dataset as BaseDataset); + return; + } + + setLoading(true); + const nextDataStructure = await fetchNextDataStructure(nextPath, typeConfig.id); + setLoading(false); + + setPath([...newPath, nextDataStructure]); + }, + [datasetService, fetchNextDataStructure, setPath] + ); + + // Auto-select if there's only one option at the current level, or if at data source level + React.useEffect(() => { + const currentIndex = path.length - 1; + const current = path[currentIndex]; + + // Skip if we've already auto-selected at this level + if (autoSelectionDone.has(currentIndex)) { return; } - const lastPathItem = newPath[newPath.length - 1]; - const nextPath = [...newPath, item]; - - const typeConfig = datasetService.getType(nextPath[1].id); - if (!typeConfig) return; - if (!lastPathItem.hasNext) { - const dataset = typeConfig!.toDataset(nextPath); - setExplorerDataset(dataset as BaseDataset); + // Skip if loading or if there are no children + if (loading || !current.children || current.children.length === 0) { return; } - setLoading(true); - const nextDataStructure = await fetchNextDataStructure(nextPath, typeConfig.id); - setLoading(false); + // Check if we're at the data source level (children have type DATA_SOURCE) + const isDataSourceLevel = current.children.some((child) => child.type === 'DATA_SOURCE'); - setPath([...newPath, nextDataStructure]); - }; + // Auto-select if there's exactly one child, OR if we're at data source level (always select first) + if (current.children.length === 1 || isDataSourceLevel) { + const firstChild = current.children[0]; + // Mark this level as auto-selected + setAutoSelectionDone((prev) => new Set([...prev, currentIndex])); + // Automatically select it + selectDataStructure(firstChild, path.slice(0, currentIndex + 1)); + } + }, [path, loading, autoSelectionDone, selectDataStructure]); - const columnCount = path[path.length - 1]?.hasNext ? path.length + 1 : path.length; + // Skip first column if it only has one child (auto-selected) + const shouldSkipFirstColumn = path.length > 0 && path[0].children?.length === 1; + const visiblePath = shouldSkipFirstColumn ? path.slice(1) : path; + const columnCount = + visiblePath.length > 0 && visiblePath[visiblePath.length - 1]?.hasNext + ? visiblePath.length + 1 + : visiblePath.length; return ( <> @@ -94,7 +132,7 @@ export const DatasetExplorer = ({

@@ -165,7 +203,9 @@ export const DatasetExplorer = ({ }, minmax(200px, 240px)) minmax(300px, 1fr)`, }} > - {path.map((current, index) => { + {visiblePath.map((current, visibleIndex) => { + // Calculate the actual index in the full path + const index = shouldSkipFirstColumn ? visibleIndex + 1 : visibleIndex; const isLast = index === path.length - 1; const isFinal = isLast && !current.hasNext; return ( @@ -242,7 +282,9 @@ export const DatasetExplorer = ({
); })} - {!!path[path.length - 1]?.hasNext && } + {visiblePath.length > 0 && !!visiblePath[visiblePath.length - 1]?.hasNext && ( + + )} diff --git a/src/plugins/dataset_management/public/plugin.test.ts b/src/plugins/dataset_management/public/plugin.test.ts index d1258941fb78..746286bb6eba 100644 --- a/src/plugins/dataset_management/public/plugin.test.ts +++ b/src/plugins/dataset_management/public/plugin.test.ts @@ -13,6 +13,12 @@ import { RegisterManagementAppArgs, } from 'src/plugins/management/public'; import { waitFor } from '@testing-library/dom'; +import { BehaviorSubject } from 'rxjs'; +import { + AppNavLinkStatus, + DEFAULT_NAV_GROUPS, + WORKSPACE_USE_CASE_PREFIX, +} from '../../../core/public'; describe('DiscoverPlugin', () => { it('setup successfully', () => { @@ -25,7 +31,7 @@ describe('DiscoverPlugin', () => { management: managementPluginMock.createSetupContract(), }) ).not.toThrow(); - expect(setupMock.application.register).toBeCalledTimes(2); + expect(setupMock.application.register).toBeCalledTimes(1); waitFor(() => { expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(1); }); @@ -59,4 +65,192 @@ describe('DiscoverPlugin', () => { expect(startMock.application.getUrlForApp).toBeCalledWith('datasets'); expect(startMock.application.navigateToUrl).toBeCalledWith('http://localhost/app/datasets'); }); + + it('redirects to indexPatterns when NOT in observability workspace', async () => { + const setupMock = coreMock.createSetup(); + const startMock = coreMock.createStart(); + const workspaceSubject = new BehaviorSubject({ + id: 'test-workspace', + name: 'Test Workspace', + features: ['some-other-feature'], // Not observability + }); + + startMock.workspaces.currentWorkspace$ = workspaceSubject; + // Mock capabilities with workspaces enabled + (startMock.application as any).capabilities = { + ...startMock.application.capabilities, + workspaces: { enabled: true }, + }; + startMock.application.getUrlForApp.mockReturnValue('/app/indexPatterns'); + + setupMock.getStartServices.mockResolvedValue([startMock, {}, {}]); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new DatasetManagementPlugin(initializerContext); + const managementMock = managementPluginMock.createSetupContract(); + + let applicationRegistration = {} as Omit; + managementMock.sections.section.opensearchDashboards.registerApp = ( + app: Omit + ) => { + applicationRegistration = app; + return {} as ManagementApp; + }; + + setupMock.chrome.navGroup.getNavGroupEnabled.mockReturnValue(false); + + pluginInstance.setup(setupMock, { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + management: managementMock, + }); + + await applicationRegistration.mount({} as ManagementAppMountParams); + + expect(startMock.application.getUrlForApp).toBeCalledWith('indexPatterns'); + expect(startMock.application.navigateToUrl).toBeCalledWith('/app/indexPatterns'); + }); + + it('allows access when in observability workspace', async () => { + const setupMock = coreMock.createSetup(); + const startMock = coreMock.createStart(); + const workspaceSubject = new BehaviorSubject({ + id: 'observability-workspace', + name: 'Observability Workspace', + features: [`${WORKSPACE_USE_CASE_PREFIX}${DEFAULT_NAV_GROUPS.observability.id}`], + }); + + startMock.workspaces.currentWorkspace$ = workspaceSubject; + // Mock capabilities with workspaces enabled + (startMock.application as any).capabilities = { + ...startMock.application.capabilities, + workspaces: { enabled: true }, + }; + + setupMock.getStartServices.mockResolvedValue([startMock, {}, {}]); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new DatasetManagementPlugin(initializerContext); + const managementMock = managementPluginMock.createSetupContract(); + + let mountFunction: ((params: ManagementAppMountParams) => Promise) | undefined; + managementMock.sections.section.opensearchDashboards.registerApp = ( + app: Omit + ) => { + mountFunction = app.mount; + return {} as ManagementApp; + }; + + setupMock.chrome.navGroup.getNavGroupEnabled.mockReturnValue(false); + + pluginInstance.setup(setupMock, { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + management: managementMock, + }); + + // Should not redirect to indexPatterns when in observability workspace + expect(mountFunction).toBeDefined(); + // We can't fully test the mount without mocking the entire management app import + // but we've verified the mount function is registered + }); + + it('updates nav link visibility when workspace changes', async () => { + const setupMock = coreMock.createSetup(); + const startMock = coreMock.createStart(); + const workspaceSubject = new BehaviorSubject({ + id: 'test-workspace', + name: 'Test Workspace', + features: ['some-other-feature'], + }); + + startMock.workspaces.currentWorkspace$ = workspaceSubject; + // Mock capabilities with workspaces enabled + (startMock.application as any).capabilities = { + ...startMock.application.capabilities, + workspaces: { enabled: true }, + }; + + setupMock.getStartServices.mockResolvedValue([startMock, {}, {}]); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new DatasetManagementPlugin(initializerContext); + + let appUpdater: any; + setupMock.application.register.mockImplementation((app: any) => { + appUpdater = app.updater$; + return {} as any; + }); + + pluginInstance.setup(setupMock, { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }); + + // Wait for the subscription to be set up + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Track updates + const updates: any[] = []; + appUpdater.subscribe((update: any) => { + updates.push(update({})); + }); + + // Wait for initial update + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Initially, nav link should be hidden (not observability workspace) + expect(updates[updates.length - 1]?.navLinkStatus).toBe(AppNavLinkStatus.hidden); + + // Change to observability workspace + workspaceSubject.next({ + id: 'obs-workspace', + name: 'Observability Workspace', + features: [`${WORKSPACE_USE_CASE_PREFIX}${DEFAULT_NAV_GROUPS.observability.id}`], + }); + + // Wait for update to propagate + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Now nav link should be visible + expect(updates[updates.length - 1]?.navLinkStatus).toBe(AppNavLinkStatus.visible); + }); + + it('does not set up workspace subscription when workspaces are disabled', async () => { + const setupMock = coreMock.createSetup(); + const startMock = coreMock.createStart(); + + // Mock capabilities with workspaces disabled + (startMock.application as any).capabilities = { + ...startMock.application.capabilities, + workspaces: { enabled: false }, + }; + + setupMock.getStartServices.mockResolvedValue([startMock, {}, {}]); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new DatasetManagementPlugin(initializerContext); + + let appUpdater: any; + setupMock.application.register.mockImplementation((app: any) => { + appUpdater = app.updater$; + return {} as any; + }); + + pluginInstance.setup(setupMock, { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }); + + // Wait a bit to ensure subscription would have happened if it was going to + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Track updates - the subscription should not trigger workspace-based updates + const updates: any[] = []; + appUpdater.subscribe((update: any) => { + updates.push(update({})); + }); + + // Wait to ensure no additional updates occur + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Should only have the initial subscription value, no workspace-based updates + expect(updates.length).toBe(1); + // And the update should not contain navLinkStatus (no workspace subscription active) + expect(updates[0]?.navLinkStatus).toBeUndefined(); + }); }); diff --git a/src/plugins/dataset_management/public/plugin.ts b/src/plugins/dataset_management/public/plugin.ts index 1ac06366f4df..c75dd275dd19 100644 --- a/src/plugins/dataset_management/public/plugin.ts +++ b/src/plugins/dataset_management/public/plugin.ts @@ -4,6 +4,8 @@ */ import { i18n } from '@osd/i18n'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { take } from 'rxjs/operators'; import { CoreSetup, CoreStart, Plugin, AppMountParameters } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataSourcePluginSetup, DataSourcePluginStart } from 'src/plugins/data_source/public'; @@ -15,7 +17,13 @@ import { } from './service'; import { ManagementSetup } from '../../management/public'; -import { AppStatus, AppNavLinkStatus, DEFAULT_NAV_GROUPS } from '../../../core/public'; +import { + AppStatus, + AppNavLinkStatus, + DEFAULT_NAV_GROUPS, + isNavGroupInFeatureConfigs, + AppUpdater, +} from '../../../core/public'; import { getScopedBreadcrumbs } from '../../opensearch_dashboards_react/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; @@ -53,6 +61,8 @@ export class DatasetManagementPlugin DatasetManagementStartDependencies > { private readonly datasetManagementService = new DatasetManagementService(); + private readonly appUpdater$ = new BehaviorSubject(() => ({})); + private workspaceSubscription?: Subscription; public setup( core: CoreSetup, @@ -79,39 +89,38 @@ export class DatasetManagementPlugin return pathInApp && `/patterns${pathInApp}`; }); - // Forward indexPatterns management page to datasets management page - urlForwarding.forwardApp('indexPatterns', DM_APP_ID, (path) => { - // Transform indexPatterns paths to datasets paths - // e.g., /indexPatterns/patterns/123 -> /patterns/123 - const transformedPath = path.replace(/^\/indexPatterns/, ''); - return transformedPath || '/'; - }); - - // Register indexPatterns app as a redirect to datasets app (for direct app URLs) - core.application.register({ - id: 'indexPatterns', - title: 'Index Patterns (Redirecting...)', - navLinkStatus: AppNavLinkStatus.hidden, - chromeless: true, - mount: async (params: AppMountParameters) => { - const [coreStart] = await core.getStartServices(); - const targetPath = params.history.location.pathname + params.history.location.search; - // Redirect to datasets app with the same path - coreStart.application.navigateToApp(DM_APP_ID, { - path: targetPath, - replace: true, - }); - return () => {}; - }, - }); - + // Register in management section only for observability workspace + // Check workspace in mount to prevent access in non-observability workspaces opensearchDashboardsSection.registerApp({ id: DM_APP_ID, title: sectionsHeader, - order: 0, + order: 1, mount: async (params) => { + const [coreStart] = await core.getStartServices(); + + // Check if we're in an observability workspace + const features = await coreStart.workspaces.currentWorkspace$ + .pipe(take(1)) + .toPromise() + .then((workspace) => workspace?.features) + .catch(() => { + // Fallback to non-workspace mode if workspace isn't available + return undefined; + }); + + const isObservabilityWorkspace = + (features && isNavGroupInFeatureConfigs(DEFAULT_NAV_GROUPS.observability.id, features)) ?? + false; + + // If not in observability workspace, don't allow access + if (!isObservabilityWorkspace && coreStart.application.capabilities.workspaces.enabled) { + // Redirect to index patterns instead + const indexPatternsUrl = coreStart.application.getUrlForApp('indexPatterns'); + coreStart.application.navigateToUrl(indexPatternsUrl); + return () => {}; + } + if (core.chrome.navGroup.getNavGroupEnabled()) { - const [coreStart] = await core.getStartServices(); const urlForStandardIPMApp = new URL( coreStart.application.getUrlForApp(DM_APP_ID), window.location.href @@ -122,7 +131,6 @@ export class DatasetManagementPlugin return () => {}; } const { mountManagementSection } = await import('./management_app'); - return mountManagementSection( core.getStartServices, params, @@ -141,6 +149,7 @@ export class DatasetManagementPlugin status: core.chrome.navGroup.getNavGroupEnabled() ? AppStatus.accessible : AppStatus.inaccessible, + updater$: this.appUpdater$, mount: async (params: AppMountParameters) => { const { mountManagementSection } = await import('./management_app'); const [coreStart] = await core.getStartServices(); @@ -162,19 +171,28 @@ export class DatasetManagementPlugin core.getStartServices().then(([coreStart]) => { /** - * The `capabilities.workspaces.enabled` indicates - * if workspace feature flag is turned on or not and - * the global index pattern management page should only be registered - * to settings and setup when workspace is turned off, + * Only show datasets in observability workspace when workspaces are enabled. + * Do not show datasets when workspaces are disabled. */ - if (!coreStart.application.capabilities.workspaces.enabled) { - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ - { - id: DM_APP_ID, - title: sectionsHeader, - order: 400, - }, - ]); + if (coreStart.application.capabilities.workspaces.enabled) { + // Subscribe to workspace changes to control nav link visibility + this.workspaceSubscription = coreStart.workspaces.currentWorkspace$.subscribe( + (workspace) => { + const features = workspace?.features; + + const isObservabilityWorkspace = + (features && + isNavGroupInFeatureConfigs(DEFAULT_NAV_GROUPS.observability.id, features)) ?? + false; + + // Update nav link visibility based on workspace + this.appUpdater$.next(() => ({ + navLinkStatus: isObservabilityWorkspace + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, + })); + } + ); } }); @@ -186,6 +204,10 @@ export class DatasetManagementPlugin } public stop() { + if (this.workspaceSubscription) { + this.workspaceSubscription.unsubscribe(); + this.workspaceSubscription = undefined; + } this.datasetManagementService.stop(); } } diff --git a/src/plugins/explore/public/components/container/bottom_container/bottom_right_container/bottom_right_container.tsx b/src/plugins/explore/public/components/container/bottom_container/bottom_right_container/bottom_right_container.tsx index 28a0bf8faf12..f63757989c6c 100644 --- a/src/plugins/explore/public/components/container/bottom_container/bottom_right_container/bottom_right_container.tsx +++ b/src/plugins/explore/public/components/container/bottom_container/bottom_right_container/bottom_right_container.tsx @@ -24,6 +24,7 @@ import { useDatasetContext } from '../../../../application/context'; import { ResizableVisControlAndTabs } from './resizable_vis_control_and_tabs'; import { useFlavorId } from '../../../../helpers/use_flavor_id'; import { ExploreFlavor } from '../../../../../common'; +import { TraceAutoDetectCallout } from '../../../trace_auto_detect_callout'; export const BottomRightContainer = () => { const dispatch = useDispatch(); @@ -46,6 +47,19 @@ export const BottomRightContainer = () => { }); if (dataset == null) { + // Show auto-detect callout for traces and logs flavors + if (flavorId === ExploreFlavor.Traces || flavorId === ExploreFlavor.Logs) { + return ( + + <> + + + + + ); + } + + // Show default empty state for other flavors return ( <> diff --git a/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.tsx b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.tsx index 16b5f3fbd64a..812d6c060c53 100644 --- a/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.tsx +++ b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.tsx @@ -13,6 +13,7 @@ import { setQueryWithHistory } from '../../../../application/utils/state_managem import { selectQuery } from '../../../../application/utils/state_management/selectors'; import { useFlavorId } from '../../../../helpers/use_flavor_id'; import { useClearEditors } from '../../../../application/hooks'; +import { EXPLORE_DEFAULT_LANGUAGE } from '../../../../../common'; import './dataset_select_terminology.scss'; import { ExploreFlavor } from '../../../../../common'; @@ -74,10 +75,25 @@ export const DatasetSelectWidget = () => { }, [currentQuery, dataViews, queryString, services]); const handleDatasetSelect = useCallback( - async (dataset: Dataset) => { - if (!dataset) return; - + async (dataset: Dataset | undefined) => { try { + if (!dataset) { + // Clear dataset - reset to empty query state with explore default language + queryString.setQuery({ + query: EMPTY_QUERY.QUERY, + language: EXPLORE_DEFAULT_LANGUAGE, + dataset: undefined, + }); + + dispatch( + setQueryWithHistory({ + ...queryString.getQuery(), + }) + ); + clearEditors(); + return; + } + const initialQuery = queryString.getInitialQueryByDataset(dataset); queryString.setQuery({ diff --git a/src/plugins/explore/public/components/trace_auto_detect_callout.test.tsx b/src/plugins/explore/public/components/trace_auto_detect_callout.test.tsx new file mode 100644 index 000000000000..d65fbc8ae316 --- /dev/null +++ b/src/plugins/explore/public/components/trace_auto_detect_callout.test.tsx @@ -0,0 +1,423 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { coreMock } from '../../../../core/public/mocks'; +import { TraceAutoDetectCallout } from './trace_auto_detect_callout'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; +import { ExploreServices } from '../types'; +import * as autoDetectModule from '../utils/auto_detect_trace_data'; +import * as createDatasetsModule from '../utils/create_auto_datasets'; + +// Mock the utility functions +jest.mock('../utils/auto_detect_trace_data'); +jest.mock('../utils/create_auto_datasets'); + +// Mock the DiscoverNoIndexPatterns component +jest.mock( + '../application/legacy/discover/application/components/no_index_patterns/no_index_patterns', + () => ({ + DiscoverNoIndexPatterns: () =>
No Index Patterns
, + }) +); + +describe('TraceAutoDetectCallout', () => { + const mockCore = coreMock.createStart(); + let mockServices: Partial; + const mockDetectTraceDataAcrossDataSources = autoDetectModule.detectTraceDataAcrossDataSources as jest.Mock; + const mockCreateAutoDetectedDatasets = createDatasetsModule.createAutoDetectedDatasets as jest.Mock; + + // Setup localStorage mock + const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; + })(); + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + }); + + // Mock window.location.reload + const mockReload = jest.fn(); + Object.defineProperty(window, 'location', { + value: { reload: mockReload }, + writable: true, + }); + + // Mock setTimeout + jest.useFakeTimers(); + + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.clear(); + mockReload.mockClear(); + + // Setup mock services + mockServices = { + savedObjects: mockCore.savedObjects, + notifications: mockCore.notifications, + indexPatterns: { + getIds: jest.fn().mockResolvedValue([]), + get: jest.fn(), + } as any, + }; + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const renderWithContext = () => { + return render( + + + + + + ); + }; + + it('should render DiscoverNoIndexPatterns when no trace data is detected', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([]); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('No Index Patterns')).toBeInTheDocument(); + }); + }); + + it('should render callout when trace data is detected', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Trace Data Detected')).toBeInTheDocument(); + expect(screen.getByText('otel-traces-*')).toBeInTheDocument(); + }); + }); + + it('should render callout when trace and log data is detected', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: true, + tracePattern: 'otel-traces-*', + logPattern: 'otel-logs-*', + traceTimeField: 'endTime', + logTimeField: 'time', + }, + ]); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Trace Data Detected')).toBeInTheDocument(); + expect(screen.getByText('otel-traces-*')).toBeInTheDocument(); + expect(screen.getByText('otel-logs-*')).toBeInTheDocument(); + }); + }); + + it('should respect localStorage dismissal', async () => { + localStorageMock.setItem('explore:traces:autoDetectDismissed', 'true'); + + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + // Mock indexPatterns to indicate there are trace datasets + (mockServices.indexPatterns!.getIds as jest.Mock).mockResolvedValue(['test-id']); + (mockServices.indexPatterns!.get as jest.Mock).mockResolvedValue({ + signalType: 'traces', + }); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('No Index Patterns')).toBeInTheDocument(); + expect(screen.queryByText('Trace Data Detected')).not.toBeInTheDocument(); + }); + }); + + it('should clear dismissal if no trace datasets exist', async () => { + localStorageMock.setItem('explore:traces:autoDetectDismissed', 'true'); + + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + // Mock indexPatterns to indicate there are NO trace datasets + (mockServices.indexPatterns!.getIds as jest.Mock).mockResolvedValue(['test-id']); + (mockServices.indexPatterns!.get as jest.Mock).mockResolvedValue({ + signalType: 'logs', // Not traces + }); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Trace Data Detected')).toBeInTheDocument(); + expect(localStorageMock.getItem('explore:traces:autoDetectDismissed')).toBeNull(); + }); + }); + + it('should dismiss callout when Dismiss button is clicked', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Trace Data Detected')).toBeInTheDocument(); + }); + + const dismissButton = screen.getByText('Dismiss'); + fireEvent.click(dismissButton); + + await waitFor(() => { + expect(localStorageMock.getItem('explore:traces:autoDetectDismissed')).toBe('true'); + expect(screen.getByText('No Index Patterns')).toBeInTheDocument(); + }); + }); + + it('should create datasets when Create button is clicked', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: true, + tracePattern: 'otel-traces-*', + logPattern: 'otel-logs-*', + traceTimeField: 'endTime', + logTimeField: 'time', + }, + ]); + + mockCreateAutoDetectedDatasets.mockResolvedValue({ + traceDatasetId: 'trace-id', + logDatasetId: 'log-id', + correlationId: 'correlation-id', + }); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Create Trace Datasets')).toBeInTheDocument(); + }); + + const createButton = screen.getByText('Create Trace Datasets'); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateAutoDetectedDatasets).toHaveBeenCalledWith( + mockServices.savedObjects!.client, + expect.objectContaining({ + tracesDetected: true, + logsDetected: true, + }) + ); + }); + }); + + it('should show success toast and reload after creating datasets', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + mockCreateAutoDetectedDatasets.mockResolvedValue({ + traceDatasetId: 'trace-id', + }); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Create Trace Datasets')).toBeInTheDocument(); + }); + + const createButton = screen.getByText('Create Trace Datasets'); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCore.notifications.toasts.addSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Trace datasets created', + }) + ); + }); + + // Fast-forward time to trigger reload + jest.advanceTimersByTime(2000); + expect(mockReload).toHaveBeenCalled(); + }); + + it('should show error toast when dataset creation fails', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + const errorMessage = 'Failed to create datasets'; + mockCreateAutoDetectedDatasets.mockRejectedValue(new Error(errorMessage)); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Create Trace Datasets')).toBeInTheDocument(); + }); + + const createButton = screen.getByText('Create Trace Datasets'); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalledWith({ + title: 'Failed to create datasets', + text: errorMessage, + }); + }); + }); + + it('should clear dismissal flag after successful dataset creation', async () => { + localStorageMock.setItem('explore:traces:autoDetectDismissed', 'true'); + + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + // Mock to show no existing trace datasets so callout shows + (mockServices.indexPatterns!.getIds as jest.Mock).mockResolvedValue([]); + + mockCreateAutoDetectedDatasets.mockResolvedValue({ + traceDatasetId: 'trace-id', + }); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Create Trace Datasets')).toBeInTheDocument(); + }); + + const createButton = screen.getByText('Create Trace Datasets'); + fireEvent.click(createButton); + + await waitFor(() => { + expect(localStorageMock.getItem('explore:traces:autoDetectDismissed')).toBeNull(); + }); + }); + + it('should handle detection failure gracefully', async () => { + mockDetectTraceDataAcrossDataSources.mockRejectedValue(new Error('Detection failed')); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('No Index Patterns')).toBeInTheDocument(); + expect(screen.queryByText('Trace Data Detected')).not.toBeInTheDocument(); + }); + + // Should not show any error toast for detection failure + expect(mockCore.notifications.toasts.addDanger).not.toHaveBeenCalled(); + }); + + it('should disable create button while creating datasets', async () => { + mockDetectTraceDataAcrossDataSources.mockResolvedValue([ + { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-traces-*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }, + ]); + + // Mock a slow creation + mockCreateAutoDetectedDatasets.mockImplementation( + () => + new Promise((resolve) => setTimeout(() => resolve({ traceDatasetId: 'trace-id' }), 1000)) + ); + + renderWithContext(); + + await waitFor(() => { + expect(screen.getByText('Create Trace Datasets')).toBeInTheDocument(); + }); + + const createButton = screen.getByText('Create Trace Datasets').closest('button'); + fireEvent.click(createButton!); + + await waitFor(() => { + expect(createButton).toBeDisabled(); + const loadingSpinner = createButton!.querySelector('.euiLoadingSpinner'); + expect(loadingSpinner).toBeInTheDocument(); + }); + }); +}); diff --git a/src/plugins/explore/public/components/trace_auto_detect_callout.tsx b/src/plugins/explore/public/components/trace_auto_detect_callout.tsx new file mode 100644 index 000000000000..899f65e71a5b --- /dev/null +++ b/src/plugins/explore/public/components/trace_auto_detect_callout.tsx @@ -0,0 +1,276 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiPanel, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { CORE_SIGNAL_TYPES } from '../../../data/common'; +import { ExploreServices } from '../types'; +import { detectTraceDataAcrossDataSources, DetectionResult } from '../utils/auto_detect_trace_data'; +import { createAutoDetectedDatasets } from '../utils/create_auto_datasets'; +import { DiscoverNoIndexPatterns } from '../application/legacy/discover/application/components/no_index_patterns/no_index_patterns'; + +const DISMISSED_KEY = 'explore:traces:autoDetectDismissed'; + +export const TraceAutoDetectCallout: React.FC = () => { + const { services } = useOpenSearchDashboards(); + const [isDetecting, setIsDetecting] = useState(true); + const [detections, setDetections] = useState([]); + const [isCreating, setIsCreating] = useState(false); + const [isDismissed, setIsDismissed] = useState(false); + + useEffect(() => { + let isMounted = true; + + // Run detection + const runDetection = async () => { + // Check if user dismissed this before + const dismissed = localStorage.getItem(DISMISSED_KEY); + if (dismissed === 'true') { + // Check if there are any existing trace datasets + // If not, clear the dismissal so user can see the callout again + try { + const allIndexPatterns = await services.indexPatterns.getIds(); + if (!isMounted) return; + + let hasTraceDatasets = false; + + for (const id of allIndexPatterns) { + if (!isMounted) return; + try { + const indexPattern = await services.indexPatterns.get(id); + if (!isMounted) return; + + if (indexPattern.signalType === CORE_SIGNAL_TYPES.TRACES) { + hasTraceDatasets = true; + break; + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + continue; + } + } + + if (!isMounted) return; + + // If no trace datasets exist, clear dismissal and run detection + if (!hasTraceDatasets) { + localStorage.removeItem(DISMISSED_KEY); + } else { + // Has trace datasets and was dismissed, so keep it dismissed + if (isMounted) { + setIsDismissed(true); + setIsDetecting(false); + } + return; + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + // If check fails, respect the dismissal + if (isMounted) { + setIsDismissed(true); + setIsDetecting(false); + } + return; + } + } + + try { + const results = await detectTraceDataAcrossDataSources( + services.savedObjects.client, + services.indexPatterns + ); + + if (!isMounted) return; + + if (results.length > 0) { + setDetections(results); + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + // Detection failed, but don't show any error + } finally { + if (isMounted) { + setIsDetecting(false); + } + } + }; + + runDetection(); + + return () => { + isMounted = false; + }; + }, [services]); + + const handleCreate = async () => { + if (detections.length === 0) return; + + setIsCreating(true); + try { + let totalCreated = 0; + const dataSourceNames: string[] = []; + + // Create datasets for each detected datasource + for (const detection of detections) { + const result = await createAutoDetectedDatasets(services.savedObjects.client, detection); + + if (result.traceDatasetId || result.logDatasetId) { + totalCreated++; + if (detection.dataSourceTitle) { + dataSourceNames.push(detection.dataSourceTitle); + } + } + } + + // Clear dismissed flag so if datasets are deleted later, callout can show again + localStorage.removeItem(DISMISSED_KEY); + + services.notifications.toasts.addSuccess({ + title: i18n.translate('explore.traces.autoDetect.successTitle', { + defaultMessage: 'Trace datasets created', + }), + text: i18n.translate('explore.traces.autoDetect.successMessageMultiple', { + defaultMessage: + 'Created trace and log datasets for {count} data {sources}: {names}. Reloading page...', + values: { + count: totalCreated, + sources: totalCreated === 1 ? 'source' : 'sources', + names: dataSourceNames.join(', '), + }, + }), + }); + + // Reload after 2 seconds to show the new datasets + setTimeout(() => window.location.reload(), 2000); + } catch (error) { + services.notifications.toasts.addDanger({ + title: i18n.translate('explore.traces.autoDetect.errorTitle', { + defaultMessage: 'Failed to create datasets', + }), + text: (error as Error).message, + }); + setIsCreating(false); + } + }; + + const handleDismiss = () => { + localStorage.setItem(DISMISSED_KEY, 'true'); + setIsDismissed(true); + }; + + // Always show default empty state while detecting, after dismissal, or when no trace data found + if (isDetecting || isDismissed || detections.length === 0) { + return ; + } + + return ( + + + + + + + + + +

+ +

+
+
+ + +

+ +

+
+
+ + +
    + {detections.map((detection, index) => ( + + {detection.dataSourceTitle && ( +
  • 0 ? '8px' : '0' }}> + {detection.dataSourceTitle}: +
  • + )} + {detection.tracesDetected && detection.tracePattern && ( +
  • + {detection.tracePattern}{' '} + +
  • + )} + {detection.logsDetected && detection.logPattern && ( +
  • + {detection.logPattern}{' '} + +
  • + )} +
    + ))} +
+
+
+ + + + + + + + + + + + + + +
+
+
+
+ ); +}; diff --git a/src/plugins/explore/public/index.ts b/src/plugins/explore/public/index.ts index 6d3d697f40fa..f112726a1edd 100644 --- a/src/plugins/explore/public/index.ts +++ b/src/plugins/explore/public/index.ts @@ -15,3 +15,7 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { ExplorePluginSetup, ExplorePluginStart } from './types'; + +// Export trace auto-detection utilities for use by other plugins +export { detectTraceData, DetectionResult } from './utils/auto_detect_trace_data'; +export { createAutoDetectedDatasets, CreateDatasetsResult } from './utils/create_auto_datasets'; diff --git a/src/plugins/explore/public/utils/auto_detect_trace_data.test.ts b/src/plugins/explore/public/utils/auto_detect_trace_data.test.ts new file mode 100644 index 000000000000..7b8627adbb49 --- /dev/null +++ b/src/plugins/explore/public/utils/auto_detect_trace_data.test.ts @@ -0,0 +1,806 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'src/core/public'; +import { IndexPatternsContract } from '../../../data/public'; +import { detectTraceData, detectTraceDataAcrossDataSources } from './auto_detect_trace_data'; + +describe('detectTraceData', () => { + let mockSavedObjectsClient: jest.Mocked; + let mockIndexPatternsService: jest.Mocked; + + beforeEach(() => { + // Create mock saved objects client + mockSavedObjectsClient = {} as jest.Mocked; + + // Create mock index patterns service + mockIndexPatternsService = { + getIds: jest.fn(), + get: jest.fn(), + getFieldsForWildcard: jest.fn(), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty result when trace datasets already exist', async () => { + mockIndexPatternsService.getIds.mockResolvedValue(['existing-trace-id']); + mockIndexPatternsService.get.mockResolvedValue({ + id: 'existing-trace-id', + signalType: 'traces', + } as any); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result).toEqual({ + tracesDetected: false, + logsDetected: false, + tracePattern: null, + logPattern: null, + traceTimeField: null, + logTimeField: null, + dataSourceId: undefined, + }); + expect(mockIndexPatternsService.getIds).toHaveBeenCalled(); + expect(mockIndexPatternsService.get).toHaveBeenCalledWith('existing-trace-id'); + // Should not check for wildcard patterns since trace datasets exist + expect(mockIndexPatternsService.getFieldsForWildcard).not.toHaveBeenCalled(); + }); + + it('should detect trace data when otel-v1-apm-span* indices exist with required fields', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result.tracesDetected).toBe(true); + expect(result.tracePattern).toBe('otel-v1-apm-span*'); + expect(result.traceTimeField).toBe('endTime'); + expect(result.logsDetected).toBe(false); + expect(mockIndexPatternsService.getFieldsForWildcard).toHaveBeenCalledWith({ + pattern: 'otel-v1-apm-span*', + dataSourceId: undefined, + }); + }); + + it('should not detect traces when required fields are missing', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + // Missing traceId field + return [ + { name: 'spanId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result.tracesDetected).toBe(false); + expect(result.tracePattern).toBeNull(); + expect(result.traceTimeField).toBeNull(); + }); + + it('should detect log data when logs-otel-v1* indices exist with required fields', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'logs-otel-v1*') { + return [ + { name: 'traceId', type: 'string' }, + { name: 'spanId', type: 'string' }, + { name: 'time', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result.logsDetected).toBe(true); + expect(result.logPattern).toBe('logs-otel-v1*'); + expect(result.logTimeField).toBe('time'); + expect(result.tracesDetected).toBe(false); + expect(mockIndexPatternsService.getFieldsForWildcard).toHaveBeenCalledWith({ + pattern: 'logs-otel-v1*', + dataSourceId: undefined, + }); + }); + + it('should not detect logs when required fields are missing', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'logs-otel-v1*') { + // Missing spanId field + return [ + { name: 'traceId', type: 'string' }, + { name: 'time', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result.logsDetected).toBe(false); + expect(result.logPattern).toBeNull(); + expect(result.logTimeField).toBeNull(); + }); + + it('should detect both traces and logs when both exist', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + if (pattern === 'logs-otel-v1*') { + return [ + { name: 'traceId', type: 'string' }, + { name: 'spanId', type: 'string' }, + { name: 'time', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result.tracesDetected).toBe(true); + expect(result.tracePattern).toBe('otel-v1-apm-span*'); + expect(result.traceTimeField).toBe('endTime'); + expect(result.logsDetected).toBe(true); + expect(result.logPattern).toBe('logs-otel-v1*'); + expect(result.logTimeField).toBe('time'); + }); + + it('should return empty result when no matching indices exist', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockRejectedValue( + new Error('No matching indices') + ); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result).toEqual({ + tracesDetected: false, + logsDetected: false, + tracePattern: null, + logPattern: null, + traceTimeField: null, + logTimeField: null, + dataSourceId: undefined, + }); + }); + + it('should pass dataSourceId to getFieldsForWildcard when provided', async () => { + const dataSourceId = 'test-datasource-id'; + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService, dataSourceId); + + expect(mockIndexPatternsService.getFieldsForWildcard).toHaveBeenCalledWith({ + pattern: 'otel-v1-apm-span*', + dataSourceId, + }); + }); + + it('should handle errors when checking existing index patterns', async () => { + mockIndexPatternsService.getIds.mockRejectedValue(new Error('Failed to get IDs')); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + // Should continue with detection even if getIds fails + expect(result.tracesDetected).toBe(true); + expect(result.tracePattern).toBe('otel-v1-apm-span*'); + }); + + it('should skip index patterns that fail to load', async () => { + mockIndexPatternsService.getIds.mockResolvedValue(['id-1', 'id-2', 'id-3']); + mockIndexPatternsService.get.mockImplementation(async (id) => { + if (id === 'id-1') { + throw new Error('Failed to load'); + } + if (id === 'id-2') { + return { id: 'id-2', signalType: 'logs' } as any; + } + if (id === 'id-3') { + return { id: 'id-3', signalType: 'metrics' } as any; + } + return {} as any; + }); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + // Should continue with detection since no trace signalType was found + expect(result.tracesDetected).toBe(true); + expect(mockIndexPatternsService.get).toHaveBeenCalledTimes(3); + }); + + it('should handle traces with extra fields beyond the required ones', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + { name: 'serviceName', type: 'string' }, + { name: 'duration', type: 'number' }, + { name: 'resource.attributes', type: 'object' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result.tracesDetected).toBe(true); + expect(result.tracePattern).toBe('otel-v1-apm-span*'); + expect(result.traceTimeField).toBe('endTime'); + }); + + it('should handle logs with extra fields beyond the required ones', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'logs-otel-v1*') { + return [ + { name: 'traceId', type: 'string' }, + { name: 'spanId', type: 'string' }, + { name: 'time', type: 'date' }, + { name: 'severityText', type: 'string' }, + { name: 'body', type: 'string' }, + { name: 'attributes', type: 'object' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + expect(result.logsDetected).toBe(true); + expect(result.logPattern).toBe('logs-otel-v1*'); + expect(result.logTimeField).toBe('time'); + }); + + it('should return empty result when existing datasets have different signalType', async () => { + mockIndexPatternsService.getIds.mockResolvedValue(['logs-id', 'metrics-id']); + mockIndexPatternsService.get.mockImplementation(async (id) => { + if (id === 'logs-id') { + return { id: 'logs-id', signalType: 'logs' } as any; + } + if (id === 'metrics-id') { + return { id: 'metrics-id', signalType: 'metrics' } as any; + } + return {} as any; + }); + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + if (pattern === 'logs-otel-v1*') { + return [ + { name: 'traceId', type: 'string' }, + { name: 'spanId', type: 'string' }, + { name: 'time', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const result = await detectTraceData(mockSavedObjectsClient, mockIndexPatternsService); + + // Should continue with detection since no trace signalType was found + expect(result.tracesDetected).toBe(true); + expect(result.logsDetected).toBe(true); + }); + + it('should not skip detection when trace dataset exists in different datasource', async () => { + // Setup: datasource A has trace datasets, but we're checking datasource B + mockIndexPatternsService.getIds.mockResolvedValue(['trace-from-datasource-a']); + mockIndexPatternsService.get.mockImplementation(async (id) => { + if (id === 'trace-from-datasource-a') { + return { + id: 'trace-from-datasource-a', + signalType: 'traces', + dataSourceRef: { id: 'datasource-a', type: 'data-source' }, + } as any; + } + return {} as any; + }); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + // Call with datasource-b, should NOT early return because trace dataset is from datasource-a + const result = await detectTraceData( + mockSavedObjectsClient, + mockIndexPatternsService, + 'datasource-b' + ); + + // Should detect traces for datasource-b even though datasource-a has trace datasets + expect(result.tracesDetected).toBe(true); + expect(result.tracePattern).toBe('otel-v1-apm-span*'); + expect(result.dataSourceId).toBe('datasource-b'); + }); +}); + +describe('detectTraceDataAcrossDataSources', () => { + let mockSavedObjectsClient: jest.Mocked; + let mockIndexPatternsService: jest.Mocked; + + beforeEach(() => { + mockSavedObjectsClient = { + find: jest.fn(), + } as any; + + mockIndexPatternsService = { + getIds: jest.fn(), + get: jest.fn(), + getFieldsForWildcard: jest.fn(), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should check datasources even when trace datasets exist for other datasources', async () => { + // Setup: existing trace dataset for datasource A + mockIndexPatternsService.getIds.mockResolvedValue(['existing-trace-id']); + mockIndexPatternsService.get.mockImplementation(async (id) => { + if (id === 'existing-trace-id') { + return { + id: 'existing-trace-id', + signalType: 'traces', + dataSourceRef: { id: 'datasource-a', type: 'data-source' }, + } as any; + } + return {} as any; + }); + + // Setup datasource B that should still be checked + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'datasource-b', + attributes: { title: 'DataSource B' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation(async ({ pattern }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + }); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + // Should still check datasource B even though datasource A has traces + expect(mockSavedObjectsClient.find).toHaveBeenCalled(); + expect(results.length).toBe(1); + expect(results[0].dataSourceId).toBe('datasource-b'); + expect(results[0].tracesDetected).toBe(true); + }); + + it('should detect traces from multiple data sources', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + { + id: 'ds2', + attributes: { title: 'DataSource 2' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + tracesDetected: true, + tracePattern: 'otel-v1-apm-span*', + traceTimeField: 'endTime', + dataSourceId: 'ds1', + dataSourceTitle: 'DataSource 1', + }); + expect(results[1]).toMatchObject({ + tracesDetected: true, + tracePattern: 'otel-v1-apm-span*', + traceTimeField: 'endTime', + dataSourceId: 'ds2', + dataSourceTitle: 'DataSource 2', + }); + }); + + it('should only include data sources with detected traces or logs', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + { + id: 'ds2', + attributes: { title: 'DataSource 2' }, + }, + ], + } as any); + + // Only ds1 has traces + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + if (dataSourceId === 'ds1' && pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + expect(results).toHaveLength(1); + expect(results[0].dataSourceId).toBe('ds1'); + expect(results[0].dataSourceTitle).toBe('DataSource 1'); + }); + + it('should check local cluster when no data sources have traces', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + // No traces in data sources, but traces in local cluster + if (dataSourceId === undefined && pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + tracesDetected: true, + dataSourceTitle: 'Local Cluster', + dataSourceId: undefined, + }); + }); + + it('should not check local cluster when data sources have traces', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + // Both data source and local cluster have traces + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + // Should only have the data source, not local cluster + expect(results).toHaveLength(1); + expect(results[0].dataSourceId).toBe('ds1'); + expect(results[0].dataSourceTitle).toBe('DataSource 1'); + }); + + it('should handle errors when checking individual data sources', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + { + id: 'ds2', + attributes: { title: 'DataSource 2' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + if (dataSourceId === 'ds1') { + throw new Error('Connection failed'); + } + if (dataSourceId === 'ds2' && pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + // Should continue and return ds2 even though ds1 failed + expect(results).toHaveLength(1); + expect(results[0].dataSourceId).toBe('ds2'); + }); + + it('should handle errors when fetching data sources and check local cluster', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockRejectedValue(new Error('Failed to fetch data sources')); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + if (dataSourceId === undefined && pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + // Should fall back to local cluster + expect(results).toHaveLength(1); + expect(results[0].dataSourceTitle).toBe('Local Cluster'); + }); + + it('should detect both traces and logs for a data source', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + if (pattern === 'logs-otel-v1*') { + return [ + { name: 'traceId', type: 'string' }, + { name: 'spanId', type: 'string' }, + { name: 'time', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + tracesDetected: true, + logsDetected: true, + tracePattern: 'otel-v1-apm-span*', + logPattern: 'logs-otel-v1*', + dataSourceId: 'ds1', + dataSourceTitle: 'DataSource 1', + }); + }); + + it('should return empty array when no traces or logs are detected anywhere', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockRejectedValue( + new Error('No matching indices') + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + expect(results).toEqual([]); + }); + + it('should handle error when checking local cluster', async () => { + mockIndexPatternsService.getIds.mockResolvedValue([]); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockRejectedValue(new Error('Connection failed')); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + // Should handle error gracefully and return empty array + expect(results).toEqual([]); + }); + + it('should continue checking other data sources if getIds fails', async () => { + mockIndexPatternsService.getIds.mockRejectedValue(new Error('Failed to get IDs')); + mockSavedObjectsClient.find.mockResolvedValue({ + savedObjects: [ + { + id: 'ds1', + attributes: { title: 'DataSource 1' }, + }, + ], + } as any); + + mockIndexPatternsService.getFieldsForWildcard.mockImplementation( + async ({ pattern, dataSourceId }) => { + if (pattern === 'otel-v1-apm-span*') { + return [ + { name: 'spanId', type: 'string' }, + { name: 'traceId', type: 'string' }, + { name: 'endTime', type: 'date' }, + ] as any; + } + throw new Error('No matching indices'); + } + ); + + const results = await detectTraceDataAcrossDataSources( + mockSavedObjectsClient, + mockIndexPatternsService + ); + + // Should continue with detection even if getIds fails + expect(results).toHaveLength(1); + expect(results[0].dataSourceId).toBe('ds1'); + }); +}); diff --git a/src/plugins/explore/public/utils/auto_detect_trace_data.ts b/src/plugins/explore/public/utils/auto_detect_trace_data.ts new file mode 100644 index 000000000000..886bd7a14bf1 --- /dev/null +++ b/src/plugins/explore/public/utils/auto_detect_trace_data.ts @@ -0,0 +1,182 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'src/core/public'; +import { IndexPatternsContract } from '../../../data/public'; + +export interface DetectionResult { + tracesDetected: boolean; + logsDetected: boolean; + tracePattern: string | null; + logPattern: string | null; + traceTimeField: string | null; + logTimeField: string | null; + dataSourceId?: string; + dataSourceTitle?: string; +} + +/** + * Auto-detect trace data following OpenTelemetry conventions + * Checks for otel-v1-apm-span* (traces) and logs-otel-v1* (logs) + */ +export async function detectTraceData( + savedObjectsClient: SavedObjectsClientContract, + indexPatternsService: IndexPatternsContract, + dataSourceId?: string +): Promise { + const result: DetectionResult = { + tracesDetected: false, + logsDetected: false, + tracePattern: null, + logPattern: null, + traceTimeField: null, + logTimeField: null, + dataSourceId, + }; + + // 1. Check if trace datasets already exist + try { + // Get all index patterns and filter by signalType + const allIndexPatterns = await indexPatternsService.getIds(); + + // Check each index pattern for signalType === 'traces' + for (const id of allIndexPatterns) { + try { + const indexPattern = await indexPatternsService.get(id); + // Only check patterns from the target datasource + if ( + indexPattern.dataSourceRef?.id !== dataSourceId && + (indexPattern.dataSourceRef?.id || dataSourceId) + ) { + continue; + } + if (indexPattern.signalType === 'traces') { + // Already have trace datasets, no need to auto-detect + return result; + } + } catch (getError: any) { + // Skip if can't load this pattern (might be deleted/stale reference) + continue; + } + } + } catch (error) { + // If loading fails, continue with detection + } + + // 2. Check for conventional trace indices: otel-v1-apm-span* + try { + const traceFields = await indexPatternsService.getFieldsForWildcard({ + pattern: 'otel-v1-apm-span*', + dataSourceId, + }); + + // Verify required trace fields exist + const hasSpanId = traceFields.some((f) => f.name === 'spanId'); + const hasTraceId = traceFields.some((f) => f.name === 'traceId'); + const hasEndTime = traceFields.some((f) => f.name === 'endTime'); + + if (hasSpanId && hasTraceId && hasEndTime) { + result.tracesDetected = true; + result.tracePattern = 'otel-v1-apm-span*'; + result.traceTimeField = 'endTime'; + } + } catch (error) { + // No matching indices found, continue + } + + // 3. Check for conventional log indices: logs-otel-v1* + try { + const logFields = await indexPatternsService.getFieldsForWildcard({ + pattern: 'logs-otel-v1*', + dataSourceId, + }); + + // Verify correlation fields exist + const hasTraceId = logFields.some((f) => f.name === 'traceId'); + const hasSpanId = logFields.some((f) => f.name === 'spanId'); + const hasTime = logFields.some((f) => f.name === 'time'); + + if (hasTraceId && hasSpanId && hasTime) { + result.logsDetected = true; + result.logPattern = 'logs-otel-v1*'; + result.logTimeField = 'time'; + } + } catch (error) { + // No matching indices found + } + + return result; +} + +/** + * Detect trace data across all OpenSearch datasource connections + * Returns detection results for each datasource that has matching indices + */ +export async function detectTraceDataAcrossDataSources( + savedObjectsClient: SavedObjectsClientContract, + indexPatternsService: IndexPatternsContract +): Promise { + const results: DetectionResult[] = []; + + // 1. Fetch all data sources + try { + const dataSourcesResp = await savedObjectsClient.find({ + type: 'data-source', + perPage: 10000, + }); + + // 2. Check each data source for trace data + for (const dataSource of dataSourcesResp.savedObjects) { + try { + const detection = await detectTraceData( + savedObjectsClient, + indexPatternsService, + dataSource.id + ); + + // If traces or logs detected, include datasource info and add to results + if (detection.tracesDetected || detection.logsDetected) { + // Create a new object with datasource info instead of mutating + const detectionWithSource: DetectionResult = { + ...detection, + dataSourceId: dataSource.id, + dataSourceTitle: dataSource.attributes.title, + }; + results.push(detectionWithSource); + } + } catch (error) { + // Skip this datasource if detection fails + continue; + } + } + } catch (error) { + // If fetching data sources fails, fall through + } + + // 3. Also check local cluster (no datasource) - but only if no datasources were found + // This prevents duplicates when a datasource points to the local cluster + if (results.length === 0) { + try { + const localDetection = await detectTraceData( + savedObjectsClient, + indexPatternsService, + undefined + ); + + if (localDetection.tracesDetected || localDetection.logsDetected) { + // Create a new object with local cluster title + const detectionWithSource: DetectionResult = { + ...localDetection, + dataSourceTitle: 'Local Cluster', + }; + results.push(detectionWithSource); + } + } catch (error) { + // Continue if local cluster check fails + } + } + + return results; +} diff --git a/src/plugins/explore/public/utils/create_auto_datasets.test.ts b/src/plugins/explore/public/utils/create_auto_datasets.test.ts new file mode 100644 index 000000000000..153bba0eb78e --- /dev/null +++ b/src/plugins/explore/public/utils/create_auto_datasets.test.ts @@ -0,0 +1,708 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'src/core/public'; +import { createAutoDetectedDatasets } from './create_auto_datasets'; +import { DetectionResult } from './auto_detect_trace_data'; + +describe('createAutoDetectedDatasets', () => { + let mockSavedObjectsClient: jest.Mocked; + + beforeEach(() => { + // Create mock saved objects client + mockSavedObjectsClient = { + create: jest.fn(), + find: jest.fn().mockResolvedValue({ total: 0, savedObjects: [] }), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create trace dataset when traces are detected', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-v1-apm-span*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'trace-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBe('trace-dataset-id'); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + + expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + { + title: 'otel-v1-apm-span*', + displayName: 'Trace Dataset', + timeFieldName: 'endTime', + signalType: 'traces', + }, + { + references: [], + } + ); + }); + + it('should create log dataset when logs are detected', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: 'logs-otel-v1*', + traceTimeField: null, + logTimeField: 'time', + }; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBe('log-dataset-id'); + expect(result.correlationId).toBeNull(); + + expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + { + title: 'logs-otel-v1*', + displayName: 'Log Dataset', + timeFieldName: 'time', + signalType: 'logs', + schemaMappings: JSON.stringify({ + otelLogs: { + timeField: 'time', + traceId: 'traceId', + spanId: 'spanId', + serviceName: 'resource.attributes.service.name', + }, + }), + }, + { + references: [], + } + ); + }); + + it('should create both datasets and correlation when both are detected', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: true, + tracePattern: 'otel-v1-apm-span*', + logPattern: 'logs-otel-v1*', + traceTimeField: 'endTime', + logTimeField: 'time', + }; + + mockSavedObjectsClient.create + .mockResolvedValueOnce({ + id: 'trace-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockResolvedValueOnce({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockResolvedValueOnce({ + id: 'correlation-id', + type: 'correlations', + attributes: {}, + references: [], + } as any); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBe('trace-dataset-id'); + expect(result.logDatasetId).toBe('log-dataset-id'); + expect(result.correlationId).toBe('correlation-id'); + + expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(3); + + // Verify trace dataset creation + expect(mockSavedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + 'index-pattern', + { + title: 'otel-v1-apm-span*', + displayName: 'Trace Dataset', + timeFieldName: 'endTime', + signalType: 'traces', + }, + { + references: [], + } + ); + + // Verify log dataset creation + expect(mockSavedObjectsClient.create).toHaveBeenNthCalledWith( + 2, + 'index-pattern', + { + title: 'logs-otel-v1*', + displayName: 'Log Dataset', + timeFieldName: 'time', + signalType: 'logs', + schemaMappings: JSON.stringify({ + otelLogs: { + timeField: 'time', + traceId: 'traceId', + spanId: 'spanId', + serviceName: 'resource.attributes.service.name', + }, + }), + }, + { + references: [], + } + ); + + // Verify correlation creation + expect(mockSavedObjectsClient.create).toHaveBeenNthCalledWith( + 3, + 'correlations', + { + correlationType: 'APM-Correlation', + version: '1.0.0', + entities: [ + { tracesDataset: { id: 'references[0].id' } }, + { logsDataset: { id: 'references[1].id' } }, + ], + }, + { + references: [ + { + name: 'entities[0].index', + type: 'index-pattern', + id: 'trace-dataset-id', + }, + { + name: 'entities[1].index', + type: 'index-pattern', + id: 'log-dataset-id', + }, + ], + } + ); + }); + + it('should include dataSourceRef when dataSourceId is provided for trace dataset', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-v1-apm-span*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }; + + const dataSourceId = 'test-datasource-id'; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'trace-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + await createAutoDetectedDatasets(mockSavedObjectsClient, detection, dataSourceId); + + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + { + title: 'otel-v1-apm-span*', + displayName: 'Trace Dataset', + timeFieldName: 'endTime', + signalType: 'traces', + }, + { + references: [ + { + id: dataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ], + } + ); + }); + + it('should include dataSourceRef when dataSourceId is provided for log dataset', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: 'logs-otel-v1*', + traceTimeField: null, + logTimeField: 'time', + }; + + const dataSourceId = 'test-datasource-id'; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + await createAutoDetectedDatasets(mockSavedObjectsClient, detection, dataSourceId); + + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + { + title: 'logs-otel-v1*', + displayName: 'Log Dataset', + timeFieldName: 'time', + signalType: 'logs', + schemaMappings: JSON.stringify({ + otelLogs: { + timeField: 'time', + traceId: 'traceId', + spanId: 'spanId', + serviceName: 'resource.attributes.service.name', + }, + }), + }, + { + references: [ + { + id: dataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ], + } + ); + }); + + it('should not create trace dataset when tracePattern is missing', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: false, + tracePattern: null, // Missing pattern + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }; + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + expect(mockSavedObjectsClient.create).not.toHaveBeenCalled(); + }); + + it('should not create trace dataset when traceTimeField is missing', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-v1-apm-span*', + logPattern: null, + traceTimeField: null, // Missing time field + logTimeField: null, + }; + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + expect(mockSavedObjectsClient.create).not.toHaveBeenCalled(); + }); + + it('should not create log dataset when logPattern is missing', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: null, // Missing pattern + traceTimeField: null, + logTimeField: 'time', + }; + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + expect(mockSavedObjectsClient.create).not.toHaveBeenCalled(); + }); + + it('should not create log dataset when logTimeField is missing', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: 'logs-otel-v1*', + traceTimeField: null, + logTimeField: null, // Missing time field + }; + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + expect(mockSavedObjectsClient.create).not.toHaveBeenCalled(); + }); + + it('should not create correlation if only trace dataset was created', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-v1-apm-span*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'trace-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.correlationId).toBeNull(); + expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(1); + }); + + it('should not create correlation if only log dataset was created', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: 'logs-otel-v1*', + traceTimeField: null, + logTimeField: 'time', + }; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.correlationId).toBeNull(); + expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(1); + }); + + it('should return empty result when nothing is detected', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: false, + tracePattern: null, + logPattern: null, + traceTimeField: null, + logTimeField: null, + }; + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + expect(mockSavedObjectsClient.create).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully when trace dataset creation fails', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: false, + tracePattern: 'otel-v1-apm-span*', + logPattern: null, + traceTimeField: 'endTime', + logTimeField: null, + }; + + const error = new Error('Failed to create trace dataset'); + mockSavedObjectsClient.create.mockRejectedValue(error); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + // Should return null instead of throwing + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + }); + + it('should handle errors gracefully when log dataset creation fails', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: 'logs-otel-v1*', + traceTimeField: null, + logTimeField: 'time', + }; + + const error = new Error('Failed to create log dataset'); + mockSavedObjectsClient.create.mockRejectedValue(error); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + // Should return null instead of throwing + expect(result.traceDatasetId).toBeNull(); + expect(result.logDatasetId).toBeNull(); + expect(result.correlationId).toBeNull(); + }); + + it('should handle errors gracefully when correlation creation fails', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: true, + tracePattern: 'otel-v1-apm-span*', + logPattern: 'logs-otel-v1*', + traceTimeField: 'endTime', + logTimeField: 'time', + }; + + mockSavedObjectsClient.create + .mockResolvedValueOnce({ + id: 'trace-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockResolvedValueOnce({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockRejectedValueOnce(new Error('Failed to create correlation')); + + const result = await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + // Should return successfully with dataset IDs even if correlation fails + expect(result.traceDatasetId).toBe('trace-dataset-id'); + expect(result.logDatasetId).toBe('log-dataset-id'); + expect(result.correlationId).toBeNull(); + }); + + it('should include dataSourceRef for both datasets when dataSourceId is provided', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: true, + tracePattern: 'otel-v1-apm-span*', + logPattern: 'logs-otel-v1*', + traceTimeField: 'endTime', + logTimeField: 'time', + }; + + const dataSourceId = 'test-datasource-id'; + + mockSavedObjectsClient.create + .mockResolvedValueOnce({ + id: 'trace-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockResolvedValueOnce({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockResolvedValueOnce({ + id: 'correlation-id', + type: 'correlations', + attributes: {}, + references: [], + } as any); + + await createAutoDetectedDatasets(mockSavedObjectsClient, detection, dataSourceId); + + // Verify both datasets have dataSourceRef in references + expect(mockSavedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + 'index-pattern', + expect.anything(), + expect.objectContaining({ + references: [ + { + id: dataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ], + }) + ); + + expect(mockSavedObjectsClient.create).toHaveBeenNthCalledWith( + 2, + 'index-pattern', + expect.anything(), + expect.objectContaining({ + references: [ + { + id: dataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ], + }) + ); + }); + + it('should create datasets with correct signal types', async () => { + const detection: DetectionResult = { + tracesDetected: true, + logsDetected: true, + tracePattern: 'otel-v1-apm-span*', + logPattern: 'logs-otel-v1*', + traceTimeField: 'endTime', + logTimeField: 'time', + }; + + mockSavedObjectsClient.create + .mockResolvedValueOnce({ + id: 'trace-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockResolvedValueOnce({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any) + .mockResolvedValueOnce({ + id: 'correlation-id', + type: 'correlations', + attributes: {}, + references: [], + } as any); + + await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + // Verify trace dataset has signalType='traces' + expect(mockSavedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + 'index-pattern', + expect.objectContaining({ + signalType: 'traces', + }), + expect.anything() + ); + + // Verify log dataset has signalType='logs' + expect(mockSavedObjectsClient.create).toHaveBeenNthCalledWith( + 2, + 'index-pattern', + expect.objectContaining({ + signalType: 'logs', + }), + expect.anything() + ); + }); + + it('should create log dataset with correct schema mappings', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: 'logs-otel-v1*', + traceTimeField: null, + logTimeField: 'time', + }; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + const expectedSchemaMappings = { + otelLogs: { + timeField: 'time', + traceId: 'traceId', + spanId: 'spanId', + serviceName: 'resource.attributes.service.name', + }, + }; + + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + expect.objectContaining({ + schemaMappings: JSON.stringify(expectedSchemaMappings), + }), + expect.anything() + ); + }); + + it('should use detected logTimeField in schema mappings when different from default', async () => { + const detection: DetectionResult = { + tracesDetected: false, + logsDetected: true, + tracePattern: null, + logPattern: 'logs-custom*', + traceTimeField: null, + logTimeField: 'timestamp', + }; + + mockSavedObjectsClient.create.mockResolvedValue({ + id: 'log-dataset-id', + type: 'index-pattern', + attributes: {}, + references: [], + } as any); + + await createAutoDetectedDatasets(mockSavedObjectsClient, detection); + + const expectedSchemaMappings = { + otelLogs: { + timeField: 'timestamp', + traceId: 'traceId', + spanId: 'spanId', + serviceName: 'resource.attributes.service.name', + }, + }; + + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'index-pattern', + expect.objectContaining({ + timeFieldName: 'timestamp', + schemaMappings: JSON.stringify(expectedSchemaMappings), + }), + expect.anything() + ); + }); +}); diff --git a/src/plugins/explore/public/utils/create_auto_datasets.ts b/src/plugins/explore/public/utils/create_auto_datasets.ts new file mode 100644 index 000000000000..58b6a6f9775f --- /dev/null +++ b/src/plugins/explore/public/utils/create_auto_datasets.ts @@ -0,0 +1,231 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'src/core/public'; +import { DetectionResult } from './auto_detect_trace_data'; + +export interface CreateDatasetsResult { + traceDatasetId: string | null; + logDatasetId: string | null; + correlationId: string | null; +} + +/** + * Create auto-detected trace and log datasets with correlation + */ +export async function createAutoDetectedDatasets( + savedObjectsClient: SavedObjectsClientContract, + detection: DetectionResult, + dataSourceId?: string +): Promise { + const result: CreateDatasetsResult = { + traceDatasetId: null, + logDatasetId: null, + correlationId: null, + }; + + // Use datasource title from detection if available, otherwise use provided dataSourceId + const effectiveDataSourceId = detection.dataSourceId || dataSourceId; + const dataSourceSuffix = detection.dataSourceTitle ? ` - ${detection.dataSourceTitle}` : ''; + + // 1. Create trace dataset (check if it already exists first) + if (detection.tracesDetected && detection.tracePattern && detection.traceTimeField) { + const displayName = `Trace Dataset${dataSourceSuffix}`; + + // Check if an index pattern with this title already exists + try { + const existingPatterns = await savedObjectsClient.find({ + type: 'index-pattern', + searchFields: ['title'], + search: detection.tracePattern, + hasReference: effectiveDataSourceId + ? { type: 'data-source', id: effectiveDataSourceId } + : undefined, + }); + + // If a matching pattern exists, use it instead of creating a new one + if (existingPatterns.total > 0) { + result.traceDatasetId = existingPatterns.savedObjects[0].id; + } else { + // Create new trace dataset + const traceResponse = await savedObjectsClient.create( + 'index-pattern', + { + title: detection.tracePattern, + displayName, + timeFieldName: detection.traceTimeField, + signalType: 'traces', + }, + { + references: effectiveDataSourceId + ? [ + { + id: effectiveDataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ] + : [], + } + ); + result.traceDatasetId = traceResponse.id; + } + } catch (error) { + // If check fails, try to create anyway (will fail if duplicate, but that's ok) + try { + const traceResponse = await savedObjectsClient.create( + 'index-pattern', + { + title: detection.tracePattern, + displayName, + timeFieldName: detection.traceTimeField, + signalType: 'traces', + }, + { + references: effectiveDataSourceId + ? [ + { + id: effectiveDataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ] + : [], + } + ); + result.traceDatasetId = traceResponse.id; + } catch (createError) { + // eslint-disable-next-line no-console + console.warn('Failed to create trace dataset:', createError); + } + } + } + + // 2. Create log dataset with schema mappings for correlation (check if it already exists first) + if (detection.logsDetected && detection.logPattern && detection.logTimeField) { + const displayName = `Log Dataset${dataSourceSuffix}`; + + // Check if an index pattern with this title already exists + try { + const existingPatterns = await savedObjectsClient.find({ + type: 'index-pattern', + searchFields: ['title'], + search: detection.logPattern, + hasReference: effectiveDataSourceId + ? { type: 'data-source', id: effectiveDataSourceId } + : undefined, + }); + + // If a matching pattern exists, use it instead of creating a new one + if (existingPatterns.total > 0) { + result.logDatasetId = existingPatterns.savedObjects[0].id; + } else { + // Create new log dataset + const logResponse = await savedObjectsClient.create( + 'index-pattern', + { + title: detection.logPattern, + displayName, + timeFieldName: detection.logTimeField, + signalType: 'logs', + schemaMappings: JSON.stringify({ + otelLogs: { + timeField: detection.logTimeField || 'time', + traceId: 'traceId', + spanId: 'spanId', + serviceName: 'resource.attributes.service.name', + }, + }), + }, + { + references: effectiveDataSourceId + ? [ + { + id: effectiveDataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ] + : [], + } + ); + result.logDatasetId = logResponse.id; + } + } catch (error) { + // If check fails, try to create anyway (will fail if duplicate, but that's ok) + try { + const logResponse = await savedObjectsClient.create( + 'index-pattern', + { + title: detection.logPattern, + displayName, + timeFieldName: detection.logTimeField, + signalType: 'logs', + schemaMappings: JSON.stringify({ + otelLogs: { + timeField: detection.logTimeField || 'time', + traceId: 'traceId', + spanId: 'spanId', + serviceName: 'resource.attributes.service.name', + }, + }), + }, + { + references: effectiveDataSourceId + ? [ + { + id: effectiveDataSourceId, + type: 'data-source', + name: 'dataSource', + }, + ] + : [], + } + ); + result.logDatasetId = logResponse.id; + } catch (createError) { + // eslint-disable-next-line no-console + console.warn('Failed to create log dataset:', createError); + } + } + } + + // 3. Create correlation if both trace and log datasets were created + if (result.traceDatasetId && result.logDatasetId) { + try { + const correlationResponse = await savedObjectsClient.create( + 'correlations', + { + correlationType: 'APM-Correlation', + version: '1.0.0', + entities: [ + { tracesDataset: { id: 'references[0].id' } }, + { logsDataset: { id: 'references[1].id' } }, + ], + }, + { + references: [ + { + name: 'entities[0].index', + type: 'index-pattern', + id: result.traceDatasetId, + }, + { + name: 'entities[1].index', + type: 'index-pattern', + id: result.logDatasetId, + }, + ], + } + ); + result.correlationId = correlationResponse.id; + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to create correlation:', error); + } + } + + return result; +} diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index df2d12ebe18f..404781045c3d 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -116,45 +116,46 @@ export class IndexPatternManagementPlugin return pathInApp && `/patterns${pathInApp}`; }); - // only display if datasetManagement is not enabled - if (!isDatasetManagementEnabled) { - opensearchDashboardsSection.registerApp({ - id: IPM_APP_ID, - title: sectionsHeader, - order: 0, - mount: async (params) => { - if (core.chrome.navGroup.getNavGroupEnabled()) { - const [coreStart] = await core.getStartServices(); - const urlForStandardIPMApp = new URL( - coreStart.application.getUrlForApp(IPM_APP_ID), - window.location.href - ); - const targetUrl = new URL(window.location.href); - targetUrl.pathname = urlForStandardIPMApp.pathname; - coreStart.application.navigateToUrl(targetUrl.toString()); - return () => {}; - } - const { mountManagementSection } = await import('./management_app'); - - return mountManagementSection( - core.getStartServices, - params, - () => this.indexPatternManagementService.environmentService.getEnvironment().ml(), - dataSource + // Always display index patterns + // Dataset management will only show in observability workspace + opensearchDashboardsSection.registerApp({ + id: IPM_APP_ID, + title: sectionsHeader, + order: 0, + mount: async (params) => { + if (core.chrome.navGroup.getNavGroupEnabled()) { + const [coreStart] = await core.getStartServices(); + const urlForStandardIPMApp = new URL( + coreStart.application.getUrlForApp(IPM_APP_ID), + window.location.href ); - }, - }); - - core.application.register({ - id: IPM_APP_ID, - title: sectionsHeader, - description: i18n.translate('indexPatternManagement.indexPattern.description', { - defaultMessage: 'Manage index patterns to retrieve data from OpenSearch.', - }), - status: core.chrome.navGroup.getNavGroupEnabled() - ? AppStatus.accessible - : AppStatus.inaccessible, - mount: async (params: AppMountParameters) => { + const targetUrl = new URL(window.location.href); + targetUrl.pathname = urlForStandardIPMApp.pathname; + coreStart.application.navigateToUrl(targetUrl.toString()); + return () => {}; + } + const { mountManagementSection } = await import('./management_app'); + + return mountManagementSection( + core.getStartServices, + params, + () => this.indexPatternManagementService.environmentService.getEnvironment().ml(), + dataSource + ); + }, + }); + + core.application.register({ + id: IPM_APP_ID, + title: sectionsHeader, + description: i18n.translate('indexPatternManagement.indexPattern.description', { + defaultMessage: 'Manage index patterns to retrieve data from OpenSearch.', + }), + status: core.chrome.navGroup.getNavGroupEnabled() + ? AppStatus.accessible + : AppStatus.inaccessible, + mount: async (params: AppMountParameters) => { + try { const { mountManagementSection } = await import('./management_app'); const [coreStart] = await core.getStartServices(); @@ -170,27 +171,52 @@ export class IndexPatternManagementPlugin () => this.indexPatternManagementService.environmentService.getEnvironment().ml(), dataSource ); - }, - }); - - core.getStartServices().then(([coreStart]) => { - /** - * The `capabilities.workspaces.enabled` indicates - * if workspace feature flag is turned on or not and - * the global index pattern management page should only be registered - * to settings and setup when workspace is turned off, - */ - if (!coreStart.application.capabilities.workspaces.enabled) { - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ - { - id: IPM_APP_ID, - title: sectionsHeader, - order: 400, - }, - ]); + } catch (error) { + // Try to show error notification to user + try { + const [coreStart] = await core.getStartServices(); + coreStart.notifications.toasts.addDanger({ + title: i18n.translate('indexPatternManagement.mountError.title', { + defaultMessage: 'Failed to mount Index Pattern Management', + }), + text: error.message, + }); + } catch (notificationError) { + // If we can't show notification, log to console as last resort + // eslint-disable-next-line no-console + console.error('Failed to mount Index Pattern Management:', error); + // eslint-disable-next-line no-console + console.error('Also failed to show error notification:', notificationError); + } + + // Return no-op unmount function + return () => {}; } - }); - } + }, + }); + + core.getStartServices().then(([coreStart]) => { + /** + * The `capabilities.workspaces.enabled` indicates + * if workspace feature flag is turned on or not and + * the global index pattern management page should only be registered + * to settings and setup when workspace is turned off. + * Additionally, only add the nav link if nav groups are enabled to match + * the app accessibility status. + */ + if ( + !coreStart.application.capabilities.workspaces.enabled && + core.chrome.navGroup.getNavGroupEnabled() + ) { + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ + { + id: IPM_APP_ID, + title: sectionsHeader, + order: 400, + }, + ]); + } + }); return this.indexPatternManagementService.setup({ httpClient: core.http }); } diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index ffb1cfbbf7d2..ae70938fed8a 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -81,6 +81,8 @@ export const WORKSPACE_USE_CASES = Object.freeze({ 'observability-applications', // Add management avoid index patterns application not found for dashboards or visualize 'management', + 'indexPatterns', + 'datasets', ] as string[], }, 'security-analytics': { @@ -105,6 +107,7 @@ export const WORKSPACE_USE_CASES = Object.freeze({ 'opensearch_security_analytics_dashboards', // Add management avoid index patterns application not found for dashboards or visualize 'management', + 'indexPatterns', ] as string[], }, essentials: { @@ -128,6 +131,7 @@ export const WORKSPACE_USE_CASES = Object.freeze({ 'anomaly-detection-dashboards', // Add management avoid index patterns application not found for dashboards or visualize 'management', + 'indexPatterns', ] as string[], }, search: { @@ -148,6 +152,7 @@ export const WORKSPACE_USE_CASES = Object.freeze({ 'searchRelevance', // Add management avoid index patterns application not found for dashboards or visualize 'management', + 'indexPatterns', ] as string[], }, }); diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 4e93cf456503..2b77ad74b66a 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -3,11 +3,19 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": [ - "savedObjects", - "opensearchDashboardsReact", - "navigation" + "requiredPlugins": ["savedObjects", "opensearchDashboardsReact", "navigation", "data"], + "optionalPlugins": [ + "savedObjectsManagement", + "management", + "dataSourceManagement", + "contentManagement", + "dataSource" ], - "optionalPlugins": ["savedObjectsManagement","management","dataSourceManagement","contentManagement", "dataSource"], - "requiredBundles": ["opensearchDashboardsReact","dataSource", "dataSourceManagement","contentManagement"] + "requiredBundles": [ + "opensearchDashboardsReact", + "dataSource", + "dataSourceManagement", + "contentManagement", + "explore" + ] } diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index efed0fd5dbd6..51c2c78ae58d 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -26,6 +26,7 @@ import { import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; +import { DataPublicPluginStart } from '../../../../data/public'; import { WorkspaceUseCase } from '../../types'; import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { useFormAvailableUseCases } from '../workspace_form/use_form_available_use_cases'; @@ -36,6 +37,7 @@ import { WorkspaceCreatorForm } from './workspace_creator_form'; import { optionIdToWorkspacePermissionModesMap } from '../workspace_form/constants'; import { getUseCaseFeatureConfig } from '../../../../../core/public'; import { UseCaseService } from '../../services'; +import { detectTraceData, createAutoDetectedDatasets } from '../../../../explore/public'; export interface WorkspaceCreatorProps { registeredUseCases$: BehaviorSubject; @@ -53,12 +55,14 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { dataSourceManagement, navigationUI: { HeaderControl }, useCaseService, + data: dataPlugin, }, } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; navigationUI: NavigationPublicPluginStart['ui']; useCaseService: UseCaseService; + data: DataPublicPluginStart; }>(); const [isFormSubmitting, setIsFormSubmitting] = useState(false); const [goToCollaborators, setGoToCollaborators] = useState(false); @@ -147,7 +151,85 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { const useCaseId = getFirstUseCaseOfFeatureConfigs(attributes.features); const useCaseLandingAppId = availableUseCases?.find(({ id }) => useCaseId === id) ?.features[0].id; - // Redirect page after one second, leave one second time to show create successful toast. + + // For observability workspaces, run trace detection and create datasets if found + const isObservabilityWorkspace = useCaseId === 'observability'; + if (isObservabilityWorkspace && savedObjects && dataPlugin?.dataViews) { + try { + // Set workspace context for saved objects client + savedObjects.client.setCurrentWorkspace(newWorkspaceId); + + // Get selected data sources (OpenSearch connections only) + const dataSourceConnections = (selectedDataSourceConnections ?? []).filter( + ({ connectionType }) => + connectionType === DataSourceConnectionType.OpenSearchConnection + ); + + // Create mapping from dataSourceId to name + const dataSourceIdToName = new Map( + dataSourceConnections.map(({ id, name }) => [id, name]) + ); + + // If no data sources selected, check local cluster + const dataSourcesToCheck = + dataSourceConnections.length > 0 + ? dataSourceConnections.map(({ id }) => id) + : [undefined]; // undefined means local cluster + + let datasetsCreatedCount = 0; + + // Run detection for each data source + for (const dataSourceId of dataSourcesToCheck) { + try { + const detection = await detectTraceData( + savedObjects.client, + dataPlugin.dataViews, + dataSourceId + ); + + if (detection.tracesDetected || detection.logsDetected) { + // Add datasource title to detection + if (dataSourceId) { + detection.dataSourceTitle = dataSourceIdToName.get(dataSourceId); + } else { + detection.dataSourceTitle = 'Local Cluster'; + } + + await createAutoDetectedDatasets( + savedObjects.client, + detection, + dataSourceId + ); + datasetsCreatedCount++; + } + } catch (error) { + // Continue with other data sources even if one fails + } + } + + if (datasetsCreatedCount > 0) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.traceDatasetsCreated', { + defaultMessage: 'Trace datasets created automatically', + }), + text: + datasetsCreatedCount > 1 + ? i18n.translate('workspace.create.traceDatasetsCreatedMultiple', { + defaultMessage: 'Created datasets for {count} data sources', + values: { count: datasetsCreatedCount }, + }) + : undefined, + }); + } + } catch (error) { + // Don't block workspace creation if trace detection fails + } finally { + // Clear the workspace context to prevent subsequent operations from targeting the new workspace + savedObjects.client.setCurrentWorkspace(undefined as any); + } + } + + // Redirect to workspace after a short delay window.setTimeout(() => { if (isPermissionEnabled && goToCollaborators) { navigateToAppWithinWorkspace( @@ -191,6 +273,8 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { availableUseCases, isPermissionEnabled, goToCollaborators, + savedObjects, + dataPlugin, ] ); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 81830a508c0b..138b089388c4 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -47,6 +47,7 @@ import { import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; import { getWorkspaceColumn } from './components/workspace_column'; import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_management/public'; +import { DataPublicPluginStart } from '../../../plugins/data/public'; import { enrichBreadcrumbsWithWorkspace, filterWorkspaceConfigurableApps, @@ -92,6 +93,7 @@ interface WorkspacePluginSetupDeps { export interface WorkspacePluginStartDeps { contentManagement: ContentManagementPluginStart; navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; } export class WorkspacePlugin @@ -298,7 +300,7 @@ export class WorkspacePlugin await this.workspaceValidationService.setup(core, workspaceId); const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { - const [coreStart, { navigation }] = await core.getStartServices(); + const [coreStart, { navigation, data }] = await core.getStartServices(); const workspaceClient = coreStart.workspaces.client$.getValue() as WorkspaceClient; const services: Services = { @@ -308,6 +310,7 @@ export class WorkspacePlugin collaboratorTypes: this.collaboratorTypes, navigationUI: navigation.ui, useCaseService: this.useCase, + data, }; return renderApp(params, services, { diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index e43780ced353..f788acbe4448 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -11,6 +11,7 @@ import { ContentManagementPluginStart } from '../../../plugins/content_managemen import { DataSourceAttributes } from '../../../plugins/data_source/common/data_sources'; import type { AddCollaboratorsModal } from './components/add_collaborators_modal'; import { UseCaseService, WorkspaceCollaboratorTypesService } from './services'; +import { DataPublicPluginStart } from '../../../plugins/data/public'; export type Services = CoreStart & { workspaceClient: WorkspaceClient; @@ -19,6 +20,7 @@ export type Services = CoreStart & { contentManagement?: ContentManagementPluginStart; collaboratorTypes: WorkspaceCollaboratorTypesService; useCaseService: UseCaseService; + data?: DataPublicPluginStart; }; export interface WorkspaceUseCaseFeature {