From f60cbe123577fdca535d3b01ce4a535aa3ec9c16 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:11:28 -0700 Subject: [PATCH 01/14] Add release notes for 3.3.0 (#382) (#383) (cherry picked from commit 873c10d1b6ed2216e655a1f3f4b115cabf9e54cb) Signed-off-by: opensearch-ci Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: Chenyang Ji --- ...sights-dashboards.release-notes-3.3.0.0.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 release-notes/opensearch-query-insights-dashboards.release-notes-3.3.0.0.md diff --git a/release-notes/opensearch-query-insights-dashboards.release-notes-3.3.0.0.md b/release-notes/opensearch-query-insights-dashboards.release-notes-3.3.0.0.md new file mode 100644 index 00000000..cbdd36c1 --- /dev/null +++ b/release-notes/opensearch-query-insights-dashboards.release-notes-3.3.0.0.md @@ -0,0 +1,30 @@ +## Version 3.3.0 Release Notes + +Compatible with OpenSearch and OpenSearch Dashboards version 3.3.0 + +### Features +* Enhance User Experience with Bi-Directional Navigation Between WLM and Live Queries ([#299](https://github.com/opensearch-project/query-insights-dashboards/pull/299)) +* Add navigation for query insights and WLM dashboards ([#330](https://github.com/opensearch-project/query-insights-dashboards/pull/330)) +* Add feature flag for wlm ([#348](https://github.com/opensearch-project/query-insights-dashboards/pull/348)) +* MDS support for WLM ([#352](https://github.com/opensearch-project/query-insights-dashboards/pull/352)) + +### Enhancements +* Added version decoupling for wlm dashboard ([#361](https://github.com/opensearch-project/query-insights-dashboards/pull/361)) +* Version decouple unit test ([#363](https://github.com/opensearch-project/query-insights-dashboards/pull/363)) + +### Bug Fixes +* Bug fix for filter and date picker [2.19] ([#338](https://github.com/opensearch-project/query-insights-dashboards/pull/338)) +* Group by selector on Configuration page always shows "None" after refresh ([#366](https://github.com/opensearch-project/query-insights-dashboards/pull/366)) +* Explicitly match query by id and fix q scope in retrieveQueryById ([#367](https://github.com/opensearch-project/query-insights-dashboards/pull/367)) + +### Infrastructure +* Enable wlm mode in pipeline ([#336](https://github.com/opensearch-project/query-insights-dashboards/pull/336)) +* Cypress-workflow-fix ([#329](https://github.com/opensearch-project/query-insights-dashboards/pull/329)) +* Revert "cypress-workflow-fix (#329)" ([#335](https://github.com/opensearch-project/query-insights-dashboards/pull/335)) +* Update delete-backport-branch workflow to include release-chores branches ([#327](https://github.com/opensearch-project/query-insights-dashboards/pull/327)) + +### Maintenance +* Increment version to 3.3.0.0 ([#332](https://github.com/opensearch-project/query-insights-dashboards/pull/332)) +* Update dependency pbkdf2 to v3.1.4 ([#375](https://github.com/opensearch-project/query-insights-dashboards/pull/375)) +* Update dependency pbkdf2 to v3.1.5 ([#378](https://github.com/opensearch-project/query-insights-dashboards/pull/378)) +* Fix form-data CVE-2025-7783 ([#380](https://github.com/opensearch-project/query-insights-dashboards/pull/380)) \ No newline at end of file From 3f11fb254f19b011857731175ece1f6c6abde293 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:33:53 -0700 Subject: [PATCH 02/14] Increment version to 3.3.0.0 (#386) Signed-off-by: opensearch-ci-bot Co-authored-by: opensearch-ci-bot --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 815d0992..545afe79 100644 --- a/package.json +++ b/package.json @@ -90,4 +90,4 @@ "node_modules/*", "target/*" ] -} +} \ No newline at end of file From 48d679ae31c289033b5c5bc0798ea0e922fb1176 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:11:24 -0700 Subject: [PATCH 03/14] Pin sha.js 2.4.12 in resolutions to fix CVE-2025-9288 (#384) (#389) (cherry picked from commit a17ad8977311e3c57acff0446b71510b397ca269) Signed-off-by: David Zane Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] --- package.json | 5 +++-- yarn.lock | 10 +--------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 545afe79..f74630c5 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "@babel/runtime": "^7.26.10", "@babel/runtime-corejs3": "^7.22.9", "pbkdf2": "3.1.5", - "form-data": "4.0.4" + "form-data": "4.0.4", + "sha.js": "^2.4.12" }, "devDependencies": { "@cypress/webpack-preprocessor": "^6.0.1", @@ -90,4 +91,4 @@ "node_modules/*", "target/*" ] -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 34c7d061..3a186f3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5694,15 +5694,7 @@ setimmediate@^1.0.4: resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== -sha.js@^2.4.0, sha.js@^2.4.8: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -sha.js@^2.4.12: +sha.js@^2.4.0, sha.js@^2.4.12, sha.js@^2.4.8: version "2.4.12" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w== From 5634d79a50cf37e1f1529af74e2a79a5621a2773 Mon Sep 17 00:00:00 2001 From: Lindsay-00 <54655271+Lindsay-00@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:20:11 -0700 Subject: [PATCH 04/14] updated branch to 3.3 (#397) Signed-off-by: Lingxi Chen Co-authored-by: Lingxi Chen --- .github/workflows/cypress-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml index 8f342de7..a179d610 100644 --- a/.github/workflows/cypress-tests.yml +++ b/.github/workflows/cypress-tests.yml @@ -7,8 +7,8 @@ on: branches: - "*" env: - OPENSEARCH_DASHBOARDS_VERSION: 'main' - QUERY_INSIGHTS_BRANCH: 'main' + OPENSEARCH_DASHBOARDS_VERSION: '3.3' + QUERY_INSIGHTS_BRANCH: '3.3' GRADLE_VERSION: '7.6.1' CYPRESS_VIDEO: true CYPRESS_SCREENSHOT_ON_RUN_FAILURE: true From 867d6e688da6bd7cc65f07e50b98830ff6c4a81e Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 9 Oct 2025 15:34:26 -0400 Subject: [PATCH 05/14] Update cypress tests with dynamic version setups (#400) Signed-off-by: Peter Zhu --- .github/workflows/build-and-test.yml | 17 ++++++- .github/workflows/cypress-tests.yml | 74 +++++++++++++--------------- .github/workflows/lint-workflow.yml | 15 +++++- 3 files changed, 62 insertions(+), 44 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e5e20621..41818ed3 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -7,7 +7,6 @@ on: branches: - "*" env: - OPENSEARCH_DASHBOARDS_VERSION: 'main' ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true jobs: @@ -23,6 +22,20 @@ jobs: os: [ ubuntu-latest, macos-latest, windows-latest ] runs-on: ${{ matrix.os }} steps: + - uses: actions/checkout@v4 + - name: Set env + run: | + opensearch_version=$(node -p "require('./package.json').opensearchDashboards.version") + major_version=$(echo $opensearch_version | cut -d. -f1) + minor_version=$(echo $opensearch_version | cut -d. -f2) + branch_version=${major_version}.${minor_version} + opensearch_dashboards_branch=$branch_version + base_branch=${{ github.base_ref }} + if [ "$base_branch" = "main" ]; then + opensearch_dashboards_branch=main + fi + echo "OPENSEARCH_DASHBOARDS_BRANCH=$opensearch_dashboards_branch" >> $GITHUB_ENV + shell: bash # Enable longer filenames for windows - name: Enable longer filenames if: ${{ matrix.os == 'windows-latest' }} @@ -31,7 +44,7 @@ jobs: uses: actions/checkout@v4 with: repository: opensearch-project/OpenSearch-Dashboards - ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + ref: ${{ env.OPENSEARCH_DASHBOARDS_BRANCH }} path: OpenSearch-Dashboards - name: Setup Node uses: actions/setup-node@v3 diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml index a179d610..158b19cb 100644 --- a/.github/workflows/cypress-tests.yml +++ b/.github/workflows/cypress-tests.yml @@ -7,8 +7,6 @@ on: branches: - "*" env: - OPENSEARCH_DASHBOARDS_VERSION: '3.3' - QUERY_INSIGHTS_BRANCH: '3.3' GRADLE_VERSION: '7.6.1' CYPRESS_VIDEO: true CYPRESS_SCREENSHOT_ON_RUN_FAILURE: true @@ -32,11 +30,33 @@ jobs: TERM: xterm steps: - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: 21 - distribution: temurin + - uses: actions/checkout@v4 + + - name: Set env + run: | + opensearch_version=$(node -p "require('./package.json').opensearchDashboards.version") + plugin_version=$(node -p "require('./package.json').version") + major_version=$(echo $opensearch_version | cut -d. -f1) + minor_version=$(echo $opensearch_version | cut -d. -f2) + opensearch_version=${opensearch_version}-SNAPSHOT + plugin_version=${plugin_version}-SNAPSHOT + branch_version=${major_version}.${minor_version} + opensearch_branch=$branch_version + opensearch_dashboards_branch=$branch_version + query_insights_branch=$branch_version + base_branch=${{ github.base_ref }} + echo "base_branch $base_branch" + if [ "$base_branch" = "main" ]; then + opensearch_branch=main + opensearch_dashboards_branch=main + query_insights_branch=main + fi + echo "QUERY_INSIGHTS_BRANCH=$query_insights_branch" >> $GITHUB_ENV + echo "OPENSEARCH_BRANCH=$opensearch_branch" >> $GITHUB_ENV + echo "OPENSEARCH_DASHBOARDS_BRANCH=$opensearch_dashboards_branch" >> $GITHUB_ENV + echo "OPENSEARCH_VERSION=$opensearch_version" >> $GITHUB_ENV + echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV + shell: bash - name: Checkout Query Insights uses: actions/checkout@v4 @@ -58,39 +78,11 @@ jobs: echo "Listing files:" ls -la - - name: Fetch OpenSearch version from build.gradle - run: | - # Navigate to the query-insights directory - cd query-insights - - # Check if build.gradle exists - if [ -f "build.gradle" ]; then - echo "build.gradle file exists!" - else - echo "build.gradle file not found!" - exit 1 - fi - - # Print the content of build.gradle for debugging - echo "Printing build.gradle content:" - cat build.gradle - - # Extract the version from build.gradle using Node.js with the updated regex - opensearch_version=$(node -e " - const fs = require('fs'); - const gradleFile = fs.readFileSync('build.gradle', 'utf-8'); - const match = gradleFile.match(/opensearch_version\\s*=\\s*System\\.getProperty\\(['\"][^'\"]+['\"],\\s*['\"]([^'\"]+)['\"]\\)/); - console.log(match ? match[1] : 'No version found'); - ") - - # Set the OpenSearch version as an environment variable - echo "OPENSEARCH_VERSION=$opensearch_version" >> $GITHUB_ENV - echo "PLUGIN_VERSION=$opensearch_version" >> $GITHUB_ENV - - # Print the version for debugging - echo "Using OpenSearch version: $opensearch_version" - shell: bash - + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: temurin - name: Verify OpenSearch version run: | @@ -179,7 +171,7 @@ jobs: with: repository: opensearch-project/OpenSearch-Dashboards path: OpenSearch-Dashboards - ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + ref: ${{ env.OPENSEARCH_DASHBOARDS_BRANCH }} - name: Checkout Query Insights Dashboards plugin uses: actions/checkout@v4 diff --git a/.github/workflows/lint-workflow.yml b/.github/workflows/lint-workflow.yml index a418f346..088a1919 100644 --- a/.github/workflows/lint-workflow.yml +++ b/.github/workflows/lint-workflow.yml @@ -13,11 +13,24 @@ jobs: name: Run lint runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Set env + run: | + opensearch_version=$(node -p "require('./package.json').opensearchDashboards.version") + major_version=$(echo $opensearch_version | cut -d. -f1) + minor_version=$(echo $opensearch_version | cut -d. -f2) + branch_version=${major_version}.${minor_version} + opensearch_dashboards_branch=$branch_version + base_branch=${{ github.base_ref }} + if [ "$base_branch" = "main" ]; then + opensearch_dashboards_branch=main + fi + echo "OPENSEARCH_DASHBOARDS_BRANCH=$opensearch_dashboards_branch" >> $GITHUB_ENV - name: Checkout OpenSearch Dashboards uses: actions/checkout@v4 with: repository: opensearch-project/OpenSearch-Dashboards - ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + ref: ${{ env.OPENSEARCH_DASHBOARDS_BRANCH }} path: OpenSearch-Dashboards - name: Setup Node uses: actions/setup-node@v3 From df7d247fd68634957f8a6d1f6b26fe7d21ac7a4c Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:00:53 -0400 Subject: [PATCH 06/14] hide auth in api response (#393) (#395) (cherry picked from commit 9c4a3c9af3320c274d7d48b066786dfa7cdea3a3) Signed-off-by: Lingxi Chen Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: Lingxi Chen Co-authored-by: Peter Zhu --- .../WLMDetails/WLMDetails.test.tsx | 101 ++++---- .../WLMDetails/WLMDetails.tsx | 17 +- .../WLMMain/WLMMain.test.tsx | 95 +++---- .../WorkloadManagement/WLMMain/WLMMain.tsx | 5 +- server/routes/wlmRoutes.test.tsx | 240 ++++++++++++++++++ server/routes/wlmRoutes.ts | 44 +--- 6 files changed, 341 insertions(+), 161 deletions(-) create mode 100644 server/routes/wlmRoutes.test.tsx diff --git a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx index 96d03d80..e641698c 100644 --- a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx +++ b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx @@ -51,21 +51,17 @@ const mockDataSourceManagement = { (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { if (url === '/api/_wlm/workload_group') { return Promise.resolve({ - body: { - workload_groups: [{ name: 'test-group', _id: 'abc123' }], - }, + workload_groups: [{ name: 'test-group', _id: 'abc123' }], }); } if (url === '/api/_wlm/stats/abc123') { return Promise.resolve({ - body: { - 'node-1': { - workload_groups: { - abc123: { - cpu: { current_usage: 0.5 }, - memory: { current_usage: 0.3 }, - }, + 'node-1': { + workload_groups: { + abc123: { + cpu: { current_usage: 0.5 }, + memory: { current_usage: 0.3 }, }, }, }, @@ -115,37 +111,33 @@ describe('WLMDetails Component', () => { (mockCore.http.get as jest.Mock).mockImplementation((path: string) => { if (path.startsWith('/api/_wlm/workload_group/test-group')) { return Promise.resolve({ - body: { - workload_groups: [ - { - _id: 'wg-123', - name: 'test-group', - resource_limits: { cpu: 0.5, memory: 0.5 }, - resiliency_mode: 'SOFT', - }, - ], - }, + workload_groups: [ + { + _id: 'wg-123', + name: 'test-group', + resource_limits: { cpu: 0.5, memory: 0.5 }, + resiliency_mode: 'SOFT', + }, + ], }); } // 2) GET existing rules if (path === '/api/_rules/workload_group') { return Promise.resolve({ - body: { - rules: [ - { - id: 'keep-me', - description: 'd', - index_pattern: ['keep-*'], - workload_group: 'wg-123', - }, - { - id: 'remove-me', - description: 'd', - index_pattern: ['remove-*'], - workload_group: 'wg-123', - }, - ], - }, + rules: [ + { + id: 'keep-me', + description: 'd', + index_pattern: ['keep-*'], + workload_group: 'wg-123', + }, + { + id: 'remove-me', + description: 'd', + index_pattern: ['remove-*'], + workload_group: 'wg-123', + }, + ], }); } // 3) GET stats (ignored here) @@ -184,7 +176,7 @@ describe('WLMDetails Component', () => { it('handles no stats returned gracefully', async () => { (mockCore.http.get as jest.Mock) .mockImplementationOnce(() => - Promise.resolve({ body: { workload_groups: [{ name: 'test-group', _id: 'abc123' }] } }) + Promise.resolve({ workload_groups: [{ name: 'test-group', _id: 'abc123' }] }) ) .mockImplementationOnce(() => Promise.resolve({ body: {} })); @@ -220,7 +212,7 @@ describe('WLMDetails Component', () => { mockCore.http.get = jest.fn((path: string) => { if (path === '/api/_wlm/workload_group') { return Promise.resolve({ - body: { workload_groups: [{ name: 'test-group', _id: 'abc123' }] }, + workload_groups: [{ name: 'test-group', _id: 'abc123' }], }); } if (path === '/api/_wlm/stats/abc123') { @@ -243,16 +235,14 @@ describe('WLMDetails Component', () => { (mockCore.http.get as jest.Mock).mockImplementation((path: string) => { if (path.startsWith('/api/_wlm/workload_group')) { return Promise.resolve({ - body: { - workload_groups: [ - { - name: 'test-group', - _id: 'abc123', - resource_limits: { cpu: 0.5, memory: 0.5 }, - resiliency_mode: 'soft', - }, - ], - }, + workload_groups: [ + { + name: 'test-group', + _id: 'abc123', + resource_limits: { cpu: 0.5, memory: 0.5 }, + resiliency_mode: 'soft', + }, + ], }); } if (path.startsWith('/api/_wlm/stats/abc123')) { @@ -295,7 +285,7 @@ describe('WLMDetails Component', () => { (mockCore.http.get as jest.Mock).mockImplementation((path: string) => { if (path.includes('/_wlm/workload_group')) { return Promise.resolve({ - body: { workload_groups: [{ name: 'test-group', _id: 'abc123' }] }, + workload_groups: [{ name: 'test-group', _id: 'abc123' }], }); } if (path.includes('/_wlm/stats/abc123')) { @@ -661,15 +651,16 @@ describe('WLMDetails Component', () => { const [, options] = updateCalls[0]; expect(options).toEqual( expect.objectContaining({ - query: { dataSourceId: 'default' }, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - description: 'd', - index_pattern: ['keep-updated-*'], - workload_group: 'wg-123', - }), + query: { dataSourceId: 'default' }, }) ); + const body = JSON.parse(options.body); + expect(body).toEqual({ + description: '-', + index_pattern: ['keep-updated-*'], + workload_group: 'wg-123', + }); }); expect(mockCore.notifications.toasts.addSuccess).toHaveBeenCalled(); diff --git a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx index 734f5673..f477dac8 100644 --- a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx +++ b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx @@ -85,12 +85,7 @@ interface WorkloadGroup { } interface WorkloadGroupByNameResponse { - body: { - workload_groups: WorkloadGroup[]; - }; - statusCode: number; - headers: Record; - meta: any; + workload_groups: WorkloadGroup[]; } interface NodeStats { @@ -253,7 +248,7 @@ export const WLMDetails = ({ const response = await core.http.get(`/api/_wlm/workload_group/${groupName}`, { query: { dataSourceId: dataSource.id }, }); - const workload = response?.body?.workload_groups?.[0]; + const workload = response?.workload_groups?.[0]; if (workload) { setGroupDetails({ name: workload.name, @@ -288,8 +283,7 @@ export const WLMDetails = ({ query: { dataSourceId: dataSource.id }, } ); - const matchedGroup = response.body?.workload_groups?.[0]; - + const matchedGroup = response?.workload_groups?.[0]; if (!matchedGroup?._id) { throw new Error('Group ID not found'); } @@ -309,7 +303,7 @@ export const WLMDetails = ({ const rulesRes = await core.http.get('/api/_rules/workload_group', { query: { dataSourceId: dataSource.id }, }); - const allRules = rulesRes?.body?.rules ?? []; + const allRules = rulesRes?.rules ?? []; const matchedRules = allRules.filter((rule: any) => rule.workload_group === groupId); @@ -340,8 +334,7 @@ export const WLMDetails = ({ const statsRes = await core.http.get(`/api/_wlm/stats/${groupId}`, { query: { dataSourceId: dataSource.id }, }); - const stats: StatsResponse = statsRes.body ?? statsRes; - + const stats: StatsResponse = statsRes; const nodeStatsList: NodeUsageData[] = []; for (const [nodeId, data] of Object.entries(stats)) { diff --git a/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx b/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx index 3fe9d5a7..237f2a1d 100644 --- a/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx +++ b/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx @@ -52,61 +52,53 @@ beforeEach(() => { (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { if (url === '/api/_wlm/workload_group') { return Promise.resolve({ - body: { - workload_groups: [ - { _id: 'group1', name: 'Group One', resource_limits: { cpu: 0.4, memory: 0.5 } }, - { _id: 'group2', name: 'Group Two', resource_limits: { cpu: 0.6, memory: 0.7 } }, - { _id: 'group3', name: 'Group Three', resource_limits: { cpu: 0.1, memory: 0.1 } }, - ], - }, + workload_groups: [ + { _id: 'group1', name: 'Group One', resource_limits: { cpu: 0.4, memory: 0.5 } }, + { _id: 'group2', name: 'Group Two', resource_limits: { cpu: 0.6, memory: 0.7 } }, + { _id: 'group3', name: 'Group Three', resource_limits: { cpu: 0.1, memory: 0.1 } }, + ], }); } if (url === '/api/_wlm/stats') { return Promise.resolve({ - body: { - node1: { - workload_groups: { - group1: { - total_completions: 10, - total_rejections: 2, - total_cancellations: 1, - cpu: { current_usage: 0.4 }, - memory: { current_usage: 0.3 }, - }, - group2: { - total_completions: 5, - total_rejections: 1, - total_cancellations: 0, - cpu: { current_usage: 0.5 }, - memory: { current_usage: 0.6 }, - }, - group3: { - total_completions: 5, - total_rejections: 1, - total_cancellations: 0, - cpu: { current_usage: 0.5 }, - memory: { current_usage: 0.6 }, - }, + node1: { + workload_groups: { + group1: { + total_completions: 10, + total_rejections: 2, + total_cancellations: 1, + cpu: { current_usage: 0.4 }, + memory: { current_usage: 0.3 }, + }, + group2: { + total_completions: 5, + total_rejections: 1, + total_cancellations: 0, + cpu: { current_usage: 0.5 }, + memory: { current_usage: 0.6 }, + }, + group3: { + total_completions: 5, + total_rejections: 1, + total_cancellations: 0, + cpu: { current_usage: 0.5 }, + memory: { current_usage: 0.6 }, }, }, - node2: { - workload_groups: { - group1: { - total_completions: 5, - total_rejections: 1, - total_cancellations: 2, - cpu: { current_usage: 0.25 }, - memory: { current_usage: 0.45 }, - }, + }, + node2: { + workload_groups: { + group1: { + total_completions: 5, + total_rejections: 1, + total_cancellations: 2, + cpu: { current_usage: 0.25 }, + memory: { current_usage: 0.45 }, }, }, }, }); } - if (url.startsWith('/api/_wlm/stats/')) { - return Promise.resolve({ body: {} }); - } - return Promise.resolve({ body: {} }); }); }); @@ -207,21 +199,6 @@ describe('WorkloadManagementMain', () => { }); }); - it('handles case when no node is selected', async () => { - (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { - if (url === '/api/_wlm_proxy/_nodes') { - return Promise.resolve({ body: { nodes: {} } }); - } - return Promise.resolve({ body: {} }); - }); - - renderComponent(); - - await waitFor(() => { - expect(screen.getByPlaceholderText(/search workload groups/i)).toBeInTheDocument(); - }); - }); - it('handles empty workload group list', async () => { (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { if (url.includes('/workload_group')) { diff --git a/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx b/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx index 1014d92e..e760ce88 100644 --- a/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx +++ b/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx @@ -283,7 +283,6 @@ export const WorkloadManagementMain = ({ cpuRejectionThreshold: number; memoryRejectionThreshold: number; }>('/api/_wlm/thresholds'); - const cpuThreshold = thresholds?.cpuRejectionThreshold ?? 1; const memoryThreshold = thresholds?.memoryRejectionThreshold ?? 1; @@ -336,14 +335,14 @@ export const WorkloadManagementMain = ({ query: { dataSourceId: dataSource.id }, }); - return res.body as Record; + return res as Record; }, [dataSource]); const fetchWorkloadGroups = async () => { const res = await core.http.get('/api/_wlm/workload_group', { query: { dataSourceId: dataSource.id }, }); - return res.body?.workload_groups ?? []; + return res?.workload_groups ?? []; }; const computeBoxStats = (arr: number[]): number[] => { diff --git a/server/routes/wlmRoutes.test.tsx b/server/routes/wlmRoutes.test.tsx new file mode 100644 index 00000000..4b8f141d --- /dev/null +++ b/server/routes/wlmRoutes.test.tsx @@ -0,0 +1,240 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineWlmRoutes } from './wlmRoutes'; +import '@testing-library/jest-dom'; + +const REG: Record Promise | any> = {}; +const router = { + get: jest.fn((cfg: any, h: any) => { + REG[`GET ${cfg.path}`] = h; + }), + put: jest.fn((cfg: any, h: any) => { + REG[`PUT ${cfg.path}`] = h; + }), + delete: jest.fn((cfg: any, h: any) => { + REG[`DELETE ${cfg.path}`] = h; + }), +} as any; + +const makeCtx = () => ({ + core: { + opensearch: { + client: { + asCurrentUser: { transport: { request: jest.fn() } }, + asInternalUser: { cluster: { getSettings: jest.fn() } }, + }, + }, + }, + queryInsights: { logger: { error: jest.fn() } }, +}); + +const makeRes = () => ({ + ok: jest.fn(), + custom: jest.fn(), + customError: jest.fn(), + internalError: jest.fn(), +}); + +const expectNoMeta = (body: any) => { + expect(body).toBeDefined(); + expect(body).not.toHaveProperty('meta'); + expect(JSON.stringify(body)).not.toContain('"meta"'); +}; + +beforeAll(() => { + defineWlmRoutes(router); +}); + +describe('defineWlmRoutes: responses must not expose `meta`', () => { + test('GET /api/_wlm/stats', async () => { + const handler = REG['GET /api/_wlm/stats']; + expect(handler).toBeDefined(); + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { nodes: { n1: {} } }, + meta: { shouldNotLeak: true }, + }); + const res = makeRes(); + await handler(ctx, {}, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('GET /api/_wlm/{nodeId}/stats', async () => { + const handler = REG['GET /api/_wlm/{nodeId}/stats']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { node: 'abc', stats: {} }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, { params: { nodeId: 'abc' } }, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('GET /api/_wlm/workload_group', async () => { + const handler = REG['GET /api/_wlm/workload_group']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { workload_groups: [{ name: 'DEFAULT_WORKLOAD_GROUP' }] }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, {}, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('GET /api/_wlm/workload_group/{name}', async () => { + const handler = REG['GET /api/_wlm/workload_group/{name}']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { name: 'G1', resource_limits: { cpu: 0, memory: 0 } }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, { params: { name: 'G1' } }, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('PUT /api/_wlm/workload_group', async () => { + const handler = REG['PUT /api/_wlm/workload_group']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { acknowledged: true, id: 'g' }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler( + ctx, + { body: { name: 'g', resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } } }, + res + ); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('PUT /api/_wlm/workload_group/{name}', async () => { + const handler = REG['PUT /api/_wlm/workload_group/{name}']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { updated: true }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler( + ctx, + { + params: { name: 'g' }, + body: { resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } }, + }, + res + ); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('DELETE /api/_wlm/workload_group/{name}', async () => { + const handler = REG['DELETE /api/_wlm/workload_group/{name}']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { acknowledged: true }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, { params: { name: 'g' } }, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('GET /api/_wlm/stats/{workloadGroupId}', async () => { + const handler = REG['GET /api/_wlm/stats/{workloadGroupId}']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { id: 'wg-1', stats: {} }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, { params: { workloadGroupId: 'wg-1' } }, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('PUT /api/_rules/workload_group', async () => { + const handler = REG['PUT /api/_rules/workload_group']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { created: true, ruleId: 'r1' }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler( + ctx, + { body: { description: 'd', index_pattern: ['logs-*'], workload_group: 'g' } }, + res + ); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('GET /api/_rules/workload_group', async () => { + const handler = REG['GET /api/_rules/workload_group']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { rules: [] }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, {}, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('DELETE /api/_rules/workload_group/{ruleId}', async () => { + const handler = REG['DELETE /api/_rules/workload_group/{ruleId}']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { acknowledged: true }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, { params: { ruleId: 'r1' } }, res); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('PUT /api/_rules/workload_group/{ruleId}', async () => { + const handler = REG['PUT /api/_rules/workload_group/{ruleId}']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ + body: { updated: true }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler( + ctx, + { + params: { ruleId: 'r1' }, + body: { description: 'd', index_pattern: ['a*'], workload_group: 'g' }, + }, + res + ); + expectNoMeta(res.ok.mock.calls[0][0].body); + }); + + test('GET /api/_wlm/thresholds', async () => { + const handler = REG['GET /api/_wlm/thresholds']; + const ctx = makeCtx(); + (ctx.core.opensearch.client.asInternalUser.cluster.getSettings as jest.Mock).mockResolvedValue({ + body: { + defaults: { + wlm: { + workload_group: { + node: { cpu_rejection_threshold: '0.8', memory_rejection_threshold: '0.6' }, + }, + }, + }, + }, + meta: { nope: true }, + }); + const res = makeRes(); + await handler(ctx, {}, res); + const payload = res.ok.mock.calls[0][0].body; + expectNoMeta(payload); + expect(payload).toEqual({ cpuRejectionThreshold: 0.8, memoryRejectionThreshold: 0.6 }); + }); +}); diff --git a/server/routes/wlmRoutes.ts b/server/routes/wlmRoutes.ts index 049671be..307cf97d 100644 --- a/server/routes/wlmRoutes.ts +++ b/server/routes/wlmRoutes.ts @@ -20,7 +20,7 @@ export function defineWlmRoutes(router: IRouter) { method: 'GET', path: '/_wlm/stats', }); - return response.ok({ body: stats }); + return response.ok({ body: stats.body }); } catch (error: any) { context.queryInsights.logger.error(`Failed to fetch WLM stats: ${error.message}`, { error, @@ -53,7 +53,7 @@ export function defineWlmRoutes(router: IRouter) { method: 'GET', path: `/_wlm/${nodeId}/stats`, }); - return response.ok({ body: stats }); + return response.ok({ body: stats.body }); } catch (error: any) { console.error(`Failed to fetch stats for node ${request.params.nodeId}:`, error); return response.custom({ @@ -79,7 +79,7 @@ export function defineWlmRoutes(router: IRouter) { method: 'GET', path: '/_wlm/workload_group', }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (error: any) { return response.customError({ statusCode: error.statusCode || 500, @@ -107,7 +107,7 @@ export function defineWlmRoutes(router: IRouter) { method: 'GET', path: `/_wlm/workload_group/${name}`, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (error: any) { return response.custom({ statusCode: error.statusCode || 500, @@ -143,7 +143,7 @@ export function defineWlmRoutes(router: IRouter) { body, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (e: any) { console.error('Failed to create workload group:', e); return response.internalError({ @@ -182,7 +182,7 @@ export function defineWlmRoutes(router: IRouter) { body, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (e: any) { console.error('Failed to update workload group:', e); return response.internalError({ @@ -212,7 +212,7 @@ export function defineWlmRoutes(router: IRouter) { path: `/_wlm/workload_group/${name}`, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (e: any) { console.error(`Failed to delete workload group '${request.params.name}':`, e); return response.internalError({ @@ -244,7 +244,7 @@ export function defineWlmRoutes(router: IRouter) { path: `/_wlm/stats/${workloadGroupId}`, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (error: any) { console.error( `Failed to fetch WLM stats for group ${request.params.workloadGroupId}:`, @@ -260,26 +260,6 @@ export function defineWlmRoutes(router: IRouter) { } ); - // Get all node IDs (used for node selection dropdown) - router.get( - { - path: '/api/_wlm_proxy/_nodes', - validate: false, - }, - async (context, request, response) => { - const esClient = context.core.opensearch.client.asCurrentUser; - try { - const result = await esClient.nodes.info(); - return response.ok({ body: result }); - } catch (e) { - return response.customError({ - statusCode: e.statusCode || 500, - body: e.message, - }); - } - } - ); - // Create index rule router.put( { @@ -309,7 +289,7 @@ export function defineWlmRoutes(router: IRouter) { }, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (error: any) { console.error(`Failed to create index rule:`, error); return response.custom({ @@ -335,7 +315,7 @@ export function defineWlmRoutes(router: IRouter) { path: '/_rules/workload_group', }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (e: any) { console.error('Failed to fetch index rules:', e); return response.internalError({ @@ -365,7 +345,7 @@ export function defineWlmRoutes(router: IRouter) { path: `/_rules/workload_group/${ruleId}`, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (e: any) { console.error(`Failed to delete index rule ${ruleId}:`, e); return response.internalError({ @@ -401,7 +381,7 @@ export function defineWlmRoutes(router: IRouter) { body, }); - return response.ok({ body: result }); + return response.ok({ body: result.body }); } catch (error) { console.error('Error updating rule:', error); return response.customError({ From b655e496157ebefbf9bda24c469d5a5659ce13ae Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:19:37 -0700 Subject: [PATCH 07/14] =?UTF-8?q?Remove=20=E2=80=9COpen=20in=20search=20co?= =?UTF-8?q?mparison=E2=80=9D=20button=20from=20Query=20Details=20&=20Query?= =?UTF-8?q?=20Group=20Details=20(#396)=20(#412)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 67377d2c78a75f84ab3b0c522fb1c6c749cea678) Signed-off-by: Kishore Kumaar Natarajan Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: Kishore Kumaar Natarajan --- .../pages/QueryDetails/QueryDetails.test.tsx | 13 ------- public/pages/QueryDetails/QueryDetails.tsx | 12 ------- .../__snapshots__/QueryDetails.test.tsx.snap | 34 ------------------- .../QueryGroupDetails.test.tsx | 14 -------- .../QueryGroupDetails/QueryGroupDetails.tsx | 12 ------- .../QueryGroupDetails.test.tsx.snap | 34 ------------------- 6 files changed, 119 deletions(-) diff --git a/public/pages/QueryDetails/QueryDetails.test.tsx b/public/pages/QueryDetails/QueryDetails.test.tsx index 99ea1927..8973ff96 100644 --- a/public/pages/QueryDetails/QueryDetails.test.tsx +++ b/public/pages/QueryDetails/QueryDetails.test.tsx @@ -122,19 +122,6 @@ describe('QueryDetails component', () => { }); }); - it('renders the search comparison button', async () => { - renderQueryDetails(); - - await waitFor(() => { - const button = screen.getByText('Open in search comparison'); - expect(button).toBeInTheDocument(); - expect(button.closest('a')).toHaveAttribute( - 'href', - 'https://playground.opensearch.org/app/searchRelevance#/' - ); - }); - }); - it('matches snapshot', async () => { const { container } = renderQueryDetails(); diff --git a/public/pages/QueryDetails/QueryDetails.tsx b/public/pages/QueryDetails/QueryDetails.tsx index 33ea5a85..f863c064 100644 --- a/public/pages/QueryDetails/QueryDetails.tsx +++ b/public/pages/QueryDetails/QueryDetails.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; // @ts-ignore import Plotly from 'plotly.js-dist'; import { - EuiButton, EuiCodeBlock, EuiFlexGrid, EuiFlexGroup, @@ -173,17 +172,6 @@ const QueryDetails = ({

Query

- - - Open in search comparison - - diff --git a/public/pages/QueryDetails/__snapshots__/QueryDetails.test.tsx.snap b/public/pages/QueryDetails/__snapshots__/QueryDetails.test.tsx.snap index 9769341b..09d17f3c 100644 --- a/public/pages/QueryDetails/__snapshots__/QueryDetails.test.tsx.snap +++ b/public/pages/QueryDetails/__snapshots__/QueryDetails.test.tsx.snap @@ -216,40 +216,6 @@ exports[`QueryDetails component matches snapshot 1`] = ` Query -
{ expect(document.getElementById('latency')).toBeInTheDocument(); }); - it('displays query details', async () => { - const { container } = renderComponent(); - - await waitFor(() => { - expect(screen.getByText('Open in search comparision')).toBeInTheDocument(); - }); - - const codeBlock = container.querySelector('.euiCodeBlock__pre'); - expect(codeBlock).toBeInTheDocument(); - - expect(codeBlock?.textContent).toContain('"query"'); - expect(codeBlock?.textContent).toMatch(/{\s*"query":/); - }); - it('renders tooltips', () => { renderComponent(); diff --git a/public/pages/QueryGroupDetails/QueryGroupDetails.tsx b/public/pages/QueryGroupDetails/QueryGroupDetails.tsx index 9fb632ce..c2411597 100644 --- a/public/pages/QueryGroupDetails/QueryGroupDetails.tsx +++ b/public/pages/QueryGroupDetails/QueryGroupDetails.tsx @@ -10,7 +10,6 @@ import Plotly from 'plotly.js-dist'; import { useHistory, useLocation } from 'react-router-dom'; import React, { useContext, useEffect, useState } from 'react'; import { - EuiButton, EuiCodeBlock, EuiFlexGrid, EuiFlexGroup, @@ -204,17 +203,6 @@ export const QueryGroupDetails = ({

Query

- - - Open in search comparision - - diff --git a/public/pages/QueryGroupDetails/__snapshots__/QueryGroupDetails.test.tsx.snap b/public/pages/QueryGroupDetails/__snapshots__/QueryGroupDetails.test.tsx.snap index 6143988c..779d3bf3 100644 --- a/public/pages/QueryGroupDetails/__snapshots__/QueryGroupDetails.test.tsx.snap +++ b/public/pages/QueryGroupDetails/__snapshots__/QueryGroupDetails.test.tsx.snap @@ -339,40 +339,6 @@ exports[`QueryGroupDetails matches snapshot 1`] = ` Query -
Date: Wed, 22 Oct 2025 10:04:54 -0700 Subject: [PATCH 08/14] fix 3.3 cypress test (#423) --- .github/workflows/cypress-tests.yml | 80 ++++++++++++++++------------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml index 158b19cb..44ea2a7c 100644 --- a/.github/workflows/cypress-tests.yml +++ b/.github/workflows/cypress-tests.yml @@ -1,12 +1,23 @@ name: Cypress e2e integration tests workflow + on: pull_request: - branches: - - "*" + branches: ["*"] push: - branches: - - "*" + branches: ["*"] + +concurrency: + group: cypress-e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + env: + OPENSEARCH_BRANCH: '3.3' + OPENSEARCH_DASHBOARDS_BRANCH: '3.3' + OPENSEARCH_VERSION: '3.3.1-SNAPSHOT' + QUERY_INSIGHTS_BRANCH: '3.3' GRADLE_VERSION: '7.6.1' CYPRESS_VIDEO: true CYPRESS_SCREENSHOT_ON_RUN_FAILURE: true @@ -24,40 +35,10 @@ jobs: cypress_cache_folder: ~/.cache/Cypress runs-on: ${{ matrix.os }} env: - # prevents extra Cypress installation progress messages CI: 1 - # avoid warnings like "tput: No value for $TERM and no -T specified" TERM: xterm - steps: - - - uses: actions/checkout@v4 - - - name: Set env - run: | - opensearch_version=$(node -p "require('./package.json').opensearchDashboards.version") - plugin_version=$(node -p "require('./package.json').version") - major_version=$(echo $opensearch_version | cut -d. -f1) - minor_version=$(echo $opensearch_version | cut -d. -f2) - opensearch_version=${opensearch_version}-SNAPSHOT - plugin_version=${plugin_version}-SNAPSHOT - branch_version=${major_version}.${minor_version} - opensearch_branch=$branch_version - opensearch_dashboards_branch=$branch_version - query_insights_branch=$branch_version - base_branch=${{ github.base_ref }} - echo "base_branch $base_branch" - if [ "$base_branch" = "main" ]; then - opensearch_branch=main - opensearch_dashboards_branch=main - query_insights_branch=main - fi - echo "QUERY_INSIGHTS_BRANCH=$query_insights_branch" >> $GITHUB_ENV - echo "OPENSEARCH_BRANCH=$opensearch_branch" >> $GITHUB_ENV - echo "OPENSEARCH_DASHBOARDS_BRANCH=$opensearch_dashboards_branch" >> $GITHUB_ENV - echo "OPENSEARCH_VERSION=$opensearch_version" >> $GITHUB_ENV - echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV - shell: bash + steps: - name: Checkout Query Insights uses: actions/checkout@v4 with: @@ -214,7 +195,22 @@ jobs: echo "Waiting for OpenSearch-Dashboards to start..." max_attempts=150 attempt=0 + + # Check if the process is running + echo "Checking if OpenSearch-Dashboards process is running..." + ps aux | grep "node.*dashboards" | grep -v grep || echo "No dashboards process found!" + while [ $attempt -lt $max_attempts ]; do + # Check every 5 attempts if process is still alive + if [ $((attempt % 5)) -eq 0 ]; then + if ! ps aux | grep "node.*dashboards" | grep -v grep > /dev/null; then + echo "ERROR: OpenSearch-Dashboards process has died!" + echo "=== Last 100 lines of dashboards.log ===" + tail -n 100 OpenSearch-Dashboards/dashboards.log || echo "Could not read dashboards.log" + exit 1 + fi + fi + if curl -s -f http://localhost:5601/api/status > /dev/null 2>&1; then echo "OpenSearch-Dashboards is ready!" echo "=== OpenSearch-Dashboards Status Debug Info ===" @@ -225,18 +221,30 @@ jobs: fi attempt=$((attempt + 1)) echo "Attempt $attempt/$max_attempts: OpenSearch-Dashboards not ready yet, waiting 10 seconds..." + + # Show logs every 10 attempts if [ $((attempt % 10)) -eq 0 ]; then + echo "=== Recent dashboards.log output (last 50 lines) ===" + tail -n 50 OpenSearch-Dashboards/dashboards.log || echo "Could not read dashboards.log" + echo "===================================================" echo "Debug: Attempting to connect to http://localhost:5601/api/status" curl -s -v http://localhost:5601/api/status || echo "Connection failed" + echo "Checking listening ports..." + netstat -an | grep 5601 || echo "Port 5601 not found in netstat" fi sleep 10 done + if [ $attempt -eq $max_attempts ]; then echo "OpenSearch-Dashboards failed to start within timeout" + echo "=== Full dashboards.log ===" + cat OpenSearch-Dashboards/dashboards.log || echo "Could not read dashboards.log" + echo "==========================" echo "Final debug attempt:" curl -s -v http://localhost:5601/api/status || echo "Final connection attempt failed" exit 1 fi + curl -s http://localhost:5601/api/status || (echo "OpenSearch-Dashboards health check failed" && exit 1) echo "Waiting additional time for plugin initialization..." sleep 15 @@ -299,4 +307,4 @@ jobs: if: always() with: name: cypress-videos-${{ matrix.os }} - path: OpenSearch-Dashboards/plugins/query-insights-dashboards/cypress/videos + path: OpenSearch-Dashboards/plugins/query-insights-dashboards/cypress/videos \ No newline at end of file From 42b84fd6ab7e38cfe1e54f22813ff04a4716560d Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:01:30 -0700 Subject: [PATCH 09/14] MDS Live Queries Support (#403) (#419) (cherry picked from commit 95db4186fe218f13a16846946a9ee2675e994826) Signed-off-by: Kishore Kumaar Natarajan Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: Kishore Kumaar Natarajan Co-authored-by: Chenyang Ji --- server/routes/index.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/server/routes/index.ts b/server/routes/index.ts index a6878c3b..2daba2d5 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -434,16 +434,22 @@ export function defineRoutes(router: IRouter, dataSourceEnabled: boolean) { wlmGroupId?: string; }; - const client = - !dataSourceEnabled || !dataSourceId - ? context.queryInsights_plugin.queryInsightsClient.asScoped(request).callAsCurrentUser - : context.dataSource.opensearch.legacy.getClient(dataSourceId); - // Call the appropriate API based on whether wlm_group is provided const hasGroup = typeof wlmGroup === 'string' && wlmGroup.trim().length > 0; - const res = hasGroup - ? await client('queryInsights.getLiveQueriesWLMGroup', { wlmGroupId: wlmGroup }) - : await client('queryInsights.getLiveQueries'); + let res; + + if (!dataSourceEnabled || !dataSourceId) { + const client = context.queryInsights_plugin.queryInsightsClient.asScoped(request) + .callAsCurrentUser; + res = hasGroup + ? await client('queryInsights.getLiveQueriesWLMGroup', { wlmGroupId: wlmGroup }) + : await client('queryInsights.getLiveQueries'); + } else { + const client = context.dataSource.opensearch.legacy.getClient(dataSourceId); + res = hasGroup + ? await client.callAPI('queryInsights.getLiveQueriesWLMGroup', { wlmGroupId: wlmGroup }) + : await client.callAPI('queryInsights.getLiveQueries', {}); + } if (!res || res.ok === false) { throw new Error(res?.error || 'Query Insights service returned an error'); From a6c70778bd89f02965f97697126d8d1ebbedadc3 Mon Sep 17 00:00:00 2001 From: Kishore Kumaar Natarajan <30365405+KishoreKicha14@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:13:41 -0700 Subject: [PATCH 10/14] updated version (#425) Signed-off-by: Kishore Kumaar Natarajan Co-authored-by: Kishore Kumaar Natarajan --- .github/workflows/cypress-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml index 44ea2a7c..0d4d5645 100644 --- a/.github/workflows/cypress-tests.yml +++ b/.github/workflows/cypress-tests.yml @@ -16,7 +16,7 @@ permissions: env: OPENSEARCH_BRANCH: '3.3' OPENSEARCH_DASHBOARDS_BRANCH: '3.3' - OPENSEARCH_VERSION: '3.3.1-SNAPSHOT' + OPENSEARCH_VERSION: '3.3.2-SNAPSHOT' QUERY_INSIGHTS_BRANCH: '3.3' GRADLE_VERSION: '7.6.1' CYPRESS_VIDEO: true @@ -307,4 +307,4 @@ jobs: if: always() with: name: cypress-videos-${{ matrix.os }} - path: OpenSearch-Dashboards/plugins/query-insights-dashboards/cypress/videos \ No newline at end of file + path: OpenSearch-Dashboards/plugins/query-insights-dashboards/cypress/videos From f3772f2fb20d4553f53fa5ee31b3c5492eeccdb8 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:32:39 -0700 Subject: [PATCH 11/14] Fix for mds in server/wlmRoutes (#411) (#417) * fix for mds in server/wlmRoutes * yarn lint fix * retrigger ut tests * updated route for _wlm/thresholds * fix ut * triggering test --------- (cherry picked from commit a170e91037da85b4ed161b1ef06367aee623d5a9) Signed-off-by: Lingxi Chen Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: Lingxi Chen Co-authored-by: Chenyang Ji --- .github/workflows/build-and-test.yml | 1 + .../WorkloadManagement/WLMMain/WLMMain.tsx | 4 +- server/clusters/wlmPlugin.ts | 129 +++ server/plugin.ts | 21 +- server/routes/wlmRoutes.test.tsx | 969 ++++++++++++++---- server/routes/wlmRoutes.ts | 322 +++--- 6 files changed, 1103 insertions(+), 343 deletions(-) create mode 100644 server/clusters/wlmPlugin.ts diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 41818ed3..164784d6 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -67,6 +67,7 @@ jobs: - name: Bootstrap plugin/OpenSearch-Dashboards run: | cd OpenSearch-Dashboards/plugins/query-insights-dashboards + yarn cache clean --all yarn osd bootstrap - name: Run unit tests run: | diff --git a/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx b/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx index e760ce88..85aa640c 100644 --- a/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx +++ b/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx @@ -282,7 +282,9 @@ export const WorkloadManagementMain = ({ const thresholds = await core.http.get<{ cpuRejectionThreshold: number; memoryRejectionThreshold: number; - }>('/api/_wlm/thresholds'); + }>('/api/_wlm/thresholds', { + query: { dataSourceId: dataSource.id }, + }); const cpuThreshold = thresholds?.cpuRejectionThreshold ?? 1; const memoryThreshold = thresholds?.memoryRejectionThreshold ?? 1; diff --git a/server/clusters/wlmPlugin.ts b/server/clusters/wlmPlugin.ts new file mode 100644 index 00000000..b17a583c --- /dev/null +++ b/server/clusters/wlmPlugin.ts @@ -0,0 +1,129 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const WlmPlugin = function (Client: any, config: any, components: any) { + const ca = components.clientAction.factory; + Client.prototype.wlm = components.clientAction.namespaceFactory(); + const wlm = Client.prototype.wlm.prototype; + + // Get WLM stats across all nodes + wlm.getStats = ca({ + url: { fmt: '/_wlm/stats' }, + method: 'GET', + }); + + // Get WLM stats for a specific node + wlm.getNodeStats = ca({ + url: { + fmt: '/_wlm/<%=nodeId%>/stats', + req: { + nodeId: { type: 'string', required: true }, + }, + }, + method: 'GET', + }); + + // List all workload groups + wlm.getWorkloadGroups = ca({ + url: { fmt: '/_wlm/workload_group' }, + method: 'GET', + }); + + // Get workload group by name + wlm.getWorkloadGroup = ca({ + url: { + fmt: '/_wlm/workload_group/<%=name%>', + req: { + name: { type: 'string', required: true }, + }, + }, + method: 'GET', + }); + + // Create workload group + wlm.createWorkloadGroup = ca({ + url: { fmt: '/_wlm/workload_group' }, + method: 'PUT', + needBody: true, + }); + + // Update workload group + wlm.updateWorkloadGroup = ca({ + url: { + fmt: '/_wlm/workload_group/<%=name%>', + req: { + name: { type: 'string', required: true }, + }, + }, + method: 'PUT', + needBody: true, + }); + + // Delete workload group + wlm.deleteWorkloadGroup = ca({ + url: { + fmt: '/_wlm/workload_group/<%=name%>', + req: { + name: { type: 'string', required: true }, + }, + }, + method: 'DELETE', + }); + + // Get stats for specific workload group + wlm.getWorkloadGroupStats = ca({ + url: { + fmt: '/_wlm/stats/<%=workloadGroupId%>', + req: { + workloadGroupId: { type: 'string', required: true }, + }, + }, + method: 'GET', + }); + + // Create index rule + wlm.createRule = ca({ + url: { fmt: '/_rules/workload_group' }, + method: 'PUT', + needBody: true, + }); + + // Get all index rules + wlm.getRules = ca({ + url: { fmt: '/_rules/workload_group' }, + method: 'GET', + }); + + // Delete index rule + wlm.deleteRule = ca({ + url: { + fmt: '/_rules/workload_group/<%=ruleId%>', + req: { + ruleId: { type: 'string', required: true }, + }, + }, + method: 'DELETE', + }); + + // Update index rule + wlm.updateRule = ca({ + url: { + fmt: '/_rules/workload_group/<%=ruleId%>', + req: { + ruleId: { type: 'string', required: true }, + }, + }, + method: 'PUT', + needBody: true, + }); + + // Get node level cpu and memory threshold + wlm.getThresholds = ca({ + url: { fmt: '/_cluster/settings' }, + method: 'GET', + needBody: false, + qs: ['include_defaults'], + }); +}; diff --git a/server/plugin.ts b/server/plugin.ts index d42f7d1e..42980671 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -12,6 +12,7 @@ import { ILegacyCustomClusterClient, } from '../../../src/core/server'; import { QueryInsightsPlugin } from './clusters/queryInsightsPlugin'; +import { WlmPlugin } from './clusters/wlmPlugin'; import { QueryInsightsDashboardsPluginSetup, QueryInsightsDashboardsPluginStart } from './types'; import { defineRoutes } from './routes'; @@ -50,10 +51,28 @@ export class QueryInsightsDashboardsPlugin queryInsightsClient, }; }); + // Register WLM custom client + const wlmClient: ILegacyCustomClusterClient = core.opensearch.legacy.createClient( + 'opensearch_wlm', + { + plugins: [WlmPlugin], + } + ); + if (dataSourceEnabled) { + dataSource.registerCustomApiSchema(WlmPlugin); + } + + // @ts-ignore - Register WLM context + core.http.registerRouteHandlerContext('wlm_plugin', (_context, _request) => { + return { + logger: this.logger, + wlmClient, + }; + }); // Register server side APIs defineRoutes(router, dataSourceEnabled); - defineWlmRoutes(router); + defineWlmRoutes(router, dataSourceEnabled); return {}; } diff --git a/server/routes/wlmRoutes.test.tsx b/server/routes/wlmRoutes.test.tsx index 4b8f141d..5c83dda6 100644 --- a/server/routes/wlmRoutes.test.tsx +++ b/server/routes/wlmRoutes.test.tsx @@ -6,37 +6,7 @@ import { defineWlmRoutes } from './wlmRoutes'; import '@testing-library/jest-dom'; -const REG: Record Promise | any> = {}; -const router = { - get: jest.fn((cfg: any, h: any) => { - REG[`GET ${cfg.path}`] = h; - }), - put: jest.fn((cfg: any, h: any) => { - REG[`PUT ${cfg.path}`] = h; - }), - delete: jest.fn((cfg: any, h: any) => { - REG[`DELETE ${cfg.path}`] = h; - }), -} as any; - -const makeCtx = () => ({ - core: { - opensearch: { - client: { - asCurrentUser: { transport: { request: jest.fn() } }, - asInternalUser: { cluster: { getSettings: jest.fn() } }, - }, - }, - }, - queryInsights: { logger: { error: jest.fn() } }, -}); - -const makeRes = () => ({ - ok: jest.fn(), - custom: jest.fn(), - customError: jest.fn(), - internalError: jest.fn(), -}); +type Handler = (ctx: any, req: any, res: any) => Promise | any; const expectNoMeta = (body: any) => { expect(body).toBeDefined(); @@ -44,183 +14,651 @@ const expectNoMeta = (body: any) => { expect(JSON.stringify(body)).not.toContain('"meta"'); }; -beforeAll(() => { - defineWlmRoutes(router); -}); - -describe('defineWlmRoutes: responses must not expose `meta`', () => { - test('GET /api/_wlm/stats', async () => { - const handler = REG['GET /api/_wlm/stats']; - expect(handler).toBeDefined(); - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { nodes: { n1: {} } }, - meta: { shouldNotLeak: true }, - }); - const res = makeRes(); - await handler(ctx, {}, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('GET /api/_wlm/{nodeId}/stats', async () => { - const handler = REG['GET /api/_wlm/{nodeId}/stats']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { node: 'abc', stats: {} }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, { params: { nodeId: 'abc' } }, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('GET /api/_wlm/workload_group', async () => { - const handler = REG['GET /api/_wlm/workload_group']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { workload_groups: [{ name: 'DEFAULT_WORKLOAD_GROUP' }] }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, {}, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('GET /api/_wlm/workload_group/{name}', async () => { - const handler = REG['GET /api/_wlm/workload_group/{name}']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { name: 'G1', resource_limits: { cpu: 0, memory: 0 } }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, { params: { name: 'G1' } }, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('PUT /api/_wlm/workload_group', async () => { - const handler = REG['PUT /api/_wlm/workload_group']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { acknowledged: true, id: 'g' }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler( - ctx, - { body: { name: 'g', resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } } }, - res - ); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('PUT /api/_wlm/workload_group/{name}', async () => { - const handler = REG['PUT /api/_wlm/workload_group/{name}']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { updated: true }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler( - ctx, - { - params: { name: 'g' }, - body: { resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } }, - }, - res - ); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('DELETE /api/_wlm/workload_group/{name}', async () => { - const handler = REG['DELETE /api/_wlm/workload_group/{name}']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { acknowledged: true }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, { params: { name: 'g' } }, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('GET /api/_wlm/stats/{workloadGroupId}', async () => { - const handler = REG['GET /api/_wlm/stats/{workloadGroupId}']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { id: 'wg-1', stats: {} }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, { params: { workloadGroupId: 'wg-1' } }, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('PUT /api/_rules/workload_group', async () => { - const handler = REG['PUT /api/_rules/workload_group']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { created: true, ruleId: 'r1' }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler( - ctx, - { body: { description: 'd', index_pattern: ['logs-*'], workload_group: 'g' } }, - res - ); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('GET /api/_rules/workload_group', async () => { - const handler = REG['GET /api/_rules/workload_group']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { rules: [] }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, {}, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('DELETE /api/_rules/workload_group/{ruleId}', async () => { - const handler = REG['DELETE /api/_rules/workload_group/{ruleId}']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { acknowledged: true }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, { params: { ruleId: 'r1' } }, res); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('PUT /api/_rules/workload_group/{ruleId}', async () => { - const handler = REG['PUT /api/_rules/workload_group/{ruleId}']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asCurrentUser.transport.request as jest.Mock).mockResolvedValue({ - body: { updated: true }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler( - ctx, - { - params: { ruleId: 'r1' }, - body: { description: 'd', index_pattern: ['a*'], workload_group: 'g' }, - }, - res - ); - expectNoMeta(res.ok.mock.calls[0][0].body); - }); - - test('GET /api/_wlm/thresholds', async () => { - const handler = REG['GET /api/_wlm/thresholds']; - const ctx = makeCtx(); - (ctx.core.opensearch.client.asInternalUser.cluster.getSettings as jest.Mock).mockResolvedValue({ - body: { +describe.each<[boolean]>([[true], [false]])( + 'defineWlmRoutes (dataSourceEnabled=%s)', + (dataSourceEnabled) => { + let REG: Record; + let router: any; + + const makeRouter = () => { + const reg: Record = {}; + const r = { + get: jest.fn((cfg: any, h: Handler) => { + reg[`GET ${cfg.path}`] = h; + }), + put: jest.fn((cfg: any, h: Handler) => { + reg[`PUT ${cfg.path}`] = h; + }), + delete: jest.fn((cfg: any, h: Handler) => { + reg[`DELETE ${cfg.path}`] = h; + }), + } as any; + return { reg, r }; + }; + + const makeCtx = () => { + const mockWlmCall = jest.fn(); + const mockDsCallAPI = jest.fn(); + const mockDsGetSettings = jest.fn(); + const mockCoreGetSettings = jest.fn(); + + const ctx = { + wlm_plugin: { + wlmClient: { + asScoped: jest.fn(() => ({ callAsCurrentUser: mockWlmCall })), + }, + }, + dataSource: { + opensearch: { + legacy: { + getClient: jest.fn(() => ({ + callAPI: mockDsCallAPI, + cluster: { getSettings: mockDsGetSettings }, + })), + }, + }, + }, + queryInsights: { logger: { error: jest.fn() } }, + }; + + return { ctx, mockWlmCall, mockDsCallAPI, mockDsGetSettings, mockCoreGetSettings }; + }; + + const makeRes = () => ({ + ok: jest.fn(), + custom: jest.fn(), + customError: jest.fn(), + internalError: jest.fn(), + }); + + beforeEach(() => { + jest.resetAllMocks(); + const { reg, r } = makeRouter(); + REG = reg; + router = r; + defineWlmRoutes(router, dataSourceEnabled); + }); + + // + // 1) GET /api/_wlm/stats + // + test('GET /api/_wlm/stats (with dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/stats']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + mockDsCallAPI.mockResolvedValue({ nodes: { n2: {} } }); + mockWlmCall.mockResolvedValue({ nodes: { n1: {} } }); + + await handler(ctx, { query: { dataSourceId: 'ds-1' } }, res); + + const expectedDsCalls = dataSourceEnabled ? [['wlm.getStats', {}]] : []; + const expectedCoreCalls = dataSourceEnabled ? [] : [['wlm.getStats']]; + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual(expectedDsCalls); + expect(mockWlmCall.mock.calls).toEqual(expectedCoreCalls); + + const expectedPayload = dataSourceEnabled ? { nodes: { n2: {} } } : { nodes: { n1: {} } }; + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual(expectedPayload); + expectNoMeta(body); + }); + + test('GET /api/_wlm/stats (no dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/stats']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + mockWlmCall.mockResolvedValue({ nodes: { n1: {} } }); + + await handler(ctx, { query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.getStats']]); + + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual({ nodes: { n1: {} } }); + expectNoMeta(body); + }); + + // + // 2) GET /api/_wlm/{nodeId}/stats + // + test('GET /api/_wlm/{nodeId}/stats (with dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/{nodeId}/stats']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { nodeId: 'node-1' }; + + mockDsCallAPI.mockResolvedValue({ node: 'node-1', stats: {} }); + mockWlmCall.mockResolvedValue({ node: 'node-1', stats: {} }); + + await handler(ctx, { params, query: { dataSourceId: 'ds-x' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-x']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.getNodeStats', params]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.getNodeStats', params]] + ); + + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual({ node: 'node-1', stats: {} }); + expectNoMeta(body); + }); + + test('GET /api/_wlm/{nodeId}/stats (no dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/{nodeId}/stats']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { nodeId: 'node-2' }; + + mockWlmCall.mockResolvedValue({ node: 'node-2', stats: {} }); + + await handler(ctx, { params, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.getNodeStats', params]]); + + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual({ node: 'node-2', stats: {} }); + expectNoMeta(body); + }); + + // + // 3) GET /api/_wlm/workload_group + // + test('GET /api/_wlm/workload_group (with dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + mockDsCallAPI.mockResolvedValue({ workload_groups: [{ name: 'DEFAULT_WORKLOAD_GROUP' }] }); + mockWlmCall.mockResolvedValue({ workload_groups: [{ name: 'DEFAULT_WORKLOAD_GROUP' }] }); + + await handler(ctx, { query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.getWorkloadGroups', {}]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual(dataSourceEnabled ? [] : [['wlm.getWorkloadGroups']]); + + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual({ workload_groups: [{ name: 'DEFAULT_WORKLOAD_GROUP' }] }); + expectNoMeta(body); + }); + + test('GET /api/_wlm/workload_group (no dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + mockWlmCall.mockResolvedValue({ workload_groups: [{ name: 'DEFAULT_WORKLOAD_GROUP' }] }); + + await handler(ctx, { query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.getWorkloadGroups']]); + + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual({ workload_groups: [{ name: 'DEFAULT_WORKLOAD_GROUP' }] }); + expectNoMeta(body); + }); + + // + // 4) GET /api/_wlm/workload_group/{name} + // + test('GET /api/_wlm/workload_group/{name} (with dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/workload_group/{name}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { name: 'G1' }; + + mockDsCallAPI.mockResolvedValue({ name: 'G1', resource_limits: { cpu: 0, memory: 0 } }); + mockWlmCall.mockResolvedValue({ name: 'G1', resource_limits: { cpu: 0, memory: 0 } }); + + await handler(ctx, { params, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.getWorkloadGroup', params]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.getWorkloadGroup', params]] + ); + + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual({ name: 'G1', resource_limits: { cpu: 0, memory: 0 } }); + expectNoMeta(body); + }); + + test('GET /api/_wlm/workload_group/{name} (no dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/workload_group/{name}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { name: 'G1' }; + + mockWlmCall.mockResolvedValue({ name: 'G1', resource_limits: { cpu: 0, memory: 0 } }); + + await handler(ctx, { params, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.getWorkloadGroup', params]]); + + const body = res.ok.mock.calls[0][0].body; + expect(body).toEqual({ name: 'G1', resource_limits: { cpu: 0, memory: 0 } }); + expectNoMeta(body); + }); + + // + // 5) PUT /api/_wlm/workload_group + // + test('PUT /api/_wlm/workload_group (with dataSourceId)', async () => { + const handler = REG['PUT /api/_wlm/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const bodyIn = { name: 'g', resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } }; + + mockDsCallAPI.mockResolvedValue({ acknowledged: true, id: 'g' }); + mockWlmCall.mockResolvedValue({ acknowledged: true, id: 'g' }); + + await handler(ctx, { body: bodyIn, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.createWorkloadGroup', { body: bodyIn }]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.createWorkloadGroup', { body: bodyIn }]] + ); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ acknowledged: true, id: 'g' }); + expectNoMeta(payload); + }); + + test('PUT /api/_wlm/workload_group (no dataSourceId)', async () => { + const handler = REG['PUT /api/_wlm/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const bodyIn = { name: 'g', resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } }; + + mockWlmCall.mockResolvedValue({ acknowledged: true, id: 'g' }); + + await handler(ctx, { body: bodyIn, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.createWorkloadGroup', { body: bodyIn }]]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ acknowledged: true, id: 'g' }); + expectNoMeta(payload); + }); + + // + // 6) PUT /api/_wlm/workload_group/{name} + // + test('PUT /api/_wlm/workload_group/{name} (with dataSourceId)', async () => { + const handler = REG['PUT /api/_wlm/workload_group/{name}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { name: 'g' }; + const bodyIn = { resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } }; + + mockDsCallAPI.mockResolvedValue({ updated: true }); + mockWlmCall.mockResolvedValue({ updated: true }); + + await handler(ctx, { params, body: bodyIn, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.updateWorkloadGroup', { name: 'g', body: bodyIn }]] : [] + ); + + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.updateWorkloadGroup', { name: 'g', body: bodyIn }]] + ); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ updated: true }); + expectNoMeta(payload); + }); + + test('PUT /api/_wlm/workload_group/{name} (no dataSourceId)', async () => { + const handler = REG['PUT /api/_wlm/workload_group/{name}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { name: 'g' }; + const bodyIn = { resiliency_mode: 'soft', resource_limits: { cpu: 0, memory: 0 } }; + + mockWlmCall.mockResolvedValue({ updated: true }); + + await handler(ctx, { params, body: bodyIn, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([ + ['wlm.updateWorkloadGroup', { name: 'g', body: bodyIn }], + ]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ updated: true }); + expectNoMeta(payload); + }); + + // + // 7) DELETE /api/_wlm/workload_group/{name} + // + test('DELETE /api/_wlm/workload_group/{name} (with dataSourceId)', async () => { + const handler = REG['DELETE /api/_wlm/workload_group/{name}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { name: 'g' }; + + mockDsCallAPI.mockResolvedValue({ acknowledged: true }); + mockWlmCall.mockResolvedValue({ acknowledged: true }); + + await handler(ctx, { params, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.deleteWorkloadGroup', params]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.deleteWorkloadGroup', params]] + ); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ acknowledged: true }); + expectNoMeta(payload); + }); + + test('DELETE /api/_wlm/workload_group/{name} (no dataSourceId)', async () => { + const handler = REG['DELETE /api/_wlm/workload_group/{name}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { name: 'g' }; + + mockWlmCall.mockResolvedValue({ acknowledged: true }); + + await handler(ctx, { params, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.deleteWorkloadGroup', params]]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ acknowledged: true }); + expectNoMeta(payload); + }); + + // + // 8) GET /api/_wlm/stats/{workloadGroupId} + // + test('GET /api/_wlm/stats/{workloadGroupId} (with dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/stats/{workloadGroupId}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { workloadGroupId: 'wg-1' }; + + mockDsCallAPI.mockResolvedValue({ id: 'wg-1', stats: {} }); + mockWlmCall.mockResolvedValue({ id: 'wg-1', stats: {} }); + + await handler(ctx, { params, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.getWorkloadGroupStats', params]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.getWorkloadGroupStats', params]] + ); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ id: 'wg-1', stats: {} }); + expectNoMeta(payload); + }); + + test('GET /api/_wlm/stats/{workloadGroupId} (no dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/stats/{workloadGroupId}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { workloadGroupId: 'wg-1' }; + + mockWlmCall.mockResolvedValue({ id: 'wg-1', stats: {} }); + + await handler(ctx, { params, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.getWorkloadGroupStats', params]]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ id: 'wg-1', stats: {} }); + expectNoMeta(payload); + }); + + // + // 9) PUT /api/_rules/workload_group + // + test('PUT /api/_rules/workload_group (with dataSourceId)', async () => { + const handler = REG['PUT /api/_rules/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const bodyIn = { description: 'd', index_pattern: ['logs-*'], workload_group: 'g' }; + + mockDsCallAPI.mockResolvedValue({ created: true, ruleId: 'r1' }); + mockWlmCall.mockResolvedValue({ created: true, ruleId: 'r1' }); + + await handler(ctx, { body: bodyIn, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.createRule', { body: bodyIn }]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.createRule', { body: bodyIn }]] + ); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ created: true, ruleId: 'r1' }); + expectNoMeta(payload); + }); + + test('PUT /api/_rules/workload_group (no dataSourceId)', async () => { + const handler = REG['PUT /api/_rules/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const bodyIn = { description: 'd', index_pattern: ['logs-*'], workload_group: 'g' }; + + mockWlmCall.mockResolvedValue({ created: true, ruleId: 'r1' }); + + await handler(ctx, { body: bodyIn, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.createRule', { body: bodyIn }]]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ created: true, ruleId: 'r1' }); + expectNoMeta(payload); + }); + + // + // 10) GET /api/_rules/workload_group + // + test('GET /api/_rules/workload_group (with dataSourceId)', async () => { + const handler = REG['GET /api/_rules/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + mockDsCallAPI.mockResolvedValue({ rules: [] }); + mockWlmCall.mockResolvedValue({ rules: [] }); + + await handler(ctx, { query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual(dataSourceEnabled ? [['wlm.getRules', {}]] : []); + expect(mockWlmCall.mock.calls).toEqual(dataSourceEnabled ? [] : [['wlm.getRules']]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ rules: [] }); + expectNoMeta(payload); + }); + + test('GET /api/_rules/workload_group (no dataSourceId)', async () => { + const handler = REG['GET /api/_rules/workload_group']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + mockWlmCall.mockResolvedValue({ rules: [] }); + + await handler(ctx, { query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.getRules']]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ rules: [] }); + expectNoMeta(payload); + }); + + // + // 11) DELETE /api/_rules/workload_group/{ruleId} + // + test('DELETE /api/_rules/workload_group/{ruleId} (with dataSourceId)', async () => { + const handler = REG['DELETE /api/_rules/workload_group/{ruleId}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { ruleId: 'r1' }; + + mockDsCallAPI.mockResolvedValue({ acknowledged: true }); + mockWlmCall.mockResolvedValue({ acknowledged: true }); + + await handler(ctx, { params, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.deleteRule', params]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual(dataSourceEnabled ? [] : [['wlm.deleteRule', params]]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ acknowledged: true }); + expectNoMeta(payload); + }); + + test('DELETE /api/_rules/workload_group/{ruleId} (no dataSourceId)', async () => { + const handler = REG['DELETE /api/_rules/workload_group/{ruleId}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { ruleId: 'r1' }; + + mockWlmCall.mockResolvedValue({ acknowledged: true }); + + await handler(ctx, { params, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.deleteRule', params]]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ acknowledged: true }); + expectNoMeta(payload); + }); + + // + // 12) PUT /api/_rules/workload_group/{ruleId} + // + test('PUT /api/_rules/workload_group/{ruleId} (with dataSourceId)', async () => { + const handler = REG['PUT /api/_rules/workload_group/{ruleId}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { ruleId: 'r1' }; + const bodyIn = { description: 'd', index_pattern: ['a*'], workload_group: 'g' }; + + mockDsCallAPI.mockResolvedValue({ updated: true }); + mockWlmCall.mockResolvedValue({ updated: true }); + + await handler(ctx, { params, body: bodyIn, query: { dataSourceId: 'ds-1' } }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual( + dataSourceEnabled ? [['wlm.updateRule', { ruleId: 'r1', body: bodyIn }]] : [] + ); + expect(mockWlmCall.mock.calls).toEqual( + dataSourceEnabled ? [] : [['wlm.updateRule', { ruleId: 'r1', body: bodyIn }]] + ); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ updated: true }); + expectNoMeta(payload); + }); + + test('PUT /api/_rules/workload_group/{ruleId} (no dataSourceId)', async () => { + const handler = REG['PUT /api/_rules/workload_group/{ruleId}']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + const params = { ruleId: 'r1' }; + const bodyIn = { description: 'd', index_pattern: ['a*'], workload_group: 'g' }; + + mockWlmCall.mockResolvedValue({ updated: true }); + + await handler(ctx, { params, body: bodyIn, query: {} }, res); + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual([]); + expect(mockDsCallAPI.mock.calls).toEqual([]); + expect(mockWlmCall.mock.calls).toEqual([['wlm.updateRule', { ruleId: 'r1', body: bodyIn }]]); + + const payload = res.ok.mock.calls[0][0].body; + expect(payload).toEqual({ updated: true }); + expectNoMeta(payload); + }); + + // + // 13) GET /api/_wlm/thresholds + // + test('GET /api/_wlm/thresholds (with dataSourceId)', async () => { + const handler = REG['GET /api/_wlm/thresholds']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + // DS returns only defaults; values are strings that should be parsed to numbers + mockDsCallAPI.mockResolvedValue({ defaults: { wlm: { workload_group: { @@ -228,13 +666,108 @@ describe('defineWlmRoutes: responses must not expose `meta`', () => { }, }, }, - }, - meta: { nope: true }, - }); - const res = makeRes(); - await handler(ctx, {}, res); - const payload = res.ok.mock.calls[0][0].body; - expectNoMeta(payload); - expect(payload).toEqual({ cpuRejectionThreshold: 0.8, memoryRejectionThreshold: 0.6 }); - }); -}); + }); + mockWlmCall.mockResolvedValue({ + defaults: { + wlm: { + workload_group: { + node: { cpu_rejection_threshold: '0.1', memory_rejection_threshold: '0.2' }, + }, + }, + }, + }); + + await handler(ctx, { query: { dataSourceId: 'ds-1' } }, res); + + // When dataSourceEnabled is true, DS client is used; otherwise core client is used + const expectedDsCalls = dataSourceEnabled + ? [['wlm.getThresholds', { include_defaults: true }]] + : []; + const expectedCoreCalls = dataSourceEnabled + ? [] + : [['wlm.getThresholds', { include_defaults: true }]]; // note: our handler uses callAsCurrentUser('wlm.getThresholds', { include_defaults: true }) + + expect(ctx.dataSource.opensearch.legacy.getClient.mock.calls).toEqual( + dataSourceEnabled ? [['ds-1']] : [] + ); + expect(mockDsCallAPI.mock.calls).toEqual(expectedDsCalls); + expect(mockWlmCall.mock.calls).toEqual(expectedCoreCalls); + + const { body } = res.ok.mock.calls[0][0]; + // If DS path used → 0.8/0.6; if core path used → 0.1/0.2 + const expected = dataSourceEnabled + ? { cpuRejectionThreshold: 0.8, memoryRejectionThreshold: 0.6 } + : { cpuRejectionThreshold: 0.1, memoryRejectionThreshold: 0.2 }; + expect(body).toEqual(expected); + expectNoMeta(body); + }); + + test('GET /api/_wlm/thresholds (no dataSourceId) uses core and respects fallback: transient → persistent → defaults', async () => { + const handler = REG['GET /api/_wlm/thresholds']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + mockWlmCall.mockResolvedValue({ + transient: { wlm: { workload_group: { node: { cpu_rejection_threshold: '0.75' } } } }, + persistent: { wlm: { workload_group: { node: { memory_rejection_threshold: '0.55' } } } }, + defaults: { + wlm: { + workload_group: { + node: { cpu_rejection_threshold: '0.33', memory_rejection_threshold: '0.22' }, + }, + }, + }, + }); + + await handler(ctx, { query: {} }, res); + + // No DS path when no dataSourceId + expect(ctx.dataSource.opensearch.legacy.getClient).not.toHaveBeenCalled(); + expect(mockDsCallAPI).not.toHaveBeenCalled(); + // Called with include_defaults true + expect(mockWlmCall).toHaveBeenCalledWith('wlm.getThresholds', { include_defaults: true }); + + const { body } = res.ok.mock.calls[0][0]; + // cpu from transient (0.75), memory from persistent (0.55) + expect(body).toEqual({ cpuRejectionThreshold: 0.75, memoryRejectionThreshold: 0.55 }); + expectNoMeta(body); + }); + + test('GET /api/_wlm/thresholds falls back to 1.0 when nothing provided', async () => { + const handler = REG['GET /api/_wlm/thresholds']; + const { ctx, mockWlmCall } = makeCtx(); + const res = makeRes(); + + mockWlmCall.mockResolvedValue({}); // no transient/persistent/defaults + + await handler(ctx, { query: {} }, res); + + const { body } = res.ok.mock.calls[0][0]; + expect(body).toEqual({ cpuRejectionThreshold: 1, memoryRejectionThreshold: 1 }); + expectNoMeta(body); + }); + + test('GET /api/_wlm/thresholds bubbles OpenSearch error via customError', async () => { + const handler = REG['GET /api/_wlm/thresholds']; + const { ctx, mockWlmCall, mockDsCallAPI } = makeCtx(); + const res = makeRes(); + + const err = Object.assign(new Error('Boom'), { + meta: { statusCode: 503, body: { error: { reason: 'service unavailable' } } }, + }); + + if (dataSourceEnabled) { + mockDsCallAPI.mockRejectedValue(err); + await handler(ctx, { query: { dataSourceId: 'ds-1' } }, res); + } else { + mockWlmCall.mockRejectedValue(err); + await handler(ctx, { query: { dataSourceId: 'ds-1' } }, res); // since dataSourceEnabled is false, still goes core path + } + + expect(res.customError).toHaveBeenCalledWith({ + statusCode: 503, + body: { message: 'service unavailable' }, + }); + }); + } +); diff --git a/server/routes/wlmRoutes.ts b/server/routes/wlmRoutes.ts index 307cf97d..22890132 100644 --- a/server/routes/wlmRoutes.ts +++ b/server/routes/wlmRoutes.ts @@ -6,21 +6,28 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../../../src/core/server'; -export function defineWlmRoutes(router: IRouter) { +export function defineWlmRoutes(router: IRouter, dataSourceEnabled: boolean) { // Get WLM stats across all nodes in the cluster router.get( { path: '/api/_wlm/stats', - validate: false, + validate: { + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; - const stats = await client.transport.request({ - method: 'GET', - path: '/_wlm/stats', - }); - return response.ok({ body: stats.body }); + let stats; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + stats = await client('wlm.getStats'); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + stats = await client.callAPI('wlm.getStats', {}); + } + return response.ok({ body: stats }); } catch (error: any) { context.queryInsights.logger.error(`Failed to fetch WLM stats: ${error.message}`, { error, @@ -43,17 +50,23 @@ export function defineWlmRoutes(router: IRouter) { params: schema.object({ nodeId: schema.string(), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; const { nodeId } = request.params; - const stats = await client.transport.request({ - method: 'GET', - path: `/_wlm/${nodeId}/stats`, - }); - return response.ok({ body: stats.body }); + let stats; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + stats = await client('wlm.getNodeStats', { nodeId }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + stats = await client.callAPI('wlm.getNodeStats', { nodeId }); + } + return response.ok({ body: stats }); } catch (error: any) { console.error(`Failed to fetch stats for node ${request.params.nodeId}:`, error); return response.custom({ @@ -70,16 +83,23 @@ export function defineWlmRoutes(router: IRouter) { router.get( { path: '/api/_wlm/workload_group', - validate: false, + validate: { + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; - const result = await client.transport.request({ - method: 'GET', - path: '/_wlm/workload_group', - }); - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.getWorkloadGroups'); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.getWorkloadGroups', {}); + } + return response.ok({ body: result }); } catch (error: any) { return response.customError({ statusCode: error.statusCode || 500, @@ -97,17 +117,23 @@ export function defineWlmRoutes(router: IRouter) { params: schema.object({ name: schema.string(), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { const { name } = request.params; try { - const client = context.core.opensearch.client.asCurrentUser; - const result = await client.transport.request({ - method: 'GET', - path: `/_wlm/workload_group/${name}`, - }); - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.getWorkloadGroup', { name }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.getWorkloadGroup', { name }); + } + return response.ok({ body: result }); } catch (error: any) { return response.custom({ statusCode: error.statusCode || 500, @@ -130,20 +156,23 @@ export function defineWlmRoutes(router: IRouter) { memory: schema.maybe(schema.number()), }), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; const body = request.body; - - const result = await client.transport.request({ - method: 'PUT', - path: '/_wlm/workload_group', - body, - }); - - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.createWorkloadGroup', { body }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.createWorkloadGroup', { body }); + } + return response.ok({ body: result }); } catch (e: any) { console.error('Failed to create workload group:', e); return response.internalError({ @@ -168,21 +197,24 @@ export function defineWlmRoutes(router: IRouter) { memory: schema.maybe(schema.number()), }), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; const { name } = request.params; const body = request.body; - - const result = await client.transport.request({ - method: 'PUT', - path: `/_wlm/workload_group/${name}`, - body, - }); - - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.updateWorkloadGroup', { name, body }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.updateWorkloadGroup', { name, body }); + } + return response.ok({ body: result }); } catch (e: any) { console.error('Failed to update workload group:', e); return response.internalError({ @@ -200,19 +232,23 @@ export function defineWlmRoutes(router: IRouter) { params: schema.object({ name: schema.string(), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; const { name } = request.params; - - const result = await client.transport.request({ - method: 'DELETE', - path: `/_wlm/workload_group/${name}`, - }); - - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.deleteWorkloadGroup', { name }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.deleteWorkloadGroup', { name }); + } + return response.ok({ body: result }); } catch (e: any) { console.error(`Failed to delete workload group '${request.params.name}':`, e); return response.internalError({ @@ -232,19 +268,23 @@ export function defineWlmRoutes(router: IRouter) { params: schema.object({ workloadGroupId: schema.string(), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; const { workloadGroupId } = request.params; - - const result = await client.transport.request({ - method: 'GET', - path: `/_wlm/stats/${workloadGroupId}`, - }); - - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.getWorkloadGroupStats', { workloadGroupId }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.getWorkloadGroupStats', { workloadGroupId }); + } + return response.ok({ body: result }); } catch (error: any) { console.error( `Failed to fetch WLM stats for group ${request.params.workloadGroupId}:`, @@ -270,26 +310,27 @@ export function defineWlmRoutes(router: IRouter) { index_pattern: schema.arrayOf(schema.string()), workload_group: schema.string(), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; - const description = request.body.description; - const indexPattern = request.body.index_pattern; - const workloadGroup = request.body.workload_group; - - const result = await client.transport.request({ - method: 'PUT', - path: '/_rules/workload_group', - body: { - description, - index_pattern: indexPattern, - workload_group: workloadGroup, - }, - }); - - return response.ok({ body: result.body }); + const body = { + description: request.body.description, + index_pattern: request.body.index_pattern, + workload_group: request.body.workload_group, + }; + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.createRule', { body }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.createRule', { body }); + } + return response.ok({ body: result }); } catch (error: any) { console.error(`Failed to create index rule:`, error); return response.custom({ @@ -304,18 +345,23 @@ export function defineWlmRoutes(router: IRouter) { router.get( { path: '/api/_rules/workload_group', - validate: false, + validate: { + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, }, async (context, request, response) => { try { - const client = context.core.opensearch.client.asCurrentUser; - - const result = await client.transport.request({ - method: 'GET', - path: '/_rules/workload_group', - }); - - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.getRules'); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.getRules', {}); + } + return response.ok({ body: result }); } catch (e: any) { console.error('Failed to fetch index rules:', e); return response.internalError({ @@ -333,19 +379,23 @@ export function defineWlmRoutes(router: IRouter) { params: schema.object({ ruleId: schema.string(), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { const { ruleId } = request.params; try { - const client = context.core.opensearch.client.asCurrentUser; - - const result = await client.transport.request({ - method: 'DELETE', - path: `/_rules/workload_group/${ruleId}`, - }); - - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.deleteRule', { ruleId }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.deleteRule', { ruleId }); + } + return response.ok({ body: result }); } catch (e: any) { console.error(`Failed to delete index rule ${ruleId}:`, e); return response.internalError({ @@ -368,6 +418,9 @@ export function defineWlmRoutes(router: IRouter) { index_pattern: schema.arrayOf(schema.string()), workload_group: schema.string(), }), + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), }, }, async (context, request, response) => { @@ -375,13 +428,15 @@ export function defineWlmRoutes(router: IRouter) { const body = request.body; try { - const result = await context.core.opensearch.client.asCurrentUser.transport.request({ - method: 'PUT', - path: `/_rules/workload_group/${ruleId}`, - body, - }); - - return response.ok({ body: result.body }); + let result; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + result = await client('wlm.updateRule', { ruleId, body }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + result = await client.callAPI('wlm.updateRule', { ruleId, body }); + } + return response.ok({ body: result }); } catch (error) { console.error('Error updating rule:', error); return response.customError({ @@ -396,30 +451,51 @@ export function defineWlmRoutes(router: IRouter) { router.get( { path: '/api/_wlm/thresholds', - validate: false, + validate: { + query: schema.object({ + dataSourceId: schema.maybe(schema.string()), + }), + }, }, async (context, request, response) => { - const esClient = context.core.opensearch.client.asInternalUser; - const { body } = await esClient.cluster.getSettings({ include_defaults: true }); - - const cpuThreshold = - body.transient?.wlm?.workload_group?.node?.cpu_rejection_threshold ?? - body.persistent?.wlm?.workload_group?.node?.cpu_rejection_threshold ?? - body.defaults?.wlm?.workload_group?.node?.cpu_rejection_threshold ?? - '1'; - - const memoryThreshold = - body.transient?.wlm?.workload_group?.node?.memory_rejection_threshold ?? - body.persistent?.wlm?.workload_group?.node?.memory_rejection_threshold ?? - body.defaults?.wlm?.workload_group?.node?.memory_rejection_threshold ?? - '1'; - - return response.ok({ - body: { - cpuRejectionThreshold: parseFloat(cpuThreshold), - memoryRejectionThreshold: parseFloat(memoryThreshold), - }, - }); + try { + let body; + if (!dataSourceEnabled || !request.query?.dataSourceId) { + const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; + body = await client('wlm.getThresholds', { include_defaults: true }); + } else { + const client = context.dataSource.opensearch.legacy.getClient(request.query.dataSourceId); + body = await client.callAPI('wlm.getThresholds', { include_defaults: true }); + } + + const cpuThreshold = + body.transient?.wlm?.workload_group?.node?.cpu_rejection_threshold ?? + body.persistent?.wlm?.workload_group?.node?.cpu_rejection_threshold ?? + body.defaults?.wlm?.workload_group?.node?.cpu_rejection_threshold ?? + '1'; + + const memoryThreshold = + body.transient?.wlm?.workload_group?.node?.memory_rejection_threshold ?? + body.persistent?.wlm?.workload_group?.node?.memory_rejection_threshold ?? + body.defaults?.wlm?.workload_group?.node?.memory_rejection_threshold ?? + '1'; + + return response.ok({ + body: { + cpuRejectionThreshold: parseFloat(cpuThreshold), + memoryRejectionThreshold: parseFloat(memoryThreshold), + }, + }); + } catch (e: any) { + const status = e?.meta?.statusCode ?? e?.statusCode ?? e?.status ?? 500; + const message = + e?.meta?.body?.error?.reason ?? + e?.meta?.body?.message ?? + e?.body?.message ?? + e?.message ?? + 'Unexpected error'; + return response.customError({ statusCode: status, body: { message } }); + } } ); } From 467223bedef54cfc3d357bac5ab891769efcfc6e Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:57:16 -0700 Subject: [PATCH 12/14] Add version-aware settings support (#407) (#427) * Added version-aware changes * adding local datasource logic * adding local datasource logic * adding local datasource logic * adding local datasource logic * adding local datasource logic * Removed _cat/plugins * hide live queries in 3.3 below * updated tests * updated tests * updated tests * Updated tests * Fixed comments * Lint fix --------- (cherry picked from commit 8192a9bfadd00eb470a39da53a7fb1f43b430d39) Signed-off-by: Kishore Kumaar Natarajan Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: Kishore Kumaar Natarajan Co-authored-by: Chenyang Ji --- common/constants.ts | 1 + cypress/e2e/5_live_queries.cy.js | 69 ++-- opensearch_dashboards.json | 3 +- public/application.tsx | 32 ++ .../InflightQueries/InflightQueries.test.tsx | 153 ++++++-- .../pages/InflightQueries/InflightQueries.tsx | 334 ++++++++++-------- .../InflightQueries.test.tsx.snap | 2 +- public/pages/TopNQueries/TopNQueries.test.tsx | 3 + public/pages/TopNQueries/TopNQueries.tsx | 138 +++++--- .../WorkloadManagement/WLMMain/WLMMain.tsx | 14 +- public/route_service.ts | 24 ++ public/service.ts | 59 ++++ public/utils/__mocks__/version-utils.ts | 13 + public/utils/datasource-utils.ts | 26 ++ public/utils/version-utils.test.ts | 92 +++++ public/utils/version-utils.ts | 53 +++ server/routes/index.ts | 90 ++--- 17 files changed, 801 insertions(+), 305 deletions(-) create mode 100644 public/route_service.ts create mode 100644 public/service.ts create mode 100644 public/utils/__mocks__/version-utils.ts create mode 100644 public/utils/version-utils.test.ts create mode 100644 public/utils/version-utils.ts diff --git a/common/constants.ts b/common/constants.ts index e1cde512..451c05d4 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -68,6 +68,7 @@ export const DEFAULT_EXPORTER_TYPE = EXPORTER_TYPE.localIndex; export const DEFAULT_DELETE_AFTER_DAYS = '7'; export const DEFAULT_REFRESH_INTERVAL = 30000; // default 30s export const TOP_N_DISPLAY_LIMIT = 9; +export const DEFAULT_SHOW_LIVE_QUERIES_ON_ERROR = false; export const WLM_GROUP_ID_PARAM = 'wlmGroupId'; export const ALL_WORKLOAD_GROUPS_TEXT = 'All workload groups'; export const CHART_COLORS = [ diff --git a/cypress/e2e/5_live_queries.cy.js b/cypress/e2e/5_live_queries.cy.js index ec4488c5..4ba1a0bf 100644 --- a/cypress/e2e/5_live_queries.cy.js +++ b/cypress/e2e/5_live_queries.cy.js @@ -358,75 +358,90 @@ describe('Inflight Queries Dashboard - WLM Enabled', () => { }).as('getLiveQueries'); }); - cy.fixture('stub_wlm_stats.json').then((wlmStatsResponse) => { + // WLM stats + cy.fixture('stub_wlm_stats.json').then((wlmStats) => { cy.intercept('GET', '**/api/_wlm/stats', { statusCode: 200, - body: wlmStatsResponse, + body: wlmStats.body, }).as('getWlmStats'); }); - cy.intercept('GET', '**/api/cat_plugins', { - statusCode: 200, - body: { hasWlm: true }, - }).as('getPluginsEnabled'); - cy.intercept('GET', '**/api/_wlm/workload_group', { statusCode: 200, body: { workload_groups: [ { _id: 'ANALYTICS_WORKLOAD_GROUP', name: 'ANALYTICS_WORKLOAD_GROUP' }, - { _id: 'DEFAULT_QUERY_GROUP', name: 'DEFAULT_QUERY_GROUP' }, + { _id: 'DEFAULT_WORKLOAD_GROUP', name: 'DEFAULT_WORKLOAD_GROUP' }, ], }, }).as('getWorkloadGroups'); - cy.intercept('GET', '**/api/cat_plugins', { + + cy.intercept('GET', '**/api/cluster/version', { statusCode: 200, - body: { hasWlm: true }, - }).as('getPluginsEnabled'); + body: { version: '3.3.0' }, + }).as('getClusterVersion'); + // Navigate AFTER all intercepts are ready, then wait initial snapshot cy.navigateToLiveQueries(); - cy.wait('@getLiveQueries'); }); it('displays WLM group links when WLM is enabled', () => { cy.wait('@getWorkloadGroups'); - cy.wait('@getPluginsEnabled'); cy.get('tbody tr') .first() .within(() => { - cy.get('td').contains('ANALYTICS_WORKLOAD_GROUP').click({ force: true }); + cy.contains('td', 'ANALYTICS_WORKLOAD_GROUP').click({ force: true }); }); }); it('calls different API when WLM group selection changes', () => { - // Intercept all live_queries calls - cy.intercept('GET', '**/api/live_queries*').as('liveQueries'); + // Robust spies that tolerate extra query params & any order + cy.intercept('GET', /\/api\/live_queries\?(?=.*\bwlmGroupId=ANALYTICS_WORKLOAD_GROUP\b).*/).as( + 'liveQueriesAnalytics' + ); + + cy.intercept('GET', /\/api\/live_queries\?(?=.*\bwlmGroupId=DEFAULT_WORKLOAD_GROUP\b).*/).as( + 'liveQueriesDefault' + ); - // 1) Select ANALYTICS first - cy.get('#wlm-group-select').should('exist').select('ANALYTICS_WORKLOAD_GROUP'); + cy.get('#wlm-group-select').should('exist'); - cy.wait('@liveQueries') + // 1) Select ANALYTICS + cy.get('#wlm-group-select').select('ANALYTICS_WORKLOAD_GROUP'); + cy.wait('@liveQueriesAnalytics') .its('request.url') - .should('include', 'wlmGroupId=ANALYTICS_WORKLOAD_GROUP'); + .should((urlStr) => { + const url = new URL(urlStr); + expect(url.searchParams.get('wlmGroupId')).to.eq('ANALYTICS_WORKLOAD_GROUP'); + }); - // Component re-fetches workload groups after selection — wait for that + // Component re-fetches groups after selection cy.wait('@getWorkloadGroups'); - // 2) Select DEFAULT_WORKLOAD_GROUP explicitly + // 2) Select DEFAULT cy.get('#wlm-group-select').select('DEFAULT_WORKLOAD_GROUP'); - cy.wait('@liveQueries') + cy.wait('@liveQueriesDefault') .its('request.url') - .should('include', 'wlmGroupId=DEFAULT_WORKLOAD_GROUP'); + .should((urlStr) => { + const url = new URL(urlStr); + expect(url.searchParams.get('wlmGroupId')).to.eq('DEFAULT_WORKLOAD_GROUP'); + }); }); it('displays total completion, cancellation, and rejection metrics correctly', () => { - // Trigger a refresh to ensure WLM stats are loaded - cy.get('[data-test-subj="live-queries-refresh-button"]').click(); + // Wait for version check to complete + cy.wait('@getClusterVersion'); + + // Wait for WLM groups to be fetched + cy.wait('@getWorkloadGroups'); + + // Wait for WLM stats to be fetched cy.wait('@getWlmStats'); - cy.contains('Total completions') + // Wait for the panels to be rendered with data + cy.contains('Total completions', { timeout: 10000 }) .closest('.euiPanel') .within(() => { cy.get('h2').should('contain.text', '300'); diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 5f6e2ead..c715ec2f 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -5,7 +5,8 @@ "server": true, "ui": true, "requiredPlugins": [ - "navigation" + "navigation", + "opensearchDashboardsUtils" ], "optionalPlugins": [ "dataSource", diff --git a/public/application.tsx b/public/application.tsx index 8c6560e4..f2344f4f 100644 --- a/public/application.tsx +++ b/public/application.tsx @@ -10,6 +10,19 @@ import { AppMountParameters, CoreStart } from '../../../src/core/public'; import { QueryInsightsDashboardsApp } from './components/app'; import { QueryInsightsDashboardsPluginStartDependencies } from './types'; import { DataSourceManagementPluginSetup } from '../../../src/plugins/data_source_management/public'; +import { + setCore, + setSavedObjectsClient, + setRouteService, + setDataSourceManagementPlugin, + setDataSourceEnabled, + setNotifications, + setUISettings, + setApplication, + setNavigationUI, + setHeaderActionMenu, +} from './service'; +import { RouteService } from './route_service'; export const renderApp = ( core: CoreStart, @@ -17,6 +30,25 @@ export const renderApp = ( params: AppMountParameters, dataSourceManagement?: DataSourceManagementPluginSetup ) => { + // Initialize services + setCore(core); + setSavedObjectsClient(core.savedObjects.client); + setRouteService(new RouteService(core.http)); + setNotifications(core.notifications); + setUISettings(core.uiSettings); + setApplication(core.application); + setHeaderActionMenu(params.setHeaderActionMenu); + + if (dataSourceManagement) { + setDataSourceManagementPlugin(dataSourceManagement); + } + + if (depsStart.navigation) { + setNavigationUI(depsStart.navigation.ui); + } + + setDataSourceEnabled({ enabled: !!dataSourceManagement }); + ReactDOM.render( ({ + getVersionOnce: jest.fn(), + isVersion33OrHigher: jest.fn(), +})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: jest.fn(), @@ -89,6 +94,9 @@ beforeEach(() => { cleanup(); // Reset useLocation mock to default (useLocation as jest.Mock).mockReturnValue({ search: '' }); + // Mock version utilities - default to 3.3.0 for WLM support + (getVersionOnce as jest.Mock).mockResolvedValue('3.3.0'); + (isVersion33OrHigher as jest.Mock).mockReturnValue(true); // Suppress console warnings for cleaner test output jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -185,6 +193,8 @@ describe('InflightQueries', () => { it('shows zeros when there are no queries', async () => { const core = makeCore(); mockLiveQueries({ ok: true, response: { live_queries: [] } }); + // Mock version < 3.3.0 to disable WLM features + (getVersionOnce as jest.Mock).mockResolvedValue('3.0.0'); render( withDataSource( @@ -201,7 +211,7 @@ describe('InflightQueries', () => { await waitFor(() => { expect(screen.getByText('Active queries')).toBeInTheDocument(); - expect(screen.getAllByText('0')).toHaveLength(8); + expect(screen.getAllByText('0')).toHaveLength(5); // No WLM panels in older versions }); }); @@ -301,14 +311,18 @@ describe('InflightQueries', () => { it('handles WLM group selection when WLM plugin is present', async () => { const core = makeCore(); - (core.http.get as jest.Mock) - .mockResolvedValueOnce([{ component: 'workload-management', name: 'wlm-plugin' }]) // _cat/plugins - .mockResolvedValueOnce({ - body: { node1: { workload_groups: { group1: { total_completions: 5 } } } }, - }) // stats - .mockResolvedValueOnce({ - body: { workload_groups: [{ _id: 'group1', name: 'Test Group' }] }, - }); // groups + + // Mock version functions to enable WLM + (getVersionOnce as jest.Mock).mockResolvedValue('3.3.0'); + (isVersion33OrHigher as jest.Mock).mockReturnValue(true); + + // Mock WLM detection + (core.http.get as jest.Mock).mockImplementation((url) => { + if (url === '/api/_wlm/workload_group') { + return Promise.resolve({ workload_groups: [] }); + } + return Promise.resolve({}); + }); mockLiveQueries(mockStubLiveQueries); @@ -325,7 +339,13 @@ describe('InflightQueries', () => { ) ); - expect(await screen.findByLabelText('Workload group selector')).toBeInTheDocument(); + // Wait for WLM components to render + await waitFor( + () => { + expect(screen.getByText('Workload group')).toBeInTheDocument(); + }, + { timeout: 10000 } + ); }); it('toggles auto-refresh', async () => { @@ -350,6 +370,18 @@ describe('InflightQueries', () => { it('displays ANALYTICS_WORKLOAD_GROUP and SEARCH_WORKLOAD_GROUP in rows', async () => { const core = makeCore(); + + // Mock version functions to enable WLM + (getVersionOnce as jest.Mock).mockResolvedValue('3.3.0'); + (isVersion33OrHigher as jest.Mock).mockReturnValue(true); + + (core.http.get as jest.Mock).mockImplementation((url) => { + if (url === '/api/_wlm/workload_group') { + return Promise.resolve({ workload_groups: [] }); + } + return Promise.resolve({ response: { live_queries: [] } }); + }); + mockLiveQueries(mockStubLiveQueries); render( @@ -369,8 +401,13 @@ describe('InflightQueries', () => { expect(screen.getByText('Active queries')).toBeInTheDocument(); }); - expect(screen.getAllByText('ANALYTICS_WORKLOAD_GROUP').length).toBeGreaterThan(0); - expect(screen.getAllByText('SEARCH_WORKLOAD_GROUP').length).toBeGreaterThan(0); + await waitFor( + () => { + expect(screen.getAllByText('ANALYTICS_WORKLOAD_GROUP').length).toBeGreaterThan(0); + expect(screen.getAllByText('SEARCH_WORKLOAD_GROUP').length).toBeGreaterThan(0); + }, + { timeout: 10000 } + ); }); it('calls API with SEARCH_WORKLOAD_GROUP parameter', async () => { @@ -435,11 +472,11 @@ describe('InflightQueries', () => { it('handles WLM stats API error gracefully', async () => { const core = makeCore(); - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleSpy = jest.spyOn(console, 'warn'); - (core.http.get as jest.Mock) - .mockResolvedValueOnce([{ component: 'workload-management', name: 'wlm-plugin' }]) - .mockRejectedValueOnce(new Error('Network error')); + // Mock version functions to disable WLM for older version + (getVersionOnce as jest.Mock).mockResolvedValue('2.0.0'); + (isVersion33OrHigher as jest.Mock).mockReturnValue(false); mockLiveQueries(mockStubLiveQueries); @@ -457,12 +494,88 @@ describe('InflightQueries', () => { ); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - '[LiveQueries] Failed to fetch WLM stats', - expect.any(Error) - ); + expect(screen.getByText('Active queries')).toBeInTheDocument(); }); + // For older versions, WLM features should not be available + expect(screen.queryByText('Workload group')).not.toBeInTheDocument(); + expect(consoleSpy).not.toHaveBeenCalledWith( + '[LiveQueries] Failed to fetch WLM stats', + expect.any(Error) + ); + consoleSpy.mockRestore(); }); + + it('shows 8 zeros when WLM is supported (version 3.3+)', async () => { + const core = makeCore(); + + // Mock version functions to enable WLM + (getVersionOnce as jest.Mock).mockResolvedValue('3.3.0'); + (isVersion33OrHigher as jest.Mock).mockReturnValue(true); + + (core.http.get as jest.Mock).mockImplementation((url) => { + if (url === '/api/_wlm/workload_group') { + return Promise.resolve({ workload_groups: [] }); + } + return Promise.resolve({ response: { live_queries: [] } }); + }); + + mockLiveQueries({ ok: true, response: { live_queries: [] } }); + + render( + withDataSource( + + ) + ); + + await waitFor(() => { + expect(screen.getByText('Active queries')).toBeInTheDocument(); + }); + + await waitFor( + () => { + expect(screen.getAllByText('0')).toHaveLength(8); // 5 base + 3 WLM panels + }, + { timeout: 10000 } + ); + }); + + it('hides WLM features for versions below 3.3.0', async () => { + const core = makeCore(); + + // Mock version functions to disable WLM for older version + (getVersionOnce as jest.Mock).mockResolvedValue('3.0.0'); + (isVersion33OrHigher as jest.Mock).mockReturnValue(false); + + mockLiveQueries(mockStubLiveQueries); + + render( + withDataSource( + + ) + ); + + await waitFor(() => { + expect(screen.getByText('Active queries')).toBeInTheDocument(); + }); + + // Should not show WLM selector or panels + expect(screen.queryByLabelText('Workload group selector')).not.toBeInTheDocument(); + expect(screen.queryByText('Total completions')).not.toBeInTheDocument(); + }); }); diff --git a/public/pages/InflightQueries/InflightQueries.tsx b/public/pages/InflightQueries/InflightQueries.tsx index 342b077e..dcdae05f 100644 --- a/public/pages/InflightQueries/InflightQueries.tsx +++ b/public/pages/InflightQueries/InflightQueries.tsx @@ -50,6 +50,7 @@ import { import { QueryInsightsDashboardsPluginStartDependencies } from '../../types'; import { DataSourceContext } from '../TopNQueries/TopNQueries'; import { QueryInsightsDataSourceMenu } from '../../components/DataSourcePicker'; +import { getVersionOnce, isVersion33OrHigher } from '../../utils/version-utils'; type LiveQueryRaw = NonNullable['live_queries'][number]; @@ -114,6 +115,7 @@ export const InflightQueries = ({ ); const [wlmAvailable, setWlmAvailable] = useState(false); + const [wlmGroupsSupported, setWlmGroupsSupported] = useState(false); const wlmCacheRef = useRef>({}); const detectWlm = useCallback(async (): Promise => { @@ -124,20 +126,41 @@ export const InflightQueries = ({ try { const httpQuery = dataSource?.id ? { dataSourceId: dataSource.id } : undefined; - const res = await core.http.get('/api/cat_plugins', { query: httpQuery }); - const has = !!res?.hasWlm; - wlmCacheRef.current[cacheKey] = has; - return has; + const res = await core.http.get('/api/_wlm/workload_group', { query: httpQuery }); + const hasValidStructure = + res && typeof res === 'object' && Array.isArray(res.workload_groups); + wlmCacheRef.current[cacheKey] = hasValidStructure; + return hasValidStructure; } catch (e) { - console.warn('[LiveQueries] _cat/plugins detection failed; assuming WLM unavailable', e); + console.warn('[LiveQueries] WLM workload group API failed; assuming WLM unavailable', e); wlmCacheRef.current[cacheKey] = false; return false; } }, [core.http, dataSource?.id]); useEffect(() => { - detectWlm().then(setWlmAvailable); - }, [detectWlm]); + const checkWlmSupport = async () => { + try { + const version = await getVersionOnce(dataSource?.id || ''); + const versionSupported = isVersion33OrHigher(version); + setWlmGroupsSupported(versionSupported); + + if (versionSupported) { + const hasWlm = await detectWlm(); + + setWlmAvailable(hasWlm); + } else { + setWlmAvailable(false); + } + } catch (e) { + console.warn('Failed to check version for WLM groups support', e); + setWlmGroupsSupported(false); + setWlmAvailable(false); + } + }; + + checkWlmSupport(); + }, [detectWlm, dataSource?.id]); const [workloadGroupStats, setWorkloadGroupStats] = useState<{ total_completions: number; @@ -150,7 +173,7 @@ export const InflightQueries = ({ let statsBody: WlmStatsBody = {}; try { const statsRes = await core.http.get(API_ENDPOINTS.WLM_STATS, { query: httpQuery }); - statsBody = (statsRes as { body?: unknown }).body as WlmStatsBody; + statsBody = statsRes as WlmStatsBody; } catch (e) { console.warn('[LiveQueries] Failed to fetch WLM stats', e); setWorkloadGroupStats({ total_completions: 0, total_cancellations: 0, total_rejections: 0 }); @@ -208,10 +231,10 @@ export const InflightQueries = ({ total_rejections: rejections, }); - // fetch group NAMES only if plugin exists (but do not block the stats) + // fetch group NAMES only if plugin exists and version supported const idToNameMap: Record = {}; try { - if (wlmAvailable) { + if (wlmAvailable && wlmGroupsSupported) { const groupsRes = await core.http.get(API_ENDPOINTS.WLM_WORKLOAD_GROUP, { query: httpQuery, }); @@ -226,10 +249,12 @@ export const InflightQueries = ({ console.warn('[LiveQueries] Failed to fetch workload groups', e); } - const options = Array.from(activeGroupIds).map((id) => ({ id, name: idToNameMap[id] || id })); + const options = wlmGroupsSupported + ? Array.from(activeGroupIds).map((id) => ({ id, name: idToNameMap[id] || id })) + : []; setWlmGroupOptions(options); return idToNameMap; - }, [core.http, dataSource?.id, wlmGroupId, wlmAvailable]); + }, [core.http, dataSource?.id, wlmGroupId, wlmGroupsSupported]); const liveQueries = query?.response?.live_queries ?? []; @@ -326,13 +351,22 @@ export const InflightQueries = ({ if (isFetching.current) return; isFetching.current = true; try { - const budget = Math.max(2000, refreshInterval - 500); - const map = await withTimeout(fetchActiveWlmGroups(), budget).catch(() => undefined); - await fetchLiveQueries(map); + if (wlmGroupsSupported) { + try { + await withTimeout(fetchActiveWlmGroups(), refreshInterval - 500); + } catch (e) { + console.warn('[LiveQueries] fetchActiveWlmGroups timed out or failed', e); + } + } + try { + await withTimeout(fetchLiveQueries(), refreshInterval - 500); + } catch (e) { + console.warn('[LiveQueries] fetchLiveQueries timed out or failed', e); + } } finally { isFetching.current = false; } - }, [refreshInterval, fetchActiveWlmGroups, fetchLiveQueries]); + }, [refreshInterval, fetchActiveWlmGroups, fetchLiveQueries, wlmGroupsSupported]); useEffect(() => { void fetchLiveQueriesSafe(); @@ -343,11 +377,11 @@ export const InflightQueries = ({ }, refreshInterval); return () => clearInterval(interval); - }, [autoRefreshEnabled, refreshInterval, fetchLiveQueriesSafe]); + }, [autoRefreshEnabled, refreshInterval, fetchLiveQueriesSafe, wlmGroupsSupported]); const [pagination, setPagination] = useState({ pageIndex: 0 }); const [tableQuery, setTableQuery] = useState(''); - const [tableFilters, setTableFilters] = useState([]); + const [_tableFilters, setTableFilters] = useState([]); const formatTime = (seconds: number): string => { if (seconds < 1e-3) return `${(seconds * 1e6).toFixed(2)} µs`; @@ -484,39 +518,40 @@ export const InflightQueries = ({ {/* LEFT: WLM status + optional selector */} - - - - Workload group - - - ({ value: g.id, text: g.name })), - ]} - value={wlmGroupId ?? ''} - onChange={(e) => { - const value = e.target.value || undefined; - setWlmGroupId(value); + {wlmGroupsSupported ? ( + + - - - - {/* */} + > + Workload group + + + ({ value: g.id, text: g.name })), + ]} + value={wlmGroupId ?? ''} + onChange={(e) => { + const value = e.target.value || undefined; + setWlmGroupId(value); + }} + aria-label="Workload group selector" + compressed + /> + + + ) : ( + + )} {/* RIGHT: refresh / auto-refresh */} @@ -546,7 +581,7 @@ export const InflightQueries = ({ { - await fetchLiveQueries(); + await fetchLiveQueriesSafe(); }} data-test-subj="live-queries-refresh-button" > @@ -787,47 +822,49 @@ export const InflightQueries = ({ - - {/* WLM Group Stats Panels */} - - - - -

Total completions

-
- -

{workloadGroupStats.total_completions}

-
-
-
-
+ {wlmGroupsSupported && ( + + {/* WLM Group Stats Panels */} + + + + +

Total completions

+
+ +

{workloadGroupStats.total_completions}

+
+
+
+
- - - - -

Total cancellations

-
- -

{workloadGroupStats.total_cancellations}

-
-
-
-
+ + + + +

Total cancellations

+
+ +

{workloadGroupStats.total_cancellations}

+
+
+
+
- - - - -

Total rejections

-
- -

{workloadGroupStats.total_rejections}

-
-
-
-
-
+ + + + +

Total rejections

+
+ +

{workloadGroupStats.total_rejections}

+
+
+
+
+
+ )} @@ -842,38 +879,41 @@ export const InflightQueries = ({ placeholder: 'Search queries', schema: false, }, - filters: tableFilters, - toolsLeft: selectedItems.length > 0 && [ - { - const httpClient = dataSource?.id - ? depsStart.data.dataSources.get(dataSource.id) - : core.http; - - await Promise.allSettled( - selectedItems.map((item) => - httpClient.post(API_ENDPOINTS.CANCEL_TASK(item.id)).then( - () => ({ status: 'fulfilled', id: item.id }), - (err) => ({ status: 'rejected', id: item.id, error: err }) - ) - ) - ); - setSelectedItems([]); - }} - > - Cancel {selectedItems.length} {selectedItems.length !== 1 ? 'queries' : 'query'} - , - ], + toolsLeft: + selectedItems.length > 0 + ? [ + { + const httpClient = dataSource?.id + ? depsStart.data.dataSources.get(dataSource.id) + : core.http; + + await Promise.allSettled( + selectedItems.map((item) => + httpClient.post(API_ENDPOINTS.CANCEL_TASK(item.id)).then( + () => ({ status: 'fulfilled', id: item.id }), + (err) => ({ status: 'rejected', id: item.id, error: err }) + ) + ) + ); + setSelectedItems([]); + }} + > + Cancel {selectedItems.length}{' '} + {selectedItems.length !== 1 ? 'queries' : 'query'} + , + ] + : undefined, toolsRight: [ { - await fetchLiveQueries(); + await fetchLiveQueriesSafe(); }} > Refresh @@ -951,34 +991,38 @@ export const InflightQueries = ({ ), }, - { - name: 'WLM Group', - render: (item: any) => { - if (!item.wlm_group || item.wlm_group === 'N/A') { - return 'N/A'; - } - - const displayName = wlmIdToNameMap[item.wlm_group] ?? item.wlm_group; - - if (wlmAvailable) { - return ( - { - core.application.navigateToApp('workloadManagement', { - path: `#/wlm-details?name=${encodeURIComponent(displayName)}`, - }); - }} - color="primary" - > - {displayName} - - ); - } - - // Plugin not available → simple text - return {displayName}; - }, - }, + ...(wlmGroupsSupported + ? [ + { + name: 'WLM Group', + render: (item: any) => { + if (!item.wlm_group || item.wlm_group === 'N/A') { + return 'N/A'; + } + + const displayName = wlmIdToNameMap[item.wlm_group] ?? item.wlm_group; + + if (wlmAvailable) { + return ( + { + core.application.navigateToApp('workloadManagement', { + path: `#/wlm-details?name=${encodeURIComponent(displayName)}`, + }); + }} + color="primary" + > + {displayName} + + ); + } + + // Plugin not available → simple text + return {displayName}; + }, + }, + ] + : []), { name: 'Actions', diff --git a/public/pages/InflightQueries/__snapshots__/InflightQueries.test.tsx.snap b/public/pages/InflightQueries/__snapshots__/InflightQueries.test.tsx.snap index 0d8d4c98..3bf9789b 100644 --- a/public/pages/InflightQueries/__snapshots__/InflightQueries.test.tsx.snap +++ b/public/pages/InflightQueries/__snapshots__/InflightQueries.test.tsx.snap @@ -55,7 +55,7 @@ exports[`InflightQueries matches snapshot 1`] = ` >
Mocked QueryInsights
); jest.mock('../Configuration/Configuration', () => () =>
Mocked Configuration
); jest.mock('../QueryDetails/QueryDetails', () => () =>
Mocked QueryDetails
); +jest.mock('../../utils/version-utils'); const mockCore = ({ http: { @@ -68,6 +70,7 @@ const renderTopNQueries = (type: string) => describe('TopNQueries Component', () => { beforeEach(() => { jest.clearAllMocks(); + (getVersionOnce as jest.Mock).mockResolvedValue('3.1.0'); }); it('renders and switches tabs correctly', () => { diff --git a/public/pages/TopNQueries/TopNQueries.tsx b/public/pages/TopNQueries/TopNQueries.tsx index 95802ac5..e6b21aca 100644 --- a/public/pages/TopNQueries/TopNQueries.tsx +++ b/public/pages/TopNQueries/TopNQueries.tsx @@ -3,12 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { createContext, useCallback, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'; import { Redirect, Route, Switch, useHistory, useLocation } from 'react-router-dom'; import { EuiTab, EuiTabs, EuiTitle, EuiSpacer } from '@elastic/eui'; import { AppMountParameters, CoreStart } from 'opensearch-dashboards/public'; import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; import { DataSourceOption } from 'src/plugins/data_source_management/public/components/data_source_menu/types'; +import { DateTime } from 'luxon'; import QueryInsights from '../QueryInsights/QueryInsights'; import Configuration from '../Configuration/Configuration'; import QueryDetails from '../QueryDetails/QueryDetails'; @@ -17,11 +18,18 @@ import { SearchQueryRecord } from '../../../types/types'; import { QueryGroupDetails } from '../QueryGroupDetails/QueryGroupDetails'; import { QueryInsightsDashboardsPluginStartDependencies } from '../../types'; import { PageHeader } from '../../components/PageHeader'; +import { + getVersionOnce, + getGroupBySettingsPath, + isVersion31OrHigher, + isVersion219, +} from '../../utils/version-utils'; import { DEFAULT_DELETE_AFTER_DAYS, DEFAULT_EXPORTER_TYPE, DEFAULT_GROUP_BY, DEFAULT_METRIC_ENABLED, + DEFAULT_SHOW_LIVE_QUERIES_ON_ERROR, DEFAULT_TIME_UNIT, DEFAULT_TOP_N_SIZE, DEFAULT_WINDOW_SIZE, @@ -86,6 +94,12 @@ const TopNQueries = ({ const [loading, setLoading] = useState(false); const [currStart, setStart] = useState(initialStart); const [currEnd, setEnd] = useState(initialEnd); + const [showLiveQueries, setShowLiveQueries] = useState(true); + const dataSourceFromUrl = getDataSourceFromUrl(); + const dataSourceId = dataSourceFromUrl.id; + + const [dataSource, setDataSource] = useState(dataSourceFromUrl); + const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([ { start: currStart, end: currEnd }, ]); @@ -132,23 +146,39 @@ const TopNQueries = ({ const [queries, setQueries] = useState([]); - const tabs: Array<{ id: string; name: string; route: string }> = [ - { - id: 'liveQueries', - name: 'Live queries', - route: LIVE_QUERIES, - }, - { - id: 'topNQueries', - name: 'Top N queries', - route: QUERY_INSIGHTS, - }, - { - id: 'configuration', - name: 'Configuration', - route: CONFIGURATION, - }, - ]; + useEffect(() => { + let isComponentUnmounted = false; + + (async () => { + try { + const version = await getVersionOnce(dataSourceId); + const shouldShowLiveQueries = isVersion31OrHigher(version); + + if (!isComponentUnmounted) { + setShowLiveQueries(shouldShowLiveQueries); + } + } catch (error) { + console.error('Failed to fetch data source version:', error); + if (!isComponentUnmounted) { + setShowLiveQueries(DEFAULT_SHOW_LIVE_QUERIES_ON_ERROR); + } + } + })(); + + return () => { + isComponentUnmounted = true; + }; + }, [dataSourceId]); + + const tabs = useMemo>(() => { + const base = [ + { id: 'topNQueries', name: 'Top N queries', route: QUERY_INSIGHTS }, + { id: 'configuration', name: 'Configuration', route: CONFIGURATION }, + ]; + return showLiveQueries + ? [{ id: 'liveQueries', name: 'Live queries', route: LIVE_QUERIES }, ...base] + : base; + }, [showLiveQueries]); const onSelectedTabChanged = (route: string) => { const { pathname: currPathname } = location; @@ -217,7 +247,22 @@ const TopNQueries = ({ const noDuplicates: SearchQueryRecord[] = newQueries.filter( (query, index, self) => index === self.findIndex((q) => q.id === query.id) ); - setQueries(noDuplicates); + + const version = await getVersionOnce(dataSourceId); + const is219OSVersion = isVersion219(version); + + const fromTime = DateTime.fromISO(parseDateString(start)); + const toTime = DateTime.fromISO(parseDateString(end)); + + const isWithinTimeWindow = (q: SearchQueryRecord) => { + const ts = DateTime.fromMillis(q.timestamp); + return ts.isValid && ts >= fromTime && ts <= toTime; + }; + + const filteredQueries = is219OSVersion + ? noDuplicates.filter(isWithinTimeWindow) + : noDuplicates; + setQueries(filteredQueries); } catch (error) { console.error('Error retrieving queries:', error); } finally { @@ -287,9 +332,10 @@ const TopNQueries = ({ }); } }); + const version = await getVersionOnce(dataSourceId); const groupBy = getMergedStringSettings( - persistentSettings?.grouping.group_by, - transientSettings?.grouping.group_by, + getGroupBySettingsPath(version, persistentSettings), + getGroupBySettingsPath(version, transientSettings), DEFAULT_GROUP_BY ); setGroupBySettings({ groupBy }); @@ -369,10 +415,6 @@ const TopNQueries = ({ retrieveQueries(currStart, currEnd); }, [latencySettings, cpuSettings, memorySettings, currStart, currEnd, retrieveQueries]); - const dataSourceFromUrl = getDataSourceFromUrl(); - - const [dataSource, setDataSource] = useState(dataSourceFromUrl); - return (
@@ -401,28 +443,30 @@ const TopNQueries = ({ ); }} - - - -

Query insights - In-flight queries

-
- - - } - /> - {tabs.map(renderTab)} - - -
+ {showLiveQueries && ( + + + +

Query insights - In-flight queries

+
+ + + } + /> + {tabs.map(renderTab)} + + +
+ )} { try { - const response = await core.http.get('/api/cat_plugins', { + const version = await getVersionOnce(dataSource?.id || ''); + if (!isVersion33OrHigher(version)) { + setIsQueryInsightsAvailable(false); + return; + } + + const res = await core.http.get('/api/live_queries', { query: { dataSourceId: dataSource.id }, }); - setIsQueryInsightsAvailable(response.hasQueryInsights || false); + const hasValidStructure = + res && typeof res === 'object' && res.response && Array.isArray(res.response.live_queries); + setIsQueryInsightsAvailable(hasValidStructure); } catch (error) { setIsQueryInsightsAvailable(false); } diff --git a/public/route_service.ts b/public/route_service.ts new file mode 100644 index 00000000..ded69791 --- /dev/null +++ b/public/route_service.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpStart } from '../../../src/core/public'; + +export class RouteService { + private http: HttpStart; + + constructor(http: HttpStart) { + this.http = http; + } + + async getLocalClusterVersion(): Promise { + try { + const response = await this.http.get('/api/cluster/version'); + return response?.version; + } catch (error) { + console.error('Error getting local cluster version:', error); + return undefined; + } + } +} diff --git a/public/service.ts b/public/service.ts new file mode 100644 index 00000000..bc70449f --- /dev/null +++ b/public/service.ts @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../../src/plugins/opensearch_dashboards_utils/public'; +import { + CoreStart, + NotificationsStart, + IUiSettingsClient, + AppMountParameters, +} from '../../../src/core/public'; +import { RouteService } from './route_service'; +import { DataSourceManagementPluginSetup } from '../../../src/plugins/data_source_management/public'; +import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; + +export interface DataSourceEnabled { + enabled: boolean; +} + +export const [getCore, setCore] = createGetterSetter('Core'); + +export const [getRouteService, setRouteService] = createGetterSetter('RouteService'); + +export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter< + CoreStart['savedObjects']['client'] +>('SavedObjectsClient'); + +export const [getDataSourceManagementPlugin, setDataSourceManagementPlugin] = createGetterSetter< + DataSourceManagementPluginSetup +>('DataSourceManagement'); + +export const [getDataSourceEnabled, setDataSourceEnabled] = createGetterSetter( + 'DataSourceEnabled' +); + +export const [getNotifications, setNotifications] = createGetterSetter( + 'Notifications' +); + +export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); + +export const [getApplication, setApplication] = createGetterSetter( + 'Application' +); + +export const [getNavigationUI, setNavigationUI] = createGetterSetter< + NavigationPublicPluginStart['ui'] +>('Navigation'); + +export const [getHeaderActionMenu, setHeaderActionMenu] = createGetterSetter< + AppMountParameters['setHeaderActionMenu'] +>('SetHeaderActionMenu'); + +export function setStartServices(coreStart: CoreStart) { + setCore(coreStart); + setSavedObjectsClient(coreStart.savedObjects.client); + setRouteService(new RouteService(coreStart.http)); +} diff --git a/public/utils/__mocks__/version-utils.ts b/public/utils/__mocks__/version-utils.ts new file mode 100644 index 00000000..931a49a7 --- /dev/null +++ b/public/utils/__mocks__/version-utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const getVersionOnce = jest.fn().mockResolvedValue('3.3.0'); +export const isVersion31OrHigher = jest.fn().mockReturnValue(true); +export const isVersion33OrHigher = jest.fn().mockReturnValue(true); +export const isVersion34OrHigher = jest.fn().mockReturnValue(true); +export const isVersion219 = jest.fn().mockReturnValue(false); +export const getGroupBySettingsPath = jest + .fn() + .mockImplementation((version, settings) => settings?.grouping?.group_by); diff --git a/public/utils/datasource-utils.ts b/public/utils/datasource-utils.ts index 8d3e868a..1bd0c8d5 100644 --- a/public/utils/datasource-utils.ts +++ b/public/utils/datasource-utils.ts @@ -23,6 +23,7 @@ import { DataSourceOption } from 'src/plugins/data_source_management/public'; import pluginManifest from '../../opensearch_dashboards.json'; import type { SavedObject } from '../../../../src/core/public'; import type { DataSourceAttributes } from '../../../../src/plugins/data_source/common/data_sources'; +import { getSavedObjectsClient, getRouteService } from '../service'; export function getDataSourceEnabledUrl(dataSource: DataSourceOption) { const url = new URL(window.location.href); @@ -30,6 +31,31 @@ export function getDataSourceEnabledUrl(dataSource: DataSourceOption) { return url; } +export const getDataSourceVersion = async ( + dataSourceId: string | undefined +): Promise => { + try { + if (dataSourceId === undefined || dataSourceId === '') { + return await getRouteService().getLocalClusterVersion(); + } + + const savedObjectsClient = getSavedObjectsClient(); + if (!savedObjectsClient) { + console.warn('SavedObjects client not available'); + return undefined; + } + + const dataSource = await savedObjectsClient.get( + 'data-source', + dataSourceId + ); + return dataSource?.attributes?.dataSourceVersion; + } catch (error) { + console.error('Error getting version: ', error); + return undefined; + } +}; + export function getDataSourceFromUrl(): DataSourceOption { const urlParams = new URLSearchParams(window.location.search); const dataSourceParam = (urlParams && urlParams.get('dataSource')) || '{}'; diff --git a/public/utils/version-utils.test.ts b/public/utils/version-utils.test.ts new file mode 100644 index 00000000..69011cba --- /dev/null +++ b/public/utils/version-utils.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isVersion219, + isVersion31OrHigher, + isVersion33OrHigher, + isVersion34OrHigher, +} from './version-utils'; + +describe('version-utils', () => { + describe('isVersion219', () => { + it('should return true for 2.19.x versions', () => { + expect(isVersion219('2.19.0')).toBe(true); + expect(isVersion219('2.19.4')).toBe(true); + expect(isVersion219('2.19.0.0')).toBe(true); + expect(isVersion219('2.19.999')).toBe(true); + }); + + it('should handle snapshot versions correctly', () => { + expect(isVersion219('2.19.0-SNAPSHOT')).toBe(true); + expect(isVersion219('2.19.4-SNAPSHOT')).toBe(true); + expect(isVersion219('2.20.0-SNAPSHOT')).toBe(false); + expect(isVersion219('2.18.9-SNAPSHOT')).toBe(false); + }); + + it('should return false for non-2.19.x versions', () => { + expect(isVersion219('2.18.9')).toBe(false); + expect(isVersion219('2.20.0')).toBe(false); + expect(isVersion219('3.0.0')).toBe(false); + expect(isVersion219('1.0.0')).toBe(false); + }); + + it('should return false for invalid versions', () => { + expect(isVersion219(undefined)).toBe(false); + expect(isVersion219('')).toBe(false); + expect(isVersion219('invalid')).toBe(false); + }); + }); + + describe('isVersion31OrHigher', () => { + it('should return true for 3.1.0 and higher', () => { + expect(isVersion31OrHigher('3.1.0')).toBe(true); + expect(isVersion31OrHigher('3.2.0')).toBe(true); + expect(isVersion31OrHigher('4.0.0')).toBe(true); + }); + + it('should return false for versions below 3.1.0', () => { + expect(isVersion31OrHigher('3.0.0')).toBe(false); + expect(isVersion31OrHigher('2.19.0')).toBe(false); + }); + + it('should handle snapshot versions correctly', () => { + expect(isVersion31OrHigher('3.1.0-SNAPSHOT')).toBe(true); + expect(isVersion31OrHigher('3.0.0-SNAPSHOT')).toBe(false); + }); + }); + + describe('isVersion33OrHigher', () => { + it('should return true for 3.3.0 and higher', () => { + expect(isVersion33OrHigher('3.3.0')).toBe(true); + expect(isVersion33OrHigher('3.4.0')).toBe(true); + }); + + it('should return false for versions below 3.3.0', () => { + expect(isVersion33OrHigher('3.2.0')).toBe(false); + }); + + it('should handle snapshot versions correctly', () => { + expect(isVersion33OrHigher('3.3.0-SNAPSHOT')).toBe(true); + expect(isVersion33OrHigher('3.2.0-SNAPSHOT')).toBe(false); + }); + }); + + describe('isVersion34OrHigher', () => { + it('should return true for 3.4.0 and higher', () => { + expect(isVersion34OrHigher('3.4.0')).toBe(true); + expect(isVersion34OrHigher('3.5.0')).toBe(true); + }); + + it('should return false for versions below 3.4.0', () => { + expect(isVersion34OrHigher('3.3.0')).toBe(false); + }); + + it('should handle snapshot versions correctly', () => { + expect(isVersion34OrHigher('3.4.0-SNAPSHOT')).toBe(true); + expect(isVersion34OrHigher('3.3.0-SNAPSHOT')).toBe(false); + }); + }); +}); diff --git a/public/utils/version-utils.ts b/public/utils/version-utils.ts new file mode 100644 index 00000000..066eb1d6 --- /dev/null +++ b/public/utils/version-utils.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import semver from 'semver'; +import { getDataSourceVersion } from './datasource-utils'; + +let cachedVersion: string | undefined; +let cachedDataSourceId: string | undefined; + +export const getVersionOnce = async (dataSourceId: string): Promise => { + if (cachedDataSourceId === dataSourceId && cachedVersion !== undefined) { + return cachedVersion; + } + + cachedVersion = await getDataSourceVersion(dataSourceId); + cachedDataSourceId = dataSourceId; + return cachedVersion; +}; + +const cleanVersion = (version: string | undefined): string | null => { + if (!version) return null; + // Remove snapshot suffix and clean the version + const cleaned = version.replace(/-SNAPSHOT$/, '').trim(); + return semver.valid(semver.coerce(cleaned)); +}; + +export const isVersion31OrHigher = (version: string | undefined): boolean => { + const cleanedVersion = cleanVersion(version); + return cleanedVersion ? semver.gte(cleanedVersion, '3.1.0') : false; +}; + +export const isVersion33OrHigher = (version: string | undefined): boolean => { + const cleanedVersion = cleanVersion(version); + return cleanedVersion ? semver.gte(cleanedVersion, '3.3.0') : false; +}; + +export const isVersion34OrHigher = (version: string | undefined): boolean => { + const cleanedVersion = cleanVersion(version); + return cleanedVersion ? semver.gte(cleanedVersion, '3.4.0') : false; +}; + +export const isVersion219 = (version: string | undefined): boolean => { + const cleanedVersion = cleanVersion(version); + return cleanedVersion + ? semver.gte(cleanedVersion, '2.19.0') && semver.lt(cleanedVersion, '2.20.0') + : false; +}; + +export const getGroupBySettingsPath = (version: string | undefined, settings: any) => { + return isVersion31OrHigher(version) ? settings?.grouping?.group_by : settings?.group_by; +}; diff --git a/server/routes/index.ts b/server/routes/index.ts index 2daba2d5..47425904 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -280,67 +280,6 @@ export function defineRoutes(router: IRouter, dataSourceEnabled: boolean) { } ); - router.get( - { - path: '/api/cat_plugins', - validate: { - query: schema.object({ - dataSourceId: schema.maybe(schema.string()), - }), - }, - }, - async (context, request, response) => { - try { - const { dataSourceId } = request.query; - - // Always use _cat (JSON) as requested - const catPath = '/_cat/plugins?format=json'; - - let resp: any; - if (dataSourceEnabled && dataSourceId) { - // Data source-aware client (proxies to the selected cluster) - const dsClient = context.dataSource.opensearch.legacy.getClient(dataSourceId); - resp = await dsClient.callAPI('transport.request', { - method: 'GET', - path: catPath, - }); - } else { - // Fallback: core client as current user - const es = context.core.opensearch.client.asCurrentUser; - resp = await es.transport.request({ - method: 'GET', - path: catPath, - }); - } - - const body = resp?.body ?? resp; // legacy/new client normalization - const rows: Array> = Array.isArray(body) ? body : []; - - // Check for query insights and WLM plugins - const hasQueryInsights = rows.some((p) => { - const s = `${p?.component ?? ''} ${p?.name ?? ''}`.toLowerCase(); - return s.includes('query-insights') || s.includes('queryinsights'); - }); - - const hasWlm = rows.some((p) => { - const s = `${p?.component ?? ''} ${p?.name ?? ''}`.toLowerCase(); - return s.includes('workload') || s.includes('wlm'); - }); - - return response.ok({ body: { ok: true, hasQueryInsights, hasWlm } }); - } catch (err: any) { - return response.ok({ - body: { - ok: false, - hasQueryInsights: false, - hasWlm: false, - error: err?.message ?? 'cat plugins failed', - }, - }); - } - } - ); - router.put( { path: '/api/update_settings', @@ -508,4 +447,33 @@ export function defineRoutes(router: IRouter, dataSourceEnabled: boolean) { } } ); + + router.get( + { + path: '/api/cluster/version', + validate: {}, + }, + async (context, request, response) => { + try { + const esClient = context.core.opensearch.client.asCurrentUser; + const res = await esClient.info(); + const version = res.body?.version?.number; + + return response.ok({ + body: { + ok: true, + version, + }, + }); + } catch (error) { + console.error('Unable to get cluster version: ', error); + return response.ok({ + body: { + ok: false, + error: error.message, + }, + }); + } + } + ); } From eb145c3b092dc676df2cd2423e18bd35e9015b75 Mon Sep 17 00:00:00 2001 From: Lindsay-00 <54655271+Lindsay-00@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:36:04 -0700 Subject: [PATCH 13/14] Backport PRs #392 and #421 to 3.3 * Fix MDS Selector for Workload Management Dashboards (#421) Signed-off-by: Lingxi Chen * Security attribute feature for WLM dashboard (#392) Signed-off-by: Lingxi Chen --- .github/workflows/cypress-tests-wlm.yml | 287 ++++++++++++ .github/workflows/cypress-tests.yml | 4 +- cypress/e2e/7_WLM_details.cy.js | 99 ----- cypress/e2e/{ => qi}/1_top_queries.cy.js | 10 +- cypress/e2e/{ => qi}/2_query_details.cy.js | 4 +- cypress/e2e/{ => qi}/3_configurations.cy.js | 2 +- cypress/e2e/{ => qi}/4_group_details.cy.js | 2 +- cypress/e2e/{ => qi}/5_live_queries.cy.js | 0 cypress/e2e/qi/6_WLM_main_wo_security.cy.js | 81 ++++ .../e2e/qi/7_WLM_details_wo_security.cy.js | 140 ++++++ .../8_WLM_create_wo_security.cy.js} | 47 +- cypress/e2e/{ => wlm}/6_WLM_main.cy.js | 13 +- cypress/e2e/wlm/7_WLM_details.cy.js | 171 ++++++++ cypress/e2e/wlm/8_WLM_create.cy.js | 213 +++++++++ cypress/support/commands.js | 47 ++ cypress/support/constants.js | 9 +- package.json | 4 +- .../WLMCreate/WLMCreate.test.tsx | 392 ++++++++++++++++- .../WLMCreate/WLMCreate.tsx | 354 ++++++++++----- .../WLMDetails/WLMDetails.test.tsx | 410 +++++++++++++++++- .../WLMDetails/WLMDetails.tsx | 345 ++++++++++----- .../WLMMain/WLMMain.test.tsx | 47 +- .../WorkloadManagement/WLMMain/WLMMain.tsx | 31 +- .../WorkloadManagement.test.tsx | 28 +- public/utils/datasource-utils.ts | 33 ++ server/routes/wlmRoutes.ts | 30 +- yarn.lock | 353 ++++++++++----- 27 files changed, 2609 insertions(+), 547 deletions(-) create mode 100644 .github/workflows/cypress-tests-wlm.yml delete mode 100644 cypress/e2e/7_WLM_details.cy.js rename cypress/e2e/{ => qi}/1_top_queries.cy.js (98%) rename cypress/e2e/{ => qi}/2_query_details.cy.js (98%) rename cypress/e2e/{ => qi}/3_configurations.cy.js (99%) rename cypress/e2e/{ => qi}/4_group_details.cy.js (98%) rename cypress/e2e/{ => qi}/5_live_queries.cy.js (100%) create mode 100644 cypress/e2e/qi/6_WLM_main_wo_security.cy.js create mode 100644 cypress/e2e/qi/7_WLM_details_wo_security.cy.js rename cypress/e2e/{8_WLM_create.cy.js => qi/8_WLM_create_wo_security.cy.js} (60%) rename cypress/e2e/{ => wlm}/6_WLM_main.cy.js (87%) create mode 100644 cypress/e2e/wlm/7_WLM_details.cy.js create mode 100644 cypress/e2e/wlm/8_WLM_create.cy.js diff --git a/.github/workflows/cypress-tests-wlm.yml b/.github/workflows/cypress-tests-wlm.yml new file mode 100644 index 00000000..9c858eab --- /dev/null +++ b/.github/workflows/cypress-tests-wlm.yml @@ -0,0 +1,287 @@ +name: Cypress e2e integration tests workflow with security +on: + pull_request: + branches: + - "*" + push: + branches: + - "*" +env: + OS_BRANCH: "3.3" + SECURITY_ENABLED: "true" + VERSION: "3.3.2" + OPENSEARCH_INITIAL_ADMIN_PASSWORD: "myStrongPassword123!" +jobs: + tests: + name: Run Cypress E2E tests for WLM + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + include: + - os: ubuntu-latest + cypress_cache_folder: ~/.cache/Cypress + runs-on: ${{ matrix.os }} + env: + # prevents extra Cypress installation progress messages + CI: 1 + # avoid warnings like "tput: No value for $TERM and no -T specified" + TERM: xterm + # make Node run in ipv4 first so that cypress can detect 5601 port in CI environment + NODE_OPTIONS: '--max-old-space-size=6144 --dns-result-order=ipv4first' + # 2.12 onwards security demo configuration require a custom admin password + OPENSEARCH_INITIAL_ADMIN_PASSWORD: 'myStrongPassword123!' + steps: + - name: Checkout Branch + uses: actions/checkout@v3 + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: 21 + - name: Checkout cypress-test + uses: actions/checkout@v2 + with: + repository: ${{github.repository}} + path: cypress-test + + - name: Checkout Security + uses: actions/checkout@v4 + with: + repository: opensearch-project/security + path: security + ref: ${{ env.OS_BRANCH }} + + - name: Build Security plugin + run: | + set -e + cd security + ./gradlew --no-daemon assemble + + echo "Checking security build directory:" + ls -la build/distributions/ + + cp build/distributions/opensearch-security-*.zip "$GITHUB_WORKSPACE/security.zip" + ls -lh "$GITHUB_WORKSPACE/security.zip" + + - name: Checkout OpenSearch + uses: actions/checkout@v4 + with: + path: OpenSearch + repository: opensearch-project/OpenSearch + ref: ${{ env.OS_BRANCH }} + + - name: Build WLM plugin from OpenSearch + run: | + set -e + echo "Using OpenSearch branch: $OS_BRANCH" + cd OpenSearch + + # Build ONLY the workload-management plugin + ./gradlew :plugins:workload-management:clean :plugins:workload-management:assemble \ + -Dopensearch.version=${{ env.VERSION }} --no-daemon + + echo "Checking WLM build directory:" + ls -la plugins/workload-management/build/distributions/ + + cp plugins/workload-management/build/distributions/workload-management-*.zip "$GITHUB_WORKSPACE/wlm.zip" + ls -lh "$GITHUB_WORKSPACE/wlm.zip" + + - name: Run Opensearch with A Single Plugin + uses: derek-ho/start-opensearch@v9 + with: + opensearch-version: ${{ env.VERSION }} + plugins: "file:$GITHUB_WORKSPACE/security.zip,file:$GITHUB_WORKSPACE/wlm.zip" + security-enabled: true + admin-password: ${{ env.OPENSEARCH_INITIAL_ADMIN_PASSWORD }} + jdk-version: 21 + + - name: Enable WLM (with or without Security) and verify + run: | + set -e + echo "Enabling Workload Management (WLM)..." + + # Detect whether security is enabled + if [ "$SECURITY_ENABLED" = 'true' ]; then + echo "Security detected — using HTTPS with credentials" + PROTOCOL=https + AUTH="-u admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" + CURL_FLAGS="--insecure --fail-with-body" + else + echo "No security detected — using HTTP without credentials" + PROTOCOL=http + AUTH="" + CURL_FLAGS="--fail-with-body" + fi + + # Enable WLM + curl -sS -k --http1.1 $CURL_FLAGS $AUTH \ + -X PUT "${PROTOCOL}://localhost:9200/_cluster/settings" \ + -H 'Content-Type: application/json' \ + -d '{"persistent":{"wlm.workload_group.mode":"enabled"}}' + + # Show WLM mode + SETTINGS=$(curl -sS -k --http1.1 -u "admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" "https://localhost:9200/_cluster/settings") + echo "Raw settings response: $SETTINGS" + echo "$SETTINGS" | jq -r '.persistent.wlm.workload_group.mode // "undefined"' + + # Test WLM stats endpoint + echo -e "Testing WLM stats endpoint:" + curl -ksu "admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" https://localhost:9200/_wlm/workload_group | jq '.' + + - name: Checkout OpenSearch-Dashboards + uses: actions/checkout@v4 + with: + repository: opensearch-project/OpenSearch-Dashboards + path: OpenSearch-Dashboards + ref: ${{ env.OS_BRANCH }} + + - name: Configure OpenSearch Dashboards for Cypress (heredoc) + run: | + set -euo pipefail + FILE="OpenSearch-Dashboards/config/opensearch_dashboards.yml" + test -f "$FILE" || { echo "Missing $FILE"; exit 1; } + + { + printf '%s\n' 'server.host: "0.0.0.0"' + printf '%s\n' 'opensearch.hosts: ["https://localhost:9200"]' + printf '%s\n' 'opensearch.ssl.verificationMode: none' + } >> "$FILE" + + echo "=== Last 60 lines of $FILE (show appended settings) ===" + tail -n 60 "$FILE" || true + + - name: Checkout Query Insights Dashboards plugin + uses: actions/checkout@v4 + with: + path: OpenSearch-Dashboards/plugins/query-insights-dashboards + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version-file: './OpenSearch-Dashboards/.nvmrc' + registry-url: 'https://registry.npmjs.org' + + - name: Install Yarn + shell: bash + run: | + YARN_VERSION=$(node -p "require('./OpenSearch-Dashboards/package.json').engines.yarn") + echo "Installing yarn@$YARN_VERSION" + npm i -g yarn@$YARN_VERSION + - run: node -v + - run: yarn -v + + - name: Bootstrap plugin/OpenSearch-Dashboards + run: | + cd OpenSearch-Dashboards/plugins/query-insights-dashboards + yarn osd bootstrap --single-version=loose + + - name: Run OpenSearch-Dashboards server + run: | + cd OpenSearch-Dashboards + export NODE_OPTIONS="--max-old-space-size=6144 --dns-result-order=ipv4first" + echo "Starting Dashboards..." + nohup yarn start --no-base-path --no-watch --server.host="0.0.0.0" > dashboards.log 2>&1 & + sleep 10 + echo "Initial Dashboards log output:" + head -n 100 dashboards.log || true + shell: bash + + - name: Wait for OpenSearch-Dashboards to be ready + run: | + echo "Waiting for OpenSearch-Dashboards to start..." + max_attempts=180 + attempt=0 + while [ $attempt -lt $max_attempts ]; do + code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5601/api/status || true) + if [ "$code" = "200" ]; then + state=$(curl -s http://localhost:5601/api/status | jq -r '.status.overall.state // .status.overall.level // "unknown"') + echo "OpenSearch-Dashboards is ready (state=$state)" + break + fi + attempt=$((attempt + 1)) + echo "Attempt $attempt/$max_attempts: /api/status -> $code; waiting 10s..." + if [ $((attempt % 10)) -eq 0 ]; then + echo "=== tail dashboards.log ===" + tail -n 120 OpenSearch-Dashboards/dashboards.log || true + echo "=== tail $LOCAL_DISTRO/os.log ===" + tail -n 200 OpenSearch/$LOCAL_DISTRO/os.log || true + echo "===========================" + fi + sleep 10 + done + [ $attempt -lt $max_attempts ] || { echo "Timeout waiting for Dashboards"; tail -n 200 OpenSearch-Dashboards/dashboards.log || true; exit 1; } + + - name: Verify services are running + run: | + echo "Checking OpenSearch status..." + curl -ksu admin:myStrongPassword123! https://localhost:9200/_cluster/health | jq '.' || echo "OpenSearch not responding" + + echo "Checking OpenSearch-Dashboards status..." + echo "=== Full OpenSearch-Dashboards Status ===" + curl -s http://localhost:5601/api/status || echo "OpenSearch-Dashboards not responding" + echo "========================================" + + echo "Checking OpenSearch-Dashboards overall state..." + curl -s http://localhost:5601/api/status | jq '.status.overall.state' || echo "Could not extract overall state" + + echo "Checking plugin endpoint..." + # 1) login and store session cookie + curl -s -c cookies.txt -X POST 'http://localhost:5601/auth/login' \ + -H 'osd-xsrf: true' -H 'Content-Type: application/json' \ + --data "{\"username\":\"admin\",\"password\":\"${OPENSEARCH_INITIAL_ADMIN_PASSWORD}\"}" >/dev/null + + # 2) hit the app route using the cookie + curl -s -b cookies.txt -o /dev/null -w "HTTP:%{http_code}\n" \ + 'http://localhost:5601/app/query-insights-dashboards' || echo "Plugin endpoint not accessible" + shell: bash + continue-on-error: true + + - name: Install Cypress + run: | + cd OpenSearch-Dashboards/plugins/query-insights-dashboards + npx cypress install + shell: bash + + - name: Get Cypress version + id: cypress_version + run: | + cd OpenSearch-Dashboards/plugins/query-insights-dashboards + echo "::set-output name=cypress_version::$(cat ./package.json | jq '.dependencies.cypress' | tr -d '"')" + + - name: Cache Cypress + id: cache-cypress + uses: actions/cache@v4 + with: + path: ${{ matrix.cypress_cache_folder }} + key: cypress-cache-v2-${{ matrix.os }}-${{ hashFiles('OpenSearch-Dashboards/plugins/query-insights-dashboards/package.json') }} + + - name: Create WLM workload group + run: | + curl -ksu admin:"${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" \ + -H 'Content-Type: application/json' \ + -X PUT 'https://localhost:9200/_wlm/workload_group' \ + -d '{"name":"test_group","resiliency_mode":"soft","resource_limits":{"cpu":0.1,"memory":0.1}}' + + - name: Cypress tests + uses: cypress-io/github-action@v5 + with: + working-directory: OpenSearch-Dashboards/plugins/query-insights-dashboards + command: yarn run cypress run --config defaultCommandTimeout=120000,requestTimeout=120000,responseTimeout=120000,pageLoadTimeout=180000,taskTimeout=120000,execTimeout=120000,excludeSpecPattern=cypress/e2e/qi/**/* + browser: chrome + env: + CYPRESS_CACHE_FOLDER: ${{ matrix.cypress_cache_folder }} + CI: true + timeout-minutes: 120 + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots-${{ matrix.os }} + path: OpenSearch-Dashboards/plugins/query-insights-dashboards/cypress/screenshots + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cypress-videos-${{ matrix.os }} + path: OpenSearch-Dashboards/plugins/query-insights-dashboards/cypress/videos diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml index 0d4d5645..c17a2573 100644 --- a/.github/workflows/cypress-tests.yml +++ b/.github/workflows/cypress-tests.yml @@ -90,7 +90,7 @@ jobs: find OpenSearch/modules/autotagging-commons/build/distributions/ -name "*.zip" -exec cp {} query-insights/plugins/ \; find OpenSearch/plugins/workload-management/build/distributions/ -name "*.zip" -exec cp {} query-insights/plugins/ \; - # List copied plugins + # List copied plugin echo "Contents of plugins directory:" ls -la query-insights/plugins/ @@ -288,7 +288,7 @@ jobs: uses: cypress-io/github-action@v5 with: working-directory: OpenSearch-Dashboards/plugins/query-insights-dashboards - command: yarn run cypress run --config defaultCommandTimeout=120000,requestTimeout=120000,responseTimeout=120000,pageLoadTimeout=180000,taskTimeout=120000,execTimeout=120000 + command: yarn run cypress run --config defaultCommandTimeout=120000,requestTimeout=120000,responseTimeout=120000,pageLoadTimeout=180000,taskTimeout=120000,execTimeout=120000,excludeSpecPattern=cypress/e2e/wlm/**/* wait-on: 'http://localhost:5601' wait-on-timeout: 1200 browser: chrome diff --git a/cypress/e2e/7_WLM_details.cy.js b/cypress/e2e/7_WLM_details.cy.js deleted file mode 100644 index f1929f4d..00000000 --- a/cypress/e2e/7_WLM_details.cy.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -describe('WLM Details Page', () => { - const groupName = `test_group_${Date.now()}`; - - before(() => { - Cypress.env('groupName', groupName); - - // Clean up existing non-default groups - cy.request({ - method: 'GET', - url: '/api/_wlm/workload_group', - headers: { 'osd-xsrf': 'true' }, - }).then((res) => { - const groups = res.body?.workload_groups ?? []; - groups.forEach((g) => { - if (g.name !== 'DEFAULT_WORKLOAD_GROUP') { - cy.request({ - method: 'DELETE', - url: `/api/_wlm/workload_group/${g.name}`, - headers: { 'osd-xsrf': 'true' }, - failOnStatusCode: false, - }); - } - }); - }); - - cy.request({ - method: 'PUT', - url: '/api/_wlm/workload_group', - headers: { 'osd-xsrf': 'true' }, - body: { - name: groupName, - resiliency_mode: 'soft', - resource_limits: { - cpu: 0.01, - memory: 0.01, - }, - }, - }); - }); - - beforeEach(() => { - cy.visit(`/app/workload-management#/wlm-details?name=${groupName}`); - // Wait until rows render - cy.get('.euiBasicTable .euiTableRow').should('have.length.greaterThan', 0); - }); - - it('should display workload group summary panel', () => { - cy.contains('Workload group name').should('exist'); - cy.contains('Resiliency mode').should('exist'); - cy.contains('CPU usage limit').should('exist'); - cy.contains('Memory usage limit').should('exist'); - cy.contains(groupName).should('exist'); - }); - - it('should switch between tabs', () => { - // Switch to Settings tab - cy.get('[data-testid="wlm-tab-settings"]').click(); - cy.contains('Workload group settings').should('exist'); - - // Switch to Resources tab - cy.get('[data-testid="wlm-tab-resources"]').click(); - cy.contains('Node ID').should('exist'); - }); - - it('should display node resource usage table', () => { - cy.contains('Node ID').should('exist'); - cy.get('.euiTableRow').should('have.length.greaterThan', 0); - }); - - it('should show delete modal', () => { - cy.contains('Delete').click(); - cy.contains('Delete workload group').should('exist'); - }); - - it('should delete the workload group successfully', () => { - cy.contains('Delete').click(); // opens modal - cy.get('input[placeholder="delete"]').type('delete'); - cy.get('.euiModalFooter button').contains('Delete').click(); - - // Confirm redirected back to WLM main page - cy.url().should('include', '/workloadManagement'); - - // Confirm toast appears - cy.contains(`Deleted workload group "${groupName}"`).should('exist'); - }); -}); - -describe('WLM Details – DEFAULT_WORKLOAD_GROUP', () => { - it('should disable settings tab for DEFAULT_WORKLOAD_GROUP', () => { - cy.visit('/app/workload-management#/wlm-details?name=DEFAULT_WORKLOAD_GROUP'); - cy.get('[data-testid="wlm-tab-settings"]').click(); - cy.contains('Settings are not available for the DEFAULT_WORKLOAD_GROUP').should('exist'); - }); -}); diff --git a/cypress/e2e/1_top_queries.cy.js b/cypress/e2e/qi/1_top_queries.cy.js similarity index 98% rename from cypress/e2e/1_top_queries.cy.js rename to cypress/e2e/qi/1_top_queries.cy.js index 5a07bceb..8d361d26 100644 --- a/cypress/e2e/1_top_queries.cy.js +++ b/cypress/e2e/qi/1_top_queries.cy.js @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import sampleDocument from '../fixtures/sample_document.json'; -import { METRICS } from '../support/constants'; +import sampleDocument from '../../fixtures/sample_document.json'; +import { METRICS } from '../../support/constants'; -import MIXED from '../fixtures/stub_top_queries.json'; -import QUERY_ONLY from '../fixtures/stub_top_queries_query_only.json'; -import GROUP_ONLY from '../fixtures/stub_top_queries_group_only.json'; +import MIXED from '../../fixtures/stub_top_queries.json'; +import QUERY_ONLY from '../../fixtures/stub_top_queries_query_only.json'; +import GROUP_ONLY from '../../fixtures/stub_top_queries_group_only.json'; const makeTimestampedBody = (raw) => { const body = JSON.parse(JSON.stringify(raw)); diff --git a/cypress/e2e/2_query_details.cy.js b/cypress/e2e/qi/2_query_details.cy.js similarity index 98% rename from cypress/e2e/2_query_details.cy.js rename to cypress/e2e/qi/2_query_details.cy.js index 830d1167..66023551 100644 --- a/cypress/e2e/2_query_details.cy.js +++ b/cypress/e2e/qi/2_query_details.cy.js @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import sampleDocument from '../fixtures/sample_document.json'; -import { METRICS } from '../support/constants'; +import sampleDocument from '../../fixtures/sample_document.json'; +import { METRICS } from '../../support/constants'; const indexName = 'sample_index'; diff --git a/cypress/e2e/3_configurations.cy.js b/cypress/e2e/qi/3_configurations.cy.js similarity index 99% rename from cypress/e2e/3_configurations.cy.js rename to cypress/e2e/qi/3_configurations.cy.js index e2be6a62..8a874bfa 100644 --- a/cypress/e2e/3_configurations.cy.js +++ b/cypress/e2e/qi/3_configurations.cy.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { METRICS } from '../support/constants'; +import { METRICS } from '../../support/constants'; const clearAll = () => { cy.disableTopQueries(METRICS.LATENCY); diff --git a/cypress/e2e/4_group_details.cy.js b/cypress/e2e/qi/4_group_details.cy.js similarity index 98% rename from cypress/e2e/4_group_details.cy.js rename to cypress/e2e/qi/4_group_details.cy.js index 9b01907b..bf92873a 100644 --- a/cypress/e2e/4_group_details.cy.js +++ b/cypress/e2e/qi/4_group_details.cy.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import sampleDocument from '../fixtures/sample_document.json'; +import sampleDocument from '../../fixtures/sample_document.json'; const indexName = 'sample_index'; diff --git a/cypress/e2e/5_live_queries.cy.js b/cypress/e2e/qi/5_live_queries.cy.js similarity index 100% rename from cypress/e2e/5_live_queries.cy.js rename to cypress/e2e/qi/5_live_queries.cy.js diff --git a/cypress/e2e/qi/6_WLM_main_wo_security.cy.js b/cypress/e2e/qi/6_WLM_main_wo_security.cy.js new file mode 100644 index 00000000..c7a29f6f --- /dev/null +++ b/cypress/e2e/qi/6_WLM_main_wo_security.cy.js @@ -0,0 +1,81 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WLM_AUTH } from '../../support/constants'; + +describe('WLM Main Page', () => { + beforeEach(() => { + cy.visit('/app/workload-management#/workloadManagement', { + auth: WLM_AUTH, + }); + }); + + it('should display the WLM page with the workload group table', () => { + cy.contains('Workload groups').should('be.visible'); + cy.get('.euiBasicTable').should('exist'); + cy.get('.euiTableRow').should('have.length.greaterThan', 0); + }); + + it('should filter workload groups with the search bar', () => { + cy.get('.euiFieldSearch').type('DEFAULT_QUERY_GROUP'); + cy.get('.euiTableRow').should('have.length.at.least', 1); + cy.get('.euiFieldSearch').clear(); + cy.get('.euiTableRow').should('have.length.greaterThan', 0); + }); + + it('should refresh stats on clicking the refresh button', () => { + return cy.get('.euiTableRow').then(() => { + cy.get('button').contains('Refresh').click(); + + cy.get('.euiTableRow', { timeout: 10000 }).should(($newRows) => { + expect($newRows.length).to.be.greaterThan(0); + }); + }); + }); + + it('should display the WLM main page with workload group table and summary stats', () => { + // Confirm table exists + cy.get('.euiBasicTable').should('be.visible'); + cy.get('.euiTableRow').should('have.length.greaterThan', 0); + + // Confirm stat cards exist + const titles = ['Total workload groups', 'Total groups exceeding limits']; + + titles.forEach((title) => { + cy.contains(title).should('be.visible'); + }); + }); + + it('should filter workload groups by name in search', () => { + cy.get('.euiFieldSearch').type('DEFAULT_WORKLOAD_GROUP'); + cy.get('.euiTableRow').should('contain.text', 'DEFAULT_WORKLOAD_GROUP'); + + cy.get('.euiFieldSearch').clear().type('nonexistent_group_12345'); + cy.get('.euiTableRow').should('contain.text', 'No items found'); + }); + + it('should route to the Create Workload Group page when clicking the Create button', () => { + // Click the "Create workload group" button + cy.contains('Create workload group').click(); + + // Confirm we are on the create page + cy.url().should('include', '/wlm-create'); + + // Validate that the form elements exist + cy.contains('Resiliency mode').should('be.visible'); + cy.get('[data-testid="indexInput"]').should('exist'); + cy.get('button').contains('+ Add another rule').should('exist'); + cy.get('input[data-testid="cpu-threshold-input"]').should('exist'); + cy.get('input[data-testid="memory-threshold-input"]').should('exist'); + }); + + it('should have Live Queries View link when available', () => { + cy.get('.euiTableHeaderCell').then(($headers) => { + if ($headers.text().includes('Live Queries')) { + cy.get('.euiTableRowCell').contains('View').should('be.visible'); + } + }); + }); +}); diff --git a/cypress/e2e/qi/7_WLM_details_wo_security.cy.js b/cypress/e2e/qi/7_WLM_details_wo_security.cy.js new file mode 100644 index 00000000..3b17782a --- /dev/null +++ b/cypress/e2e/qi/7_WLM_details_wo_security.cy.js @@ -0,0 +1,140 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WLM_AUTH } from '../../support/constants'; + +describe('WLM Details Page', () => { + const api = (opts) => + cy.request({ + headers: { 'osd-xsrf': 'true' }, + auth: WLM_AUTH, + failOnStatusCode: false, + ...opts, + }); + + const groupName = `wlm-e2e-${Date.now()}`; + + before(() => { + // Just create the test group + return api({ + method: 'PUT', + url: '/api/_wlm/workload_group', + body: { + name: groupName, + resiliency_mode: 'soft', + resource_limits: { cpu: 0.01, memory: 0.01 }, + }, + }) + .its('status') + .should('be.oneOf', [200, 201, 204, 409]); + }); + + beforeEach(() => { + cy.visit(`/app/workload-management#/wlm-details?name=${groupName}`, { auth: WLM_AUTH }); + cy.contains(groupName).should('exist'); + }); + + it('should display workload group summary panel', () => { + cy.contains('Workload group name').should('exist'); + cy.contains('Resiliency mode').should('exist'); + cy.contains('CPU usage limit').should('exist'); + cy.contains('Memory usage limit').should('exist'); + }); + + it('should switch between tabs', () => { + cy.get('[data-testid="wlm-tab-settings"]').click(); + cy.contains('Workload group settings').should('exist'); + cy.get('[data-testid="wlm-tab-resources"]').click(); + cy.contains('Node ID').should('exist'); + }); + + it('should display node resource usage table', () => { + cy.get('[data-testid="wlm-tab-resources"]').click(); + cy.contains('Node ID').should('exist'); + cy.get('.euiTableRow').should('have.length.greaterThan', 0); + }); + + it('should show delete modal', () => { + cy.contains('Delete').click(); + cy.contains('Delete workload group').should('exist'); + cy.get('.euiModalFooter button').contains('Cancel').click(); + }); + + it('should create, update, and delete an index rule on the details page', () => { + const i1 = `logs-${Date.now()}-*`; + const i2 = `metrics-${Date.now()}-*`; + + cy.intercept('PUT', '**/api/_wlm/workload_group/*').as('saveGroup'); + cy.intercept('GET', '**/api/_rules/workload_group*').as('listRules'); + + cy.get('[data-testid="wlm-tab-settings"]').click(); + cy.contains('+ Add another rule').click(); + + cy.get('textarea[data-testid="indexInput"]').last().type(i1); + + cy.contains('button', /^Apply Changes$/) + .should('not.be.disabled') + .click(); + cy.wait('@saveGroup'); + + cy.reload(); + cy.wait('@listRules'); + cy.get('[data-testid="wlm-tab-settings"]').click(); + + cy.contains(i1, { timeout: 20000 }).should('exist'); + + cy.get('textarea[data-testid="indexInput"]') + .last() + .type('{selectAll}{backspace}') + .type(i2) + .blur(); + + cy.contains('button', /^Apply Changes$/) + .should('not.be.disabled') + .click(); + cy.wait('@saveGroup'); + + cy.reload(); + cy.wait('@listRules'); + cy.get('[data-testid="wlm-tab-settings"]').click(); + + cy.contains(i2, { timeout: 20000 }).should('exist'); + cy.contains(i1).should('not.exist'); + + cy.get('button[aria-label="Delete rule"]', { timeout: 20000 }).last().click({ force: true }); + + cy.contains('button', /^Apply Changes$/) + .should('not.be.disabled') + .click(); + cy.wait('@saveGroup'); + + cy.reload(); + cy.wait('@listRules'); + cy.get('[data-testid="wlm-tab-settings"]').click(); + + cy.get('textarea[data-testid="indexInput"]').should(($areas) => { + const values = Array.from($areas, (el) => (el.value || '').trim()); + expect(values).to.not.include(i2); + }); + }); + + it('should delete the workload group successfully', () => { + cy.contains('Delete').click(); + cy.get('input[placeholder="delete"]').type('delete'); + cy.get('.euiModalFooter button').contains('Delete').click(); + cy.url().should('include', '/workloadManagement'); + cy.contains(`Deleted workload group "${groupName}"`).should('exist'); + }); +}); + +describe('WLM Details – DEFAULT_WORKLOAD_GROUP', () => { + it('should disable settings tab for DEFAULT_WORKLOAD_GROUP', () => { + cy.visit('/app/workload-management#/wlm-details?name=DEFAULT_WORKLOAD_GROUP', { + auth: WLM_AUTH, + }); + cy.get('[data-testid="wlm-tab-settings"]').click(); + cy.contains('Settings are not available for the DEFAULT_WORKLOAD_GROUP').should('exist'); + }); +}); diff --git a/cypress/e2e/8_WLM_create.cy.js b/cypress/e2e/qi/8_WLM_create_wo_security.cy.js similarity index 60% rename from cypress/e2e/8_WLM_create.cy.js rename to cypress/e2e/qi/8_WLM_create_wo_security.cy.js index ae97d604..5cbea3e3 100644 --- a/cypress/e2e/8_WLM_create.cy.js +++ b/cypress/e2e/qi/8_WLM_create_wo_security.cy.js @@ -3,16 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { WLM_AUTH } from '../../support/constants'; + +const auth = WLM_AUTH; + describe('WLM Create Page', () => { beforeEach(() => { - cy.visit('/app/workload-management#/wlm-create'); + cy.visit('/app/workload-management#/wlm-create', { auth }); }); it('renders the full create form with required fields', () => { cy.contains('h1', 'Create workload group').should('exist'); cy.contains('h2', 'Overview').should('exist'); - - // Form labels and fields [ 'Name', 'Description – Optional', @@ -23,7 +25,6 @@ describe('WLM Create Page', () => { ].forEach((label) => { cy.contains(label).should('exist'); }); - cy.contains('Soft').should('exist'); cy.contains('Enforced').should('exist'); cy.contains('+ Add another rule').should('exist'); @@ -33,29 +34,23 @@ describe('WLM Create Page', () => { it('shows validation errors for CPU and memory thresholds', () => { cy.get('[data-testid="cpu-threshold-input"]').clear().type('150'); cy.contains('Value must be between 0 and 100').should('exist'); - cy.get('[data-testid="cpu-threshold-input"]').clear().type('0'); cy.contains('Value must be between 0 and 100').should('exist'); - cy.get('[data-testid="memory-threshold-input"]').clear().type('101'); cy.contains('Value must be between 0 and 100').should('exist'); - cy.get('[data-testid="memory-threshold-input"]').clear().type('0'); cy.contains('Value must be between 0 and 100').should('exist'); }); it('creates a workload group successfully with valid inputs', () => { const groupName = `wlm_test_${Date.now()}`; - cy.get('[data-testid="name-input"]').type(groupName); cy.contains('Soft').click(); - cy.get('[data-testid="indexInput"]').type('test-index'); + cy.get('[data-testid="indexInput"]').type(`test-index_${Date.now()}`); cy.get('[data-testid="cpu-threshold-input"]').type('10'); cy.get('[data-testid="memory-threshold-input"]').type('20'); - cy.intercept('PUT', '/api/_wlm/workload_group').as('createRequest'); cy.get('button').contains('Create workload group').click(); - cy.url().should('include', '/workloadManagement'); cy.contains(groupName).should('exist'); }); @@ -63,11 +58,39 @@ describe('WLM Create Page', () => { it('adds and deletes a rule block', () => { cy.contains('+ Add another rule').click(); cy.get('[data-testid="indexInput"]').should('have.length', 2); - cy.get('[aria-label="Delete rule"]').first().click(); cy.get('[data-testid="indexInput"]').should('have.length', 1); }); + it('does not create a rule when all fields are blank (no rule PUT)', () => { + const groupName = `wlm_blank_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[data-testid="memory-threshold-input"]').clear().type('1'); + cy.get('button').contains('Create workload group').click(); + cy.wait('@createGroup'); + cy.get('@createRule.all').should('have.length', 0); + cy.url().should('include', '/workloadManagement'); + }); + + it('ignores commas-only inputs and does not send empty arrays', () => { + const groupName = `wlm_commas_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[data-testid="indexInput"]').type(' , , , '); + cy.get('[placeholder="Enter username"]').type(' , , '); + cy.get('[placeholder="Enter role"]').type(' , '); + cy.get('[data-testid="memory-threshold-input"]').clear().type('1'); + cy.get('button').contains('Create workload group').click(); + cy.wait('@createGroup'); + cy.get('@createRule.all').should('have.length', 0); + cy.url().should('include', '/workloadManagement'); + }); + it('navigates back to main page on Cancel', () => { cy.get('button').contains('Cancel').click(); cy.url().should('include', '/workloadManagement'); diff --git a/cypress/e2e/6_WLM_main.cy.js b/cypress/e2e/wlm/6_WLM_main.cy.js similarity index 87% rename from cypress/e2e/6_WLM_main.cy.js rename to cypress/e2e/wlm/6_WLM_main.cy.js index be1fcade..e549e115 100644 --- a/cypress/e2e/6_WLM_main.cy.js +++ b/cypress/e2e/wlm/6_WLM_main.cy.js @@ -3,16 +3,19 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { WLM_AUTH } from '../../support/constants'; + describe('WLM Main Page', () => { beforeEach(() => { - cy.visit('/app/workload-management#/workloadManagement'); - cy.get('.euiBasicTable .euiTableRow').should('have.length.greaterThan', 0); + cy.visit('/app/workload-management#/workloadManagement', { + auth: WLM_AUTH, + }); }); it('should display the WLM page with the workload group table', () => { - cy.contains('Workload groups').should('be.visible'); - cy.get('.euiBasicTable').should('exist'); - cy.get('.euiTableRow').should('have.length.greaterThan', 0); + cy.contains('Workload groups', { timeout: 240000 }).should('be.visible'); + cy.get('.euiBasicTable', { timeout: 240000 }).should('exist'); + cy.get('.euiTableRow', { timeout: 240000 }).should('have.length.greaterThan', 0); }); it('should filter workload groups with the search bar', () => { diff --git a/cypress/e2e/wlm/7_WLM_details.cy.js b/cypress/e2e/wlm/7_WLM_details.cy.js new file mode 100644 index 00000000..307321f5 --- /dev/null +++ b/cypress/e2e/wlm/7_WLM_details.cy.js @@ -0,0 +1,171 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WLM_AUTH } from '../../support/constants'; + +describe('WLM Details Page', () => { + const api = (opts) => + cy.request({ + headers: { 'osd-xsrf': 'true' }, + auth: WLM_AUTH, + failOnStatusCode: false, + ...opts, + }); + + const groupName = `wlm-e2e-${Date.now()}`; + + before(() => { + // Just create the test group + return api({ + method: 'PUT', + url: '/api/_wlm/workload_group', + body: { + name: groupName, + resiliency_mode: 'soft', + resource_limits: { cpu: 0.01, memory: 0.01 }, + }, + }) + .its('status') + .should('be.oneOf', [200, 201, 204, 409]); + }); + + beforeEach(() => { + cy.visit(`/app/workload-management#/wlm-details?name=${groupName}`, { auth: WLM_AUTH }); + cy.contains(groupName).should('exist'); + }); + + it('should display workload group summary panel', () => { + cy.contains('Workload group name').should('exist'); + cy.contains('Resiliency mode').should('exist'); + cy.contains('CPU usage limit').should('exist'); + cy.contains('Memory usage limit').should('exist'); + }); + + it('should switch between tabs', () => { + cy.get('[data-testid="wlm-tab-settings"]').click(); + cy.contains('Workload group settings').should('exist'); + cy.get('[data-testid="wlm-tab-resources"]').click(); + cy.contains('Node ID').should('exist'); + }); + + it('should display node resource usage table', () => { + cy.get('[data-testid="wlm-tab-resources"]').click(); + cy.contains('Node ID').should('exist'); + cy.get('.euiTableRow').should('have.length.greaterThan', 0); + }); + + it('should show delete modal', () => { + cy.contains('Delete').click(); + cy.contains('Delete workload group').should('exist'); + cy.get('.euiModalFooter button').contains('Cancel').click(); + }); + + it('should create, update, and delete a username + role + index rule on the details page', () => { + const u1 = `user_${Date.now()}`; + const r1 = `role_${Date.now()}`; + const i1 = `logs-${Date.now()}-*`; + + const u2 = `user_updated`; + const r2 = `role_updated`; + const i2 = `metrics-${Date.now()}-*`; + + cy.intercept('PUT', '**/api/_wlm/workload_group/*').as('saveGroup'); + cy.intercept('GET', '**/api/_rules/workload_group*').as('listRules'); + + cy.get('[data-testid="wlm-tab-settings"]').click(); + cy.contains('+ Add another rule').click(); + + cy.get('textarea[placeholder="Enter username"]').last().type(u1); + cy.get('textarea[placeholder="Enter role"]').last().type(r1); + cy.get('textarea[data-testid="indexInput"]').last().type(i1); + + cy.contains('button', /^Apply Changes$/) + .should('not.be.disabled') + .click(); + cy.wait('@saveGroup'); + + cy.reload(); + cy.wait('@listRules'); + cy.get('[data-testid="wlm-tab-settings"]').click(); + + cy.contains(u1, { timeout: 20000 }).should('exist'); + cy.contains(r1, { timeout: 20000 }).should('exist'); + cy.contains(i1, { timeout: 20000 }).should('exist'); + + cy.get('textarea[placeholder="Enter username"]') + .last() + .type('{selectAll}{backspace}') + .type(u2) + .blur(); + cy.get('textarea[placeholder="Enter role"]') + .last() + .type('{selectAll}{backspace}') + .type(r2) + .blur(); + cy.get('textarea[data-testid="indexInput"]') + .last() + .type('{selectAll}{backspace}') + .type(i2) + .blur(); + + cy.contains('button', /^Apply Changes$/) + .should('not.be.disabled') + .click(); + cy.wait('@saveGroup'); + + cy.reload(); + cy.wait('@listRules'); + cy.get('[data-testid="wlm-tab-settings"]').click(); + + cy.contains(u2, { timeout: 20000 }).should('exist'); + cy.contains(r2, { timeout: 20000 }).should('exist'); + cy.contains(i2, { timeout: 20000 }).should('exist'); + cy.contains(u1).should('not.exist'); + cy.contains(r1).should('not.exist'); + cy.contains(i1).should('not.exist'); + + cy.get('button[aria-label="Delete rule"]', { timeout: 20000 }).last().click({ force: true }); + + cy.contains('button', /^Apply Changes$/) + .should('not.be.disabled') + .click(); + cy.wait('@saveGroup'); + + cy.reload(); + cy.wait('@listRules'); + cy.get('[data-testid="wlm-tab-settings"]').click(); + + cy.get('textarea[placeholder="Enter username"]').should(($areas) => { + const values = Array.from($areas, (el) => (el.value || '').trim()); + expect(values).to.not.include(u2); + }); + cy.get('textarea[placeholder="Enter role"]').should(($areas) => { + const values = Array.from($areas, (el) => (el.value || '').trim()); + expect(values).to.not.include(r2); + }); + cy.get('textarea[data-testid="indexInput"]').should(($areas) => { + const values = Array.from($areas, (el) => (el.value || '').trim()); + expect(values).to.not.include(i2); + }); + }); + + it('should delete the workload group successfully', () => { + cy.contains('Delete').click(); + cy.get('input[placeholder="delete"]').type('delete'); + cy.get('.euiModalFooter button').contains('Delete').click(); + cy.url().should('include', '/workloadManagement'); + cy.contains(`Deleted workload group "${groupName}"`).should('exist'); + }); +}); + +describe('WLM Details – DEFAULT_WORKLOAD_GROUP', () => { + it('should disable settings tab for DEFAULT_WORKLOAD_GROUP', () => { + cy.visit('/app/workload-management#/wlm-details?name=DEFAULT_WORKLOAD_GROUP', { + auth: WLM_AUTH, + }); + cy.get('[data-testid="wlm-tab-settings"]').click(); + cy.contains('Settings are not available for the DEFAULT_WORKLOAD_GROUP').should('exist'); + }); +}); diff --git a/cypress/e2e/wlm/8_WLM_create.cy.js b/cypress/e2e/wlm/8_WLM_create.cy.js new file mode 100644 index 00000000..6f4c651c --- /dev/null +++ b/cypress/e2e/wlm/8_WLM_create.cy.js @@ -0,0 +1,213 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WLM_AUTH } from '../../support/constants'; + +const auth = WLM_AUTH; + +describe('WLM Create Page', () => { + beforeEach(() => { + cy.visit('/app/workload-management#/wlm-create', { auth }); + }); + + it('renders the full create form with required fields', () => { + cy.contains('h1', 'Create workload group').should('exist'); + cy.contains('h2', 'Overview').should('exist'); + [ + 'Name', + 'Description – Optional', + 'Resiliency mode', + 'Index wildcard', + 'Reject queries when CPU usage exceeds', + 'Reject queries when memory usage exceeds', + ].forEach((label) => { + cy.contains(label).should('exist'); + }); + cy.contains('Soft').should('exist'); + cy.contains('Enforced').should('exist'); + cy.contains('+ Add another rule').should('exist'); + cy.get('button').contains('Create workload group').should('exist'); + }); + + it('shows validation errors for CPU and memory thresholds', () => { + cy.get('[data-testid="cpu-threshold-input"]').clear().type('150'); + cy.contains('Value must be between 0 and 100').should('exist'); + cy.get('[data-testid="cpu-threshold-input"]').clear().type('0'); + cy.contains('Value must be between 0 and 100').should('exist'); + cy.get('[data-testid="memory-threshold-input"]').clear().type('101'); + cy.contains('Value must be between 0 and 100').should('exist'); + cy.get('[data-testid="memory-threshold-input"]').clear().type('0'); + cy.contains('Value must be between 0 and 100').should('exist'); + }); + + it('creates a workload group successfully with valid inputs', () => { + const groupName = `wlm_test_${Date.now()}`; + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[data-testid="indexInput"]').type(`test-index_${Date.now()}`); + cy.get('[data-testid="cpu-threshold-input"]').type('10'); + cy.get('[data-testid="memory-threshold-input"]').type('20'); + cy.intercept('PUT', '/api/_wlm/workload_group').as('createRequest'); + cy.get('button').contains('Create workload group').click(); + cy.url().should('include', '/workloadManagement'); + cy.contains(groupName).should('exist'); + }); + + it('adds and deletes a rule block', () => { + cy.contains('+ Add another rule').click(); + cy.get('[data-testid="indexInput"]').should('have.length', 2); + cy.get('[aria-label="Delete rule"]').first().click(); + cy.get('[data-testid="indexInput"]').should('have.length', 1); + }); + + it('sends principal.username only when usernames provided', () => { + const groupName = `wlm_user_${Date.now()}`; + const user1 = `alice_${Date.now()}`; + const user2 = `bob_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[placeholder="Enter username"]').type(` ${user1} , ${user2} , `); + cy.get('[data-testid="memory-threshold-input"]').clear().type('10'); + cy.get('button').contains('Create workload group').click(); + cy.wait('@createGroup'); + return cy + .wait('@createRule') + .then(({ request }) => { + const { body } = request; + expect(body.principal.username).to.deep.eq([user1, user2]); + expect(body.principal).not.to.have.property('role'); + expect(body).not.to.have.property('index_pattern'); + }) + .then(() => { + cy.url().should('include', '/workloadManagement'); + cy.contains(groupName).should('exist'); + }); + }); + + it('sends principal.role only when roles provided', () => { + const groupName = `wlm_role_${Date.now()}`; + const role1 = `admin_${Date.now()}`; + const role2 = `reader_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[placeholder="Enter role"]').type(` ${role1} , ${role2} `); + cy.get('[data-testid="memory-threshold-input"]').clear().type('1'); + cy.get('button').contains('Create workload group').click(); + cy.wait('@createGroup'); + return cy + .wait('@createRule') + .then(({ request }) => { + const body = request.body; + expect(body.principal.role).to.deep.eq([role1, role2]); + expect(body.principal).not.to.have.property('username'); + expect(body).not.to.have.property('index_pattern'); + }) + .then(() => { + cy.url().should('include', '/workloadManagement'); + cy.contains(groupName).should('exist'); + }); + }); + + it('sends both username and role when both provided', () => { + const groupName = `wlm_both_${Date.now()}`; + const user1 = `alice_${Date.now()}`; + const role1 = `admin_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[placeholder="Enter username"]').type(user1); + cy.get('[placeholder="Enter role"]').type(role1); + cy.get('[data-testid="memory-threshold-input"]').clear().type('1'); + cy.get('button').contains('Create workload group').click(); + return cy + .wait('@createGroup') + .then(() => { + return cy.wait('@createRule').then(({ request }) => { + const body = request.body; + + expect(body).to.have.property('workload_group'); + expect(String(body.workload_group)).to.have.length.greaterThan(0); + + expect(body.principal).to.deep.eq({ username: [user1], role: [role1] }); + expect(body).not.to.have.property('index_pattern'); + }); + }) + .then(() => { + cy.url().should('include', '/workloadManagement'); + cy.contains(groupName).should('exist'); + }); + }); + + it('trims and splits comma-separated values for index/username/role', () => { + const groupName = `wlm_trim_${Date.now()}`; + const idx1 = `logs_${Date.now()}`; + const idx2 = `metrics_${Date.now()}`; + const user1 = `alice_${Date.now()}`; + const user2 = `bob_${Date.now()}`; + const role1 = `admin_${Date.now()}`; + const role2 = `reader_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[data-testid="indexInput"]').type(` ${idx1}-* , , ${idx2}-* `); + cy.get('[placeholder="Enter username"]').type(` ${user1} , , ${user2} `); + cy.get('[placeholder="Enter role"]').type(` ${role1} , , ${role2} `); + cy.get('[data-testid="memory-threshold-input"]').clear().type('1'); + cy.get('button').contains('Create workload group').click(); + cy.wait('@createGroup'); + return cy + .wait('@createRule') + .then(({ request }) => { + const body = request.body; + expect(body.index_pattern).to.deep.eq([`${idx1}-*`, `${idx2}-*`]); + expect(body.principal.username).to.deep.eq([user1, user2]); + expect(body.principal.role).to.deep.eq([role1, role2]); + }) + .then(() => { + cy.url().should('include', '/workloadManagement'); + cy.contains(groupName).should('exist'); + }); + }); + + it('does not create a rule when all fields are blank (no rule PUT)', () => { + const groupName = `wlm_blank_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[data-testid="memory-threshold-input"]').clear().type('1'); + cy.get('button').contains('Create workload group').click(); + cy.wait('@createGroup'); + cy.get('@createRule.all').should('have.length', 0); + cy.url().should('include', '/workloadManagement'); + }); + + it('ignores commas-only inputs and does not send empty arrays', () => { + const groupName = `wlm_commas_${Date.now()}`; + cy.intercept('PUT', '/api/_wlm/workload_group').as('createGroup'); + cy.intercept('PUT', '/api/_rules/workload_group').as('createRule'); + cy.get('[data-testid="name-input"]').type(groupName); + cy.contains('Soft').click(); + cy.get('[data-testid="indexInput"]').type(' , , , '); + cy.get('[placeholder="Enter username"]').type(' , , '); + cy.get('[placeholder="Enter role"]').type(' , '); + cy.get('[data-testid="memory-threshold-input"]').clear().type('1'); + cy.get('button').contains('Create workload group').click(); + cy.wait('@createGroup'); + cy.get('@createRule.all').should('have.length', 0); + cy.url().should('include', '/workloadManagement'); + }); + + it('navigates back to main page on Cancel', () => { + cy.get('button').contains('Cancel').click(); + cy.url().should('include', '/workloadManagement'); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 764e0c77..155bc829 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -279,3 +279,50 @@ Cypress.Commands.add('waitForQueryInsightsPlugin', () => { cy.log(`=== PLUGIN LOADING COMPLETE ===`); }); + +Cypress.Commands.add('enableWlmMode', (auth) => { + const host = (Cypress.env('openSearchUrl') || 'http://localhost:9200').replace( + /^https?:\/\//, + '' + ); + + // Probe HTTPS first (ignore certs). If it returns an HTTP code, use https; otherwise fallback to http. + return cy + .exec( + `curl -ks -o /dev/null -w "%{http_code}" https://${host}/_cluster/health -u ${auth.username}:${auth.password}`, + { failOnNonZeroExit: false, timeout: 15000 } + ) + .then(({ stdout }) => { + const httpsWorks = /^\d{3}$/.test((stdout || '').trim()); + const base = `${httpsWorks ? 'https' : 'http'}://${host}`; + const url = `${base}/_cluster/settings`; + + return ( + cy + .request({ + method: 'PUT', + url, + auth, + headers: { 'Content-Type': 'application/json' }, + body: { persistent: { 'wlm.workload_group.mode': 'enabled' } }, + }) + // Verify + .then(() => + cy.request({ + method: 'GET', + url, + auth, + qs: { include_defaults: true, flat_settings: true }, + }) + ) + .then((res) => { + const b = res.body || {}; + const mode = + b?.persistent?.['wlm.workload_group.mode'] ?? + b?.transient?.['wlm.workload_group.mode'] ?? + b?.defaults?.['wlm.workload_group.mode']; + cy.wrap(mode, { log: false }).should('eq', 'enabled'); + }) + ); + }); +}); diff --git a/cypress/support/constants.js b/cypress/support/constants.js index a5b0523c..cec245f6 100644 --- a/cypress/support/constants.js +++ b/cypress/support/constants.js @@ -18,6 +18,11 @@ export const METRICS = { }; export const ADMIN_AUTH = { - username: Cypress.env('username'), - password: Cypress.env('password'), + username: Cypress.env('admin'), + password: Cypress.env('myStrongPassword123!'), +}; + +export const WLM_AUTH = { + username: 'admin', + password: 'myStrongPassword123!', }; diff --git a/package.json b/package.json index f74630c5..e25860b4 100644 --- a/package.json +++ b/package.json @@ -80,10 +80,10 @@ "eslint-plugin-cypress": "^2.8.1", "eslint-plugin-no-unsanitized": "^3.0.2", "eslint-plugin-prefer-object-spread": "^1.2.1", - "husky": "^8.0.0", + "husky": "^8.0.3", "jest-cli": "^27.5.1", "jest-environment-jsdom": "^27.5.1", - "lint-staged": "^10.2.0", + "lint-staged": "^13.2.2", "string.prototype.replaceall": "1.0.7", "ts-loader": "^6.2.1" }, diff --git a/public/pages/WorkloadManagement/WLMCreate/WLMCreate.test.tsx b/public/pages/WorkloadManagement/WLMCreate/WLMCreate.test.tsx index f9ffa9a5..aa63c2fe 100644 --- a/public/pages/WorkloadManagement/WLMCreate/WLMCreate.test.tsx +++ b/public/pages/WorkloadManagement/WLMCreate/WLMCreate.test.tsx @@ -18,6 +18,7 @@ const mockAddDanger = jest.fn(); const coreMock = { http: { put: jest.fn(), + delete: jest.fn(), }, notifications: { toasts: { @@ -28,17 +29,35 @@ const coreMock = { chrome: { setBreadcrumbs: jest.fn(), }, + savedObjects: { + client: { + get: jest.fn().mockResolvedValue({ attributes: { dataSourceVersion: '3.3.0' } }), + }, + }, +}; + +const depsMock = { + dataSource: { + dataSourceEnabled: true, + }, }; -const depsMock = {}; // Not used in this component +const MockDataSourceMenu = (_props: any) =>
Mocked Data Source Menu
; + const mockDataSourceManagement = { - get: () => ({ id: 'default', name: 'default' }), - getDataSourceMenu: jest.fn(() =>
Mocked Data Source Menu
), + ui: { + getDataSourceMenu: jest.fn(() => MockDataSourceMenu), + }, } as any; const mockDataSource = { id: 'default', name: 'default', + dataSourceVersion: '3.3.0', +} as any; + +const mockParams = { + setHeaderActionMenu: jest.fn(), } as any; jest.mock('react-router-dom', () => ({ @@ -55,6 +74,9 @@ jest.mock('../../../components/PageHeader', () => ({ describe('WLMCreate', () => { beforeEach(() => { jest.clearAllMocks(); + + // Restore the data source menu mock after reset + mockDataSourceManagement.ui.getDataSourceMenu.mockReturnValue(MockDataSourceMenu); }); const renderComponent = () => @@ -66,7 +88,7 @@ describe('WLMCreate', () => { @@ -112,7 +134,7 @@ describe('WLMCreate', () => { }); it('calls API and shows success toast on successful creation', async () => { - coreMock.http.put.mockResolvedValue({}); + coreMock.http.put.mockResolvedValueOnce({ _id: 'gid-123', name: 'MyGroup' }); renderComponent(); @@ -151,7 +173,7 @@ describe('WLMCreate', () => { await waitFor(() => { expect(mockAddDanger).toHaveBeenCalledWith({ - title: 'Failed to create workload group', + title: 'Failed to create workload group and rules', text: 'Creation failed', }); }); @@ -236,4 +258,362 @@ describe('WLMCreate', () => { fireEvent.click(memoryHeader); expect(memoryHeader).toBeInTheDocument(); }); + + // Testing rules related + const fillRequiredFields = async () => { + await userEvent.type(screen.getByTestId('name-input'), 'MyGroup'); + fireEvent.change(screen.getByTestId('cpu-threshold-input'), { target: { value: '50' } }); + await userEvent.click(screen.getByLabelText(/Soft/i)); + }; + + describe('WLMCreate – new rules payload behavior', () => { + beforeEach(() => { + coreMock.http.put.mockReset(); + }); + + it('skips creating a rule when index/username/role are all empty', async () => { + // 1) create group -> returns id + coreMock.http.put.mockResolvedValueOnce({ body: { _id: 'gid-skip' } }); + + renderComponent(); + await fillRequiredFields(); + + fireEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + // Only 1 PUT (group). No second PUT to /api/_rules/workload_group + expect(coreMock.http.put).toHaveBeenCalledTimes(1); + expect(coreMock.http.put).toHaveBeenNthCalledWith( + 1, + '/api/_wlm/workload_group', + expect.any(Object) + ); + }); + }); + + it('includes principal.username only when usernames provided', async () => { + coreMock.http.put + .mockResolvedValueOnce({ _id: 'gid-usernames' }) + .mockResolvedValueOnce({ _id: 'rid-1' }); + + renderComponent(); + await fillRequiredFields(); + + await userEvent.type(screen.getByPlaceholderText(/username/i), 'alice, bob'); + + fireEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + const call = coreMock.http.put.mock.calls.find(([u]) => u === '/api/_rules/workload_group'); + expect(call).toBeTruthy(); + const [, args] = call!; + const body = JSON.parse((args as any).body); + expect(body.workload_group).toBe('gid-usernames'); + expect(body).not.toHaveProperty('index_pattern'); + expect(body).toHaveProperty('principal.username', ['alice', 'bob']); + expect(body.principal).not.toHaveProperty('role'); + }); + }); + + it('includes principal.role only when roles provided', async () => { + coreMock.http.put + .mockResolvedValueOnce({ _id: 'gid-roles' }) + .mockResolvedValueOnce({ _id: 'rid-1' }); + + renderComponent(); + await fillRequiredFields(); + + await userEvent.type(screen.getByPlaceholderText(/role/i), 'admin, reader'); + + fireEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + const call = coreMock.http.put.mock.calls.find(([u]) => u === '/api/_rules/workload_group'); + const [, args] = call!; + const body = JSON.parse((args as any).body); + expect(body.workload_group).toBe('gid-roles'); + expect(body).not.toHaveProperty('index_pattern'); + expect(body).toHaveProperty('principal.role', ['admin', 'reader']); + expect(body.principal).not.toHaveProperty('username'); + }); + }); + + it('includes both username and role when both are provided', async () => { + coreMock.http.put + .mockResolvedValueOnce({ _id: 'gid-both' }) + .mockResolvedValueOnce({ _id: 'rid-1' }); + + renderComponent(); + await fillRequiredFields(); + + await userEvent.type(screen.getByPlaceholderText(/username/i), 'alice'); + await userEvent.type(screen.getByPlaceholderText(/role/i), 'admin'); + + fireEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + const call = coreMock.http.put.mock.calls.find(([u]) => u === '/api/_rules/workload_group'); + const [, args] = call!; + const body = JSON.parse((args as any).body); + expect(body.principal).toEqual({ username: ['alice'], role: ['admin'] }); + }); + }); + + it('includes index_pattern only when non-empty and trims entries', async () => { + coreMock.http.put + .mockResolvedValueOnce({ _id: 'gid-index' }) + .mockResolvedValueOnce({ _id: 'rid-1' }); + + renderComponent(); + await fillRequiredFields(); + + const indexInput = screen.getByTestId('indexInput'); + await userEvent.type(indexInput, ' logs-*, , metrics-* '); + + fireEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + const call = coreMock.http.put.mock.calls.find(([u]) => u === '/api/_rules/workload_group'); + const [, args] = call!; + const body = JSON.parse((args as any).body); + expect(body).toHaveProperty('index_pattern', ['logs-*', 'metrics-*']); + expect(body).not.toHaveProperty('principal'); + }); + }); + + it('skips rule when input is only commas/spaces', async () => { + coreMock.http.put.mockResolvedValueOnce({ _id: 'gid-1' }); // group + + renderComponent(); + await fillRequiredFields(); + + const indexInput = screen.getByTestId('indexInput'); + await userEvent.type(indexInput, ' , , , '); + + await userEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + // only group PUT, no rules PUT + expect(coreMock.http.put).toHaveBeenCalledTimes(1); + expect(coreMock.http.put).toHaveBeenNthCalledWith( + 1, + '/api/_wlm/workload_group', + expect.any(Object) + ); + }); + }); + + it('sends resource_limits only when valid CPU/memory provided', async () => { + coreMock.http.put.mockResolvedValueOnce({ body: { _id: 'gid-rl' } }); + + renderComponent(); + // name + resiliency + await userEvent.type(screen.getByTestId('name-input'), 'MyGroup'); + await userEvent.click(screen.getByLabelText(/Soft/i)); + + // valid cpu and memory + fireEvent.change(screen.getByTestId('cpu-threshold-input'), { target: { value: '40' } }); + fireEvent.change(screen.getByTestId('memory-threshold-input'), { target: { value: '80' } }); + + fireEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + const [, args] = coreMock.http.put.mock.calls[0]; + const body = JSON.parse((args as any).body); + expect(body.resource_limits).toEqual({ cpu: 0.4, memory: 0.8 }); + }); + }); + }); +}); + +describe('WLMCreate – rollback and created-rule cleanup', () => { + const fillRequiredFields = async () => { + await userEvent.type(screen.getByTestId('name-input'), 'MyGroup'); + fireEvent.change(screen.getByTestId('cpu-threshold-input'), { target: { value: '50' } }); + await userEvent.click(screen.getByLabelText(/Soft/i)); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates two rules successfully (no cleanup, navigates)', async () => { + // group -> ok, returns id + coreMock.http.put.mockResolvedValueOnce({ _id: 'g-ok' }); + // rule 1 -> ok + coreMock.http.put.mockResolvedValueOnce({ _id: 'r1' }); + // rule 2 -> ok + coreMock.http.put.mockResolvedValueOnce({ id: 'r2' }); + + render( + + + + + + ); + + await fillRequiredFields(); + + // Rule row #1: set an index to make it a valid rule + await userEvent.type(screen.getAllByTestId('indexInput')[0], 'logs-*'); + + // Add a second rule row + await userEvent.click(screen.getByRole('button', { name: /\+ Add another rule/i })); + + // Rule row #2: provide a username (different row) + const usernameInputs = screen.getAllByPlaceholderText(/username/i); + await userEvent.type(usernameInputs[1], 'alice'); + + // Create + await userEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + // 3 PUTs total: group, rule1, rule2 + expect(coreMock.http.put).toHaveBeenCalledTimes(3); + + // no deletes + expect(coreMock.http.delete).not.toHaveBeenCalled(); + + expect(mockAddSuccess).toHaveBeenCalledWith('Workload group and rules created successfully.'); + expect(mockPush).toHaveBeenCalledWith('/workloadManagement'); + }); + }); + + it('when rule 2 fails, deletes rule 1 (by id) and deletes the workload group, shows danger toast, no navigation', async () => { + coreMock.http.put.mockResolvedValueOnce({ _id: 'g-rollback', name: 'g-rollback' }); + coreMock.http.put.mockResolvedValueOnce({ _id: 'r1' }); + coreMock.http.put.mockRejectedValueOnce({ body: { message: 'rule 2 failed' } }); + coreMock.http.delete.mockResolvedValue({ ok: true }); + + render( + + + + + + ); + + await fillRequiredFields(); + + await userEvent.type(screen.getAllByTestId('indexInput')[0], 'logs-*'); + await userEvent.click(screen.getByRole('button', { name: /\+ Add another rule/i })); + await userEvent.type(screen.getAllByPlaceholderText(/role/i)[1], 'admin'); + + await userEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + expect(coreMock.http.put).toHaveBeenCalledTimes(3); + expect(coreMock.http.delete).toHaveBeenCalledWith('/api/_rules/workload_group/r1', { + query: { dataSourceId: 'default' }, + }); + expect(coreMock.http.delete).toHaveBeenCalledWith('/api/_wlm/workload_group/g-rollback', { + query: { dataSourceId: 'default' }, + }); + expect(mockAddDanger).toHaveBeenCalledWith({ + title: 'Rule creation failed', + text: 'rule 2 failed', + }); + expect(mockPush).not.toHaveBeenCalled(); + }); + }); + + it('if group rollback fails after deleting created rules, surfaces cleanup failure toast', async () => { + coreMock.http.put.mockResolvedValueOnce({ _id: 'g-cleanup-fail', name: 'g-cleanup-fail' }); + coreMock.http.put.mockResolvedValueOnce({ _id: 'r1' }); + coreMock.http.put.mockRejectedValueOnce(new Error('boom')); + coreMock.http.delete.mockResolvedValueOnce({ ok: true }); + coreMock.http.delete.mockRejectedValueOnce(new Error('rollback failed')); + + render( + + + + + + ); + + await fillRequiredFields(); + + await userEvent.type(screen.getAllByTestId('indexInput')[0], 'logs-*'); + await userEvent.click(screen.getByRole('button', { name: /\+ Add another rule/i })); + await userEvent.type(screen.getAllByPlaceholderText(/username/i)[1], 'alice'); + + await userEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + expect(coreMock.http.delete).toHaveBeenCalledWith('/api/_rules/workload_group/r1', { + query: { dataSourceId: 'default' }, + }); + expect(coreMock.http.delete).toHaveBeenCalledWith('/api/_wlm/workload_group/g-cleanup-fail', { + query: { dataSourceId: 'default' }, + }); + expect(mockAddDanger).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'rollback failed', + title: 'Rule creation failed; group rollback also failed', + }) + ); + expect(mockPush).not.toHaveBeenCalled(); + }); + }); + + it('skips cleanup if there were no successfully created rules before failure (only group deletion happens)', async () => { + coreMock.http.put.mockResolvedValueOnce({ _id: 'g-only-delete', name: 'g-only-delete' }); + coreMock.http.put.mockRejectedValueOnce(new Error('first rule failed')); + coreMock.http.delete.mockResolvedValueOnce({ ok: true }); + + render( + + + + + + ); + + await fillRequiredFields(); + await userEvent.type(screen.getAllByTestId('indexInput')[0], 'logs-*'); + + await userEvent.click(screen.getByRole('button', { name: /create workload group/i })); + + await waitFor(() => { + expect(coreMock.http.delete).not.toHaveBeenCalledWith( + expect.stringMatching(/\/api\/_rules\/workload_group\//), + expect.anything() + ); + expect(coreMock.http.delete).toHaveBeenCalledWith('/api/_wlm/workload_group/g-only-delete', { + query: { dataSourceId: 'default' }, + }); + expect(mockAddDanger).toHaveBeenCalled(); + expect(mockPush).not.toHaveBeenCalled(); + }); + }); }); diff --git a/public/pages/WorkloadManagement/WLMCreate/WLMCreate.tsx b/public/pages/WorkloadManagement/WLMCreate/WLMCreate.tsx index 5f2cc8eb..0709768a 100644 --- a/public/pages/WorkloadManagement/WLMCreate/WLMCreate.tsx +++ b/public/pages/WorkloadManagement/WLMCreate/WLMCreate.tsx @@ -25,10 +25,15 @@ import { WLM_CREATE, WLM_MAIN } from '../WorkloadManagement'; import { WLMDataSourceMenu } from '../../../components/DataSourcePicker'; import { DataSourceContext } from '../WorkloadManagement'; import { getDataSourceEnabledUrl } from '../../../utils/datasource-utils'; -import { PageHeader } from '../../../components/PageHeader'; +import { + resolveDataSourceVersion, + isSecurityAttributesSupported, +} from '../../../utils/datasource-utils'; interface Rule { index: string; + username: string; + role: string; } export const WLMCreate = ({ @@ -49,12 +54,17 @@ export const WLMCreate = ({ const [resiliencyMode, setResiliencyMode] = useState<'soft' | 'enforced'>(); const [cpuThreshold, setCpuThreshold] = useState(); const [memThreshold, setMemThreshold] = useState(); - const [rules, setRules] = useState([{ index: '' }]); + const [rules, setRules] = useState([{ index: '', username: '', role: '' }]); const [indexErrors, setIndexErrors] = useState>([]); + const [usernameErrors, setUsernameErrors] = useState>([]); + const [roleErrors, setRoleErrors] = useState>([]); const [loading, setLoading] = useState(false); const { dataSource, setDataSource } = useContext(DataSourceContext)!; const isMounted = useRef(true); + const [dsVersion, setDsVersion] = useState(); + const dataSourceEnabled = !!depsStart?.dataSource?.dataSourceEnabled; + const showSecurity = !dataSourceEnabled || isSecurityAttributesSupported(dsVersion); const isFormValid = name.trim() !== '' && @@ -83,8 +93,26 @@ export const WLMCreate = ({ }; }, []); + useEffect(() => { + let cancelled = false; + (async () => { + const v = await resolveDataSourceVersion(core, dataSource); + if (!cancelled) setDsVersion(v); + })(); + return () => { + cancelled = true; + }; + }, [core, dataSource?.id]); + + const splitCSV = (v?: string | null) => + (v ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const handleCreate = async () => { setLoading(true); + const getRuleId = (res: any) => res?._id ?? res?.id; try { const resourceLimits: Record = {}; @@ -115,65 +143,112 @@ export const WLMCreate = ({ }, }); - const groupId = res?.body?._id; - if (groupId && rules.length > 0) { - await Promise.all( - rules.map((rule) => { - const indexPattern = rule.index - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - if (indexPattern.length === 0) return null; - - return core.http.put('/api/_rules/workload_group', { - body: JSON.stringify({ - description: (description && description.trim()) || '-', - index_pattern: indexPattern, - workload_group: groupId, - }), - headers: { 'Content-Type': 'application/json' }, - }); - }) - ); + const groupId = res?._id; + const currentName = res?.name; + if (!groupId) throw new Error('Workload group ID missing from response'); + const payloads = (rules ?? []) + .map((rule) => { + const indexPattern = splitCSV(rule.index); + const usernames = splitCSV(rule.username); + const roles = splitCSV(rule.role); + + const hasIndexes = indexPattern.length > 0; + const hasUsernames = usernames.length > 0; + const hasRoles = roles.length > 0; + if (!hasIndexes && !hasUsernames && !hasRoles) return null; + + return { + description: (description || '-').trim(), + ...(hasUsernames || hasRoles + ? { + principal: { + ...(hasUsernames ? { username: usernames } : {}), + ...(hasRoles ? { role: roles } : {}), + }, + } + : {}), + ...(hasIndexes ? { index_pattern: indexPattern } : {}), + workload_group: groupId, + }; + }) + .filter(Boolean) as Array>; + + if (payloads.length === 0) { + core.notifications.toasts.addSuccess('Workload group created successfully.'); + history.push('/workloadManagement'); + return; } + // 3) Create rules, recording created IDs + const createdRuleIds: string[] = []; + try { + for (const payload of payloads) { + const resRule = await core.http.put('/api/_rules/workload_group', { + query: { dataSourceId: dataSource.id }, + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }); + const ruleId = getRuleId(resRule); + if (!ruleId) throw new Error('Rule ID missing from response'); + createdRuleIds.push(ruleId); + } - core.notifications.toasts.addSuccess(`Workload group created successfully.`); - history.push('/workloadManagement'); - return; - } catch (err) { + // 4) Success + core.notifications.toasts.addSuccess('Workload group and rules created successfully.'); + history.push('/workloadManagement'); + return; + } catch (ruleErr: any) { + // 5) Cleanup: delete any rules that were already created + await Promise.allSettled( + createdRuleIds.map((id) => + core.http.delete(`/api/_rules/workload_group/${id}`, { + query: { dataSourceId: dataSource.id }, + }) + ) + ); + + try { + await core.http.delete(`/api/_wlm/workload_group/${currentName}`, { + query: { dataSourceId: dataSource.id }, + }); + } catch (cleanupErr: any) { + core.notifications.toasts.addDanger({ + title: 'Rule creation failed; group rollback also failed', + text: cleanupErr?.body?.message || cleanupErr?.message || 'Check server logs.', + }); + } + + core.notifications.toasts.addDanger({ + title: 'Rule creation failed', + text: + ruleErr?.body?.message || ruleErr?.message || 'Rolled back created rules and group.', + }); + return; + } + } catch (err: any) { console.error(err); core.notifications.toasts.addDanger({ - title: 'Failed to create workload group', - text: err?.body?.message || 'Something went wrong', + title: 'Failed to create workload group and rules', + text: err?.body?.message || err?.message || 'Something went wrong', }); } finally { - if (isMounted.current) { - setLoading(false); - } + if (isMounted.current) setLoading(false); } }; return (
- {}} - onSelectedDataSource={() => { - window.history.replaceState({}, '', getDataSourceEnabledUrl(dataSource).toString()); - }} - dataSourcePickerReadOnly={true} - /> - } + params={params} + dataSourceManagement={dataSourceManagement} + setDataSource={setDataSource} + selectedDataSource={dataSource} + onManageDataSource={() => {}} + onSelectedDataSource={() => { + window.history.replaceState({}, '', getDataSourceEnabledUrl(dataSource).toString()); + }} + dataSourcePickerReadOnly={true} /> @@ -267,81 +342,120 @@ export const WLMCreate = ({ - {/*

Define your rule using any combination of index, role, or username.

*/} -

Define your rule using index.

+

Define your rule using any combination of username, role, or index.

- {/* Index wildcard */} <> - - Index wildcard - - - { - const value = e.target.value; - const commaCount = (value.match(/,/g) || []).length; - - const updatedRules = [...rules]; - const updatedErrors = [...indexErrors]; - - updatedRules[idx].index = value; - updatedErrors[idx] = - commaCount >= 10 ? 'You can specify at most 10 indexes per rule.' : null; - - setRules(updatedRules); - setIndexErrors(updatedErrors); - }} - isInvalid={Boolean(indexErrors[idx])} - /> - - You can use (,) to add multiple indexes. - + {/* ---- Username / Role (gated by showSecurity) ---- */} +
+ + Username + + + { + const value = e.target.value; + + const updatedRules = [...rules]; + const updatedErrors = [...usernameErrors]; + + updatedRules[idx].username = value; + updatedErrors[idx] = + value.length > 100 ? 'Maximum total length is 100 characters.' : null; + + setRules(updatedRules); + setUsernameErrors(updatedErrors); + }} + disabled={!showSecurity} + isInvalid={Boolean(usernameErrors[idx])} + /> + + + {!showSecurity + ? 'Username rules require data source ≥ 3.3.' + : 'You can use (,) to add multiple usernames.'} + +
+ +
+ + Role + + + { + const value = e.target.value; + + const updatedRules = [...rules]; + const updatedErrors = [...roleErrors]; + + updatedRules[idx].role = value; + updatedErrors[idx] = + value.length > 100 ? 'Maximum total length is 100 characters.' : null; + + setRules(updatedRules); + setRoleErrors(updatedErrors); + }} + disabled={!showSecurity} + isInvalid={Boolean(roleErrors[idx])} + /> + + + {!showSecurity + ? 'Role rules require data source ≥ 3.3.' + : 'You can use (,) to add multiple roles.'} + +
+ + {/* ---- Index (always available) ---- */} +
+ + Index wildcard + + + { + const value = e.target.value; + const commaCount = (value.match(/,/g) || []).length; + + const updatedRules = [...rules]; + const updatedErrors = [...indexErrors]; + + updatedRules[idx].index = value; + updatedErrors[idx] = + commaCount >= 10 ? 'You can specify at most 10 indexes per rule.' : null; + + setRules(updatedRules); + setIndexErrors(updatedErrors); + }} + isInvalid={Boolean(indexErrors[idx])} + /> + + You can use (,) to add multiple indexes. + +
- {/* */} - - {/*
*/} - {/* */} - {/* Role*/} - {/* */} - {/* {*/} - {/* const updated = [...rules];*/} - {/* updated[idx].role = e.target.value;*/} - {/* setRules(updated);*/} - {/* }}*/} - {/* />*/} - {/* */} - {/* You can use (,) to add multiple roles.*/} - {/* */} - {/*
*/} - - {/* */} - - {/*
*/} - {/* */} - {/* Username*/} - {/* */} - {/* {*/} - {/* const updated = [...rules];*/} - {/* updated[idx].username = e.target.value;*/} - {/* setRules(updated);*/} - {/* }}*/} - {/* />*/} - {/* */} - {/* You can use (,) to add multiple usernames.*/} - {/* */} - {/*
*/} + ))} - setRules([...rules, { index: '' }])} disabled={rules.length >= 5}> + { + setRules((prev) => [...prev, { index: '', username: '', role: '' }]); + setIndexErrors((prev) => [...prev, null]); + }} + disabled={rules.length >= 5} + > + Add another rule diff --git a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx index e641698c..e4b1fef5 100644 --- a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx +++ b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.test.tsx @@ -32,6 +32,7 @@ const mockCore = ({ toasts: { addSuccess: jest.fn(), addDanger: jest.fn(), + addWarning: jest.fn(), }, }, chrome: { @@ -41,11 +42,25 @@ const mockCore = ({ navigateToApp: jest.fn(), getUrlForApp: jest.fn(() => '/app/query-insights'), }, + savedObjects: { + client: { + get: jest.fn().mockResolvedValue({ attributes: { dataSourceVersion: '3.3.0' } }), + }, + }, } as unknown) as CoreStart; -const mockDeps = {}; +const mockDeps = { + dataSource: { + dataSourceEnabled: true, + }, +} as any; + +const MockDataSourceMenu = (_props: any) =>
Mocked Data Source Menu
; + const mockDataSourceManagement = { - getDataSourceMenu: jest.fn(() =>
Mocked Data Source Menu
), + ui: { + getDataSourceMenu: jest.fn(() => MockDataSourceMenu), + }, } as any; (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { @@ -74,6 +89,11 @@ const mockDataSourceManagement = { const mockDataSource = { id: 'default', name: 'default', + dataSourceVersion: '3.3.0', +} as any; + +const mockParams = { + setHeaderActionMenu: jest.fn(), } as any; const renderComponent = (name = 'test-group') => { @@ -83,7 +103,7 @@ const renderComponent = (name = 'test-group') => { @@ -108,6 +128,10 @@ jest.mock('../../../components/PageHeader', () => ({ describe('WLMDetails Component', () => { beforeEach(() => { jest.resetAllMocks(); // Reset mock function calls + + // Restore the data source menu mock after reset + mockDataSourceManagement.ui.getDataSourceMenu.mockReturnValue(MockDataSourceMenu); + (mockCore.http.get as jest.Mock).mockImplementation((path: string) => { if (path.startsWith('/api/_wlm/workload_group/test-group')) { return Promise.resolve({ @@ -201,8 +225,12 @@ describe('WLMDetails Component', () => { renderComponent('non-existent-group'); await waitFor(() => { - expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalledWith( - expect.stringContaining('Failed to find workload group') + expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalled(); + + expect(mockCore.notifications.toasts.addDanger).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('Failed to find workload group'), + expect.any(Error) ); expect(mockPush).toHaveBeenCalledWith(WLM_MAIN); }); @@ -297,8 +325,11 @@ describe('WLMDetails Component', () => { renderComponent('test-group'); await waitFor(() => { - expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalledWith( - expect.stringContaining('Could not load workload group stats') + expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalledTimes(2); + expect(mockCore.notifications.toasts.addDanger).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('Could not load workload group stats.'), + expect.any(Error) ); }); }); @@ -388,8 +419,11 @@ describe('WLMDetails Component', () => { renderComponent('test-group'); await waitFor(() => { - expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalledWith( - expect.stringContaining('Failed to find workload group') + expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(mockCore.notifications.toasts.addDanger).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('Failed to find workload group'), + expect.any(Error) ); }); }); @@ -421,7 +455,7 @@ describe('WLMDetails Component', () => { @@ -444,7 +478,7 @@ describe('WLMDetails Component', () => { core={mockCore as CoreStart} depsStart={mockDeps as any} dataSourceManagement={mockDataSourceManagement} - params={{} as any} + params={mockParams} /> @@ -525,7 +559,7 @@ describe('WLMDetails Component', () => { @@ -665,4 +699,356 @@ describe('WLMDetails Component', () => { expect(mockCore.notifications.toasts.addSuccess).toHaveBeenCalled(); }); + + it('clicking Refresh calls both details and stats fetchers', async () => { + jest.useFakeTimers(); + (mockCore.http.get as jest.Mock).mockClear(); + + renderComponent('test-group'); + + // Wait initial load + await waitFor(() => expect(screen.getByText(/Workload group name/i)).toBeInTheDocument()); + + const initialGetCalls = (mockCore.http.get as jest.Mock).mock.calls.length; + + fireEvent.click(screen.getByRole('button', { name: /refresh/i })); + + await waitFor(() => { + // expect at least 2 more GET calls: group details & stats + expect((mockCore.http.get as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual( + initialGetCalls + 2 + ); + }); + + jest.useRealTimers(); + }); + + it('auto-refresh runs only while isSaved === true', async () => { + jest.useFakeTimers(); + (mockCore.http.get as jest.Mock).mockClear(); + + renderComponent('test-group'); + + await waitFor(() => expect(screen.getByText(/Workload group name/i)).toBeInTheDocument()); + + const callsAfterMount = (mockCore.http.get as jest.Mock).mock.calls.length; + + // Advance 60s -> expect auto refresh fired (details + stats) + await act(async () => { + jest.advanceTimersByTime(60000); + }); + + const afterFirstTick = (mockCore.http.get as jest.Mock).mock.calls.length; + expect(afterFirstTick).toBeGreaterThanOrEqual(callsAfterMount + 2); + + // Make unsaved change -> isSaved = false stops interval + fireEvent.click(screen.getByTestId('wlm-tab-settings')); + await waitFor(() => expect(screen.getByText(/Workload group settings/i)).toBeInTheDocument()); + fireEvent.change(screen.getByPlaceholderText(/Describe the workload group/i), { + target: { value: 'changed' }, + }); + + await act(async () => { + jest.advanceTimersByTime(60000); + }); + + const afterSecondTick = (mockCore.http.get as jest.Mock).mock.calls.length; + // no additional auto-refresh when unsaved + expect(afterSecondTick).toBe(afterFirstTick); + + jest.useRealTimers(); + }); + + it('index validation: shows error when any single index exceeds 100 characters', async () => { + renderComponent('test-group'); + fireEvent.click(screen.getByTestId('wlm-tab-settings')); + + const input = await screen.findByTestId('indexInput'); + const tooLong = 'x'.repeat(101); + + fireEvent.change(input, { target: { value: tooLong } }); + + expect( + await screen.findByText(/Index names must be 100 characters or fewer/i) + ).toBeInTheDocument(); + }); + + it('role onBlur reverts to original and warns when cleared after being non-empty', async () => { + // Override rules to include role so "originallyNonEmpty" = true + (mockCore.http.get as jest.Mock).mockImplementation((path: string) => { + if (path.startsWith('/api/_wlm/workload_group/test-group')) { + return Promise.resolve({ + workload_groups: [ + { + _id: 'wg-123', + name: 'test-group', + resource_limits: { cpu: 0.5, memory: 0.5 }, + resiliency_mode: 'SOFT', + }, + ], + }); + } + if (path === '/api/_rules/workload_group') { + return Promise.resolve({ + rules: [ + { + id: 'r1', + description: 'd', + index_pattern: ['keep-*'], + principal: { role: ['admin'] }, + workload_group: 'wg-123', + }, + ], + }); + } + if (path.startsWith('/api/_wlm/stats')) { + return Promise.resolve({ body: {} }); + } + return Promise.resolve({ body: {} }); + }); + + const warnSpy = jest.fn(); + ((mockCore.notifications.toasts.addWarning as unknown) as jest.Mock) = warnSpy; + + renderComponent('test-group'); + fireEvent.click(screen.getByTestId('wlm-tab-settings')); + + // First rule's Role textarea + const roleBox = await screen.findByPlaceholderText('Enter role'); + expect(roleBox).toHaveValue('admin'); + + fireEvent.change(roleBox, { target: { value: '' } }); + fireEvent.blur(roleBox); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith('Role cannot be cleared once set.'); + expect(roleBox).toHaveValue('admin'); + }); + }); + + it('username onBlur reverts to original and warns when cleared after being non-empty', async () => { + (mockCore.http.get as jest.Mock).mockImplementation((path: string) => { + if (path.startsWith('/api/_wlm/workload_group/test-group')) { + return Promise.resolve({ + workload_groups: [ + { + _id: 'wg-123', + name: 'test-group', + resource_limits: { cpu: 0.5, memory: 0.5 }, + resiliency_mode: 'SOFT', + }, + ], + }); + } + if (path === '/api/_rules/workload_group') { + return Promise.resolve({ + rules: [ + { + id: 'r1', + description: 'd', + index_pattern: ['keep-*'], + principal: { username: ['alice'] }, + workload_group: 'wg-123', + }, + ], + }); + } + if (path.startsWith('/api/_wlm/stats')) { + return Promise.resolve({ body: {} }); + } + return Promise.resolve({ body: {} }); + }); + + const warnSpy = jest.fn(); + ((mockCore.notifications.toasts.addWarning as unknown) as jest.Mock) = warnSpy; + + renderComponent('test-group'); + fireEvent.click(screen.getByTestId('wlm-tab-settings')); + + const userBox = await screen.findByPlaceholderText('Enter username'); + expect(userBox).toHaveValue('alice'); + + fireEvent.change(userBox, { target: { value: '' } }); + fireEvent.blur(userBox); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith('Username cannot be cleared once set.'); + expect(userBox).toHaveValue('alice'); + }); + }); + + it('Add another rule button disables at 5 rules', async () => { + renderComponent('test-group'); + fireEvent.click(screen.getByTestId('wlm-tab-settings')); + + const addBtn = await screen.findByRole('button', { name: /\+ add another rule/i }); + + // You start with 2 rules in your mocks; add until 5 + fireEvent.click(addBtn); + fireEvent.click(addBtn); + fireEvent.click(addBtn); + + // Now 5 -> disabled + expect(addBtn).toBeDisabled(); + }); + + it('Delete button is disabled for DEFAULT_WORKLOAD_GROUP', async () => { + renderComponent('DEFAULT_WORKLOAD_GROUP'); + + const deleteBtn = screen.getByRole('button', { name: /delete/i }); + expect(deleteBtn).toBeDisabled(); + }); + + it("Delete modal's confirm button stays disabled until user types 'delete'", async () => { + renderComponent('test-group'); + fireEvent.click(screen.getByRole('button', { name: /delete/i })); + + const confirmBtn = await screen.findByRole('button', { name: /^delete$/i }); + expect(confirmBtn).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText('delete'), { target: { value: 'dele' } }); + expect(confirmBtn).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText('delete'), { target: { value: 'delete' } }); + expect(confirmBtn).not.toBeDisabled(); + }); + + it('blocks saving when resiliency mode missing and both resource limits invalid', async () => { + const dangerSpy = jest.fn(); + ((mockCore.notifications.toasts.addDanger as unknown) as jest.Mock) = dangerSpy; + + renderComponent('test-group'); + fireEvent.click(screen.getByTestId('wlm-tab-settings')); + + // Make both invalid + const cpu = await screen.findByTestId('cpu-threshold-input'); + const mem = await screen.findByTestId('memory-threshold-input'); + fireEvent.change(cpu, { target: { value: '0' } }); + fireEvent.change(mem, { target: { value: '101' } }); + + // Also unset resiliency mode by selecting then hacking idSelected? (simulate no change but still invalid limits) + // Just click Apply with invalid state: + const applyBtn = screen.getByRole('button', { name: /apply changes/i }); + expect(applyBtn).toBeDisabled(); // guarded by isInvalid + + // Force an attempt by making one small valid change then back to invalid to trigger message: + fireEvent.change(cpu, { target: { value: '1' } }); + fireEvent.change(mem, { target: { value: '101' } }); + expect(applyBtn).toBeDisabled(); + + // The component surfaces error toast only when saveChanges runs; to cover the message path, + // set both undefined and try save: make both empty, then click Apply (enabled due to isSaved flag). + fireEvent.change(cpu, { target: { value: '' } }); + fireEvent.change(mem, { target: { value: '' } }); + // Now flip a setting to unsave & enable Apply + fireEvent.click(screen.getByLabelText('Enforced')); + expect(applyBtn).not.toBeDisabled(); + + // Make them invalid again: mem=0 + fireEvent.change(mem, { target: { value: '0' } }); + expect(applyBtn).toBeDisabled(); // stays guarded by isInvalid + }); + + it('Apply Changes performs create + update + delete diff of rules', async () => { + // Start with keep-me (update) and remove-me (delete) + (mockCore.http.get as jest.Mock).mockImplementation((path: string) => { + if (path.startsWith('/api/_wlm/workload_group/test-group')) { + return Promise.resolve({ + workload_groups: [ + { + _id: 'wg-123', + name: 'test-group', + resource_limits: { cpu: 0.5, memory: 0.5 }, + resiliency_mode: 'SOFT', + }, + ], + }); + } + if (path === '/api/_rules/workload_group') { + return Promise.resolve({ + rules: [ + { + id: 'keep-me', + description: 'd', + index_pattern: ['keep-*'], + workload_group: 'wg-123', + }, + { + id: 'remove-me', + description: 'd', + index_pattern: ['remove-*'], + workload_group: 'wg-123', + }, + ], + }); + } + if (path.startsWith('/api/_wlm/stats')) { + return Promise.resolve({ body: {} }); + } + return Promise.resolve({ body: {} }); + }); + + (mockCore.http.put as jest.Mock).mockResolvedValue({}); + (mockCore.http.delete as jest.Mock).mockResolvedValue({}); + + renderComponent('test-group'); + fireEvent.click(screen.getByTestId('wlm-tab-settings')); + await waitFor(() => screen.getByText(/Workload group settings/i)); + + // Update existing "keep-me" + const inputs = screen.getAllByTestId('indexInput'); + fireEvent.change(inputs[0], { target: { value: 'keep-updated-*' } }); + + // Delete "remove-me" (second panel) + const deleteIcons = screen.getAllByLabelText('Delete rule'); + fireEvent.click(deleteIcons[1]); + + // Create a new rule (indexId empty) + fireEvent.click(screen.getByRole('button', { name: /\+ add another rule/i })); + const newInputs = screen.getAllByTestId('indexInput'); + const newest = newInputs[newInputs.length - 1]; + fireEvent.change(newest, { target: { value: 'new-*' } }); + + // Apply + const applyBtn = screen.getByRole('button', { name: /apply changes/i }); + fireEvent.click(applyBtn); + + await waitFor(() => { + // Update call + expect((mockCore.http.put as jest.Mock).mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + '/api/_rules/workload_group/keep-me', + expect.objectContaining({ + query: { dataSourceId: 'default' }, + headers: { 'Content-Type': 'application/json' }, + }), + ]), + ]) + ); + + // Create call (no id in path) + expect((mockCore.http.put as jest.Mock).mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + '/api/_rules/workload_group', + expect.objectContaining({ + query: { dataSourceId: 'default' }, + headers: { 'Content-Type': 'application/json' }, + }), + ]), + ]) + ); + + // Delete call + expect((mockCore.http.delete as jest.Mock).mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + '/api/_rules/workload_group/remove-me', + expect.objectContaining({ query: { dataSourceId: 'default' } }), + ]), + ]) + ); + }); + }); }); diff --git a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx index f477dac8..69cff17e 100644 --- a/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx +++ b/public/pages/WorkloadManagement/WLMDetails/WLMDetails.tsx @@ -29,11 +29,14 @@ import { import { useHistory, useLocation } from 'react-router-dom'; import { CoreStart, AppMountParameters } from 'opensearch-dashboards/public'; import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; -import { PageHeader } from '../../../components/PageHeader'; import { QueryInsightsDashboardsPluginStartDependencies } from '../../../types'; import { WLM_MAIN, DataSourceContext } from '../WorkloadManagement'; import { WLMDataSourceMenu } from '../../../components/DataSourcePicker'; import { getDataSourceEnabledUrl } from '../../../utils/datasource-utils'; +import { + resolveDataSourceVersion, + isSecurityAttributesSupported, +} from '../../../utils/datasource-utils'; // === Constants & Types === const DEFAULT_WORKLOAD_GROUP = 'DEFAULT_WORKLOAD_GROUP'; @@ -119,6 +122,8 @@ interface StatsResponse { interface Rule { index: string; indexId: string; + role: string; + username: string; } // === Main Component === @@ -148,8 +153,10 @@ export const WLMDetails = ({ const [originalCpuLimit, setOriginalCpuLimit] = useState(undefined); const [originalMemoryLimit, setOriginalMemoryLimit] = useState(undefined); const [description, setDescription] = useState(); - const [rules, setRules] = useState([{ index: '', indexId: '' }]); - const [existingRules, setExistingRules] = useState([{ index: '', indexId: '' }]); + const [rules, setRules] = useState([{ index: '', indexId: '', role: '', username: '' }]); + const [existingRules, setExistingRules] = useState([ + { index: '', indexId: '', role: '', username: '' }, + ]); const [indexErrors, setIndexErrors] = useState>([]); const [isSaved, setIsSaved] = useState(true); const isCpuInvalid = cpuLimit !== undefined && (cpuLimit <= 0 || cpuLimit > 100); @@ -165,6 +172,9 @@ export const WLMDetails = ({ const [deleteConfirmation, setDeleteConfirmation] = useState(''); const [lastUpdated, setLastUpdated] = useState(null); const { dataSource, setDataSource } = useContext(DataSourceContext)!; + const [dsVersion, setDsVersion] = useState(); + const dataSourceEnabled = !!depsStart?.dataSource?.dataSourceEnabled; + const showSecurity = !dataSourceEnabled || isSecurityAttributesSupported(dsVersion); // === Helpers === const resiliencyOptions = [ @@ -206,18 +216,33 @@ export const WLMDetails = ({ }, [nodesData]); useEffect(() => { - // Initial fetch + let cancelled = false; + (async () => { + const v = await resolveDataSourceVersion(core, dataSource); + if (!cancelled) setDsVersion(v); + })(); + return () => { + cancelled = true; + }; + }, [core, dataSource?.id]); + + // Do the initial fetch when inputs change + useEffect(() => { fetchGroupDetails(); updateStats(); + }, [groupName, dataSource]); + + // Auto-refresh only while saved + useEffect(() => { + if (!isSaved) return; - // Set up interval to refresh every 60 seconds const interval = setInterval(() => { fetchGroupDetails(); updateStats(); }, 60000); return () => clearInterval(interval); - }, [groupName, dataSource]); + }, [isSaved, groupName, dataSource]); // === Data Fetching === const fetchDefaultGroupDetails = () => { @@ -266,7 +291,7 @@ export const WLMDetails = ({ console.error('Failed to fetch workload group details:', err); setGroupDetails(null); history.push(WLM_MAIN); - core.notifications.toasts.addDanger(`Workload group "${groupName}" not found.`); + core.notifications.toasts.addDanger(`Workload group "${groupName}" not found.`, err); } }; @@ -291,7 +316,7 @@ export const WLMDetails = ({ groupId = matchedGroup._id; } catch (err) { console.error('Failed to get group ID by name:', err); - core.notifications.toasts.addDanger(`Failed to find workload group "${groupName}"`); + core.notifications.toasts.addDanger(`Failed to find workload group "${groupName}".`, err); return; } } @@ -306,25 +331,28 @@ export const WLMDetails = ({ const allRules = rulesRes?.rules ?? []; const matchedRules = allRules.filter((rule: any) => rule.workload_group === groupId); - setRules( matchedRules.map((rule: any) => ({ - index: rule.index_pattern.join(','), + index: (rule?.index_pattern ?? []).join(','), indexId: rule.id, + role: (rule?.principal?.role ?? []).join(','), + username: (rule?.principal?.username ?? []).join(','), })) ); setExistingRules( matchedRules.map((rule: any) => ({ - index: rule.index_pattern.join(','), + index: (rule?.index_pattern ?? []).join(','), indexId: rule.id, + role: (rule?.principal?.role ?? []).join(','), + username: (rule?.principal?.username ?? []).join(','), })) ); extractDescriptionFromRules(rulesRes, groupId); } catch (err) { console.error('Failed to fetch group stats', err); - core.notifications.toasts.addDanger('Could not load rules.'); + core.notifications.toasts.addDanger('Could not load rules.', err); } } else { setDescription('System default workload group'); @@ -358,7 +386,7 @@ export const WLMDetails = ({ setLastUpdated(new Date()); } catch (err) { console.error('Failed to fetch group stats', err); - core.notifications.toasts.addDanger('Could not load workload group stats.'); + core.notifications.toasts.addDanger('Could not load workload group stats.', err); } }; @@ -371,6 +399,49 @@ export const WLMDetails = ({ }; // === Actions === + const splitCSV = (v?: string | null) => + (v ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + interface RulePayload { + description: string; + workload_group: string; + index_pattern?: string[]; + principal?: { username?: string[]; role?: string[] }; + } + + const buildRulePayload = ( + currentGroupId: string, + rule: { index?: string | null; username?: string | null; role?: string | null }, + currentDescription?: string | null + ): RulePayload | null => { + const indexPattern = splitCSV(rule.index); + const usernames = showSecurity ? splitCSV(rule.username) : []; + const roles = showSecurity ? splitCSV(rule.role) : []; + + const hasIndexes = indexPattern.length > 0; + const hasUsernames = usernames.length > 0; + const hasRoles = roles.length > 0; + + if (!hasIndexes && !hasUsernames && !hasRoles) return null; // skip blank rule + + return { + description: (currentDescription || '-').trim(), + ...(hasUsernames || hasRoles + ? { + principal: { + ...(hasUsernames ? { username: usernames } : {}), + ...(hasRoles ? { role: roles } : {}), + }, + } + : {}), + ...(hasIndexes ? { index_pattern: indexPattern } : {}), + workload_group: currentGroupId, + }; + }; + const saveChanges = async () => { const validCpu = cpuLimit === undefined || (cpuLimit > 0 && cpuLimit <= 100); const validMem = memoryLimit === undefined || (memoryLimit > 0 && memoryLimit <= 100); @@ -422,11 +493,8 @@ export const WLMDetails = ({ } for (const rule of rulesToCreate) { - const response = { - description: description || '-', - index_pattern: rule.index.split(',').map((s) => s.trim()), - workload_group: currentId, - }; + const response = buildRulePayload(currentId!, rule, description); + if (!response) continue; await core.http.put(`/api/_rules/workload_group`, { query: { dataSourceId: dataSource.id }, @@ -436,11 +504,8 @@ export const WLMDetails = ({ } for (const rule of rulesToUpdate) { - const response = { - description: description || '-', - index_pattern: rule.index.split(',').map((s) => s.trim()), - workload_group: currentId, - }; + const response = buildRulePayload(currentId!, rule, description); + if (!response) continue; await core.http.put(`/api/_rules/workload_group/${rule.indexId}`, { query: { dataSourceId: dataSource.id }, @@ -552,26 +617,18 @@ export const WLMDetails = ({ )} - - {}} - onSelectedDataSource={() => { - window.history.replaceState({}, '', getDataSourceEnabledUrl(dataSource).toString()); - }} - dataSourcePickerReadOnly={true} - /> - - } + params={params} + dataSourceManagement={dataSourceManagement} + setDataSource={setDataSource} + selectedDataSource={dataSource} + onManageDataSource={() => {}} + onSelectedDataSource={() => { + window.history.replaceState({}, '', getDataSourceEnabledUrl(dataSource).toString()); + }} + dataSourcePickerReadOnly={true} /> @@ -832,9 +889,96 @@ export const WLMDetails = ({ - {/* Define your rule using any combination of index, role, or username.*/} - Define your rule using index. + Define your rule using any combination of username, role, or index. +
+
+ + Username + + { + const updated = [...rules]; + updated[idx].username = e.target.value; + setRules(updated); + setIsSaved(false); + }} + onBlur={(e) => { + const originallyNonEmpty = !!existingRules[idx]?.username?.trim(); + const nowEmpty = + (e.target.value ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean).length === 0; + + if (originallyNonEmpty && nowEmpty) { + // revert to original and warn + setRules((prev) => { + const next = [...prev]; + next[idx] = { + ...next[idx], + username: existingRules[idx]?.username ?? '', + }; + return next; + }); + core.notifications.toasts.addWarning( + 'Username cannot be cleared once set.' + ); + } + }} + disabled={!showSecurity} + /> + + {!showSecurity + ? 'Username rules require data source ≥ 3.3.' + : 'You can use (,) to add multiple usernames.'} + +
+ + + + + Role + + { + const updated = [...rules]; + updated[idx].role = e.target.value; + setRules(updated); + setIsSaved(false); + }} + onBlur={(e) => { + const originallyNonEmpty = !!existingRules[idx]?.role?.trim(); + const nowEmpty = + (e.target.value ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean).length === 0; + + if (originallyNonEmpty && nowEmpty) { + // revert to original and warn + setRules((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], role: existingRules[idx]?.role ?? '' }; + return next; + }); + core.notifications.toasts.addWarning('Role cannot be cleared once set.'); + } + }} + disabled={!showSecurity} + /> + + {!showSecurity + ? 'Role rules require data source ≥ 3.3.' + : 'You can use (,) to add multiple roles.'} + +
+ + {/* Index */} @@ -843,12 +987,12 @@ export const WLMDetails = ({ Index wildcard - { const value = e.target.value; - const trimmedValue = value.trim(); const updatedRules = [...rules]; const updatedErrors = [...indexErrors]; @@ -856,25 +1000,16 @@ export const WLMDetails = ({ let error: string | null = null; - // 1) Entirely empty? - if (trimmedValue === '') { - error = 'Please specify at least one index.'; - } else { - // split on commas, trim each segment - const items = value.split(',').map((s) => s.trim()); - - // 2) Any blank item? - if (items.some((item) => item === '')) { - error = 'Index names cannot be empty.'; - } - // 3) Any item too long? - else if (items.some((item) => item.length > 100)) { - error = 'Index names must be 100 characters or fewer.'; - } - // 4) Too many items? - else if (items.length > 10) { - error = 'You can specify at most 10 indexes per rule.'; - } + // split on commas, trim each segment + const items = value.split(',').map((s) => s.trim()); + + // 1) Any item too long? + if (items.some((item) => item.length > 100)) { + error = 'Index names must be 100 characters or fewer.'; + } + // 2) Too many items? + else if (items.length > 10) { + error = 'You can specify at most 10 indexes per rule.'; } updatedErrors[idx] = error; @@ -882,6 +1017,26 @@ export const WLMDetails = ({ setIndexErrors(updatedErrors); setIsSaved(false); }} + onBlur={(e) => { + const originallyNonEmpty = !!existingRules[idx]?.index?.trim(); + const nowEmpty = + (e.target.value ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean).length === 0; + + if (originallyNonEmpty && nowEmpty) { + // revert to original and warn + setRules((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], index: existingRules[idx]?.index ?? '' }; + return next; + }); + core.notifications.toasts.addWarning( + 'Index cannot be cleared once set.' + ); + } + }} isInvalid={Boolean(indexErrors[idx])} /> @@ -890,45 +1045,7 @@ export const WLMDetails = ({ - {/* */} - - {/*
*/} - {/* */} - {/* Role*/} - {/* */} - {/* {*/} - {/* const updated = [...rules];*/} - {/* updated[idx].role = e.target.value;*/} - {/* setRules(updated);*/} - {/* }}*/} - {/* />*/} - {/* */} - {/* You can use (,) to add multiple roles.*/} - {/* */} - {/*
*/} - - {/* */} - - {/*
*/} - {/* */} - {/* Username*/} - {/* */} - {/* {*/} - {/* const updated = [...rules];*/} - {/* updated[idx].username = e.target.value;*/} - {/* setRules(updated);*/} - {/* }}*/} - {/* />*/} - {/* */} - {/* You can use (,) to add multiple usernames.*/} - {/* */} - {/*
*/} + { - setRules([...rules, { index: '', indexId: '' }]); + setRules([...rules, { index: '', indexId: '', role: '', username: '' }]); setIndexErrors([...indexErrors, null]); setIsSaved(false); }} @@ -983,16 +1100,22 @@ export const WLMDetails = ({ onChange={(e) => { const val = e.target.value; const newVal = val === '' ? undefined : Number(val); + setCpuLimit(newVal); + setIsSaved(false); + }} + onBlur={(e) => { + const val = e.target.value; + const newVal = val.trim() === '' ? undefined : Number(val); - // If it was originally defined, do not allow clearing it if (originalCpuLimit !== undefined && newVal === undefined) { core.notifications.toasts.addWarning( 'Once set, CPU limit cannot be cleared.' ); + // revert to original + setCpuLimit(originalCpuLimit); return; } setCpuLimit(newVal); - setIsSaved(false); }} onKeyDown={(e) => { if (['e', 'E', '+', '-'].includes(e.key)) { @@ -1026,17 +1149,21 @@ export const WLMDetails = ({ onChange={(e) => { const val = e.target.value; const newVal = val === '' ? undefined : Number(val); + setMemoryLimit(newVal); + setIsSaved(false); + }} + onBlur={(e) => { + const val = e.target.value; + const newVal = val.trim() === '' ? undefined : Number(val); - // If it was originally defined, do not allow clearing it if (originalMemoryLimit !== undefined && newVal === undefined) { core.notifications.toasts.addWarning( 'Once set, memory limit cannot be cleared.' ); + setMemoryLimit(originalMemoryLimit); // revert return; } - setMemoryLimit(newVal); - setIsSaved(false); }} onKeyDown={(e) => { if (['e', 'E', '+', '-'].includes(e.key)) { diff --git a/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx b/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx index 237f2a1d..bece8281 100644 --- a/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx +++ b/public/pages/WorkloadManagement/WLMMain/WLMMain.test.tsx @@ -30,10 +30,24 @@ const mockCore = ({ addDanger: jest.fn(), }, }, + savedObjects: { + client: {}, + }, } as unknown) as CoreStart; -const mockDepsStart = {} as any; -const mockDataSourceManagement = {} as any; +const mockDepsStart = { + dataSource: { + dataSourceEnabled: true, + }, +} as any; + +const MockDataSourceMenu = (_props: any) =>
Mocked Data Source Menu
; + +const mockDataSourceManagement = { + ui: { + getDataSourceMenu: jest.fn(() => MockDataSourceMenu), + }, +} as any; const capturedOptions: any[] = []; jest.mock('echarts-for-react', () => ({ @@ -49,6 +63,9 @@ beforeEach(() => { jest.clearAllMocks(); capturedOptions.length = 0; + // Restore the data source menu mock after reset + mockDataSourceManagement.ui.getDataSourceMenu.mockReturnValue(MockDataSourceMenu); + (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { if (url === '/api/_wlm/workload_group') { return Promise.resolve({ @@ -107,6 +124,10 @@ const mockDataSource = { name: 'default', } as any; +const mockParams = { + setHeaderActionMenu: jest.fn(), +} as any; + const renderComponent = () => render( @@ -114,7 +135,7 @@ const renderComponent = () => @@ -448,26 +469,6 @@ describe('WorkloadManagementMain', () => { expect(slice).not.toThrow(); }); - it('shows toast on failed stats fetch', async () => { - (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { - if (url.includes('/_wlm/stats')) { - return Promise.reject(new Error('API failure')); - } - return Promise.resolve({ body: {} }); - }); - - renderComponent(); - - await waitFor(() => { - expect(mockCore.notifications.toasts.addDanger).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.any(String), - text: expect.any(String), - }) - ); - }); - }); - it('handles missing stats gracefully', async () => { (mockCore.http.get as jest.Mock).mockImplementation((url: string) => { if (url.includes('/_wlm/stats')) { diff --git a/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx b/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx index a432f5b3..8d060cdd 100644 --- a/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx +++ b/public/pages/WorkloadManagement/WLMMain/WLMMain.tsx @@ -23,7 +23,6 @@ import { useHistory, useLocation } from 'react-router-dom'; import { CoreStart, AppMountParameters } from 'opensearch-dashboards/public'; import ReactECharts from 'echarts-for-react'; import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; -import { PageHeader } from '../../../components/PageHeader'; import { QueryInsightsDashboardsPluginStartDependencies } from '../../../types'; import { WLM_CREATE } from '../WorkloadManagement'; import { DataSourceContext } from '../WorkloadManagement'; @@ -310,10 +309,6 @@ export const WorkloadManagementMain = ({ setLastUpdated(new Date()); } catch (err) { console.error(`Failed to fetch node stats:`, err); - core.notifications.toasts.addDanger({ - title: 'Failed to fetch workload stats', - text: 'An error occurred while retrieving workload statistics. Please try again.', - }); } setLoading(false); }; @@ -610,24 +605,18 @@ export const WorkloadManagementMain = ({ return (
- {}} - onSelectedDataSource={() => { - window.history.replaceState({}, '', getDataSourceEnabledUrl(dataSource).toString()); - }} - dataSourcePickerReadOnly={false} - /> - } + params={params} + dataSourceManagement={dataSourceManagement} + setDataSource={setDataSource} + selectedDataSource={dataSource} + onManageDataSource={() => {}} + onSelectedDataSource={() => { + window.history.replaceState({}, '', getDataSourceEnabledUrl(dataSource).toString()); + }} + dataSourcePickerReadOnly={false} /> diff --git a/public/pages/WorkloadManagement/WorkloadManagement.test.tsx b/public/pages/WorkloadManagement/WorkloadManagement.test.tsx index 8aaf9d5c..5db2ab9a 100644 --- a/public/pages/WorkloadManagement/WorkloadManagement.test.tsx +++ b/public/pages/WorkloadManagement/WorkloadManagement.test.tsx @@ -29,15 +29,34 @@ const mockCore = ({ addDanger: jest.fn(), }, }, + savedObjects: { + client: {}, + }, } as unknown) as CoreStart; -const mockDepsStart = {} as QueryInsightsDashboardsPluginStartDependencies; +const mockDepsStart = { + dataSource: { + dataSourceEnabled: true, + }, +} as QueryInsightsDashboardsPluginStartDependencies; const mockDataSource = { id: 'default', name: 'default', } as any; +const mockParams = { + setHeaderActionMenu: jest.fn(), +} as any; + +const MockDataSourceMenu = (_props: any) =>
Mocked Data Source Menu
; + +const mockDataSourceManagement = { + ui: { + getDataSourceMenu: jest.fn(() => MockDataSourceMenu), + }, +} as any; + jest.mock('../../components/PageHeader', () => ({ PageHeader: () =>
Mocked PageHeader
, })); @@ -52,8 +71,8 @@ const renderWithRoute = (initialRoute: string) => { @@ -64,6 +83,9 @@ const renderWithRoute = (initialRoute: string) => { describe('WorkloadManagement Routing', () => { beforeEach(() => { jest.clearAllMocks(); + + // Restore the data source menu mock after reset + mockDataSourceManagement.ui.getDataSourceMenu.mockReturnValue(MockDataSourceMenu); }); it('renders WLMMain component at WLM_MAIN route', () => { diff --git a/public/utils/datasource-utils.ts b/public/utils/datasource-utils.ts index 1bd0c8d5..8c914271 100644 --- a/public/utils/datasource-utils.ts +++ b/public/utils/datasource-utils.ts @@ -20,6 +20,7 @@ import semver from 'semver'; import { DataSourceOption } from 'src/plugins/data_source_management/public'; +import type { CoreStart } from 'opensearch-dashboards/public'; import pluginManifest from '../../opensearch_dashboards.json'; import type { SavedObject } from '../../../../src/core/public'; import type { DataSourceAttributes } from '../../../../src/plugins/data_source/common/data_sources'; @@ -99,3 +100,35 @@ export const isWLMDataSourceCompatible = (dataSource: SavedObject { + // 1) fetch the saved object to read attributes.dataSourceVersion. + const id = (selected as any)?.id; + if (id) { + try { + const so = await core.savedObjects.client.get>( + 'data-source', + id + ); + return (so as any)?.attributes?.dataSourceVersion; + } catch { + return undefined; + } + } + + // 2) If it's local cluster then always show security + if (selected?.label === 'Local cluster') { + return '3.3.0'; + } + return undefined; +} + +/** Whether Security rule attributes (username/role) are supported (>= 3.3.0). */ +export function isSecurityAttributesSupported(version?: string): boolean { + // defend against undefined or loose versions + const v = version && semver.valid(version) ? version : semver.coerce(version || '')?.version; + return !!v && semver.gte(v, '3.3.0'); +} diff --git a/server/routes/wlmRoutes.ts b/server/routes/wlmRoutes.ts index 22890132..4e7158bc 100644 --- a/server/routes/wlmRoutes.ts +++ b/server/routes/wlmRoutes.ts @@ -307,7 +307,13 @@ export function defineWlmRoutes(router: IRouter, dataSourceEnabled: boolean) { validate: { body: schema.object({ description: schema.string(), - index_pattern: schema.arrayOf(schema.string()), + principal: schema.maybe( + schema.object({ + username: schema.maybe(schema.arrayOf(schema.string())), + role: schema.maybe(schema.arrayOf(schema.string())), + }) + ), + index_pattern: schema.maybe(schema.arrayOf(schema.string())), workload_group: schema.string(), }), query: schema.object({ @@ -317,11 +323,7 @@ export function defineWlmRoutes(router: IRouter, dataSourceEnabled: boolean) { }, async (context, request, response) => { try { - const body = { - description: request.body.description, - index_pattern: request.body.index_pattern, - workload_group: request.body.workload_group, - }; + const body = request.body; let result; if (!dataSourceEnabled || !request.query?.dataSourceId) { const client = context.wlm_plugin.wlmClient.asScoped(request).callAsCurrentUser; @@ -332,10 +334,10 @@ export function defineWlmRoutes(router: IRouter, dataSourceEnabled: boolean) { } return response.ok({ body: result }); } catch (error: any) { - console.error(`Failed to create index rule:`, error); + console.error(`Failed to create rule:`, error); return response.custom({ statusCode: error.statusCode || 500, - body: { message: `Failed to create index rule: ${error.message}` }, + body: { message: `Failed to create rule: ${error.message}` }, }); } } @@ -363,9 +365,9 @@ export function defineWlmRoutes(router: IRouter, dataSourceEnabled: boolean) { } return response.ok({ body: result }); } catch (e: any) { - console.error('Failed to fetch index rules:', e); + console.error('Failed to fetch rules:', e); return response.internalError({ - body: { message: `Failed to fetch index rules: ${e.message}` }, + body: { message: `Failed to fetch rules: ${e.message}` }, }); } } @@ -415,7 +417,13 @@ export function defineWlmRoutes(router: IRouter, dataSourceEnabled: boolean) { }), body: schema.object({ description: schema.string(), - index_pattern: schema.arrayOf(schema.string()), + principal: schema.maybe( + schema.object({ + username: schema.maybe(schema.arrayOf(schema.string())), + role: schema.maybe(schema.arrayOf(schema.string())), + }) + ), + index_pattern: schema.maybe(schema.arrayOf(schema.string())), workload_group: schema.string(), }), query: schema.object({ diff --git a/yarn.lock b/yarn.lock index 3a186f3f..d659cd2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -917,11 +917,6 @@ resolved "https://registry.yarnpkg.com/@types/object-hash/-/object-hash-3.0.6.tgz#25c052428199d374ef723b7b0ed44b5bfe1b3029" integrity sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w== -"@types/parse-json@^4.0.0": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" - integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== - "@types/prettier@^2.1.5": version "2.7.3" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" @@ -1251,11 +1246,23 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: dependencies: type-fest "^0.21.3" +ansi-escapes@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-5.0.0.tgz#b6a0caf0eef0c41af190e9a749e0c00ec04bb2a6" + integrity sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA== + dependencies: + type-fest "^1.0.2" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -1275,6 +1282,11 @@ ansi-styles@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== +ansi-styles@^6.0.0, ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -1774,6 +1786,11 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== +chalk@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + chalk@^2.3.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -1897,6 +1914,13 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" +cli-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" + integrity sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== + dependencies: + restore-cursor "^4.0.0" + cli-table3@~0.6.1: version "0.6.5" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.5.tgz#013b91351762739c16a9567c21a04632e449bf2f" @@ -1914,6 +1938,14 @@ cli-truncate@^2.1.0: slice-ansi "^3.0.0" string-width "^4.2.0" +cli-truncate@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-3.1.0.tgz#3f23ab12535e3d73e839bb43e73c9de487db1389" + integrity sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA== + dependencies: + slice-ansi "^5.0.0" + string-width "^5.0.0" + cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" @@ -1966,7 +1998,7 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@^2.0.16: +colorette@^2.0.16, colorette@^2.0.20: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== @@ -1978,12 +2010,17 @@ combined-stream@^1.0.8, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" +commander@11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" + integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^6.2.0, commander@^6.2.1: +commander@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== @@ -2060,17 +2097,6 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cosmiconfig@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" - integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.2.1" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.10.0" - create-ecdh@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" @@ -2392,13 +2418,20 @@ dayjs@^1.10.4: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.4: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4: version "4.4.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" +debug@4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -2565,6 +2598,11 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -2617,6 +2655,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + emojis-list@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" @@ -2914,6 +2957,11 @@ eventemitter2@6.4.7: resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.7.tgz#a7f6c4d7abf28a14c1ef3442f21cb306a054271d" integrity sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg== +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + events@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -2927,7 +2975,7 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" -execa@4.1.0, execa@^4.1.0: +execa@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== @@ -2942,6 +2990,21 @@ execa@4.1.0, execa@^4.1.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" +execa@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-7.2.0.tgz#657e75ba984f42a70f38928cedc87d6f2d4fe4e9" + integrity sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.1" + human-signals "^4.3.0" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^3.0.7" + strip-final-newline "^3.0.0" + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -3223,11 +3286,6 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@ hasown "^2.0.2" math-intrinsics "^1.1.0" -get-own-enumerable-property-symbols@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" - integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== - get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" @@ -3248,7 +3306,7 @@ get-stream@^5.0.0, get-stream@^5.1.0: dependencies: pump "^3.0.0" -get-stream@^6.0.0: +get-stream@^6.0.0, get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== @@ -3498,12 +3556,17 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +human-signals@^4.3.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2" + integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ== + humanize-duration@^3.27.3: version "3.33.0" resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.33.0.tgz#29b3276e68443e513fc85223d094faacdbb8454c" integrity sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ== -husky@^8.0.0: +husky@^8.0.3: version "8.0.3" resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== @@ -3525,14 +3588,6 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA== -import-fresh@^3.2.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" - integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - import-local@^3.0.2: version "3.2.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" @@ -3711,6 +3766,11 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + is-generator-fn@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" @@ -3764,11 +3824,6 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-obj@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - integrity sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg== - is-path-inside@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" @@ -3789,11 +3844,6 @@ is-regex@^1.1.4, is-regex@^1.2.1: has-tostringtag "^1.0.2" hasown "^2.0.2" -is-regexp@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" - integrity sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA== - is-set@^2.0.2, is-set@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" @@ -3811,6 +3861,11 @@ is-stream@^2.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +is-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" + integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== + is-string@^1.0.7, is-string@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" @@ -4500,33 +4555,45 @@ leven@^3.1.0: resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== +lilconfig@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -lint-staged@^10.2.0: - version "10.5.4" - resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.5.4.tgz#cd153b5f0987d2371fc1d2847a409a2fe705b665" - integrity sha512-EechC3DdFic/TdOPgj/RB3FicqE6932LTHCUm0Y2fsD9KGlLB+RwJl2q1IYBIvEsKzDOgn0D4gll+YxG5RsrKg== +lint-staged@^13.2.2: + version "13.3.0" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.3.0.tgz#7965d72a8d6a6c932f85e9c13ccf3596782d28a5" + integrity sha512-mPRtrYnipYYv1FEE134ufbWpeggNTo+O/UPzngoaKzbzHAthvR55am+8GfHTnqNRQVRRrYQLGW9ZyUoD7DsBHQ== + dependencies: + chalk "5.3.0" + commander "11.0.0" + debug "4.3.4" + execa "7.2.0" + lilconfig "2.1.0" + listr2 "6.6.1" + micromatch "4.0.5" + pidtree "0.6.0" + string-argv "0.3.2" + yaml "2.3.1" + +listr2@6.6.1: + version "6.6.1" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-6.6.1.tgz#08b2329e7e8ba6298481464937099f4a2cd7f95d" + integrity sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg== dependencies: - chalk "^4.1.0" - cli-truncate "^2.1.0" - commander "^6.2.0" - cosmiconfig "^7.0.0" - debug "^4.2.0" - dedent "^0.7.0" - enquirer "^2.3.6" - execa "^4.1.0" - listr2 "^3.2.2" - log-symbols "^4.0.0" - micromatch "^4.0.2" - normalize-path "^3.0.0" - please-upgrade-node "^3.2.0" - string-argv "0.3.1" - stringify-object "^3.3.0" + cli-truncate "^3.1.0" + colorette "^2.0.20" + eventemitter3 "^5.0.1" + log-update "^5.0.1" + rfdc "^1.3.0" + wrap-ansi "^8.1.0" -listr2@^3.2.2, listr2@^3.8.3: +listr2@^3.8.3: version "3.14.0" resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.14.0.tgz#23101cc62e1375fd5836b248276d1d2b51fdbe9e" integrity sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g== @@ -4606,6 +4673,17 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" +log-update@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-5.0.1.tgz#9e928bf70cb183c1f0c9e91d9e6b7115d597ce09" + integrity sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw== + dependencies: + ansi-escapes "^5.0.0" + cli-cursor "^4.0.0" + slice-ansi "^5.0.0" + strip-ansi "^7.0.1" + wrap-ansi "^8.0.1" + loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -4700,7 +4778,7 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -micromatch@4.0.8, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4: +micromatch@4.0.5, micromatch@4.0.8, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^4.0.0, micromatch@^4.0.4: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -4733,6 +4811,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" + integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== + min-document@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" @@ -4807,6 +4890,11 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -4885,6 +4973,13 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +npm-run-path@^5.1.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.3.0.tgz#e23353d0ebb9317f174e93417e4a4d82d0249e9f" + integrity sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ== + dependencies: + path-key "^4.0.0" + nwsapi@^2.2.0: version "2.2.22" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.22.tgz#109f9530cda6c156d6a713cdf5939e9f0de98b9d" @@ -4944,6 +5039,13 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +onetime@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" + integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== + dependencies: + mimic-fn "^4.0.0" + os-browserify@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" @@ -5010,13 +5112,6 @@ parallel-transform@^1.1.0: inherits "^2.0.3" readable-stream "^2.1.5" -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - parse-asn1@^5.0.0, parse-asn1@^5.1.9: version "5.1.9" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.9.tgz#8dd24c3ea8da77dffbc708d94eaf232fd6156e95" @@ -5028,7 +5123,7 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.9: pbkdf2 "^3.1.5" safe-buffer "^5.2.1" -parse-json@^5.0.0, parse-json@^5.2.0: +parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== @@ -5068,16 +5163,16 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-key@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" + integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== + path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - pbkdf2@3.1.5, pbkdf2@^3.1.2, pbkdf2@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.5.tgz#444a59d7a259a95536c56e80c89de31cc01ed366" @@ -5115,6 +5210,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pidtree@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" + integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== + pify@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -5144,13 +5244,6 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -please-upgrade-node@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" - integrity sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg== - dependencies: - semver-compare "^1.0.0" - plotly.js-dist@^2.34.0: version "2.35.3" resolved "https://registry.yarnpkg.com/plotly.js-dist/-/plotly.js-dist-2.35.3.tgz#edbcdd9934e2f0d74e270acf7a72eb789acbf280" @@ -5477,11 +5570,6 @@ resolve-cwd@^3.0.0: dependencies: resolve-from "^5.0.0" -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - resolve-from@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" @@ -5518,6 +5606,14 @@ restore-cursor@^3.1.0: onetime "^5.1.0" signal-exit "^3.0.2" +restore-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-4.0.0.tgz#519560a4318975096def6e609d44100edaa4ccb9" + integrity sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + rfdc@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" @@ -5626,11 +5722,6 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" -semver-compare@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" - integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== - semver@^5.6.0, semver@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" @@ -5755,7 +5846,7 @@ side-channel@^1.0.4, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.2, signal-exit@^3.0.3: +signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -5793,6 +5884,14 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== + dependencies: + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" + source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" @@ -5890,10 +5989,10 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== -string-argv@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" - integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== +string-argv@0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" + integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== string-length@^4.0.1: version "4.0.2" @@ -5912,6 +6011,15 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^5.0.0, string-width@^5.0.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + string.prototype.replaceall@1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/string.prototype.replaceall/-/string.prototype.replaceall-1.0.7.tgz#6cf36b20bcb12d55653e1119ddf5bc1d6363103d" @@ -5970,15 +6078,6 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -stringify-object@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" - integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== - dependencies: - get-own-enumerable-property-symbols "^3.0.0" - is-obj "^1.0.1" - is-regexp "^1.0.0" - strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -5986,6 +6085,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + strip-bom@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" @@ -5996,6 +6102,11 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-final-newline@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" + integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== + strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" @@ -6263,6 +6374,11 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-fest@^1.0.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" + integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== + typed-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" @@ -6645,6 +6761,15 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -6700,10 +6825,10 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yaml@^1.10.0: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== yargs-parser@^18.1.2: version "18.1.3" From e800cb2e0cb22dfc8c46998870ec485a65ae0115 Mon Sep 17 00:00:00 2001 From: opensearch-ci-bot Date: Sat, 1 Nov 2025 00:33:22 +0000 Subject: [PATCH 14/14] Increment version to 3.3.0.0 Signed-off-by: opensearch-ci-bot --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e25860b4..4877ef89 100644 --- a/package.json +++ b/package.json @@ -91,4 +91,4 @@ "node_modules/*", "target/*" ] -} +} \ No newline at end of file