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}
-
-
-
- ),
-}));
-
-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 {