diff --git a/.github/workflows/scripts/__tests__/bundle.test.ts b/.github/workflows/scripts/__tests__/bundle.test.ts index 2fef551a4..38d29cb25 100644 --- a/.github/workflows/scripts/__tests__/bundle.test.ts +++ b/.github/workflows/scripts/__tests__/bundle.test.ts @@ -2,7 +2,7 @@ import {generateBundleSizeSection, getBundleInfo} from '../utils/bundle'; describe('bundle utils', () => { describe('generateBundleSizeSection', () => { - it('should generate section for increased bundle size', () => { + test('should generate section for increased bundle size', () => { const bundleInfo = { currentSize: 1024 * 1024 * 2, // 2MB mainSize: 1024 * 1024, // 1MB @@ -17,7 +17,7 @@ describe('bundle utils', () => { expect(result).toContain('⚠️ Bundle size increased. Please review.'); }); - it('should generate section for decreased bundle size', () => { + test('should generate section for decreased bundle size', () => { const bundleInfo = { currentSize: 1024 * 1024, // 1MB mainSize: 1024 * 1024 * 2, // 2MB @@ -32,7 +32,7 @@ describe('bundle utils', () => { expect(result).toContain('✅ Bundle size decreased.'); }); - it('should generate section for unchanged bundle size', () => { + test('should generate section for unchanged bundle size', () => { const bundleInfo = { currentSize: 1024 * 1024, // 1MB mainSize: 1024 * 1024, // 1MB @@ -47,7 +47,7 @@ describe('bundle utils', () => { expect(result).toContain('✅ Bundle size unchanged.'); }); - it('should handle N/A percent', () => { + test('should handle N/A percent', () => { const bundleInfo = { currentSize: 1024 * 1024, // 1MB mainSize: 0, @@ -75,7 +75,7 @@ describe('bundle utils', () => { process.env = originalEnv; }); - it('should get bundle info from environment variables', () => { + test('should get bundle info from environment variables', () => { process.env.CURRENT_SIZE = '2097152'; // 2MB process.env.MAIN_SIZE = '1048576'; // 1MB process.env.SIZE_DIFF = '1048576'; // 1MB @@ -90,7 +90,7 @@ describe('bundle utils', () => { }); }); - it('should handle missing environment variables', () => { + test('should handle missing environment variables', () => { process.env.CURRENT_SIZE = undefined; process.env.MAIN_SIZE = undefined; process.env.SIZE_DIFF = undefined; diff --git a/.github/workflows/scripts/__tests__/format.test.ts b/.github/workflows/scripts/__tests__/format.test.ts index eec6949f5..77d7015c5 100644 --- a/.github/workflows/scripts/__tests__/format.test.ts +++ b/.github/workflows/scripts/__tests__/format.test.ts @@ -2,28 +2,28 @@ import {formatSize, generateTestChangesSummary} from '../utils/format'; describe('format utils', () => { describe('formatSize', () => { - it('should format size in KB when less than 1024 bytes', () => { + test('should format size in KB when less than 1024 bytes', () => { const size = 512; // 512 bytes expect(formatSize(size)).toBe('0.50 KB'); }); - it('should format size in MB when greater than or equal to 1024 bytes', () => { + test('should format size in MB when greater than or equal to 1024 bytes', () => { const size = 2.5 * 1024; // 2.5 KB -> will be shown in MB expect(formatSize(size)).toBe('2.50 KB'); }); - it('should handle small sizes', () => { + test('should handle small sizes', () => { const size = 100; // 100 bytes expect(formatSize(size)).toBe('0.10 KB'); }); - it('should handle zero', () => { + test('should handle zero', () => { expect(formatSize(0)).toBe('0.00 KB'); }); }); describe('generateTestChangesSummary', () => { - it('should generate summary for new tests only', () => { + test('should generate summary for new tests only', () => { const comparison = { new: ['Test 1 (file1.ts)', 'Test 2 (file2.ts)'], skipped: [], @@ -38,7 +38,7 @@ describe('format utils', () => { expect(summary).not.toContain('🗑️ Deleted Tests'); }); - it('should generate summary for skipped tests only', () => { + test('should generate summary for skipped tests only', () => { const comparison = { new: [], skipped: ['Test 1 (file1.ts)', 'Test 2 (file2.ts)'], @@ -53,7 +53,7 @@ describe('format utils', () => { expect(summary).not.toContain('🗑️ Deleted Tests'); }); - it('should generate summary for deleted tests only', () => { + test('should generate summary for deleted tests only', () => { const comparison = { new: [], skipped: [], @@ -68,7 +68,7 @@ describe('format utils', () => { expect(summary).not.toContain('⏭️ Skipped Tests'); }); - it('should generate summary for all types of changes', () => { + test('should generate summary for all types of changes', () => { const comparison = { new: ['New Test (file1.ts)'], skipped: ['Skipped Test (file2.ts)'], @@ -84,7 +84,7 @@ describe('format utils', () => { expect(summary).toContain('Deleted Test (file3.ts)'); }); - it('should handle no changes', () => { + test('should handle no changes', () => { const comparison = { new: [], skipped: [], diff --git a/.github/workflows/scripts/__tests__/results.test.ts b/.github/workflows/scripts/__tests__/results.test.ts index 0dc74793c..cf3f38093 100644 --- a/.github/workflows/scripts/__tests__/results.test.ts +++ b/.github/workflows/scripts/__tests__/results.test.ts @@ -11,7 +11,7 @@ describe('results utils', () => { jest.clearAllMocks(); }); - it('should handle non-existent file', () => { + test('should handle non-existent file', () => { (fs.existsSync as jest.Mock).mockReturnValue(false); const result = readTestResults('nonexistent.json'); @@ -25,7 +25,7 @@ describe('results utils', () => { }); }); - it('should read and process test results correctly', () => { + test('should read and process test results correctly', () => { const mockTestResults: TestResults = { config: {} as any, suites: [ @@ -87,7 +87,7 @@ describe('results utils', () => { }); describe('getTestStatus', () => { - it('should return failed status when there are failures', () => { + test('should return failed status when there are failures', () => { const results: TestResultsInfo = { total: 10, passed: 8, @@ -102,7 +102,7 @@ describe('results utils', () => { expect(result.statusColor).toBe('red'); }); - it('should return flaky status when there are flaky tests but no failures', () => { + test('should return flaky status when there are flaky tests but no failures', () => { const results: TestResultsInfo = { total: 10, passed: 8, @@ -117,7 +117,7 @@ describe('results utils', () => { expect(result.statusColor).toBe('orange'); }); - it('should return passed status when all tests pass', () => { + test('should return passed status when all tests pass', () => { const results: TestResultsInfo = { total: 10, passed: 10, diff --git a/.github/workflows/scripts/__tests__/test.test.ts b/.github/workflows/scripts/__tests__/test.test.ts index ecc0e888e..e36ec0836 100644 --- a/.github/workflows/scripts/__tests__/test.test.ts +++ b/.github/workflows/scripts/__tests__/test.test.ts @@ -3,7 +3,7 @@ import type {Spec, Suite, TestInfo} from './types'; describe('test utils', () => { describe('isTestSkipped', () => { - it('should return true for test with skip annotation', () => { + test('should return true for test with skip annotation', () => { const spec: Spec = { title: 'Test', ok: true, @@ -27,7 +27,7 @@ describe('test utils', () => { expect(isTestSkipped(spec)).toBe(true); }); - it('should return true for test with skipped status', () => { + test('should return true for test with skipped status', () => { const spec: Spec = { title: 'Test', ok: true, @@ -51,7 +51,7 @@ describe('test utils', () => { expect(isTestSkipped(spec)).toBe(true); }); - it('should return false for non-skipped test', () => { + test('should return false for non-skipped test', () => { const spec: Spec = { title: 'Test', ok: true, @@ -77,7 +77,7 @@ describe('test utils', () => { }); describe('extractTestsFromSuite', () => { - it('should extract tests from a simple suite', () => { + test('should extract tests from a simple suite', () => { const suite: Suite = { title: 'Suite 1', file: 'test.spec.ts', @@ -120,7 +120,7 @@ describe('test utils', () => { ]); }); - it('should handle nested suites', () => { + test('should handle nested suites', () => { const suite: Suite = { title: 'Parent Suite', file: 'test.spec.ts', @@ -174,7 +174,7 @@ describe('test utils', () => { }); describe('compareTests', () => { - it('should identify new, skipped, and deleted tests', () => { + test('should identify new, skipped, and deleted tests', () => { const currentTests: TestInfo[] = [ { title: 'Test 1', @@ -210,7 +210,7 @@ describe('test utils', () => { }); }); - it('should handle empty test arrays', () => { + test('should handle empty test arrays', () => { const result = compareTests([], []); expect(result).toEqual({ new: [], diff --git a/.github/workflows/scripts/__tests__/update-pr-description.test.ts b/.github/workflows/scripts/__tests__/update-pr-description.test.ts index ee9251f1d..0cd53e61c 100644 --- a/.github/workflows/scripts/__tests__/update-pr-description.test.ts +++ b/.github/workflows/scripts/__tests__/update-pr-description.test.ts @@ -113,7 +113,7 @@ describe('updatePRDescription', () => { mockGithub.rest.pulls.update.mockResolvedValue({}); }); - it('should read both current and main test results', async () => { + test('should read both current and main test results', async () => { await updatePRDescription(mockGithub, mockContext); expect(readTestResults).toHaveBeenCalledTimes(2); @@ -121,7 +121,7 @@ describe('updatePRDescription', () => { expect(readTestResults).toHaveBeenCalledWith('gh-pages/main/test-results.json'); }); - it('should format CI section with correct table and details', async () => { + test('should format CI section with correct table and details', async () => { const mockResults: TestResultsInfo = { total: 5, passed: 3, @@ -154,7 +154,7 @@ describe('updatePRDescription', () => { expect(body).toContain(''); }); - it('should handle PR without existing description', async () => { + test('should handle PR without existing description', async () => { mockGithub.rest.pulls.get.mockResolvedValue({ data: { body: null, @@ -168,7 +168,7 @@ describe('updatePRDescription', () => { expect(updateCall.body).toContain('## CI Results'); }); - it('should handle errors in test results', async () => { + test('should handle errors in test results', async () => { const emptyResults: TestResultsInfo = { total: 0, passed: 0, @@ -192,7 +192,7 @@ describe('updatePRDescription', () => { expect(updateCall.body).toContain('| 0 | 0 | 0 | 0 | 0 |'); }); - it('should include report URL in description', async () => { + test('should include report URL in description', async () => { await updatePRDescription(mockGithub, mockContext); expect(mockGithub.rest.pulls.update).toHaveBeenCalled(); @@ -201,7 +201,7 @@ describe('updatePRDescription', () => { expect(updateCall.body).toContain(expectedUrl); }); - it('should handle failed tests status color', async () => { + test('should handle failed tests status color', async () => { const failedResults: TestResultsInfo = { total: 10, passed: 8, @@ -224,7 +224,7 @@ describe('updatePRDescription', () => { expect(updateCall.body).toContain('color: red'); }); - it('should handle flaky tests status color', async () => { + test('should handle flaky tests status color', async () => { const flakyResults: TestResultsInfo = { total: 10, passed: 8, diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cabfd400e..04ff60435 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "8.11.1" + ".": "8.12.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2bdf330..12970b401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [8.12.0](https://github.com/ydb-platform/ydb-embedded-ui/compare/v8.11.1...v8.12.0) (2025-02-25) + + +### Features + +* add database to authentication process ([#1976](https://github.com/ydb-platform/ydb-embedded-ui/issues/1976)) ([d9067b3](https://github.com/ydb-platform/ydb-embedded-ui/commit/d9067b373f832083c537eb35ca51ab6e56853552)) +* hide some columns and storage nodes for users-viewers ([#1967](https://github.com/ydb-platform/ydb-embedded-ui/issues/1967)) ([249011d](https://github.com/ydb-platform/ydb-embedded-ui/commit/249011d93bff8a20e71ea48504f8c4a4367ff33b)) +* support multipart responses in query ([#1865](https://github.com/ydb-platform/ydb-embedded-ui/issues/1865)) ([99ee997](https://github.com/ydb-platform/ydb-embedded-ui/commit/99ee99713be9165ffd2140e3cab29ec26758b70f)) +* **TabletsTable:** add search by id ([#1981](https://github.com/ydb-platform/ydb-embedded-ui/issues/1981)) ([d68adba](https://github.com/ydb-platform/ydb-embedded-ui/commit/d68adba4d239bbfaf35f006b25e337d1976e1ab5)) + + +### Bug Fixes + +* **Node:** fix developer ui link in dev mode ([#1979](https://github.com/ydb-platform/ydb-embedded-ui/issues/1979)) ([ad64c8c](https://github.com/ydb-platform/ydb-embedded-ui/commit/ad64c8c8e3aa951ec5f97ce6cbb68e9cb3b3254b)) + ## [8.11.1](https://github.com/ydb-platform/ydb-embedded-ui/compare/v8.11.0...v8.11.1) (2025-02-18) diff --git a/config-overrides.js b/config-overrides.js index a16b73a0f..98185f097 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -58,7 +58,7 @@ module.exports = { // By default jest does not transform anything in node_modules // So this override excludes node_modules except @gravity-ui // see https://github.com/timarney/react-app-rewired/issues/241 - config.transformIgnorePatterns = ['node_modules/(?!(@gravity-ui)/)']; + config.transformIgnorePatterns = ['node_modules/(?!(@gravity-ui|@mjackson)/)']; // Add .github directory to roots config.roots = ['/src', '/.github']; diff --git a/package-lock.json b/package-lock.json index 9333b345c..06e7f5515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ydb-embedded-ui", - "version": "8.11.1", + "version": "8.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ydb-embedded-ui", - "version": "8.11.1", + "version": "8.12.0", "dependencies": { "@bem-react/classname": "^1.6.0", "@ebay/nice-modal-react": "^1.2.13", @@ -19,12 +19,13 @@ "@gravity-ui/icons": "^2.12.0", "@gravity-ui/navigation": "^2.30.0", "@gravity-ui/paranoid": "^2.0.1", - "@gravity-ui/react-data-table": "^2.1.1", + "@gravity-ui/react-data-table": "^2.2.1", "@gravity-ui/table": "^1.7.0", "@gravity-ui/uikit": "^6.40.0", "@gravity-ui/unipika": "^5.2.1", "@gravity-ui/websql-autocomplete": "^13.7.0", "@hookform/resolvers": "^3.10.0", + "@mjackson/multipart-parser": "^0.8.2", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.20.6", "@ydb-platform/monaco-ghost": "^0.6.1", @@ -100,6 +101,7 @@ "typescript": "^5.7.3" }, "peerDependencies": { + "monaco-yql-languages": ">=1.3.0", "prop-types": "^15.8.1", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -3189,9 +3191,10 @@ } }, "node_modules/@gravity-ui/react-data-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@gravity-ui/react-data-table/-/react-data-table-2.1.1.tgz", - "integrity": "sha512-7h5Idn1hRrdjVr46xDfJ57I5Zu9n4biV8ytuYCKBoj2LpuH4t93VHLGy+E1CTx87gjoDLPCVI6FEGAI/iyQJ3Q==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@gravity-ui/react-data-table/-/react-data-table-2.2.1.tgz", + "integrity": "sha512-wPegyv9InoeiiGdT5vbvrfV5eRcEq/fFK/X3pt+W2SnNrexiKDy+18nz94gqQTvp1iNhL9VQJlZs91e+mpQiiA==", + "license": "MIT", "dependencies": { "@bem-react/classname": ">=1.6.0", "react-list": "^0.8.17", @@ -4238,6 +4241,21 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mjackson/headers": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@mjackson/headers/-/headers-0.10.0.tgz", + "integrity": "sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==", + "license": "MIT" + }, + "node_modules/@mjackson/multipart-parser": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@mjackson/multipart-parser/-/multipart-parser-0.8.2.tgz", + "integrity": "sha512-KltttyypazaJ9kD1GpiOTEop9/YA5aZPwKfpbmuMYoYSyJhQc+0pqaQcZSHUJVdJBvIWgx7TTQSDJdnNqP5dxA==", + "license": "MIT", + "dependencies": { + "@mjackson/headers": "^0.10.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", diff --git a/package.json b/package.json index d099b4b89..aa34b7185 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ydb-embedded-ui", - "version": "8.11.1", + "version": "8.12.0", "files": [ "dist" ], @@ -21,12 +21,13 @@ "@gravity-ui/icons": "^2.12.0", "@gravity-ui/navigation": "^2.30.0", "@gravity-ui/paranoid": "^2.0.1", - "@gravity-ui/react-data-table": "^2.1.1", + "@gravity-ui/react-data-table": "^2.2.1", "@gravity-ui/table": "^1.7.0", "@gravity-ui/uikit": "^6.40.0", "@gravity-ui/unipika": "^5.2.1", "@gravity-ui/websql-autocomplete": "^13.7.0", "@hookform/resolvers": "^3.10.0", + "@mjackson/multipart-parser": "^0.8.2", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.20.6", "@ydb-platform/monaco-ghost": "^0.6.1", @@ -163,10 +164,10 @@ "typescript": "^5.7.3" }, "peerDependencies": { + "monaco-yql-languages": ">=1.3.0", "prop-types": "^15.8.1", "react": "^18.3.1", - "react-dom": "^18.3.1", - "monaco-yql-languages": ">=1.3.0" + "react-dom": "^18.3.1" }, "overrides": { "fork-ts-checker-webpack-plugin": "^9.0.2", diff --git a/src/components/DateRange/__test__/fromDateRangeValues.test.ts b/src/components/DateRange/__test__/fromDateRangeValues.test.ts index d200b6252..db77f9733 100644 --- a/src/components/DateRange/__test__/fromDateRangeValues.test.ts +++ b/src/components/DateRange/__test__/fromDateRangeValues.test.ts @@ -3,7 +3,7 @@ import {dateTimeParse} from '@gravity-ui/date-utils'; import {fromDateRangeValues} from '../utils'; describe('From daterange values to datepicker values', () => { - it('should return the correct datepicker values for to-absolute, from-absolute values', () => { + test('should return the correct datepicker values for to-absolute, from-absolute values', () => { const from = new Date('2020-01-01 19:00:00').getTime(); const to = new Date('2022-01-01 19:00:00').getTime(); @@ -26,7 +26,7 @@ describe('From daterange values to datepicker values', () => { }); }); - it('should return the correct datepicker values for to-relative, from-absolute values', () => { + test('should return the correct datepicker values for to-relative, from-absolute values', () => { const from = new Date('2020-01-01 19:00:00').getTime(); const to = 'now'; @@ -49,7 +49,7 @@ describe('From daterange values to datepicker values', () => { }); }); - it('should return the correct datepicker values for from-relative, to-absolute values', () => { + test('should return the correct datepicker values for from-relative, to-absolute values', () => { const from = 'now'; const to = new Date('2022-01-01 19:00:00').getTime(); @@ -72,7 +72,7 @@ describe('From daterange values to datepicker values', () => { }); }); - it('should return the correct datepicker values for from-relative, to-relative values', () => { + test('should return the correct datepicker values for from-relative, to-relative values', () => { const from = 'now'; const to = 'now + 1h'; diff --git a/src/components/DateRange/__test__/getdatePickerSize.test.ts b/src/components/DateRange/__test__/getdatePickerSize.test.ts index 07830bcfc..0931a9bd3 100644 --- a/src/components/DateRange/__test__/getdatePickerSize.test.ts +++ b/src/components/DateRange/__test__/getdatePickerSize.test.ts @@ -4,7 +4,7 @@ import {dateTimeParse} from '@gravity-ui/date-utils'; import {getdatePickerSize} from '../utils'; describe('getdatePickerSize test', () => { - it('should return the correct datepicker size', () => { + test('should return the correct datepicker size', () => { const datePickerRangeValues = { start: { type: 'relative', @@ -18,7 +18,7 @@ describe('getdatePickerSize test', () => { expect(getdatePickerSize(datePickerRangeValues)).toEqual('s'); }); - it('should return the correct datepicker size', () => { + test('should return the correct datepicker size', () => { const datePickerRangeValues = { start: { type: 'absolute', @@ -32,7 +32,7 @@ describe('getdatePickerSize test', () => { expect(getdatePickerSize(datePickerRangeValues)).toEqual('m'); }); - it('should return the correct datepicker size', () => { + test('should return the correct datepicker size', () => { const datePickerRangeValues = { start: { type: 'relative', @@ -46,7 +46,7 @@ describe('getdatePickerSize test', () => { expect(getdatePickerSize(datePickerRangeValues)).toEqual('m'); }); - it('should return the correct datepicker size', () => { + test('should return the correct datepicker size', () => { const datePickerRangeValues = { start: { type: 'absolute', diff --git a/src/components/DateRange/__test__/toDateRangeValues.test.ts b/src/components/DateRange/__test__/toDateRangeValues.test.ts index e733ddb56..45648df27 100644 --- a/src/components/DateRange/__test__/toDateRangeValues.test.ts +++ b/src/components/DateRange/__test__/toDateRangeValues.test.ts @@ -4,7 +4,7 @@ import {dateTimeParse} from '@gravity-ui/date-utils'; import {toDateRangeValues} from '../utils'; describe('To daterange values from datepicker values', () => { - it('should return the correct datepicker values for to-absolute, from-absolute values', () => { + test('should return the correct datepicker values for to-absolute, from-absolute values', () => { const from = new Date('2020-01-01 19:00:00').getTime(); const to = new Date('2022-01-01 19:00:00').getTime(); @@ -28,7 +28,7 @@ describe('To daterange values from datepicker values', () => { }); }); -it('should return the correct daterange values for to-relative, from-absolute values', () => { +test('should return the correct daterange values for to-relative, from-absolute values', () => { const from = new Date('2020-01-01 19:00:00').getTime(); const to = 'now'; const datePickerRangeValues = { @@ -50,7 +50,7 @@ it('should return the correct daterange values for to-relative, from-absolute va }); }); -it('should return the correct daterange values for from-relative, to-absolute values', () => { +test('should return the correct daterange values for from-relative, to-absolute values', () => { const from = 'now'; const to = new Date('2022-01-01 19:00:00').getTime(); const datePickerRangeValues = { @@ -72,7 +72,7 @@ it('should return the correct daterange values for from-relative, to-absolute va }); }); -it('should return the correct daterange values for from-relative, to-relative values', () => { +test('should return the correct daterange values for from-relative, to-relative values', () => { const from = 'now'; const to = 'now + 1'; const datePickerRangeValues = { diff --git a/src/components/NodeHostWrapper/NodeHostWrapper.tsx b/src/components/NodeHostWrapper/NodeHostWrapper.tsx index 2a9e18d0d..6ddf3ca37 100644 --- a/src/components/NodeHostWrapper/NodeHostWrapper.tsx +++ b/src/components/NodeHostWrapper/NodeHostWrapper.tsx @@ -1,7 +1,7 @@ import {PopoverBehavior} from '@gravity-ui/uikit'; import {getDefaultNodePath} from '../../containers/Node/NodePages'; -import type {NodeAddress} from '../../types/additionalProps'; +import type {GetNodeRefFunc, NodeAddress} from '../../types/additionalProps'; import type {TNodeInfo, TSystemStateInfo} from '../../types/api/nodes'; import { createDeveloperUIInternalPageHref, @@ -23,7 +23,7 @@ export type StatusForIcon = 'SystemState' | 'ConnectStatus'; interface NodeHostWrapperProps { node: NodeHostData; - getNodeRef?: (node?: NodeAddress) => string | null; + getNodeRef?: GetNodeRefFunc; database?: string; statusForIcon?: StatusForIcon; } diff --git a/src/components/PDiskInfo/PDiskInfo.tsx b/src/components/PDiskInfo/PDiskInfo.tsx index 5091594d5..676b1210c 100644 --- a/src/components/PDiskInfo/PDiskInfo.tsx +++ b/src/components/PDiskInfo/PDiskInfo.tsx @@ -1,14 +1,13 @@ import {Flex} from '@gravity-ui/uikit'; import {getPDiskPagePath} from '../../routes'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {valueIsDefined} from '../../utils'; import {formatBytes} from '../../utils/bytesParsers'; import {cn} from '../../utils/cn'; import {formatStorageValuesToGb} from '../../utils/dataFormatters/dataFormatters'; import {createPDiskDeveloperUILink} from '../../utils/developerUI/developerUI'; import type {PreparedPDisk} from '../../utils/disks/types'; -import {useTypedSelector} from '../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import type {InfoViewerItem} from '../InfoViewer'; import {InfoViewer} from '../InfoViewer/InfoViewer'; import {LinkWithIcon} from '../LinkWithIcon/LinkWithIcon'; @@ -189,7 +188,7 @@ export function PDiskInfo({ withPDiskPageLink, className, }: PDiskInfoProps) { - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const [generalInfo, statusInfo, spaceInfo, additionalInfo] = getPDiskInfo({ pDisk, diff --git a/src/components/PDiskPopup/PDiskPopup.tsx b/src/components/PDiskPopup/PDiskPopup.tsx index 502ca1aeb..886451879 100644 --- a/src/components/PDiskPopup/PDiskPopup.tsx +++ b/src/components/PDiskPopup/PDiskPopup.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {selectNodesMap} from '../../store/reducers/nodesList'; import {EFlag} from '../../types/api/enums'; import {valueIsDefined} from '../../utils'; @@ -8,6 +7,7 @@ import {EMPTY_DATA_PLACEHOLDER} from '../../utils/constants'; import {createPDiskDeveloperUILink} from '../../utils/developerUI/developerUI'; import type {PreparedPDisk} from '../../utils/disks/types'; import {useTypedSelector} from '../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import {bytesToGB, isNumeric} from '../../utils/utils'; import {InfoViewer} from '../InfoViewer'; import type {InfoViewerItem} from '../InfoViewer'; @@ -92,7 +92,7 @@ interface PDiskPopupProps { } export const PDiskPopup = ({data}: PDiskPopupProps) => { - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const nodesMap = useTypedSelector(selectNodesMap); const nodeData = valueIsDefined(data.NodeId) ? nodesMap?.get(data.NodeId) : undefined; const info = React.useMemo( diff --git a/src/components/QueryResultTable/QueryResultTable.scss b/src/components/QueryResultTable/QueryResultTable.scss index 89129744d..ee984ff46 100644 --- a/src/components/QueryResultTable/QueryResultTable.scss +++ b/src/components/QueryResultTable/QueryResultTable.scss @@ -9,4 +9,10 @@ &__message { padding: 15px 10px; } + + // Must have fixed height for frequent data updates (to avoid interface shaking). + // Will be fixed after migration to new @gravity-ui/table. + &__table-wrapper { + height: 0px; + } } diff --git a/src/components/QueryResultTable/QueryResultTable.tsx b/src/components/QueryResultTable/QueryResultTable.tsx index 8319e46cb..a7d7c1f7f 100644 --- a/src/components/QueryResultTable/QueryResultTable.tsx +++ b/src/components/QueryResultTable/QueryResultTable.tsx @@ -7,7 +7,7 @@ import type {ColumnType, KeyValueRow} from '../../types/api/query'; import {cn} from '../../utils/cn'; import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants'; import {getColumnWidth} from '../../utils/getColumnWidth'; -import {getColumnType, prepareQueryResponse} from '../../utils/query'; +import {getColumnType} from '../../utils/query'; import {isNumeric} from '../../utils/utils'; import type {ResizeableDataTableProps} from '../ResizeableDataTable/ResizeableDataTable'; import {ResizeableDataTable} from '../ResizeableDataTable/ResizeableDataTable'; @@ -49,8 +49,8 @@ const prepareTypedColumns = (columns: ColumnType[], data?: KeyValueRow[]) => { }); }; -const prepareGenericColumns = (data: KeyValueRow[]) => { - if (!data.length) { +const prepareGenericColumns = (data?: KeyValueRow[]) => { + if (!data?.length) { return []; } @@ -77,35 +77,42 @@ interface QueryResultTableProps extends Omit, 'data' | 'columns'> { data?: KeyValueRow[]; columns?: ColumnType[]; + settings?: Partial; } export const QueryResultTable = (props: QueryResultTableProps) => { - const {columns: rawColumns, data: rawData, ...restProps} = props; + const {columns, data, settings: propsSettings} = props; - const data = React.useMemo(() => prepareQueryResponse(rawData), [rawData]); - const columns = React.useMemo(() => { - return rawColumns ? prepareTypedColumns(rawColumns, data) : prepareGenericColumns(data); - }, [data, rawColumns]); + const preparedColumns = React.useMemo(() => { + return columns ? prepareTypedColumns(columns, data) : prepareGenericColumns(data); + }, [data, columns]); + + const settings = React.useMemo(() => { + return { + ...TABLE_SETTINGS, + ...propsSettings, + }; + }, [propsSettings]); // empty data is expected to be be an empty array // undefined data is not rendered at all - if (!Array.isArray(rawData)) { + if (!Array.isArray(data)) { return null; } - if (!columns.length) { + if (!preparedColumns.length) { return
{i18n('empty')}
; } return ( ); }; diff --git a/src/components/TabletNameWrapper/TabletNameWrapper.tsx b/src/components/TabletNameWrapper/TabletNameWrapper.tsx index d8809b119..f3bf697c3 100644 --- a/src/components/TabletNameWrapper/TabletNameWrapper.tsx +++ b/src/components/TabletNameWrapper/TabletNameWrapper.tsx @@ -1,9 +1,8 @@ import {DefinitionList, PopoverBehavior} from '@gravity-ui/uikit'; import {getTabletPagePath} from '../../routes'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {createTabletDeveloperUIHref} from '../../utils/developerUI/developerUI'; -import {useTypedSelector} from '../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import {CellWithPopover} from '../CellWithPopover/CellWithPopover'; import {EntityStatus} from '../EntityStatus/EntityStatus'; import {LinkWithIcon} from '../LinkWithIcon/LinkWithIcon'; @@ -16,7 +15,7 @@ interface TabletNameWrapperProps { } export function TabletNameWrapper({tabletId, database}: TabletNameWrapperProps) { - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const tabletPath = getTabletPagePath(tabletId, {database}); diff --git a/src/components/TenantNameWrapper/TenantNameWrapper.tsx b/src/components/TenantNameWrapper/TenantNameWrapper.tsx index e05f4236a..757bd1332 100644 --- a/src/components/TenantNameWrapper/TenantNameWrapper.tsx +++ b/src/components/TenantNameWrapper/TenantNameWrapper.tsx @@ -1,10 +1,9 @@ import {DefinitionList, PopoverBehavior} from '@gravity-ui/uikit'; import {getTenantPath} from '../../containers/Tenant/TenantPages'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import type {PreparedTenant} from '../../store/reducers/tenants/types'; import type {AdditionalTenantsProps, NodeAddress} from '../../types/additionalProps'; -import {useTypedSelector} from '../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import {CellWithPopover} from '../CellWithPopover/CellWithPopover'; import {EntityStatus} from '../EntityStatus/EntityStatus'; import {LinkWithIcon} from '../LinkWithIcon/LinkWithIcon'; @@ -34,7 +33,7 @@ const getTenantBackend = ( }; export function TenantNameWrapper({tenant, additionalTenantsProps}: TenantNameWrapperProps) { - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const backend = getTenantBackend(tenant, additionalTenantsProps); const isExternalLink = Boolean(backend); diff --git a/src/components/TooltipsContent/NodeEndpointsTooltipContent/NodeEndpointsTooltipContent.tsx b/src/components/TooltipsContent/NodeEndpointsTooltipContent/NodeEndpointsTooltipContent.tsx index bdf3ae935..8e824599e 100644 --- a/src/components/TooltipsContent/NodeEndpointsTooltipContent/NodeEndpointsTooltipContent.tsx +++ b/src/components/TooltipsContent/NodeEndpointsTooltipContent/NodeEndpointsTooltipContent.tsx @@ -1,10 +1,9 @@ import type {DefinitionListItemProps} from '@gravity-ui/uikit'; import {DefinitionList} from '@gravity-ui/uikit'; -import {selectIsUserAllowedToMakeChanges} from '../../../store/reducers/authentication/authentication'; import type {TSystemStateInfo} from '../../../types/api/nodes'; import {cn} from '../../../utils/cn'; -import {useTypedSelector} from '../../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../../utils/hooks/useIsUserAllowedToMakeChanges'; import {LinkWithIcon} from '../../LinkWithIcon/LinkWithIcon'; import i18n from './i18n'; @@ -19,7 +18,7 @@ interface NodeEdpointsTooltipProps { } export const NodeEndpointsTooltipContent = ({data, nodeHref}: NodeEdpointsTooltipProps) => { - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const info: (DefinitionListItemProps & {key: string})[] = []; if (data?.Roles?.length) { diff --git a/src/components/VDiskInfo/VDiskInfo.tsx b/src/components/VDiskInfo/VDiskInfo.tsx index d65063ac8..5f90a3469 100644 --- a/src/components/VDiskInfo/VDiskInfo.tsx +++ b/src/components/VDiskInfo/VDiskInfo.tsx @@ -1,14 +1,13 @@ import React from 'react'; import {getVDiskPagePath} from '../../routes'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {valueIsDefined} from '../../utils'; import {cn} from '../../utils/cn'; import {formatStorageValuesToGb} from '../../utils/dataFormatters/dataFormatters'; import {createVDiskDeveloperUILink} from '../../utils/developerUI/developerUI'; import {getSeverityColor} from '../../utils/disks/helpers'; import type {PreparedVDisk} from '../../utils/disks/types'; -import {useTypedSelector} from '../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import {bytesToSpeed} from '../../utils/utils'; import {InfoViewer} from '../InfoViewer'; import type {InfoViewerProps} from '../InfoViewer/InfoViewer'; @@ -35,7 +34,7 @@ export function VDiskInfo({ withTitle, ...infoViewerProps }: VDiskInfoProps) { - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const { AllocatedSize, diff --git a/src/components/VDiskPopup/VDiskPopup.tsx b/src/components/VDiskPopup/VDiskPopup.tsx index 4c685afc0..5f983a0a9 100644 --- a/src/components/VDiskPopup/VDiskPopup.tsx +++ b/src/components/VDiskPopup/VDiskPopup.tsx @@ -2,7 +2,6 @@ import React from 'react'; import {Label} from '@gravity-ui/uikit'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {selectNodesMap} from '../../store/reducers/nodesList'; import {EFlag} from '../../types/api/enums'; import {valueIsDefined} from '../../utils'; @@ -12,6 +11,7 @@ import {createVDiskDeveloperUILink} from '../../utils/developerUI/developerUI'; import {isFullVDiskData} from '../../utils/disks/helpers'; import type {PreparedVDisk, UnavailableDonor} from '../../utils/disks/types'; import {useTypedSelector} from '../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import {bytesToGB, bytesToSpeed} from '../../utils/utils'; import type {InfoViewerItem} from '../InfoViewer'; import {InfoViewer} from '../InfoViewer'; @@ -178,7 +178,7 @@ interface VDiskPopupProps { export const VDiskPopup = ({data}: VDiskPopupProps) => { const isFullData = isFullVDiskData(data); - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const vdiskInfo = React.useMemo( () => diff --git a/src/components/nodesColumns/__test__/utils.test.ts b/src/components/nodesColumns/__test__/utils.test.ts index 683184642..b60ee5bfe 100644 --- a/src/components/nodesColumns/__test__/utils.test.ts +++ b/src/components/nodesColumns/__test__/utils.test.ts @@ -2,7 +2,7 @@ import {UNBREAKABLE_GAP} from '../../../utils/utils'; import {prepareClockSkewValue, preparePingTimeValue} from '../utils'; describe('preparePingTimeValue', () => { - it('Should correctly prepare value', () => { + test('Should correctly prepare value', () => { expect(preparePingTimeValue(1)).toEqual(`0${UNBREAKABLE_GAP}ms`); expect(preparePingTimeValue(100)).toEqual(`0.1${UNBREAKABLE_GAP}ms`); expect(preparePingTimeValue(5_550)).toEqual(`6${UNBREAKABLE_GAP}ms`); @@ -11,18 +11,18 @@ describe('preparePingTimeValue', () => { }); describe('prepareClockSkewValue', () => { - it('Should correctly prepare 0 or very low values', () => { + test('Should correctly prepare 0 or very low values', () => { expect(prepareClockSkewValue(0)).toEqual(`0${UNBREAKABLE_GAP}ms`); expect(prepareClockSkewValue(10)).toEqual(`0${UNBREAKABLE_GAP}ms`); expect(prepareClockSkewValue(-10)).toEqual(`0${UNBREAKABLE_GAP}ms`); }); - it('Should correctly prepare positive values', () => { + test('Should correctly prepare positive values', () => { expect(prepareClockSkewValue(100)).toEqual(`+0.1${UNBREAKABLE_GAP}ms`); expect(prepareClockSkewValue(5_500)).toEqual(`+6${UNBREAKABLE_GAP}ms`); expect(prepareClockSkewValue(100_000)).toEqual(`+100${UNBREAKABLE_GAP}ms`); }); - it('Should correctly prepare negative values', () => { + test('Should correctly prepare negative values', () => { expect(prepareClockSkewValue(-100)).toEqual(`-0.1${UNBREAKABLE_GAP}ms`); expect(prepareClockSkewValue(-5_500)).toEqual(`-6${UNBREAKABLE_GAP}ms`); expect(prepareClockSkewValue(-100_000)).toEqual(`-100${UNBREAKABLE_GAP}ms`); diff --git a/src/components/nodesColumns/constants.ts b/src/components/nodesColumns/constants.ts index a84f367fa..3fdf6f7cc 100644 --- a/src/components/nodesColumns/constants.ts +++ b/src/components/nodesColumns/constants.ts @@ -35,6 +35,13 @@ export const NODES_COLUMNS_IDS = { export type NodesColumnId = ValueOf; +// Columns, that should displayed only for users with isMonitoringAllowed:true +const MONITORING_USER_COLUMNS_IDS: NodesColumnId[] = ['Pools', 'Memory']; + +export function isMonitoringUserNodesColumn(columnId: string): boolean { + return MONITORING_USER_COLUMNS_IDS.includes(columnId as NodesColumnId); +} + // This code is running when module is initialized and correct language may not be set yet // get functions guarantee that i18n fields will be inited on render with current render language export const NODES_COLUMNS_TITLES = { diff --git a/src/containers/App/Content.tsx b/src/containers/App/Content.tsx index e24803b45..82741845c 100644 --- a/src/containers/App/Content.tsx +++ b/src/containers/App/Content.tsx @@ -4,6 +4,7 @@ import {connect} from 'react-redux'; import type {RedirectProps} from 'react-router-dom'; import {Redirect, Route, Switch} from 'react-router-dom'; +import {AccessDenied} from '../../components/Errors/403'; import {PageError} from '../../components/Errors/PageError/PageError'; import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; import {useSlots} from '../../components/slots'; @@ -12,7 +13,11 @@ import type {SlotComponent} from '../../components/slots/types'; import routes from '../../routes'; import type {RootState} from '../../store'; import {authenticationApi} from '../../store/reducers/authentication/authentication'; -import {useCapabilitiesLoaded, useCapabilitiesQuery} from '../../store/reducers/capabilities/hooks'; +import { + useCapabilitiesLoaded, + useCapabilitiesQuery, + useClusterWithoutAuthInUI, +} from '../../store/reducers/capabilities/hooks'; import {nodesListApi} from '../../store/reducers/nodesList'; import {cn} from '../../utils/cn'; import {useDatabaseFromQuery} from '../../utils/hooks/useDatabaseFromQuery'; @@ -177,10 +182,14 @@ export function Content(props: ContentProps) { function DataWrapper({children}: {children: React.ReactNode}) { return ( - - - {children} - + // capabilities is going to work without authentication, but not all running systems are supporting this yet + + + + {/* this GetCapabilities will be removed */} + {children} + + ); } @@ -219,15 +228,25 @@ interface ContentWrapperProps { function ContentWrapper(props: ContentWrapperProps) { const {singleClusterMode, isAuthenticated} = props; + const authUnavailable = useClusterWithoutAuthInUI(); + + const renderNotAuthenticated = () => { + if (authUnavailable) { + return ; + } + return ; + }; return ( - - - + {!authUnavailable && ( + + + + )}
- {isAuthenticated ? props.children : } + {isAuthenticated ? props.children : renderNotAuthenticated()}
diff --git a/src/containers/AppWithClusters/ExtendedCluster/ExtendedCluster.tsx b/src/containers/AppWithClusters/ExtendedCluster/ExtendedCluster.tsx index 92c2fbdc8..e0d2969d1 100644 --- a/src/containers/AppWithClusters/ExtendedCluster/ExtendedCluster.tsx +++ b/src/containers/AppWithClusters/ExtendedCluster/ExtendedCluster.tsx @@ -11,11 +11,14 @@ import type {MetaClusterVersion} from '../../../types/api/meta'; import type {ETenantType} from '../../../types/api/tenant'; import {getVersionColors, getVersionMap} from '../../../utils/clusterVersionColors'; import {cn} from '../../../utils/cn'; +import {USE_CLUSTER_BALANCER_AS_BACKEND_KEY} from '../../../utils/constants'; +import {useSetting} from '../../../utils/hooks'; +import {useAdditionalNodesProps} from '../../../utils/hooks/useAdditionalNodesProps'; import type {GetMonitoringClusterLink, GetMonitoringLink} from '../../../utils/monitoring'; import {getCleanBalancerValue, removeViewerPathname} from '../../../utils/parseBalancer'; import {getBackendFromNodeHost, getBackendFromRawNodeData} from '../../../utils/prepareBackend'; import type {Cluster} from '../../Cluster/Cluster'; -import {useClusterData} from '../useClusterData'; +import {useClusterVersions} from '../useClusterData'; import './ExtendedCluster.scss'; @@ -123,10 +126,12 @@ export function ExtendedCluster({ getMonitoringLink, getMonitoringClusterLink, }: ExtendedClusterProps) { - const {versions, useClusterBalancerAsBackend, additionalNodesProps} = useClusterData(); - + const versions = useClusterVersions(); + const additionalNodesProps = useAdditionalNodesProps(); const {name, balancer, monitoring} = useClusterBaseInfo(); + const [useClusterBalancerAsBackend] = useSetting(USE_CLUSTER_BALANCER_AS_BACKEND_KEY); + return (
{ diff --git a/src/containers/AppWithClusters/useClusterData.ts b/src/containers/AppWithClusters/useClusterData.ts index e8ff56c1d..3d0368c95 100644 --- a/src/containers/AppWithClusters/useClusterData.ts +++ b/src/containers/AppWithClusters/useClusterData.ts @@ -3,34 +3,15 @@ import React from 'react'; import {StringParam, useQueryParam} from 'use-query-params'; import {clustersApi} from '../../store/reducers/clusters/clusters'; -import {getAdditionalNodesProps} from '../../utils/additionalProps'; -import {USE_CLUSTER_BALANCER_AS_BACKEND_KEY} from '../../utils/constants'; -import {useSetting} from '../../utils/hooks'; -export function useClusterData() { +export function useClusterVersions() { const [clusterName] = useQueryParam('clusterName', StringParam); const {data} = clustersApi.useGetClustersListQuery(undefined); - const info = React.useMemo(() => { + return React.useMemo(() => { const clusters = data || []; - return clusters.find((cluster) => cluster.name === clusterName); + const info = clusters.find((cluster) => cluster.name === clusterName); + return info?.versions; }, [data, clusterName]); - - const {solomon: monitoring, balancer, versions} = info || {}; - - return { - monitoring, - balancer, - versions, - ...useAdditionalNodeProps({balancer}), - }; -} - -export function useAdditionalNodeProps({balancer}: {balancer?: string}) { - const [useClusterBalancerAsBackend] = useSetting(USE_CLUSTER_BALANCER_AS_BACKEND_KEY); - - const additionalNodesProps = getAdditionalNodesProps(balancer, useClusterBalancerAsBackend); - - return {additionalNodesProps, useClusterBalancerAsBackend}; } diff --git a/src/containers/AsideNavigation/YdbInternalUser/YdbInternalUser.tsx b/src/containers/AsideNavigation/YdbInternalUser/YdbInternalUser.tsx index 6c7397d27..c0490b467 100644 --- a/src/containers/AsideNavigation/YdbInternalUser/YdbInternalUser.tsx +++ b/src/containers/AsideNavigation/YdbInternalUser/YdbInternalUser.tsx @@ -4,7 +4,9 @@ import {useHistory} from 'react-router-dom'; import routes, {createHref} from '../../../routes'; import {authenticationApi} from '../../../store/reducers/authentication/authentication'; +import {useClusterWithoutAuthInUI} from '../../../store/reducers/capabilities/hooks'; import {cn} from '../../../utils/cn'; +import {useDatabaseFromQuery} from '../../../utils/hooks/useDatabaseFromQuery'; import i18n from '../i18n'; import './YdbInternalUser.scss'; @@ -13,11 +15,16 @@ const b = cn('kv-ydb-internal-user'); export function YdbInternalUser({login}: {login?: string}) { const [logout] = authenticationApi.useLogoutMutation(); + const authUnavailable = useClusterWithoutAuthInUI(); + const database = useDatabaseFromQuery(); const history = useHistory(); const handleLoginClick = () => { history.push( - createHref(routes.auth, undefined, {returnUrl: encodeURIComponent(location.href)}), + createHref(routes.auth, undefined, { + returnUrl: encodeURIComponent(location.href), + database, + }), ); }; @@ -25,6 +32,17 @@ export function YdbInternalUser({login}: {login?: string}) { logout(undefined); }; + const renderLoginButton = () => { + if (authUnavailable) { + return null; + } + return ( + + ); + }; + return (
@@ -36,13 +54,7 @@ export function YdbInternalUser({login}: {login?: string}) { ) : ( - + renderLoginButton() )}
); diff --git a/src/containers/Authentication/Authentication.tsx b/src/containers/Authentication/Authentication.tsx index fbee833cf..5f87792b4 100644 --- a/src/containers/Authentication/Authentication.tsx +++ b/src/containers/Authentication/Authentication.tsx @@ -6,9 +6,10 @@ import {useHistory, useLocation} from 'react-router-dom'; import {parseQuery} from '../../routes'; import {authenticationApi} from '../../store/reducers/authentication/authentication'; +import {useLoginWithDatabase} from '../../store/reducers/capabilities/hooks'; import {cn} from '../../utils/cn'; -import {isPasswordError, isUserError} from './utils'; +import {isDatabaseError, isPasswordError, isUserError} from './utils'; import ydbLogoIcon from '../../assets/icons/ydb.svg'; @@ -24,20 +25,28 @@ function Authentication({closable = false}: AuthenticationProps) { const history = useHistory(); const location = useLocation(); + const needDatabase = useLoginWithDatabase(); + const [authenticate, {isLoading}] = authenticationApi.useAuthenticateMutation(undefined); - const {returnUrl} = parseQuery(location); + const {returnUrl, database: databaseFromQuery} = parseQuery(location); const [login, setLogin] = React.useState(''); + const [database, setDatabase] = React.useState(databaseFromQuery?.toString() ?? ''); const [password, setPass] = React.useState(''); const [loginError, setLoginError] = React.useState(''); const [passwordError, setPasswordError] = React.useState(''); + const [databaseError, setDatabaseError] = React.useState(''); const [showPassword, setShowPassword] = React.useState(false); const onLoginUpdate = (value: string) => { setLogin(value); setLoginError(''); }; + const onDatabaseUpdate = (value: string) => { + setDatabase(value); + setDatabaseError(''); + }; const onPassUpdate = (value: string) => { setPass(value); @@ -45,7 +54,7 @@ function Authentication({closable = false}: AuthenticationProps) { }; const onLoginClick = () => { - authenticate({user: login, password}) + authenticate({user: login, password, database}) .unwrap() .then(() => { if (returnUrl) { @@ -66,6 +75,9 @@ function Authentication({closable = false}: AuthenticationProps) { if (isPasswordError(error)) { setPasswordError(error.data.error); } + if (isDatabaseError(error)) { + setDatabaseError(error.data.error); + } }); }; @@ -125,6 +137,18 @@ function Authentication({closable = false}: AuthenticationProps) {
+ {needDatabase && ( +
+ +
+ )}
@@ -248,13 +292,17 @@ interface ResultProps { type?: EPathType; theme: string; result?: QueryResult; + cancelQueryResponse?: Pick; tenantName: string; path: string; showPreview?: boolean; queryText: string; + tableSettings?: Partial; + onCancelRunningQuery: VoidFunction; } function Result({ resultVisibilityState, + cancelQueryResponse, onExpandResultHandler, onCollapseResultHandler, type, @@ -264,6 +312,8 @@ function Result({ path, showPreview, queryText, + tableSettings, + onCancelRunningQuery, }: ResultProps) { if (showPreview) { return ; @@ -277,9 +327,13 @@ function Result({ theme={theme} tenantName={tenantName} isResultsCollapsed={resultVisibilityState.collapsed} + isCancelError={Boolean(cancelQueryResponse?.error)} + isCancelling={Boolean(cancelQueryResponse?.isLoading)} + tableSettings={tableSettings} onExpandResults={onExpandResultHandler} onCollapseResults={onCollapseResultHandler} queryText={queryText} + onCancelRunningQuery={onCancelRunningQuery} /> ); } diff --git a/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettings.test.ts b/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettings.test.ts index 086a7b1ba..41cb080c9 100644 --- a/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettings.test.ts +++ b/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettings.test.ts @@ -10,13 +10,13 @@ import { import getChangedQueryExecutionSettings from './getChangedQueryExecutionSettings'; describe('getChangedQueryExecutionSettings', () => { - it('should return an empty array if no settings have changed', () => { + test('should return an empty array if no settings have changed', () => { const currentSettings: QuerySettings = {...DEFAULT_QUERY_SETTINGS}; const result = getChangedQueryExecutionSettings(currentSettings, DEFAULT_QUERY_SETTINGS); expect(result).toEqual([]); }); - it('should return the keys of settings that have changed', () => { + test('should return the keys of settings that have changed', () => { const currentSettings: QuerySettings = { ...DEFAULT_QUERY_SETTINGS, queryMode: QUERY_MODES.data, @@ -27,7 +27,7 @@ describe('getChangedQueryExecutionSettings', () => { expect(result).toEqual(['queryMode', 'timeout']); }); - it('should return all keys if all settings have changed', () => { + test('should return all keys if all settings have changed', () => { const currentSettings: QuerySettings = { queryMode: QUERY_MODES.data, transactionMode: TRANSACTION_MODES.onlinero, diff --git a/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettingsDescription.test.ts b/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettingsDescription.test.ts index d8e23f126..eb8b6b2e6 100644 --- a/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettingsDescription.test.ts +++ b/src/containers/Tenant/Query/QueryEditorControls/utils/getChangedQueryExecutionSettingsDescription.test.ts @@ -15,7 +15,7 @@ import {QUERY_SETTINGS_FIELD_SETTINGS} from '../../QuerySettingsDialog/constants import getChangedQueryExecutionSettingsDescription from './getChangedQueryExecutionSettingsDescription'; describe('getChangedQueryExecutionSettingsDescription', () => { - it('should return an empty object if no settings changed', () => { + test('should return an empty object if no settings changed', () => { const currentSettings: QuerySettings = {...DEFAULT_QUERY_SETTINGS}; const result = getChangedQueryExecutionSettingsDescription({ @@ -26,7 +26,7 @@ describe('getChangedQueryExecutionSettingsDescription', () => { expect(result).toEqual({}); }); - it('should return the description for changed settings', () => { + test('should return the description for changed settings', () => { const currentSettings: QuerySettings = { ...DEFAULT_QUERY_SETTINGS, queryMode: QUERY_MODES.pg, @@ -49,7 +49,7 @@ describe('getChangedQueryExecutionSettingsDescription', () => { }); }); - it('should return the correct description for all changed settings', () => { + test('should return the correct description for all changed settings', () => { const currentSettings: QuerySettings = { queryMode: QUERY_MODES.data, transactionMode: TRANSACTION_MODES.snapshot, diff --git a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx index 65a586a9f..7543d6de1 100644 --- a/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/QueryResultViewer.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import type {Settings} from '@gravity-ui/react-data-table'; import type {ControlGroupOption} from '@gravity-ui/uikit'; import {ClipboardButton, RadioButton} from '@gravity-ui/uikit'; @@ -81,9 +82,14 @@ interface ExecuteResultProps { isResultsCollapsed?: boolean; theme?: string; tenantName: string; + queryText?: string; + tableSettings?: Partial; + + isCancelling: boolean; + isCancelError: boolean; + onCancelRunningQuery?: VoidFunction; onCollapseResults: VoidFunction; onExpandResults: VoidFunction; - queryText?: string; } export function QueryResultViewer({ @@ -93,6 +99,10 @@ export function QueryResultViewer({ theme, tenantName, queryText, + isCancelling, + isCancelError, + tableSettings, + onCancelRunningQuery, onCollapseResults, onExpandResults, }: ExecuteResultProps) { @@ -107,7 +117,7 @@ export function QueryResultViewer({ }); const [useShowPlanToSvg] = useSetting(USE_SHOW_PLAN_SVG_KEY); - const {error, isLoading, queryId, data = {}} = result; + const {error, isLoading, data = {}} = result; const {preparedPlan, simplifiedPlan, stats, resultSets, ast} = data; React.useEffect(() => { @@ -168,6 +178,10 @@ export function QueryResultViewer({ }; const renderClipboardButton = () => { + if (isLoading) { + return null; + } + const statsToCopy = getStatsToCopy(); const copyText = getStringifiedData(statsToCopy); if (!copyText) { @@ -213,19 +227,22 @@ export function QueryResultViewer({ }; const renderResultSection = () => { - if (error) { - return ; - } if (activeSection === RESULT_OPTIONS_IDS.result) { return ( ); } + if (error) { + return ; + } + if (activeSection === RESULT_OPTIONS_IDS.schema) { if (!preparedPlan?.nodes?.length) { return renderStubMessage(); @@ -284,7 +301,11 @@ export function QueryResultViewer({ {isLoading ? ( - + ) : null} {data?.traceId && isExecute ? : null} @@ -315,7 +336,7 @@ export function QueryResultViewer({ {renderRightControls()} {isLoading || isQueryCancelledError(error) ? null : } - + {renderResultSection()} diff --git a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx index ac3f1daa4..e949d4a2a 100644 --- a/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/QueryResultError/QueryResultError.tsx @@ -1,5 +1,6 @@ import {cn} from '../../../../../../utils/cn'; import {parseQueryError} from '../../../../../../utils/query'; +import {isNetworkError} from '../../../../../../utils/response'; import {ResultIssues} from '../../../Issues/Issues'; import {isQueryCancelledError} from '../../../utils/isQueryCancelledError'; @@ -10,12 +11,16 @@ const b = cn('ydb-query-result-error '); export function QueryResultError({error}: {error: unknown}) { const parsedError = parseQueryError(error); - // "Stopped" message is displayd in QueryExecutionStatus + // "Stopped" message is displayed in QueryExecutionStatus // There is no need to display "Query is cancelled" message too if (!parsedError || isQueryCancelledError(error)) { return null; } + if (isNetworkError(error)) { + return
{error.message}
; + } + if (typeof parsedError === 'object') { return ; } diff --git a/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx b/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx index 6bb77857a..0319340ec 100644 --- a/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx +++ b/src/containers/Tenant/Query/QueryResult/components/ResultSetsViewer/ResultSetsViewer.tsx @@ -1,3 +1,4 @@ +import type {Settings} from '@gravity-ui/react-data-table'; import {Tabs, Text} from '@gravity-ui/uikit'; import {QueryResultTable} from '../../../../../../components/QueryResultTable'; @@ -5,6 +6,7 @@ import type {ParsedResultSet} from '../../../../../../types/store/query'; import {getArray} from '../../../../../../utils'; import {cn} from '../../../../../../utils/cn'; import i18n from '../../i18n'; +import {QueryResultError} from '../QueryResultError/QueryResultError'; import './ResultSetsViewer.scss'; @@ -13,14 +15,14 @@ const b = cn('ydb-query-result-sets-viewer'); interface ResultSetsViewerProps { resultSets?: ParsedResultSet[]; selectedResultSet: number; + error?: unknown; + tableSettings?: Partial; setSelectedResultSet: (resultSet: number) => void; } -export function ResultSetsViewer({ - resultSets, - selectedResultSet, - setSelectedResultSet, -}: ResultSetsViewerProps) { +export function ResultSetsViewer(props: ResultSetsViewerProps) { + const {selectedResultSet, setSelectedResultSet, resultSets, error} = props; + const resultsSetsCount = resultSets?.length || 0; const currentResult = resultSets?.[selectedResultSet]; @@ -54,11 +56,13 @@ export function ResultSetsViewer({ {currentResult?.truncated ? i18n('title.truncated') : i18n('title.result')} {currentResult?.result ? ( - {`(${currentResult?.result.length})`} + + {`(${currentResult?.result.length}${ + currentResult.streamMetrics?.rowsPerSecond + ? `, ${currentResult.streamMetrics.rowsPerSecond.toFixed(0)} rows/s` + : '' + })`} + ) : null} ); @@ -67,10 +71,15 @@ export function ResultSetsViewer({ return (
{renderTabs()} + {props.error ? : null} {currentResult ? (
{renderResultHeadWithCount()} - +
) : null}
diff --git a/src/containers/Tenant/Query/utils/isQueryCancelledError.ts b/src/containers/Tenant/Query/utils/isQueryCancelledError.ts index 383d8575a..5a4afef5c 100644 --- a/src/containers/Tenant/Query/utils/isQueryCancelledError.ts +++ b/src/containers/Tenant/Query/utils/isQueryCancelledError.ts @@ -1,6 +1,30 @@ -import {parseQueryError} from '../../../../utils/query'; +import type {IResponseError} from '../../../../types/api/error'; +import {isQueryErrorResponse, parseQueryError} from '../../../../utils/query'; + +function isAbortError(error: unknown): error is {name: string} { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError' + ); +} + +function isResponseError(error: unknown): error is IResponseError { + return typeof error === 'object' && error !== null && 'isCancelled' in error; +} + +export function isQueryCancelledError(error: unknown): boolean { + if (isAbortError(error)) { + return true; + } + + if (isResponseError(error) && error.isCancelled) { + return true; + } -export function isQueryCancelledError(error: unknown) { const parsedError = parseQueryError(error); - return typeof parsedError === 'object' && parsedError.error?.message === 'Query was cancelled'; + return ( + isQueryErrorResponse(parsedError) && parsedError.error?.message === 'Query was cancelled' + ); } diff --git a/src/containers/Tenant/Schema/SchemaViewer/__tests__/prepareData.test.ts b/src/containers/Tenant/Schema/SchemaViewer/__tests__/prepareData.test.ts index 01ecf1826..2ee1d511a 100644 --- a/src/containers/Tenant/Schema/SchemaViewer/__tests__/prepareData.test.ts +++ b/src/containers/Tenant/Schema/SchemaViewer/__tests__/prepareData.test.ts @@ -3,7 +3,7 @@ import {EPathType} from '../../../../../types/api/schema'; import {prepareSchemaData, prepareViewSchema} from '../prepareData'; describe('prepareSchemaData', () => { - it('correctly parses row table data', () => { + test('correctly parses row table data', () => { const data: TEvDescribeSchemeResult = { PathDescription: { Table: { @@ -131,7 +131,7 @@ describe('prepareSchemaData', () => { expect(prepareSchemaData(EPathType.EPathTypeTable, data)).toEqual(result); }); - it('correctly parses column table data', () => { + test('correctly parses column table data', () => { const data: TEvDescribeSchemeResult = { PathDescription: { ColumnTableDescription: { @@ -241,7 +241,7 @@ describe('prepareSchemaData', () => { ]; expect(prepareSchemaData(EPathType.EPathTypeColumnTable, data)).toEqual(result); }); - it('returns empty array if data is undefined, empty or null', () => { + test('returns empty array if data is undefined, empty or null', () => { expect(prepareSchemaData(EPathType.EPathTypeTable, {})).toEqual([]); expect(prepareSchemaData(EPathType.EPathTypeTable, undefined)).toEqual([]); expect(prepareSchemaData(EPathType.EPathTypeTable, null)).toEqual([]); @@ -249,7 +249,7 @@ describe('prepareSchemaData', () => { }); describe('prepareViewSchema', () => { - it('correctly parses data', () => { + test('correctly parses data', () => { const data = [ {name: 'cost', type: 'Int32'}, {name: 'id', type: 'Int32'}, @@ -265,7 +265,7 @@ describe('prepareViewSchema', () => { expect(prepareViewSchema(data)).toEqual(result); }); - it('returns empty array if data is undefined or empty', () => { + test('returns empty array if data is undefined or empty', () => { expect(prepareViewSchema()).toEqual([]); expect(prepareViewSchema([])).toEqual([]); }); diff --git a/src/containers/Tenant/Schema/SchemaViewer/__tests__/utils.test.ts b/src/containers/Tenant/Schema/SchemaViewer/__tests__/utils.test.ts index 74c6b6190..470317119 100644 --- a/src/containers/Tenant/Schema/SchemaViewer/__tests__/utils.test.ts +++ b/src/containers/Tenant/Schema/SchemaViewer/__tests__/utils.test.ts @@ -1,7 +1,7 @@ import {getPartitioningKeys, getPrimaryKeys} from '../utils'; describe('getPartitioningKeys', () => { - it('returns column in the provided order', () => { + test('returns column in the provided order', () => { const data1 = [ { id: 1, @@ -62,7 +62,7 @@ describe('getPartitioningKeys', () => { }); describe('getPrimaryKeys', () => { - it('returns column in the provided order', () => { + test('returns column in the provided order', () => { const data = [ { id: 1, diff --git a/src/containers/UserSettings/i18n/en.json b/src/containers/UserSettings/i18n/en.json index c4cd42f89..cfc868b31 100644 --- a/src/containers/UserSettings/i18n/en.json +++ b/src/containers/UserSettings/i18n/en.json @@ -17,6 +17,9 @@ "settings.editor.codeAssistant.title": "Code Assistant", "settings.editor.codeAssistant.description": "Use Code Assistant for autocomplete.", + "settings.editor.queryStreaming.title": "Query Streaming", + "settings.editor.queryStreaming.description": "Use streaming api for query results.", + "settings.editor.autocomplete-on-enter.title": "Accept suggestion on Enter", "settings.editor.autocomplete-on-enter.description": "Controls whether suggestions should be accepted on Enter, in addition to Tab. Helps to avoid ambiguity between inserting new lines or accepting suggestions.", diff --git a/src/containers/UserSettings/settings.tsx b/src/containers/UserSettings/settings.tsx index 0dd0344e4..5a8692afe 100644 --- a/src/containers/UserSettings/settings.tsx +++ b/src/containers/UserSettings/settings.tsx @@ -8,6 +8,7 @@ import { ENABLE_AUTOCOMPLETE, ENABLE_CODE_ASSISTANT, ENABLE_NETWORK_TABLE_KEY, + ENABLE_QUERY_STREAMING, INVERTED_DISKS_KEY, LANGUAGE_KEY, SHOW_DOMAIN_DATABASE_KEY, @@ -127,6 +128,12 @@ export const enableCodeAssistantSetting: SettingProps = { description: i18n('settings.editor.codeAssistant.description'), }; +export const enableQueryStreamingSetting: SettingProps = { + settingKey: ENABLE_QUERY_STREAMING, + title: i18n('settings.editor.queryStreaming.title'), + description: i18n('settings.editor.queryStreaming.description'), +}; + export const autocompleteOnEnterSetting: SettingProps = { settingKey: AUTOCOMPLETE_ON_ENTER, title: i18n('settings.editor.autocomplete-on-enter.title'), @@ -153,7 +160,7 @@ export const appearanceSection: SettingsSection = { export const experimentsSection: SettingsSection = { id: 'experimentsSection', title: i18n('section.experiments'), - settings: [enableNetworkTable, useShowPlanToSvgTables], + settings: [enableNetworkTable, useShowPlanToSvgTables, enableQueryStreamingSetting], }; export const devSettingsSection: SettingsSection = { diff --git a/src/containers/VDiskPage/VDiskPage.tsx b/src/containers/VDiskPage/VDiskPage.tsx index 7c0542aca..922e11be0 100644 --- a/src/containers/VDiskPage/VDiskPage.tsx +++ b/src/containers/VDiskPage/VDiskPage.tsx @@ -13,7 +13,6 @@ import {InfoViewerSkeleton} from '../../components/InfoViewerSkeleton/InfoViewer import {PageMetaWithAutorefresh} from '../../components/PageMeta/PageMeta'; import {VDiskInfo} from '../../components/VDiskInfo/VDiskInfo'; import {api} from '../../store/reducers/api'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; import {useDiskPagesAvailable} from '../../store/reducers/capabilities/hooks'; import {setHeaderBreadcrumbs} from '../../store/reducers/header/header'; import {vDiskApi} from '../../store/reducers/vdisk/vdisk'; @@ -21,7 +20,8 @@ import type {ModifyDiskResponse} from '../../types/api/modifyDisk'; import {valueIsDefined} from '../../utils'; import {cn} from '../../utils/cn'; import {getSeverityColor, getVDiskSlotBasedId} from '../../utils/disks/helpers'; -import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks'; +import {useAutoRefreshInterval, useTypedDispatch} from '../../utils/hooks'; +import {useIsUserAllowedToMakeChanges} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; import {PaginatedStorage} from '../Storage/PaginatedStorage'; import {vDiskPageKeyset} from './i18n'; @@ -34,7 +34,7 @@ export function VDiskPage() { const dispatch = useTypedDispatch(); const containerRef = React.useRef(null); - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); const newDiskApiAvailable = useDiskPagesAvailable(); const [{nodeId, pDiskId, vDiskSlotId}] = useQueryParams({ diff --git a/src/containers/Versions/NodesTable/NodesTable.tsx b/src/containers/Versions/NodesTable/NodesTable.tsx index f614cb840..572f4e183 100644 --- a/src/containers/Versions/NodesTable/NodesTable.tsx +++ b/src/containers/Versions/NodesTable/NodesTable.tsx @@ -10,10 +10,9 @@ import { getUptimeColumn, } from '../../../components/nodesColumns/columns'; import type {GetNodesColumnsParams} from '../../../components/nodesColumns/types'; -import {useClusterBaseInfo} from '../../../store/reducers/cluster/cluster'; import type {NodesPreparedEntity} from '../../../store/reducers/nodes/types'; import {DEFAULT_TABLE_SETTINGS} from '../../../utils/constants'; -import {useAdditionalNodeProps} from '../../AppWithClusters/useClusterData'; +import {useAdditionalNodesProps} from '../../../utils/hooks/useAdditionalNodesProps'; const VERSIONS_COLUMNS_WIDTH_LS_KEY = 'versionsTableColumnsWidth'; @@ -33,10 +32,9 @@ interface NodesTableProps { } export const NodesTable = ({nodes}: NodesTableProps) => { - const {balancer} = useClusterBaseInfo(); - const {additionalNodesProps} = useAdditionalNodeProps({balancer}); + const additionalNodesProps = useAdditionalNodesProps(); - const columns = getColumns({getNodeRef: additionalNodesProps.getNodeRef}); + const columns = getColumns({getNodeRef: additionalNodesProps?.getNodeRef}); return ( void; + onQueryResponseChunk: (chunk: QueryResponseChunk) => void; + onSessionChunk: (chunk: SessionChunk) => void; +} + +export class StreamingAPI extends BaseYdbAPI { + async streamQuery( + params: StreamQueryParams, + options: StreamQueryOptions, + ) { + const base64 = !settingsManager.readUserSettingsValue( + BINARY_DATA_IN_PLAIN_TEXT_DISPLAY, + true, + ); + + const queryParams = qs.stringify( + {timeout: params.timeout, base64, schema: 'multipart'}, + {encoder: encodeURIComponent}, + ); + + const body = {...params, base64, schema: 'multipart'}; + const headers = new Headers({ + Accept: 'multipart/form-data', + 'Content-Type': 'application/json', + }); + + if (params.tracingLevel) { + headers.set('X-Trace-Verbosity', String(params.tracingLevel)); + } + + const enableTracing = settingsManager.readUserSettingsValue( + DEV_ENABLE_TRACING_FOR_ALL_REQUESTS, + ); + + if (enableTracing) { + headers.set('X-Want-Trace', '1'); + } + + const response = await fetch(`${this.getPath('/viewer/query')}?${queryParams}`, { + method: 'POST', + signal: options.signal, + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const responseData = await response.json().catch(() => ({})); + if (isRedirectToAuth({status: response.status, data: responseData})) { + window.location.assign(responseData.authUrl); + return; + } + throw new Error(`${response.status}`); + } + + if (!response.body) { + throw new Error('Empty response body'); + } + + const traceId = response.headers.get('traceresponse')?.split('-')[1]; + + await parseMultipart(response.body, {boundary: BOUNDARY}, async (part) => { + try { + const chunk = JSON.parse(await part.text()); + + if (isSessionChunk(chunk)) { + const sessionChunk = chunk; + sessionChunk.meta.trace_id = traceId; + options.onSessionChunk(chunk); + } else if (isStreamDataChunk(chunk)) { + options.onStreamDataChunk(chunk); + } else if (isQueryResponseChunk(chunk)) { + options.onQueryResponseChunk(chunk); + } + } catch (e) { + throw new Error(`Error parsing chunk: ${e}`); + } + }); + } +} diff --git a/src/services/api/viewer.ts b/src/services/api/viewer.ts index 6ae84e681..6c6342fbc 100644 --- a/src/services/api/viewer.ts +++ b/src/services/api/viewer.ts @@ -15,9 +15,7 @@ import type { Actions, ErrorResponse, QueryAPIResponse, - Stats, - Timeout, - TracingLevel, + SendQueryParams, } from '../../types/api/query'; import type {JsonRenderRequestParams, JsonRenderResponse} from '../../types/api/render'; import type {TEvDescribeSchemeResult} from '../../types/api/schema'; @@ -32,7 +30,6 @@ import type {TTenantInfo, TTenants} from '../../types/api/tenant'; import type {DescribeTopicResult} from '../../types/api/topic'; import type {TEvVDiskStateResponse} from '../../types/api/vdisk'; import type {TUserToken} from '../../types/api/whoami'; -import type {QuerySyntax, TransactionMode} from '../../types/store/query'; import {BINARY_DATA_IN_PLAIN_TEXT_DISPLAY} from '../../utils/constants'; import type {Nullable} from '../../utils/typecheckers'; import {settingsManager} from '../settings'; @@ -329,18 +326,7 @@ export class ViewerAPI extends BaseYdbAPI { } sendQuery( - params: { - query?: string; - database?: string; - action?: Action; - syntax?: QuerySyntax; - stats?: Stats; - tracingLevel?: TracingLevel; - transaction_mode?: TransactionMode; - timeout?: Timeout; - query_id?: string; - limit_rows?: number; - }, + params: SendQueryParams, {concurrentId, signal, withRetries}: AxiosOptions = {}, ) { const base64 = !settingsManager.readUserSettingsValue( diff --git a/src/services/settings.ts b/src/services/settings.ts index 6e34f0269..a33095d7f 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -8,6 +8,7 @@ import { ENABLE_AUTOCOMPLETE, ENABLE_CODE_ASSISTANT, ENABLE_NETWORK_TABLE_KEY, + ENABLE_QUERY_STREAMING, INVERTED_DISKS_KEY, IS_HOTKEYS_HELP_HIDDEN_KEY, LANGUAGE_KEY, @@ -44,6 +45,7 @@ export const DEFAULT_USER_SETTINGS = { [USE_CLUSTER_BALANCER_AS_BACKEND_KEY]: true, [ENABLE_AUTOCOMPLETE]: true, [ENABLE_CODE_ASSISTANT]: true, + [ENABLE_QUERY_STREAMING]: false, [AUTOCOMPLETE_ON_ENTER]: true, [IS_HOTKEYS_HELP_HIDDEN_KEY]: false, [AUTO_REFRESH_INTERVAL]: 0, diff --git a/src/setupTests.js b/src/setupTests.js index c894baa7a..4abca6a23 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,4 +1,4 @@ -/* eslint-disable import/order */ +/* eslint-disable import/order, no-undef */ // jest-dom adds custom jest matchers for asserting on DOM nodes. // allows you to do things like: // expect(element).toHaveTextContent(/react/i) @@ -14,3 +14,8 @@ configureUiKit({lang: Lang.En}); // only to prevent warnings from history lib // all api calls in tests should be mocked window.custom_backend = '/'; + +// Mock multipart-parser globally for all tests +jest.mock('@mjackson/multipart-parser', () => ({ + parseMultipart: jest.fn(), +})); diff --git a/src/store/reducers/authentication/authentication.ts b/src/store/reducers/authentication/authentication.ts index ef5309517..460ebef8a 100644 --- a/src/store/reducers/authentication/authentication.ts +++ b/src/store/reducers/authentication/authentication.ts @@ -65,7 +65,10 @@ export const authenticationApi = api.injectEndpoints({ providesTags: ['UserData'], }), authenticate: build.mutation({ - queryFn: async (params: {user: string; password: string}, {dispatch}) => { + queryFn: async ( + params: {user: string; password: string; database?: string}, + {dispatch}, + ) => { try { const data = await window.api.auth.authenticate(params); dispatch(setIsAuthenticated(true)); diff --git a/src/store/reducers/capabilities/capabilities.ts b/src/store/reducers/capabilities/capabilities.ts index 5ff82b977..fdd63a77a 100644 --- a/src/store/reducers/capabilities/capabilities.ts +++ b/src/store/reducers/capabilities/capabilities.ts @@ -1,6 +1,6 @@ import {createSelector} from '@reduxjs/toolkit'; -import type {Capability} from '../../../types/api/capabilities'; +import type {Capability, SecuritySetting} from '../../../types/api/capabilities'; import type {AppDispatch, RootState} from '../../defaultStore'; import {api} from './../api'; @@ -38,8 +38,16 @@ export const selectCapabilityVersion = createSelector( (state: RootState) => state, (_state: RootState, capability: Capability) => capability, (_state: RootState, _capability: Capability, database?: string) => database, - (state, capability, database) => - selectDatabaseCapabilities(state, database).data?.Capabilities?.[capability], + (state, capability, database) => { + return selectDatabaseCapabilities(state, database).data?.Capabilities?.[capability]; + }, +); +export const selectSecuritySetting = createSelector( + (state: RootState) => state, + (_state: RootState, setting: SecuritySetting) => setting, + (_state: RootState, _setting: SecuritySetting, database?: string) => database, + (state, setting, database) => + selectDatabaseCapabilities(state, database).data?.Settings?.Security?.[setting], ); export async function queryCapability( @@ -50,5 +58,5 @@ export async function queryCapability( const thunk = capabilitiesApi.util.getRunningQueryThunk('getClusterCapabilities', {database}); await dispatch(thunk); - return selectCapabilityVersion(getState(), capability) || 0; + return selectCapabilityVersion(getState(), capability, database) || 0; } diff --git a/src/store/reducers/capabilities/hooks.ts b/src/store/reducers/capabilities/hooks.ts index a0222071b..80609f301 100644 --- a/src/store/reducers/capabilities/hooks.ts +++ b/src/store/reducers/capabilities/hooks.ts @@ -1,8 +1,13 @@ -import type {Capability} from '../../../types/api/capabilities'; +import type {Capability, SecuritySetting} from '../../../types/api/capabilities'; import {useTypedSelector} from '../../../utils/hooks'; import {useDatabaseFromQuery} from '../../../utils/hooks/useDatabaseFromQuery'; -import {capabilitiesApi, selectCapabilityVersion, selectDatabaseCapabilities} from './capabilities'; +import { + capabilitiesApi, + selectCapabilityVersion, + selectDatabaseCapabilities, + selectSecuritySetting, +} from './capabilities'; export function useCapabilitiesQuery() { const database = useDatabaseFromQuery(); @@ -64,3 +69,21 @@ export const useFeatureFlagsAvailable = () => { export const useClusterDashboardAvailable = () => { return useGetFeatureVersion('/viewer/cluster') > 4; }; + +export const useStreamingAvailable = () => { + return useGetFeatureVersion('/viewer/query') >= 7; +}; + +const useGetSecuritySetting = (feature: SecuritySetting) => { + const database = useDatabaseFromQuery(); + + return useTypedSelector((state) => selectSecuritySetting(state, feature, database)); +}; + +export const useClusterWithoutAuthInUI = () => { + return useGetSecuritySetting('UseLoginProvider') === false; +}; + +export const useLoginWithDatabase = () => { + return useGetSecuritySetting('DomainLoginOnly') === false; +}; diff --git a/src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts b/src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts index da0fe2ffb..1e8dc6ea2 100644 --- a/src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts +++ b/src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts @@ -136,7 +136,7 @@ describe('parseGroupsStatsQueryResponse', () => { }, }, }; - it('should correctly parse data', () => { + test('should correctly parse data', () => { expect(parseGroupsStatsQueryResponse(dataSet1)).toEqual(parsedDataSet1); expect(parseGroupsStatsQueryResponse(dataSet2)).toEqual(parsedDataSet2); }); diff --git a/src/store/reducers/pdisk/__tests__/preparePDiskDataResponse.test.ts b/src/store/reducers/pdisk/__tests__/preparePDiskDataResponse.test.ts index 00d7335e3..84f005e34 100644 --- a/src/store/reducers/pdisk/__tests__/preparePDiskDataResponse.test.ts +++ b/src/store/reducers/pdisk/__tests__/preparePDiskDataResponse.test.ts @@ -107,7 +107,7 @@ describe('preparePDiskDataResponse', () => { }, } as unknown as TPDiskInfoResponse; - it('Should correctly retrieve slots', () => { + test('Should correctly retrieve slots', () => { const preparedData = preparePDiskDataResponse([rawData, {}]); expect(preparedData.SlotItems?.length).toEqual(17); @@ -119,14 +119,14 @@ describe('preparePDiskDataResponse', () => { 15, ); }); - it('Should correctly calculate empty slots size if EnforcedDynamicSlotSize is provided', () => { + test('Should correctly calculate empty slots size if EnforcedDynamicSlotSize is provided', () => { const preparedData = preparePDiskDataResponse([rawData, {}]); expect(preparedData.SlotItems?.find((slot) => slot.SlotType === 'empty')?.Total).toEqual( 20_000_000_000, ); }); - it('Should correctly calculate empty slots size if EnforcedDynamicSlotSize is undefined', () => { + test('Should correctly calculate empty slots size if EnforcedDynamicSlotSize is undefined', () => { const data: TPDiskInfoResponse = { ...rawData, Whiteboard: { @@ -150,7 +150,7 @@ describe('preparePDiskDataResponse', () => { 1_000_000_000, ); }); - it('Should return yellow or red severity for log if its size exceeds thresholds', () => { + test('Should return yellow or red severity for log if its size exceeds thresholds', () => { const dataWarning: TPDiskInfoResponse = { ...rawData, Whiteboard: { @@ -185,7 +185,7 @@ describe('preparePDiskDataResponse', () => { preparedDataDanger.SlotItems?.find((slot) => slot.SlotType === 'log')?.Severity, ).toEqual(5); }); - it('Should return yellow or red severity for vdisk if its size exceeds thresholds', () => { + test('Should return yellow or red severity for vdisk if its size exceeds thresholds', () => { const dataWarning: TPDiskInfoResponse = { ...rawData, Whiteboard: { diff --git a/src/store/reducers/query/__test__/utils.test.ts b/src/store/reducers/query/__test__/utils.test.ts index d215dec12..5cd6b46f0 100644 --- a/src/store/reducers/query/__test__/utils.test.ts +++ b/src/store/reducers/query/__test__/utils.test.ts @@ -1,22 +1,22 @@ import {getActionAndSyntaxFromQueryMode} from '../utils'; describe('getActionAndSyntaxFromQueryMode', () => { - it('Correctly prepares execute action', () => { + test('Correctly prepares execute action', () => { const {action, syntax} = getActionAndSyntaxFromQueryMode('execute', 'script'); expect(action).toBe('execute-script'); expect(syntax).toBe('yql_v1'); }); - it('Correctly prepares execute action with pg syntax', () => { + test('Correctly prepares execute action with pg syntax', () => { const {action, syntax} = getActionAndSyntaxFromQueryMode('execute', 'pg'); expect(action).toBe('execute-query'); expect(syntax).toBe('pg'); }); - it('Correctly prepares explain action', () => { + test('Correctly prepares explain action', () => { const {action, syntax} = getActionAndSyntaxFromQueryMode('explain', 'script'); expect(action).toBe('explain-script'); expect(syntax).toBe('yql_v1'); }); - it('Correctly prepares explain action with pg syntax', () => { + test('Correctly prepares explain action with pg syntax', () => { const {action, syntax} = getActionAndSyntaxFromQueryMode('explain', 'pg'); expect(action).toBe('explain-query'); expect(syntax).toBe('pg'); diff --git a/src/store/reducers/query/preparePlanData.ts b/src/store/reducers/query/preparePlanData.ts new file mode 100644 index 000000000..6a6377713 --- /dev/null +++ b/src/store/reducers/query/preparePlanData.ts @@ -0,0 +1,74 @@ +import type {ExplainPlanNodeData, GraphNode, Link} from '@gravity-ui/paranoid'; + +import type {QueryPlan, ScriptPlan, TKqpStatsQuery} from '../../../types/api/query'; +import {preparePlan, prepareSimplifiedPlan} from '../../../utils/prepareQueryExplain'; +import {parseQueryExplainPlan} from '../../../utils/query'; + +import type {PreparedQueryData} from './types'; + +const explainVersions = { + v2: '0.2', +}; + +const supportedExplainQueryVersions = Object.values(explainVersions); + +export function preparePlanData( + rawPlan?: QueryPlan | ScriptPlan, + stats?: TKqpStatsQuery, +): PreparedQueryData['preparedPlan'] & { + simplifiedPlan?: PreparedQueryData['simplifiedPlan']; +} { + // Handle plan from explain + if (rawPlan) { + const {tables, meta, Plan, SimplifiedPlan} = parseQueryExplainPlan(rawPlan); + + if (supportedExplainQueryVersions.indexOf(meta.version) === -1) { + // Do not prepare plan for not supported versions + return { + pristine: rawPlan, + version: meta.version, + }; + } + + let links: Link[] = []; + let nodes: GraphNode[] = []; + + if (Plan) { + const preparedPlan = preparePlan(Plan); + links = preparedPlan.links; + nodes = preparedPlan.nodes; + } + + let preparedSimplifiedPlan; + if (SimplifiedPlan) { + preparedSimplifiedPlan = prepareSimplifiedPlan([SimplifiedPlan]); + } + + return { + links, + nodes, + tables, + version: meta.version, + pristine: rawPlan, + simplifiedPlan: SimplifiedPlan + ? { + plan: preparedSimplifiedPlan, + pristine: SimplifiedPlan, + } + : undefined, + }; + } + + // Handle plan from stats + const planFromStats = stats?.Executions?.[0]?.TxPlansWithStats?.[0]; + if (planFromStats) { + try { + const planWithStats = JSON.parse(planFromStats); + return {...preparePlan(planWithStats), pristine: planWithStats}; + } catch { + return {}; + } + } + + return {}; +} diff --git a/src/store/reducers/query/prepareQueryData.ts b/src/store/reducers/query/prepareQueryData.ts index cf8232a04..3c7d6ec39 100644 --- a/src/store/reducers/query/prepareQueryData.ts +++ b/src/store/reducers/query/prepareQueryData.ts @@ -1,73 +1,19 @@ -import type {ExplainPlanNodeData, GraphNode, Link} from '@gravity-ui/paranoid'; - import type {ExecuteResponse, ExplainResponse} from '../../../types/api/query'; -import {preparePlan, prepareSimplifiedPlan} from '../../../utils/prepareQueryExplain'; -import {parseQueryAPIResponse, parseQueryExplainPlan} from '../../../utils/query'; +import {parseQueryAPIResponse} from '../../../utils/query'; +import {preparePlanData} from './preparePlanData'; import type {PreparedQueryData} from './types'; -const explainVersions = { - v2: '0.2', -}; - -const supportedExplainQueryVersions = Object.values(explainVersions); - export function prepareQueryData( response: ExplainResponse | ExecuteResponse | null, ): PreparedQueryData { const result = parseQueryAPIResponse(response); const {plan: rawPlan, stats} = result; - if (rawPlan) { - const {tables, meta, Plan, SimplifiedPlan} = parseQueryExplainPlan(rawPlan); - - if (supportedExplainQueryVersions.indexOf(meta.version) === -1) { - // Do not prepare plan for not supported versions - return { - ...result, - preparedPlan: { - pristine: rawPlan, - version: meta.version, - }, - }; - } - - let links: Link[] = []; - let nodes: GraphNode[] = []; - - if (Plan) { - const preparedPlan = preparePlan(Plan); - links = preparedPlan.links; - nodes = preparedPlan.nodes; - } - let preparedSimplifiedPlan; - if (SimplifiedPlan) { - preparedSimplifiedPlan = prepareSimplifiedPlan([SimplifiedPlan]); - } - - return { - ...result, - preparedPlan: { - links, - nodes, - tables, - version: meta.version, - pristine: rawPlan, - }, - simplifiedPlan: {plan: preparedSimplifiedPlan, pristine: SimplifiedPlan}, - }; - } - - const planFromStats = stats?.Executions?.[0]?.TxPlansWithStats?.[0]; - if (planFromStats) { - try { - const planWithStats = JSON.parse(planFromStats); - return { - ...result, - preparedPlan: {...preparePlan(planWithStats), pristine: planWithStats}, - }; - } catch {} - } - - return result; + const {simplifiedPlan, ...planData} = preparePlanData(rawPlan, stats); + return { + ...result, + preparedPlan: Object.keys(planData).length > 0 ? planData : undefined, + simplifiedPlan, + }; } diff --git a/src/store/reducers/query/query.ts b/src/store/reducers/query/query.ts index 6291d6c2c..1b5e6d3e5 100644 --- a/src/store/reducers/query/query.ts +++ b/src/store/reducers/query/query.ts @@ -4,12 +4,19 @@ import type {PayloadAction} from '@reduxjs/toolkit'; import {settingsManager} from '../../../services/settings'; import {TracingLevelNumber} from '../../../types/api/query'; import type {QueryAction, QueryRequestParams, QuerySettings} from '../../../types/store/query'; +import type {StreamDataChunk} from '../../../types/store/streaming'; import {QUERIES_HISTORY_KEY} from '../../../utils/constants'; import {isQueryErrorResponse} from '../../../utils/query'; import {isNumeric} from '../../../utils/utils'; +import type {RootState} from '../../defaultStore'; import {api} from '../api'; import {prepareQueryData} from './prepareQueryData'; +import { + addStreamingChunks as addStreamingChunksReducer, + setStreamQueryResponse as setStreamQueryResponseReducer, + setStreamSession as setStreamSessionReducer, +} from './streamingReducers'; import type {QueryResult, QueryState} from './types'; import {getActionAndSyntaxFromQueryMode, getQueryInHistory} from './utils'; @@ -117,6 +124,9 @@ const slice = createSlice({ setQueryHistoryFilter: (state, action: PayloadAction) => { state.history.filter = action.payload; }, + setStreamSession: setStreamSessionReducer, + addStreamingChunks: addStreamingChunksReducer, + setStreamQueryResponse: setStreamQueryResponseReducer, }, selectors: { selectQueriesHistoryFilter: (state) => state.history.filter || '', @@ -145,7 +155,11 @@ export const { goToNextQuery, setTenantPath, setQueryHistoryFilter, + addStreamingChunks, + setStreamQueryResponse, + setStreamSession, } = slice.actions; + export const { selectQueriesHistoryFilter, selectQueriesHistoryCurrentIndex, @@ -169,8 +183,98 @@ interface QueryStats { endTime?: string | number; } +const DEFAULT_STREAM_CHUNK_SIZE = 1000; +const DEFAULT_CONCURRENT_RESULTS = false; + export const queryApi = api.injectEndpoints({ endpoints: (build) => ({ + useStreamQuery: build.mutation({ + queryFn: async ( + {query, database, querySettings = {}, enableTracingLevel, queryId}, + {signal, dispatch, getState}, + ) => { + dispatch(setQueryResult({type: 'execute', queryId, isLoading: true})); + + const {action, syntax} = getActionAndSyntaxFromQueryMode( + 'execute', + querySettings?.queryMode, + ); + + try { + let streamDataChunkBatch: StreamDataChunk[] = []; + let batchTimeout: number | null = null; + + const flushBatch = () => { + if (streamDataChunkBatch.length > 0) { + dispatch(addStreamingChunks(streamDataChunkBatch)); + streamDataChunkBatch = []; + } + batchTimeout = null; + }; + + await window.api.streaming.streamQuery( + { + query, + database, + action, + syntax, + stats: querySettings.statisticsMode, + tracingLevel: + querySettings.tracingLevel && enableTracingLevel + ? TracingLevelNumber[querySettings.tracingLevel] + : undefined, + limit_rows: isNumeric(querySettings.limitRows) + ? Number(querySettings.limitRows) + : undefined, + transaction_mode: + querySettings.transactionMode === 'implicit' + ? undefined + : querySettings.transactionMode, + timeout: isNumeric(querySettings.timeout) + ? Number(querySettings.timeout) * 1000 + : undefined, + output_chunk_max_size: DEFAULT_STREAM_CHUNK_SIZE, + concurrent_results: DEFAULT_CONCURRENT_RESULTS || undefined, + }, + { + signal, + onQueryResponseChunk: (chunk) => { + dispatch(setStreamQueryResponse(chunk)); + }, + onSessionChunk: (chunk) => { + dispatch(setStreamSession(chunk)); + }, + onStreamDataChunk: (chunk) => { + streamDataChunkBatch.push(chunk); + if (!batchTimeout) { + batchTimeout = window.requestAnimationFrame(flushBatch); + } + }, + }, + ); + + // Flush any remaining chunks + if (batchTimeout) { + window.cancelAnimationFrame(batchTimeout); + flushBatch(); + } + + return {data: null}; + } catch (error) { + const state = getState() as RootState; + dispatch( + setQueryResult({ + ...state.query.result, + type: 'execute', + error, + isLoading: false, + queryId, + }), + ); + return {error}; + } + }, + }), useSendQuery: build.mutation({ queryFn: async ( { diff --git a/src/store/reducers/query/streamingReducers.ts b/src/store/reducers/query/streamingReducers.ts new file mode 100644 index 000000000..493d200ae --- /dev/null +++ b/src/store/reducers/query/streamingReducers.ts @@ -0,0 +1,157 @@ +import type {PayloadAction} from '@reduxjs/toolkit'; + +import type {StreamMetrics} from '../../../types/store/query'; +import type { + QueryResponseChunk, + SessionChunk, + StreamDataChunk, +} from '../../../types/store/streaming'; +import {parseResult} from '../../../utils/query'; + +import {preparePlanData} from './preparePlanData'; +import {prepareQueryData} from './prepareQueryData'; +import type {QueryState} from './types'; + +export const setStreamSession = (state: QueryState, action: PayloadAction) => { + if (!state.result) { + return; + } + + if (!state.result.data) { + state.result.data = prepareQueryData(null); + } + + const chunk = action.payload; + state.result.isLoading = true; + state.result.queryId = chunk.meta.query_id; + state.result.data.traceId = chunk.meta.trace_id; +}; + +export const setStreamQueryResponse = ( + state: QueryState, + action: PayloadAction, +) => { + if (!state.result) { + return; + } + + if (!state.result.data) { + state.result.data = prepareQueryData(null); + } + + state.result.isLoading = false; + + const chunk = action.payload; + if ('error' in chunk) { + state.result.error = chunk; + } else if ('plan' in chunk) { + if (!state.result.data) { + state.result.data = prepareQueryData(null); + } + + const {plan: rawPlan, stats} = chunk; + const {simplifiedPlan, ...planData} = preparePlanData(rawPlan, stats); + state.result.data.preparedPlan = Object.keys(planData).length > 0 ? planData : undefined; + state.result.data.simplifiedPlan = simplifiedPlan; + state.result.data.plan = chunk.plan; + state.result.data.stats = chunk.stats; + } +}; + +const updateStreamMetrics = (metrics: StreamMetrics, totalNewRows: number) => { + const currentTime = Date.now(); + const WINDOW_SIZE = 5000; // 5 seconds in milliseconds + + metrics.recentChunks.push({timestamp: currentTime, rowCount: totalNewRows}); + metrics.recentChunks = metrics.recentChunks.filter( + (chunk) => currentTime - chunk.timestamp <= WINDOW_SIZE, + ); + + if (metrics.recentChunks.length > 0) { + const oldestChunkTime = metrics.recentChunks[0].timestamp; + const timeWindow = (currentTime - oldestChunkTime) / 1000; + const totalRows = metrics.recentChunks.reduce( + (sum: number, chunk) => sum + chunk.rowCount, + 0, + ); + metrics.rowsPerSecond = timeWindow > 0 ? totalRows / timeWindow : 0; + } + + metrics.lastUpdateTime = currentTime; +}; + +const getEmptyResultSet = () => { + return { + columns: [], + result: [], + truncated: false, + streamMetrics: { + rowsPerSecond: 0, + lastUpdateTime: Date.now(), + recentChunks: [], + }, + }; +}; + +export const addStreamingChunks = (state: QueryState, action: PayloadAction) => { + if (!state.result) { + return; + } + + state.result.data = state.result.data || prepareQueryData(null); + state.result.data.resultSets = state.result.data.resultSets || []; + + // Merge chunks by result index + const mergedChunks = action.payload.reduce((acc: Map, chunk) => { + const resultIndex = chunk.meta.result_index; + const currentMergedChunk = acc.get(resultIndex); + + if (currentMergedChunk) { + currentMergedChunk.result.rows?.push(...(chunk.result.rows || [])); + currentMergedChunk.result.truncated = + currentMergedChunk.result.truncated || chunk.result.truncated; + } else { + acc.set(resultIndex, { + ...chunk, + result: { + ...chunk.result, + rows: chunk.result.rows || [], + truncated: chunk.result.truncated, + }, + }); + } + return acc; + }, new Map()); + + const totalNewRows = action.payload.reduce( + (sum: number, chunk) => sum + (chunk.result.rows?.length || 0), + 0, + ); + + // Process merged chunks + for (const [resultIndex, chunk] of mergedChunks.entries()) { + const {columns, rows} = chunk.result; + const resultSets = state.result.data.resultSets; + + if (!resultSets[resultIndex]) { + resultSets[resultIndex] = getEmptyResultSet(); + } + const resultSet = resultSets[resultIndex]; + + if (columns && !resultSet.columns?.length) { + resultSet.columns = columns; + } + + const safeRows = rows || []; + const formattedRows = parseResult(safeRows, resultSet.columns || []); + + formattedRows.forEach((row) => { + resultSet.result?.push(row); + }); + resultSet.truncated = chunk.result.truncated; + + if (resultSet.streamMetrics) { + updateStreamMetrics(resultSet.streamMetrics, totalNewRows); + } + } +}; diff --git a/src/store/reducers/query/utils.ts b/src/store/reducers/query/utils.ts index ae9b9f0f9..073ff5f92 100644 --- a/src/store/reducers/query/utils.ts +++ b/src/store/reducers/query/utils.ts @@ -1,5 +1,11 @@ import type {Actions} from '../../../types/api/query'; import type {QueryAction, QueryMode, QuerySyntax} from '../../../types/store/query'; +import type { + QueryResponseChunk, + SessionChunk, + StreamDataChunk, + StreamingChunk, +} from '../../../types/store/streaming'; import type {QueryInHistory} from './types'; @@ -28,3 +34,15 @@ export function getQueryInHistory(rawQuery: string | QueryInHistory) { } return rawQuery; } + +export function isSessionChunk(content: StreamingChunk): content is SessionChunk { + return content?.meta?.event === 'SessionCreated'; +} + +export function isStreamDataChunk(content: StreamingChunk): content is StreamDataChunk { + return content?.meta?.event === 'StreamData'; +} + +export function isQueryResponseChunk(content: StreamingChunk): content is QueryResponseChunk { + return content?.meta?.event === 'QueryResponse'; +} diff --git a/src/store/reducers/storage/__tests__/calculateMaximumDisksPerNode.test.ts b/src/store/reducers/storage/__tests__/calculateMaximumDisksPerNode.test.ts index 30774f66b..5e20367ee 100644 --- a/src/store/reducers/storage/__tests__/calculateMaximumDisksPerNode.test.ts +++ b/src/store/reducers/storage/__tests__/calculateMaximumDisksPerNode.test.ts @@ -3,24 +3,24 @@ import {TPDiskState} from '../../../../types/api/pdisk'; import {calculateMaximumDisksPerNode} from '../utils'; describe('calculateMaximumDisksPerNode', () => { - it('should return providedMaximumDisksPerNode when it is provided', () => { + test('should return providedMaximumDisksPerNode when it is provided', () => { const nodes: TNodeInfo[] = []; const providedMaximumDisksPerNode = '5'; expect(calculateMaximumDisksPerNode(nodes, providedMaximumDisksPerNode)).toBe('5'); }); - it('should return "1" for empty nodes array', () => { + test('should return "1" for empty nodes array', () => { const nodes: TNodeInfo[] = []; expect(calculateMaximumDisksPerNode(nodes)).toBe('1'); }); - it('should return "1" for undefined nodes', () => { + test('should return "1" for undefined nodes', () => { expect(calculateMaximumDisksPerNode(undefined)).toBe('1'); }); - it('should return "1" for nodes without PDisks', () => { + test('should return "1" for nodes without PDisks', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, @@ -31,7 +31,7 @@ describe('calculateMaximumDisksPerNode', () => { expect(calculateMaximumDisksPerNode(nodes)).toBe('1'); }); - it('should calculate maximum disks correctly for single node with multiple PDisks', () => { + test('should calculate maximum disks correctly for single node with multiple PDisks', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, @@ -56,7 +56,7 @@ describe('calculateMaximumDisksPerNode', () => { expect(calculateMaximumDisksPerNode(nodes)).toBe('3'); }); - it('should calculate maximum disks across multiple nodes', () => { + test('should calculate maximum disks across multiple nodes', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, @@ -109,7 +109,7 @@ describe('calculateMaximumDisksPerNode', () => { expect(calculateMaximumDisksPerNode(nodes)).toBe('4'); }); - it('should handle nodes with empty PDisks array', () => { + test('should handle nodes with empty PDisks array', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, diff --git a/src/store/reducers/storage/__tests__/calculateMaximumSlotsPerDisk.test.ts b/src/store/reducers/storage/__tests__/calculateMaximumSlotsPerDisk.test.ts index 0815f84fb..cfb256a03 100644 --- a/src/store/reducers/storage/__tests__/calculateMaximumSlotsPerDisk.test.ts +++ b/src/store/reducers/storage/__tests__/calculateMaximumSlotsPerDisk.test.ts @@ -13,24 +13,24 @@ const createVDiskId = (id: number): TVDiskID => ({ }); describe('calculateMaximumSlotsPerDisk', () => { - it('should return providedMaximumSlotsPerDisk when it is provided', () => { + test('should return providedMaximumSlotsPerDisk when it is provided', () => { const nodes: TNodeInfo[] = []; const providedMaximumSlotsPerDisk = '5'; expect(calculateMaximumSlotsPerDisk(nodes, providedMaximumSlotsPerDisk)).toBe('5'); }); - it('should return "1" for empty nodes array', () => { + test('should return "1" for empty nodes array', () => { const nodes: TNodeInfo[] = []; expect(calculateMaximumSlotsPerDisk(nodes)).toBe('1'); }); - it('should return "1" for undefined nodes', () => { + test('should return "1" for undefined nodes', () => { expect(calculateMaximumSlotsPerDisk(undefined)).toBe('1'); }); - it('should return "1" for nodes without PDisks or VDisks', () => { + test('should return "1" for nodes without PDisks or VDisks', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, @@ -41,7 +41,7 @@ describe('calculateMaximumSlotsPerDisk', () => { expect(calculateMaximumSlotsPerDisk(nodes)).toBe('1'); }); - it('should calculate maximum slots correctly for single node with one PDisk and multiple VDisks', () => { + test('should calculate maximum slots correctly for single node with one PDisk and multiple VDisks', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, @@ -70,7 +70,7 @@ describe('calculateMaximumSlotsPerDisk', () => { expect(calculateMaximumSlotsPerDisk(nodes)).toBe('2'); }); - it('should calculate maximum slots across multiple nodes', () => { + test('should calculate maximum slots across multiple nodes', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, @@ -121,7 +121,7 @@ describe('calculateMaximumSlotsPerDisk', () => { expect(calculateMaximumSlotsPerDisk(nodes)).toBe('3'); }); - it('should handle nodes with multiple PDisks', () => { + test('should handle nodes with multiple PDisks', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, @@ -159,7 +159,7 @@ describe('calculateMaximumSlotsPerDisk', () => { expect(calculateMaximumSlotsPerDisk(nodes)).toBe('2'); }); - it('should ignore VDisks with non-matching PDiskId', () => { + test('should ignore VDisks with non-matching PDiskId', () => { const nodes: TNodeInfo[] = [ { NodeId: 1, diff --git a/src/store/reducers/storage/__tests__/prepareGroupsDisks.test.ts b/src/store/reducers/storage/__tests__/prepareGroupsDisks.test.ts index 8ba454355..1d7a006e6 100644 --- a/src/store/reducers/storage/__tests__/prepareGroupsDisks.test.ts +++ b/src/store/reducers/storage/__tests__/prepareGroupsDisks.test.ts @@ -2,7 +2,7 @@ import type {TStoragePDisk, TStorageVDisk} from '../../../../types/api/storage'; import {prepareGroupsPDisk, prepareGroupsVDisk} from '../prepareGroupsDisks'; describe('prepareGroupsVDisk', () => { - it('Should correctly parse data', () => { + test('Should correctly parse data', () => { const vDiksDataWithoutPDisk = { VDiskId: '2181038134-22-0-0-0', NodeId: 224, @@ -114,7 +114,7 @@ describe('prepareGroupsVDisk', () => { expect(preparedData).toEqual(expectedResult); }); - it('Should use BSC data when no Whiteboard data', () => { + test('Should use BSC data when no Whiteboard data', () => { const vDiksDataWithoutPDisk = { VDiskId: '2181038134-22-0-0-0', NodeId: 224, @@ -155,7 +155,7 @@ describe('prepareGroupsVDisk', () => { expect(preparedData).toEqual(expectedResult); }); - it('Should use Whiteboard data when no BSC data', () => { + test('Should use Whiteboard data when no BSC data', () => { const vDiksDataWithoutPDisk = { Whiteboard: { VDiskId: { @@ -262,7 +262,7 @@ describe('prepareGroupsVDisk', () => { }); describe('prepareGroupsPDisk', () => { - it('Should correctly parse data', () => { + test('Should correctly parse data', () => { const pDiskData = { PDiskId: '224-1001', NodeId: 224, @@ -339,7 +339,7 @@ describe('prepareGroupsPDisk', () => { expect(preparedData).toEqual(expectedResult); }); - it('Should use BSC data when no Whiteboard data', () => { + test('Should use BSC data when no Whiteboard data', () => { const pDiskData = { PDiskId: '224-1001', NodeId: 224, @@ -385,7 +385,7 @@ describe('prepareGroupsPDisk', () => { expect(preparedData).toEqual(expectedResult); }); - it('Should use Whiteboard data when no BSC data', () => { + test('Should use Whiteboard data when no BSC data', () => { const pDiskData = { NodeId: 224, Whiteboard: { diff --git a/src/types/additionalProps.ts b/src/types/additionalProps.ts index f2c76508a..ee22defe1 100644 --- a/src/types/additionalProps.ts +++ b/src/types/additionalProps.ts @@ -24,7 +24,7 @@ export interface AdditionalTenantsProps { export type NodeAddress = Pick; -export type GetNodeRefFunc = (node?: NodeAddress) => string | null; +export type GetNodeRefFunc = (node?: NodeAddress) => string | undefined; export interface AdditionalNodesProps extends Record { getNodeRef?: GetNodeRefFunc; diff --git a/src/types/api/capabilities.ts b/src/types/api/capabilities.ts index cb40134f9..e4b9b2f96 100644 --- a/src/types/api/capabilities.ts +++ b/src/types/api/capabilities.ts @@ -3,6 +3,9 @@ */ export interface CapabilitiesResponse { Capabilities: Record, number>; + Settings?: { + Security?: Record, boolean>; + }; } // Add feature name before using it @@ -14,3 +17,5 @@ export type Capability = | '/viewer/feature_flags' | '/viewer/cluster' | '/viewer/nodes'; + +export type SecuritySetting = 'UseLoginProvider' | 'DomainLoginOnly'; diff --git a/src/types/api/query.ts b/src/types/api/query.ts index 5cc98bd33..51481284c 100644 --- a/src/types/api/query.ts +++ b/src/types/api/query.ts @@ -1,5 +1,5 @@ import {TRACING_LEVELS} from '../../utils/query'; -import type {StatisticsMode} from '../store/query'; +import type {QuerySyntax, StatisticsMode, TransactionMode} from '../store/query'; // ==== types from backend protos ==== interface Position { @@ -315,6 +315,24 @@ export type CancelResponse = { stats?: TKqpStatsQuery; }; +export interface SendQueryParams { + query?: string; + database?: string; + action?: Action; + syntax?: QuerySyntax; + stats?: Stats; + tracingLevel?: TracingLevel; + transaction_mode?: TransactionMode; + timeout?: Timeout; + query_id?: string; + limit_rows?: number; +} + +export interface StreamQueryParams extends SendQueryParams { + output_chunk_max_size?: number; + concurrent_results?: boolean; +} + // ==== Combined API response ==== export type QueryAPIResponseByAction = Action extends ExplainActions ? GenericExplainResponse diff --git a/src/types/store/query.ts b/src/types/store/query.ts index bca59a38c..4d6db185b 100644 --- a/src/types/store/query.ts +++ b/src/types/store/query.ts @@ -20,10 +20,20 @@ import type { } from '../api/query'; import type {ValueOf} from '../common'; +export interface StreamMetrics { + rowsPerSecond: number; + lastUpdateTime: number; + recentChunks: Array<{ + timestamp: number; + rowCount: number; + }>; +} + export interface ParsedResultSet { columns?: ColumnType[]; result?: KeyValueRow[]; truncated?: boolean; + streamMetrics?: StreamMetrics; } export interface IQueryResult { diff --git a/src/types/store/streaming.ts b/src/types/store/streaming.ts new file mode 100644 index 000000000..78228350a --- /dev/null +++ b/src/types/store/streaming.ts @@ -0,0 +1,54 @@ +import type { + ArrayRow, + ColumnType, + ErrorResponse, + QueryPlan, + ScriptPlan, + TKqpStatsQuery, +} from '../api/query'; + +export interface SessionChunk { + meta: { + event: 'SessionCreated'; + node_id: number; + query_id: string; + session_id: string; + + // Custom client-set property. + trace_id?: string; + }; +} + +export interface StreamDataChunk { + meta: { + event: 'StreamData'; + seq_no: number; + result_index: number; + }; + result: { + columns?: ColumnType[]; + rows: ArrayRow[] | null; + truncated?: boolean; + }; +} + +export interface SuccessQueryResponseData { + stats?: TKqpStatsQuery; + plan?: ScriptPlan | QueryPlan; + ast?: string; +} + +export type ErrorQueryResponseData = ErrorResponse; + +export interface BaseQueryResponseChunk { + meta: { + event: 'QueryResponse'; + version: string; + type: string; + }; +} + +export type QueryResponseChunk = BaseQueryResponseChunk & + (SuccessQueryResponseData | ErrorQueryResponseData); + +export type StreamingChunk = SessionChunk | StreamDataChunk | QueryResponseChunk; diff --git a/src/utils/__test__/getColumnWidth.test.ts b/src/utils/__test__/getColumnWidth.test.ts index 26bf8387f..7aa513fab 100644 --- a/src/utils/__test__/getColumnWidth.test.ts +++ b/src/utils/__test__/getColumnWidth.test.ts @@ -7,12 +7,12 @@ import { } from '../getColumnWidth'; describe('getColumnWidth', () => { - it('returns minimum width for empty data', () => { + test('returns minimum width for empty data', () => { const result = getColumnWidth({data: [], name: 'test'}); expect(result).toBe(HEADER_PADDING + 'test'.length * PIXELS_PER_CHARACTER); }); - it('calculates correct width for string columns', () => { + test('calculates correct width for string columns', () => { const data = [{test: 'short'}, {test: 'medium length'}, {test: 'this is a longer string'}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe( @@ -20,13 +20,13 @@ describe('getColumnWidth', () => { ); }); - it('calculates correct width for columns with sorting', () => { + test('calculates correct width for columns with sorting', () => { const result = getColumnWidth({data: [], name: 'test', sortable: true}); expect(result).toBe( HEADER_PADDING + ('test'.length + SORT_ICON_TO_CHARACTERS) * PIXELS_PER_CHARACTER, ); }); - it('calculates correct width for columns with sorting and column name wider than header', () => { + test('calculates correct width for columns with sorting and column name wider than header', () => { const data = [{test: 'this is a longer string'}]; const result = getColumnWidth({data, name: 'test', sortable: true}); expect(result).toBe( @@ -34,65 +34,65 @@ describe('getColumnWidth', () => { ); }); - it('calculates correct width for columns with header', () => { + test('calculates correct width for columns with header', () => { const result = getColumnWidth({data: [], name: 'test', header: 'a'}); expect(result).toBe(HEADER_PADDING + 'a'.length * PIXELS_PER_CHARACTER); }); - it('returns MAX_COLUMN_WIDTH when calculated width exceeds it', () => { + test('returns MAX_COLUMN_WIDTH when calculated width exceeds it', () => { const data = [{test: 'a'.repeat(100)}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe(MAX_COLUMN_WIDTH); }); - it('handles undefined data correctly', () => { + test('handles undefined data correctly', () => { const result = getColumnWidth({name: 'test'}); expect(result).toBe(HEADER_PADDING + 'test'.length * PIXELS_PER_CHARACTER); }); - it('handles missing values in data correctly', () => { + test('handles missing values in data correctly', () => { const data = [{test: 'short'}, {}, {test: 'longer string'}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe(HEADER_PADDING + 'longer string'.length * PIXELS_PER_CHARACTER); }); - it('uses column name length when all values are shorter', () => { + test('uses column name length when all values are shorter', () => { const data = [{longColumnName: 'a'}, {longColumnName: 'bb'}]; const result = getColumnWidth({data, name: 'longColumnName'}); expect(result).toBe(HEADER_PADDING + 'longColumnName'.length * PIXELS_PER_CHARACTER); }); - it('handles null values in data correctly', () => { + test('handles null values in data correctly', () => { const data = [{test: 'a'}, {test: null}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe(HEADER_PADDING + 'test'.length * PIXELS_PER_CHARACTER); }); - it('handles undefined values in data correctly', () => { + test('handles undefined values in data correctly', () => { const data = [{test: 'a'}, {test: undefined}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe(HEADER_PADDING + 'test'.length * PIXELS_PER_CHARACTER); }); - it('handles empty string values in data correctly', () => { + test('handles empty string values in data correctly', () => { const data = [{test: 'short'}, {test: ''}, {test: 'longer string'}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe(HEADER_PADDING + 'longer string'.length * PIXELS_PER_CHARACTER); }); - it('handles an array of numbers correctly', () => { + test('handles an array of numbers correctly', () => { const data = [{test: 1}, {test: 123}, {test: 12345}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe(HEADER_PADDING + '12345'.length * PIXELS_PER_CHARACTER); }); - it('handles an array of mixed data types correctly', () => { + test('handles an array of mixed data types correctly', () => { const data = [{test: 'short'}, {test: 123}, {test: null}, {test: 'longer string'}]; const result = getColumnWidth({data, name: 'test'}); expect(result).toBe(HEADER_PADDING + 'longer string'.length * PIXELS_PER_CHARACTER); }); - it('handles empty name correctly', () => { + test('handles empty name correctly', () => { const data = [{test: 'test'}]; const result = getColumnWidth({data, name: ''}); expect(result).toBe(HEADER_PADDING); diff --git a/src/utils/__test__/monitoring.test.ts b/src/utils/__test__/monitoring.test.ts index e89c7045c..5cfc15bf3 100644 --- a/src/utils/__test__/monitoring.test.ts +++ b/src/utils/__test__/monitoring.test.ts @@ -1,7 +1,7 @@ import {getMonitoringClusterLink, getMonitoringLink} from '../monitoring'; describe('getMonitoringLink', () => { - it('should create database monitoring link from JSON', () => { + test('should create database monitoring link from JSON', () => { const solomonRaw = { monitoring_url: 'https://monitoring.test.ai/projects/yc.ydb.ydbaas-cloud/dashboards', serverless_dashboard: '', @@ -20,7 +20,7 @@ describe('getMonitoringLink', () => { 'https://monitoring.test.ai/projects/yc.ydb.ydbaas-cloud/dashboards/aol34hftdn7o4fls50sv?p.cluster=global&p.host=cluster&p.slot=static&p.database=database', ); }); - it('should create cluster monitoring link from JSON', () => { + test('should create cluster monitoring link from JSON', () => { const solomonRaw = { monitoring_url: 'https://monitoring.test.ai/projects/yc.ydb.ydbaas-cloud/dashboards', cluster_dashboard: 'aol34hftdn7o4fls50sv', @@ -31,7 +31,7 @@ describe('getMonitoringLink', () => { 'https://monitoring.test.ai/projects/yc.ydb.ydbaas-cloud/dashboards/aol34hftdn7o4fls50sv/view?p.cluster=clusterName&p.database=-', ); }); - it('should not parse ready to use database monitoring link', () => { + test('should not parse ready to use database monitoring link', () => { const solomonRaw = { monitoring_url: 'https://monitoring.test.ai/projects/ydbaas/dashboards/aol34hftdn7o4fls50sv?p.cluster=cluster_name&a=', @@ -49,7 +49,7 @@ describe('getMonitoringLink', () => { 'https://monitoring.test.ai/projects/ydbaas/dashboards/aol34hftdn7o4fls50sv?p.cluster=cluster_name&a=&p.host=cluster&p.slot=static&p.database=database', ); }); - it('should not parse ready to use cluster monitoring link', () => { + test('should not parse ready to use cluster monitoring link', () => { const solomonRaw = { monitoring_url: 'https://monitoring.test.ai/projects/ydbaas/dashboards/aol34hftdn7o4fls50sv/view?p.cluster=cluster_name&a=', diff --git a/src/utils/__test__/parseBalancer.test.ts b/src/utils/__test__/parseBalancer.test.ts index 07ee2af3b..940cb7d80 100644 --- a/src/utils/__test__/parseBalancer.test.ts +++ b/src/utils/__test__/parseBalancer.test.ts @@ -7,7 +7,7 @@ import { } from '../parseBalancer'; describe('parseBalancer', () => { - it('should parse balancer with viewer proxy', () => { + test('should parse balancer with viewer proxy', () => { const rawValue = 'https://viewer.ydb.ru:443/ydb-testing-0000.search.ydb.net:8765/viewer/json'; const balancer = 'ydb-testing-0000.search.ydb.net:8765'; @@ -18,7 +18,7 @@ describe('parseBalancer', () => { expect(parsedBalancerWithViewer.balancer).toBe(balancer); expect(parsedBalancerWithViewer.proxy).toBe(proxy); }); - it('should parse balancer with bastion proxy', () => { + test('should parse balancer with bastion proxy', () => { const rawValue = 'https://ydb.bastion.cloud.ru:443/ydbproxy.ydb.cloud.net:8765/viewer/json'; const balancer = 'ydbproxy.ydb.cloud.net:8765'; const proxy = 'ydb.bastion.cloud.ru:443'; @@ -28,7 +28,7 @@ describe('parseBalancer', () => { expect(parsedBalancerWithBastion.balancer).toBe(balancer); expect(parsedBalancerWithBastion.proxy).toBe(proxy); }); - it('should parse balancer with custom proxy', () => { + test('should parse balancer with custom proxy', () => { const rawValue = 'https://proxy.ydb.mdb.cloud-preprod.net:443/ydbproxy-public.ydb.cloud-preprod.net:8765/viewer/json'; const balancer = 'ydbproxy-public.ydb.cloud-preprod.net:8765'; @@ -39,7 +39,7 @@ describe('parseBalancer', () => { expect(parsedBalancerWithCustomProxy.balancer).toBe(balancer); expect(parsedBalancerWithCustomProxy.proxy).toBe(proxy); }); - it('should parse balancer without proxy', () => { + test('should parse balancer without proxy', () => { const rawValue = 'https://ydb-testing-0000.search.net:8765/viewer/json'; const balancer = 'ydb-testing-0000.search.net:8765'; @@ -49,7 +49,7 @@ describe('parseBalancer', () => { expect(parsedBalancerWithoutProxy.proxy).toBe(undefined); }); - it('should parse pure balancer', () => { + test('should parse pure balancer', () => { const pureBalancer = 'ydb-testing-0000.search.net:8765'; const parsedPureBalancer = parseBalancer(pureBalancer); @@ -60,7 +60,7 @@ describe('parseBalancer', () => { }); describe('removeViewerPathname', () => { - it('should remove pathname', () => { + test('should remove pathname', () => { const initialValue = 'https://ydb-testing-0000.search.net:8765/viewer/json'; const result = 'https://ydb-testing-0000.search.net:8765'; @@ -68,7 +68,7 @@ describe('removeViewerPathname', () => { }); }); describe('removeProtocol', () => { - it('should remove protocol', () => { + test('should remove protocol', () => { const initialValue = 'https://ydb-testing-0000.search.net:8765/viewer/json'; const result = 'ydb-testing-0000.search.net:8765/viewer/json'; @@ -76,7 +76,7 @@ describe('removeProtocol', () => { }); }); describe('removePort', () => { - it('should remove port', () => { + test('should remove port', () => { const initialValue = 'ydb-testing-0000.search.net:8765'; const result = 'ydb-testing-0000.search.net'; @@ -84,7 +84,7 @@ describe('removePort', () => { }); }); describe('getCleanBalancerValue', () => { - it('should return balancer value without protocol, proxy, port and pathname', () => { + test('should return balancer value without protocol, proxy, port and pathname', () => { const initialValue = 'https://ydb.bastion.cloud.ru:443/ydbproxy.ydb.cloud.net:8765/viewer/json'; const result = 'ydbproxy.ydb.cloud.net'; diff --git a/src/utils/__test__/prepareBackend.test.ts b/src/utils/__test__/prepareBackend.test.ts index 1640a3b30..215b4b405 100644 --- a/src/utils/__test__/prepareBackend.test.ts +++ b/src/utils/__test__/prepareBackend.test.ts @@ -1,7 +1,7 @@ import {getBackendFromNodeHost, getBackendFromRawNodeData, prepareHost} from '../prepareBackend'; describe('prepareHost', () => { - it('should add vm prefix to cloud din nodes', () => { + test('should add vm prefix to cloud din nodes', () => { const cloudDinNodeInitialHost = 'vm-cc8mco0j0snqehgh7r2a-ru-central1-c-nlmw-aniq.cc8mco0j0snqehgh7r2a.ydb.mdb.cloud-preprod.net'; const cloudDinNodeResultHost = @@ -14,7 +14,7 @@ describe('prepareHost', () => { }); }); describe('getBackendFromNodeHost', () => { - it('should prepare correct backend value from node host', () => { + test('should prepare correct backend value from node host', () => { const balancer = 'https://viewer.ydb.ru:443/dev02.ydb.net:8765'; const nodeHost = 'ydb-dev02-001.search.net:31012'; const result = 'https://viewer.ydb.ru:443/ydb-dev02-001.search.net:31012'; @@ -23,7 +23,7 @@ describe('getBackendFromNodeHost', () => { }); }); describe('getBackendFromRawNodeData', () => { - it('should prepare correct backend value from raw node data', () => { + test('should prepare correct backend value from raw node data', () => { const balancer = 'https://viewer.ydb.ru:443/dev02.ydb.net:8765'; const node = { Host: 'ydb-dev02-000.search.net', diff --git a/src/utils/bytesParsers/__test__/formatBytes.test.ts b/src/utils/bytesParsers/__test__/formatBytes.test.ts index 53e5e3579..11f6ff1e9 100644 --- a/src/utils/bytesParsers/__test__/formatBytes.test.ts +++ b/src/utils/bytesParsers/__test__/formatBytes.test.ts @@ -2,14 +2,14 @@ import {UNBREAKABLE_GAP} from '../../utils'; import {formatBytes} from '../formatBytes'; describe('formatBytes', () => { - it('should work with only value', () => { + test('should work with only value', () => { expect(formatBytes({value: 100})).toBe(`100${UNBREAKABLE_GAP}B`); expect(formatBytes({value: 100_000})).toBe(`100${UNBREAKABLE_GAP}KB`); expect(formatBytes({value: 100_000_000})).toBe(`100${UNBREAKABLE_GAP}MB`); expect(formatBytes({value: 100_000_000_000})).toBe(`100${UNBREAKABLE_GAP}GB`); expect(formatBytes({value: 100_000_000_000_000})).toBe(`100${UNBREAKABLE_GAP}TB`); }); - it('should convert to size', () => { + test('should convert to size', () => { expect(formatBytes({value: 100_000, size: 'b'})).toBe( `100${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}B`, ); @@ -17,7 +17,7 @@ describe('formatBytes', () => { `100${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}GB`, ); }); - it('should convert without labels', () => { + test('should convert without labels', () => { expect(formatBytes({value: 100_000, size: 'b', withSizeLabel: false})).toBe( `100${UNBREAKABLE_GAP}000`, ); @@ -25,7 +25,7 @@ describe('formatBytes', () => { `100${UNBREAKABLE_GAP}000`, ); }); - it('should convert to speed', () => { + test('should convert to speed', () => { expect(formatBytes({value: 100_000, withSpeedLabel: true})).toBe( `100${UNBREAKABLE_GAP}KB/s`, ); @@ -33,14 +33,14 @@ describe('formatBytes', () => { `100${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}B/s`, ); }); - it('should return empty string on invalid data', () => { + test('should return empty string on invalid data', () => { expect(formatBytes({value: undefined})).toEqual(''); expect(formatBytes({value: null})).toEqual(''); expect(formatBytes({value: ''})).toEqual(''); expect(formatBytes({value: 'false'})).toEqual(''); expect(formatBytes({value: '123qwe'})).toEqual(''); }); - it('should work with precision', () => { + test('should work with precision', () => { expect(formatBytes({value: 123.123, precision: 2})).toBe(`123${UNBREAKABLE_GAP}B`); expect(formatBytes({value: 12.123, precision: 2})).toBe(`12${UNBREAKABLE_GAP}B`); expect(formatBytes({value: 1.123, precision: 2})).toBe(`1.1${UNBREAKABLE_GAP}B`); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 70e3bdb76..a74b6a439 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -119,6 +119,8 @@ export const ENABLE_AUTOCOMPLETE = 'enableAutocomplete'; export const ENABLE_CODE_ASSISTANT = 'enableCodeAssistant'; +export const ENABLE_QUERY_STREAMING = 'enableQueryStreaming'; + export const AUTOCOMPLETE_ON_ENTER = 'autocompleteOnEnter'; export const IS_HOTKEYS_HELP_HIDDEN_KEY = 'isHotKeysHelpHidden'; diff --git a/src/utils/dataFormatters/__test__/formatNumbers.test.ts b/src/utils/dataFormatters/__test__/formatNumbers.test.ts index e9f161db2..a99bc528d 100644 --- a/src/utils/dataFormatters/__test__/formatNumbers.test.ts +++ b/src/utils/dataFormatters/__test__/formatNumbers.test.ts @@ -2,47 +2,47 @@ import {UNBREAKABLE_GAP} from '../../utils'; import {formatNumericValues} from '../dataFormatters'; describe('formatNumericValues', () => { - it('should return ["", ""] when both value and total are undefined', () => { + test('should return ["", ""] when both value and total are undefined', () => { const result = formatNumericValues(); expect(result).toEqual(['', '']); }); - it('should format value correctly when total is undefined', () => { + test('should format value correctly when total is undefined', () => { const result = formatNumericValues(1000); expect(result).toEqual([`1${UNBREAKABLE_GAP}k`, '']); }); - it('should format total correctly when value is undefined', () => { + test('should format total correctly when value is undefined', () => { const result = formatNumericValues(undefined, 1_000_000); expect(result).toEqual(['', `1${UNBREAKABLE_GAP}m`]); }); - it('should format both value and total correctly', () => { + test('should format both value and total correctly', () => { const result = formatNumericValues(1024, 2048); expect(result).toEqual(['1', `2${UNBREAKABLE_GAP}k`]); }); - it('should format values without units (less than 1000)', () => { + test('should format values without units (less than 1000)', () => { const result1 = formatNumericValues(10, 20); expect(result1).toEqual(['10', `20`]); }); - it('should format value with label if set', () => { + test('should format value with label if set', () => { const result = formatNumericValues(1024, 2048, undefined, undefined, true); expect(result).toEqual([`1${UNBREAKABLE_GAP}k`, `2${UNBREAKABLE_GAP}k`]); }); - it('should return ["0", formattedTotal] when value is 0', () => { + test('should return ["0", formattedTotal] when value is 0', () => { const result = formatNumericValues(0, 2048); expect(result).toEqual(['0', `2${UNBREAKABLE_GAP}k`]); }); - it('should use provided size and delimiter', () => { + test('should use provided size and delimiter', () => { const result = formatNumericValues(5120, 10240, 'billion', '/'); expect(result).toEqual(['0', '0/b']); }); - it('should handle non-numeric total gracefully', () => { + test('should handle non-numeric total gracefully', () => { const result = formatNumericValues(2048, 'Not a number' as any); expect(result).toEqual([`2${UNBREAKABLE_GAP}k`, '']); }); diff --git a/src/utils/dataFormatters/__test__/formatStorageValues.test.ts b/src/utils/dataFormatters/__test__/formatStorageValues.test.ts index 1a9197789..ea54a1d97 100644 --- a/src/utils/dataFormatters/__test__/formatStorageValues.test.ts +++ b/src/utils/dataFormatters/__test__/formatStorageValues.test.ts @@ -2,37 +2,37 @@ import {UNBREAKABLE_GAP} from '../../utils'; import {formatStorageValues} from '../dataFormatters'; describe('formatStorageValues', () => { - it('should return ["", ""] when both value and total are undefined', () => { + test('should return ["", ""] when both value and total are undefined', () => { const result = formatStorageValues(); expect(result).toEqual(['', '']); }); - it('should format value correctly when total is undefined', () => { + test('should format value correctly when total is undefined', () => { const result = formatStorageValues(1024); expect(result).toEqual([`1${UNBREAKABLE_GAP}KB`, '']); }); - it('should format total correctly when value is undefined', () => { + test('should format total correctly when value is undefined', () => { const result = formatStorageValues(undefined, 2048); expect(result).toEqual(['', `2${UNBREAKABLE_GAP}KB`]); }); - it('should format both value and total correctly', () => { + test('should format both value and total correctly', () => { const result = formatStorageValues(1024, 2048); expect(result).toEqual(['1', `2${UNBREAKABLE_GAP}KB`]); }); - it('should return ["0", formattedTotal] when value is 0', () => { + test('should return ["0", formattedTotal] when value is 0', () => { const result = formatStorageValues(0, 2048); expect(result).toEqual(['0', `2${UNBREAKABLE_GAP}KB`]); }); - it('should use provided size and delimiter', () => { + test('should use provided size and delimiter', () => { const result = formatStorageValues(5120, 10240, 'mb', '/'); expect(result).toEqual(['0', '0/MB']); }); - it('should handle non-numeric total gracefully', () => { + test('should handle non-numeric total gracefully', () => { const result = formatStorageValues(2048, 'Not a number' as any); expect(result).toEqual([`2${UNBREAKABLE_GAP}KB`, '']); }); diff --git a/src/utils/dataFormatters/__test__/formatUptime.test.ts b/src/utils/dataFormatters/__test__/formatUptime.test.ts index ca9f80211..8ad347c2f 100644 --- a/src/utils/dataFormatters/__test__/formatUptime.test.ts +++ b/src/utils/dataFormatters/__test__/formatUptime.test.ts @@ -6,21 +6,21 @@ import { } from '../dataFormatters'; describe('getUptimeFromDateFormatted', () => { - it('should calculate and format uptime', () => { + test('should calculate and format uptime', () => { expect(getUptimeFromDateFormatted(3_600_000, 7_200_000)).toBe('1:00:00'); }); - it('should return 0 if dateFrom after dateTo', () => { + test('should return 0 if dateFrom after dateTo', () => { expect(getUptimeFromDateFormatted(3_600_000, 3_599_000)).toBe('0s'); }); }); describe('getDowntimeFromDateFormatted', () => { - it('should calculate and format downtime as -uptime', () => { + test('should calculate and format downtime as -uptime', () => { expect(getDowntimeFromDateFormatted(3_600_000, 7_200_000)).toBe('-1:00:00'); }); - it('should not add sign if downtime is 0', () => { + test('should not add sign if downtime is 0', () => { expect(getDowntimeFromDateFormatted(3_600_000, 3_600_000)).toBe('0s'); }); - it('should return 0 if dateFrom after dateTo', () => { + test('should return 0 if dateFrom after dateTo', () => { expect(getDowntimeFromDateFormatted(3_600_000, 3_599_000)).toBe('0s'); }); }); @@ -29,7 +29,7 @@ describe('formatUptimeInSeconds', () => { const H = 60 * M; const D = 24 * H; - it('should return days if value is more than 24h', () => { + test('should return days if value is more than 24h', () => { expect(formatUptimeInSeconds(24 * H)).toBe('1d' + UNBREAKABLE_GAP + '00:00:00'); expect(formatUptimeInSeconds(D + H + M + 12)).toBe('1d' + UNBREAKABLE_GAP + '01:01:12'); // 1 week @@ -42,19 +42,19 @@ describe('formatUptimeInSeconds', () => { '1234d' + UNBREAKABLE_GAP + '12:12:12', ); }); - it('should return hours if value is less than 24h', () => { + test('should return hours if value is less than 24h', () => { expect(formatUptimeInSeconds(H + M + 12)).toBe('1:01:12'); expect(formatUptimeInSeconds(12 * H + 12 * M + 12)).toBe('12:12:12'); }); - it('should return minutes if value is less than hour', () => { + test('should return minutes if value is less than hour', () => { expect(formatUptimeInSeconds(M + 12)).toBe('1:12'); expect(formatUptimeInSeconds(12 * M + 2)).toBe('12:02'); }); - it('should return second if value is less than hour', () => { + test('should return second if value is less than hour', () => { expect(formatUptimeInSeconds(12)).toBe('12s'); expect(formatUptimeInSeconds(2)).toBe('2s'); }); - it('should correctly process negative values', () => { + test('should correctly process negative values', () => { expect(formatUptimeInSeconds(-0)).toBe('0s'); expect(formatUptimeInSeconds(-12)).toBe('-12s'); expect(formatUptimeInSeconds(-1 * (12 * M + 2))).toBe('-12:02'); @@ -63,7 +63,7 @@ describe('formatUptimeInSeconds', () => { '-12d' + UNBREAKABLE_GAP + '12:12:12', ); }); - it('should return empty placeholder on NaN', () => { + test('should return empty placeholder on NaN', () => { expect(formatUptimeInSeconds(Number.NaN)).toBe(undefined); }); }); diff --git a/src/utils/dataFormatters/__test__/roundToSignificant.test.ts b/src/utils/dataFormatters/__test__/roundToSignificant.test.ts index 7eb5a96a3..a4acee363 100644 --- a/src/utils/dataFormatters/__test__/roundToSignificant.test.ts +++ b/src/utils/dataFormatters/__test__/roundToSignificant.test.ts @@ -1,7 +1,7 @@ import {roundToPrecision} from '../dataFormatters'; describe('roundToSignificant', () => { - it('should work with only value', () => { + test('should work with only value', () => { expect(roundToPrecision(123)).toBe(123); expect(roundToPrecision(123.123)).toBe(123); expect(roundToPrecision(12.123)).toBe(12); @@ -9,7 +9,7 @@ describe('roundToSignificant', () => { expect(roundToPrecision(0.123)).toBe(0); expect(roundToPrecision(0)).toBe(0); }); - it('should work with precision', () => { + test('should work with precision', () => { expect(roundToPrecision(123, 2)).toBe(123); expect(roundToPrecision(123.123, 2)).toBe(123); expect(roundToPrecision(12.123, 2)).toBe(12); diff --git a/src/utils/developerUI/__test__/developerUI.test.ts b/src/utils/developerUI/__test__/developerUI.test.ts index 09473baff..59487c962 100644 --- a/src/utils/developerUI/__test__/developerUI.test.ts +++ b/src/utils/developerUI/__test__/developerUI.test.ts @@ -7,41 +7,41 @@ import { describe('Developer UI links generators', () => { describe('createDeveloperUIInternalPageHref', () => { - it('should create correct link for embedded UI', () => { + test('should create correct link for embedded UI', () => { expect(createDeveloperUIInternalPageHref('')).toBe('/internal'); }); - it('should create correct link for embedded UI with node', () => { + test('should create correct link for embedded UI with node', () => { expect(createDeveloperUIInternalPageHref('/node/5')).toBe('/node/5/internal'); }); - it('should create correct link for embedded UI with proxy', () => { + test('should create correct link for embedded UI with proxy', () => { expect(createDeveloperUIInternalPageHref('/my-ydb-host.net:8765')).toBe( '/my-ydb-host.net:8765/internal', ); }); - it('should create correct link for UI with custom host', () => { + test('should create correct link for UI with custom host', () => { expect(createDeveloperUIInternalPageHref('http://my-ydb-host.net:8765')).toBe( 'http://my-ydb-host.net:8765/internal', ); }); - it('should create correct link for UI with custom host and node', () => { + test('should create correct link for UI with custom host and node', () => { expect(createDeveloperUIInternalPageHref('http://my-ydb-host.net:8765/node/5')).toBe( 'http://my-ydb-host.net:8765/node/5/internal', ); }); - it('should create correct link for UI with custom host and proxy', () => { + test('should create correct link for UI with custom host and proxy', () => { expect( createDeveloperUIInternalPageHref('https://my-ydb-proxy/my-ydb-host.net:8765'), ).toBe('https://my-ydb-proxy/my-ydb-host.net:8765/internal'); }); }); describe('createDeveloperUILinkWithNodeId', () => { - it('should create relative link with no host', () => { + test('should create relative link with no host', () => { expect(createDeveloperUILinkWithNodeId(1)).toBe('/node/1'); }); - it('should create relative link with existing relative path with nodeId', () => { + test('should create relative link with existing relative path with nodeId', () => { expect(createDeveloperUILinkWithNodeId(1, '/node/3/')).toBe('/node/1'); }); - it('should create full link with host', () => { + test('should create full link with host', () => { expect( createDeveloperUILinkWithNodeId( 1, @@ -49,7 +49,7 @@ describe('Developer UI links generators', () => { ), ).toBe('http://ydb-vla-dev02-001.search.yandex.net:8765/node/1'); }); - it('should create full link with host with existing node path with nodeId', () => { + test('should create full link with host with existing node path with nodeId', () => { expect( createDeveloperUILinkWithNodeId( 1, @@ -59,14 +59,14 @@ describe('Developer UI links generators', () => { }); }); describe('createPDiskDeveloperUILink', () => { - it('should create link with pDiskId and nodeId', () => { + test('should create link with pDiskId and nodeId', () => { expect(createPDiskDeveloperUILink({nodeId: 1, pDiskId: 1})).toBe( '/node/1/actors/pdisks/pdisk000000001', ); }); }); describe('createVDiskDeveloperUILink', () => { - it('should create link with pDiskId, vDiskSlotId nodeId', () => { + test('should create link with pDiskId, vDiskSlotId nodeId', () => { expect( createVDiskDeveloperUILink({ nodeId: 1, diff --git a/src/utils/disks/__test__/calculatePDiskSeverity.test.ts b/src/utils/disks/__test__/calculatePDiskSeverity.test.ts index 2a9671f42..903db2e07 100644 --- a/src/utils/disks/__test__/calculatePDiskSeverity.test.ts +++ b/src/utils/disks/__test__/calculatePDiskSeverity.test.ts @@ -3,14 +3,14 @@ import {calculatePDiskSeverity} from '../calculatePDiskSeverity'; import {DISK_COLOR_STATE_TO_NUMERIC_SEVERITY} from '../constants'; describe('PDisk state', () => { - it('Should determine severity based on State if space severity is OK', () => { + test('Should determine severity based on State if space severity is OK', () => { const normalDiskSeverity = calculatePDiskSeverity({State: TPDiskState.Normal}); const erroredDiskSeverity = calculatePDiskSeverity({State: TPDiskState.ChunkQuotaError}); expect(normalDiskSeverity).not.toEqual(erroredDiskSeverity); }); - it('Should determine severity based on space utilization if state severity is OK', () => { + test('Should determine severity based on space utilization if state severity is OK', () => { const severity1 = calculatePDiskSeverity({State: TPDiskState.Normal, AllocatedPercent: 0}); const severity2 = calculatePDiskSeverity({State: TPDiskState.Normal, AllocatedPercent: 86}); const severity3 = calculatePDiskSeverity({State: TPDiskState.Normal, AllocatedPercent: 96}); @@ -20,7 +20,7 @@ describe('PDisk state', () => { expect(severity3).toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Red); }); - it('Should determine severity based on max severity of state and space utilization ', () => { + test('Should determine severity based on max severity of state and space utilization ', () => { const severity1 = calculatePDiskSeverity({ State: TPDiskState.ChunkQuotaError, AllocatedPercent: 0, @@ -31,7 +31,7 @@ describe('PDisk state', () => { expect(severity2).toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Red); }); - it('Should display as unavailabe when no State is provided', () => { + test('Should display as unavailabe when no State is provided', () => { const severity1 = calculatePDiskSeverity({}); const severity2 = calculatePDiskSeverity({State: TPDiskState.ChunkQuotaError}); @@ -39,7 +39,7 @@ describe('PDisk state', () => { expect(severity2).not.toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Grey); }); - it('Should display as unavailabe when no State is provided event if space severity is not OK', () => { + test('Should display as unavailabe when no State is provided event if space severity is not OK', () => { const severity1 = calculatePDiskSeverity({AllocatedPercent: 86}); const severity2 = calculatePDiskSeverity({AllocatedPercent: 96}); diff --git a/src/utils/disks/__test__/calculateVDiskSeverity.test.ts b/src/utils/disks/__test__/calculateVDiskSeverity.test.ts index a16f208fe..aae7ddb18 100644 --- a/src/utils/disks/__test__/calculateVDiskSeverity.test.ts +++ b/src/utils/disks/__test__/calculateVDiskSeverity.test.ts @@ -4,7 +4,7 @@ import {calculateVDiskSeverity} from '../calculateVDiskSeverity'; import {DISK_COLOR_STATE_TO_NUMERIC_SEVERITY} from '../constants'; describe('VDisk state', () => { - it('Should determine severity based on the highest value among VDiskState, DiskSpace and FrontQueues', () => { + test('Should determine severity based on the highest value among VDiskState, DiskSpace and FrontQueues', () => { const severity1 = calculateVDiskSeverity({ VDiskState: EVDiskState.OK, // severity 1, green DiskSpace: EFlag.Yellow, // severity 3, yellow @@ -26,7 +26,7 @@ describe('VDisk state', () => { expect(severity3).toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Orange); }); - it('Should not pick the highest severity based on FrontQueues value', () => { + test('Should not pick the highest severity based on FrontQueues value', () => { const severity1 = calculateVDiskSeverity({ VDiskState: EVDiskState.OK, // severity 1, green DiskSpace: EFlag.Green, // severity 1, green @@ -43,7 +43,7 @@ describe('VDisk state', () => { }); // prettier-ignore - it('Should display as unavailable when no VDiskState is provided', () => { + test('Should display as unavailable when no VDiskState is provided', () => { const severity1 = calculateVDiskSeverity({}); const severity2 = calculateVDiskSeverity({ VDiskState: EVDiskState.OK @@ -78,7 +78,7 @@ describe('VDisk state', () => { expect(severity8).not.toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Grey); }); - it('Should display as unavailable when no VDiskState is provided even if DiskSpace or FrontQueues flags are not green', () => { + test('Should display as unavailable when no VDiskState is provided even if DiskSpace or FrontQueues flags are not green', () => { const severity1 = calculateVDiskSeverity({ DiskSpace: EFlag.Red, FrontQueues: EFlag.Yellow, @@ -88,7 +88,7 @@ describe('VDisk state', () => { expect(severity1).toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Grey); }); - it('Should display replicating VDisks in OK state with a distinct color', () => { + test('Should display replicating VDisks in OK state with a distinct color', () => { const severity1 = calculateVDiskSeverity({ VDiskState: EVDiskState.OK, // severity 1, green Replicated: false, @@ -102,7 +102,7 @@ describe('VDisk state', () => { expect(severity2).not.toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Blue); }); - it('Should not display VDisk as replicating if Replicated is undefined', () => { + test('Should not display VDisk as replicating if Replicated is undefined', () => { const severity = calculateVDiskSeverity({ VDiskState: EVDiskState.OK, // severity 1, green Replicated: undefined, @@ -111,7 +111,7 @@ describe('VDisk state', () => { expect(severity).not.toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Blue); }); - it('Should display replicating VDisks in a not-OK state with a regular color', () => { + test('Should display replicating VDisks in a not-OK state with a regular color', () => { const severity1 = calculateVDiskSeverity({ VDiskState: EVDiskState.Initial, // severity 3, yellow Replicated: false, @@ -125,7 +125,7 @@ describe('VDisk state', () => { expect(severity2).toEqual(DISK_COLOR_STATE_TO_NUMERIC_SEVERITY.Red); }); - it('Should always display donor VDisks with a regular color', () => { + test('Should always display donor VDisks with a regular color', () => { const severity1 = calculateVDiskSeverity({ VDiskState: EVDiskState.OK, // severity 1, green Replicated: false, // donors are always in the not replicated state since they are leftovers diff --git a/src/utils/disks/__test__/prepareDisks.test.ts b/src/utils/disks/__test__/prepareDisks.test.ts index 1d9272f78..491dd2f67 100644 --- a/src/utils/disks/__test__/prepareDisks.test.ts +++ b/src/utils/disks/__test__/prepareDisks.test.ts @@ -8,7 +8,7 @@ import { } from '../prepareDisks'; describe('prepareWhiteboardVDiskData', () => { - it('Should correctly parse data', () => { + test('Should correctly parse data', () => { const data = { VDiskId: { GroupID: 0, @@ -98,7 +98,7 @@ describe('prepareWhiteboardVDiskData', () => { expect(preparedData).toEqual(expectedResult); }); - it('Should parse unavailable donors', () => { + test('Should parse unavailable donors', () => { const data = { NodeId: 1, PDiskId: 2, @@ -119,7 +119,7 @@ describe('prepareWhiteboardVDiskData', () => { }); describe('prepareWhiteboardPDiskData', () => { - it('Should correctly parse data', () => { + test('Should correctly parse data', () => { const data = { PDiskId: 1, ChangeTime: '1730383540716', @@ -180,7 +180,7 @@ describe('prepareWhiteboardPDiskData', () => { }); describe('prepareVDiskSizeFields', () => { - it('Should prepare VDisk size fields', () => { + test('Should prepare VDisk size fields', () => { expect( prepareVDiskSizeFields({ AvailableSize: '400', @@ -193,7 +193,7 @@ describe('prepareVDiskSizeFields', () => { AllocatedPercent: 20, }); }); - it('Returns NaN if on undefined data', () => { + test('Returns NaN if on undefined data', () => { expect( prepareVDiskSizeFields({ AvailableSize: undefined, @@ -209,7 +209,7 @@ describe('prepareVDiskSizeFields', () => { }); describe('preparePDiskSizeFields', () => { - it('Should prepare PDisk size fields', () => { + test('Should prepare PDisk size fields', () => { expect( preparePDiskSizeFields({ AvailableSize: '400', @@ -222,7 +222,7 @@ describe('preparePDiskSizeFields', () => { AllocatedPercent: 20, }); }); - it('Returns NaN if on undefined data', () => { + test('Returns NaN if on undefined data', () => { expect( preparePDiskSizeFields({ AvailableSize: undefined, diff --git a/src/utils/hooks/__test__/useTableSort.test.ts b/src/utils/hooks/__test__/useTableSort.test.ts index 8b80395bd..193b5da43 100644 --- a/src/utils/hooks/__test__/useTableSort.test.ts +++ b/src/utils/hooks/__test__/useTableSort.test.ts @@ -5,7 +5,7 @@ import {renderHook} from '@testing-library/react'; import {useTableSort} from '../useTableSort'; describe('useTableSort', function () { - it('It works with single sort', () => { + test('It works with single sort', () => { const onSortSpy = jest.fn(); const {result} = renderHook(() => useTableSort({ @@ -76,7 +76,7 @@ describe('useTableSort', function () { }, ]); }); - it('It works with multiple sort', () => { + test('It works with multiple sort', () => { const onSortSpy = jest.fn(); const {result} = renderHook(() => useTableSort({ @@ -152,7 +152,7 @@ describe('useTableSort', function () { }, ]); }); - it('Have the same sort order if sort order is fixed', () => { + test('Have the same sort order if sort order is fixed', () => { const onSortSpy = jest.fn(); const {result} = renderHook(() => useTableSort({ diff --git a/src/utils/hooks/useAdditionalNodesProps.ts b/src/utils/hooks/useAdditionalNodesProps.ts new file mode 100644 index 000000000..0015bc3ee --- /dev/null +++ b/src/utils/hooks/useAdditionalNodesProps.ts @@ -0,0 +1,17 @@ +import {useClusterBaseInfo} from '../../store/reducers/cluster/cluster'; +import {getAdditionalNodesProps} from '../additionalProps'; +import {USE_CLUSTER_BALANCER_AS_BACKEND_KEY} from '../constants'; + +import {useSetting} from './useSetting'; +import {useTypedSelector} from './useTypedSelector'; + +/** For multi-cluster version */ +export function useAdditionalNodesProps() { + const {balancer} = useClusterBaseInfo(); + const [useClusterBalancerAsBackend] = useSetting(USE_CLUSTER_BALANCER_AS_BACKEND_KEY); + const singleClusterMode = useTypedSelector((state) => state.singleClusterMode); + + const additionalNodesProps = getAdditionalNodesProps(balancer, useClusterBalancerAsBackend); + + return singleClusterMode ? undefined : additionalNodesProps; +} diff --git a/src/utils/hooks/useIsUserAllowedToMakeChanges.ts b/src/utils/hooks/useIsUserAllowedToMakeChanges.ts new file mode 100644 index 000000000..cb4b16025 --- /dev/null +++ b/src/utils/hooks/useIsUserAllowedToMakeChanges.ts @@ -0,0 +1,7 @@ +import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; + +import {useTypedSelector} from './useTypedSelector'; + +export function useIsUserAllowedToMakeChanges() { + return useTypedSelector(selectIsUserAllowedToMakeChanges); +} diff --git a/src/utils/hooks/useNodeDeveloperUIHref.ts b/src/utils/hooks/useNodeDeveloperUIHref.ts index 411270327..e21159bb2 100644 --- a/src/utils/hooks/useNodeDeveloperUIHref.ts +++ b/src/utils/hooks/useNodeDeveloperUIHref.ts @@ -1,18 +1,15 @@ -import {useAdditionalNodeProps} from '../../containers/AppWithClusters/useClusterData'; -import {selectIsUserAllowedToMakeChanges} from '../../store/reducers/authentication/authentication'; -import {useClusterBaseInfo} from '../../store/reducers/cluster/cluster'; import type {PreparedNode} from '../../store/reducers/node/types'; import { createDeveloperUIInternalPageHref, createDeveloperUILinkWithNodeId, } from '../developerUI/developerUI'; -import {useTypedSelector} from './useTypedSelector'; +import {useAdditionalNodesProps} from './useAdditionalNodesProps'; +import {useIsUserAllowedToMakeChanges} from './useIsUserAllowedToMakeChanges'; export function useNodeDeveloperUIHref(node?: PreparedNode) { - const {balancer} = useClusterBaseInfo(); - const {additionalNodesProps} = useAdditionalNodeProps({balancer}); - const isUserAllowedToMakeChanges = useTypedSelector(selectIsUserAllowedToMakeChanges); + const additionalNodesProps = useAdditionalNodesProps(); + const isUserAllowedToMakeChanges = useIsUserAllowedToMakeChanges(); if (!isUserAllowedToMakeChanges) { return undefined; diff --git a/src/utils/prepareBackend.ts b/src/utils/prepareBackend.ts index 04855f48a..87ca7cded 100644 --- a/src/utils/prepareBackend.ts +++ b/src/utils/prepareBackend.ts @@ -20,6 +20,7 @@ export const getBackendFromNodeHost = (nodeHost: string, balancer: string) => { return https + preparedHost; }; +/** For multi-cluster version */ export const getBackendFromRawNodeData = ( node: NodeAddress, balancer: string, @@ -36,7 +37,7 @@ export const getBackendFromRawNodeData = ( const nodePort = Endpoints.find((endpoint) => endpoint.Name === 'http-mon')?.Address; if (!nodePort || !Host) { - return null; + return undefined; } const hostWithPort = Host + nodePort; @@ -46,5 +47,5 @@ export const getBackendFromRawNodeData = ( return getBackendFromNodeHost(hostWithPort, balancer); } - return null; + return undefined; }; diff --git a/src/utils/query.test.ts b/src/utils/query.test.ts index 720dd9077..4d298d63d 100644 --- a/src/utils/query.test.ts +++ b/src/utils/query.test.ts @@ -12,30 +12,30 @@ describe('API utils', () => { describe('json/viewer/query', () => { describe('parseQueryAPIResponse', () => { describe('should handle responses with incorrect format', () => { - it('should handle null response', () => { + test('should handle null response', () => { expect(parseQueryAPIResponse(null)).toEqual({}); }); - it('should handle undefined response', () => { + test('should handle undefined response', () => { expect(parseQueryAPIResponse(undefined)).toEqual({}); }); - it('should handle string response', () => { + test('should handle string response', () => { expect(parseQueryAPIResponse('foo')).toEqual({}); }); - it('should handle array response', () => { + test('should handle array response', () => { expect(parseQueryAPIResponse([{foo: 'bar'}])).toEqual({}); }); - it('should handle json string in the result field', () => { + test('should handle json string in the result field', () => { const json = {foo: 'bar'}; const response = {result: JSON.stringify(json)}; expect(parseQueryAPIResponse(response)).toEqual({}); }); - it('should handle object with request plan in the result field', () => { + test('should handle object with request plan in the result field', () => { const response = {result: {queries: 'some queries'}}; expect(parseQueryAPIResponse(response)).toEqual({}); }); }); describe('should correctly parse data', () => { - it('should accept stats without a result', () => { + test('should accept stats without a result', () => { const stats = {metric: 'good'} as TKqpStatsQuery; const response = {stats}; const actual = parseQueryAPIResponse(response); @@ -58,7 +58,7 @@ describe('API utils', () => { ['Type:ROT', 'block-4-2', '2000', '1000', 50, 0], ]; - it('should parse a valid ExecuteResponse correctly', () => { + test('should parse a valid ExecuteResponse correctly', () => { const input = { result: [ { @@ -101,7 +101,7 @@ describe('API utils', () => { expect(parseQueryAPIResponse(input)).toEqual(expected); }); - it('should handle empty result array', () => { + test('should handle empty result array', () => { const input = { result: [], stats: {DurationUs: '1000'}, @@ -115,7 +115,7 @@ describe('API utils', () => { expect(parseQueryAPIResponse(input)).toEqual(expected); }); - it('should handle result with columns but no rows', () => { + test('should handle result with columns but no rows', () => { const input = { result: [ { @@ -141,7 +141,7 @@ describe('API utils', () => { expect(parseQueryAPIResponse(input)).toEqual(expected); }); - it('should return empty object for unsupported format', () => { + test('should return empty object for unsupported format', () => { const input = { result: 'unsupported', }; @@ -149,7 +149,7 @@ describe('API utils', () => { expect(parseQueryAPIResponse(input)).toEqual({}); }); - it('should handle multiple result sets', () => { + test('should handle multiple result sets', () => { const input = { result: [ { @@ -202,7 +202,7 @@ describe('API utils', () => { expect(parseQueryAPIResponse(input)).toEqual(expected); }); - it('should handle null values in rows', () => { + test('should handle null values in rows', () => { const input = { result: [ { @@ -248,7 +248,7 @@ describe('API utils', () => { expect(parseQueryAPIResponse(input)).toEqual(expected); }); - it('should handle truncated results', () => { + test('should handle truncated results', () => { const input = { result: [ { @@ -264,7 +264,7 @@ describe('API utils', () => { expect(result.resultSets?.[0].truncated).toBe(true); }); - it('should handle empty columns and rows', () => { + test('should handle empty columns and rows', () => { const input = { result: [ { @@ -291,7 +291,7 @@ describe('API utils', () => { }); }); describe('should correctly parse plans', () => { - it('should parse explain-scan', () => { + test('should parse explain-scan', () => { const plan: PlanNode = {}; const tables: PlanTable[] = []; const meta: PlanMeta = {version: '0.2', type: 'script'}; @@ -299,7 +299,7 @@ describe('API utils', () => { const response = {plan: {Plan: plan, tables, meta}, ast}; expect(parseQueryAPIResponse(response)).toBe(response); }); - it('should parse explain-script', () => { + test('should parse explain-script', () => { const plan: PlanNode = {}; const tables: PlanTable[] = []; const meta: PlanMeta = {version: '0.2', type: 'script'}; @@ -313,7 +313,7 @@ describe('API utils', () => { }); describe('parseQueryExplainPlan', () => { - it('should parse explain script plan to explain scan', () => { + test('should parse explain script plan to explain scan', () => { const plan: PlanNode = {}; const simplifiedPlan: SimplifiedNode = {}; const tables: PlanTable[] = []; @@ -335,7 +335,7 @@ describe('API utils', () => { expect(parsedPlan.tables).toBe(tables); expect(parsedPlan.meta).toEqual(meta); }); - it('should left scan plan unchanged', () => { + test('should left scan plan unchanged', () => { const plan: PlanNode = {}; const tables: PlanTable[] = []; const meta: PlanMeta = {version: '0.2', type: 'script'}; diff --git a/src/utils/query.ts b/src/utils/query.ts index 4dc87615c..692c975be 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -139,14 +139,30 @@ export const getColumnType = (type: string) => { } }; -/** parse response result from ArrayRow to KeyValueRow */ -const parseResult = (rows: ArrayRow[], columns: ColumnType[]) => { +/** parse response result from ArrayRow to KeyValueRow and format values */ +export const parseResult = (rows: ArrayRow[], columns: ColumnType[]): KeyValueRow[] => { + // Precompute the mapping from column index to column name + const columnNames: string[] = columns.map((column) => column.name); + return rows.map((row) => { - return row.reduce((newRow, cellData, columnIndex) => { - const {name} = columns[columnIndex]; - newRow[name] = cellData; - return newRow; - }, {}); + const obj: KeyValueRow = {}; + + row.forEach((value, index) => { + const columnName = columnNames[index]; + + // Format the value based on its type + if ( + (value !== null && typeof value === 'object') || + typeof value === 'boolean' || + Array.isArray(value) + ) { + obj[columnName] = JSON.stringify(value); + } else { + obj[columnName] = value; + } + }); + + return obj; }); }; @@ -251,36 +267,6 @@ export const parseQueryExplainPlan = (plan: ScriptPlan | QueryPlan): QueryPlan = return plan; }; -export const prepareQueryResponse = (data?: KeyValueRow[]) => { - if (!Array.isArray(data)) { - return []; - } - - return data.map((row) => { - const formattedData: KeyValueRow = {}; - - for (const field in row) { - if (Object.prototype.hasOwnProperty.call(row, field)) { - const type = typeof row[field]; - - // Although typeof null == 'object' - // null result should be preserved - if ( - (row[field] !== null && type === 'object') || - type === 'boolean' || - Array.isArray(row[field]) - ) { - formattedData[field] = JSON.stringify(row[field]); - } else { - formattedData[field] = row[field]; - } - } - } - - return formattedData; - }); -}; - export const parseQueryError = (error: unknown): ErrorResponse | string | undefined => { if (typeof error === 'string' || isQueryErrorResponse(error)) { return error; diff --git a/src/utils/response.ts b/src/utils/response.ts index f040ce229..1e2a5ec41 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -7,7 +7,8 @@ export const isNetworkError = (error: unknown): error is NetworkError => { error && typeof error === 'object' && 'message' in error && - error.message === 'Network Error', + typeof error.message === 'string' && + error.message.toLowerCase() === 'network error', ); }; diff --git a/src/utils/timeParsers/__test__/formatDuration.test.ts b/src/utils/timeParsers/__test__/formatDuration.test.ts index 13986e550..744e4094b 100644 --- a/src/utils/timeParsers/__test__/formatDuration.test.ts +++ b/src/utils/timeParsers/__test__/formatDuration.test.ts @@ -25,26 +25,26 @@ describe('formatDurationToShortTimeFormat', () => { // 6 - zero const formattedZero = i18n('ms', {seconds: 0, ms: 0}); - it('should return ms on values less than second', () => { + test('should return ms on values less than second', () => { expect(formatDurationToShortTimeFormat(timeWithMs)).toEqual(formattedTimeWithMs); }); - it('should return seconds and ms', () => { + test('should return seconds and ms', () => { expect(formatDurationToShortTimeFormat(timeWithSecondsAndMsInMs)).toEqual( formattedTimeWithSecondsAndMs, ); }); - it('should return minutes and seconds', () => { + test('should return minutes and seconds', () => { expect(formatDurationToShortTimeFormat(timeWithMinutesInMs)).toEqual( formattedTimeWithMinutes, ); }); - it('should return hours and minutes', () => { + test('should return hours and minutes', () => { expect(formatDurationToShortTimeFormat(timeWithHoursInMs)).toEqual(formattedTimeWithHours); }); - it('should return days and hours', () => { + test('should return days and hours', () => { expect(formatDurationToShortTimeFormat(timeWithDaysInMs)).toEqual(formattedTimeWithDays); }); - it('should process zero values', () => { + test('should process zero values', () => { expect(formatDurationToShortTimeFormat(0)).toEqual(formattedZero); }); }); diff --git a/src/utils/timeParsers/__test__/protobuf.test.ts b/src/utils/timeParsers/__test__/protobuf.test.ts index 40499ef4a..08a402b64 100644 --- a/src/utils/timeParsers/__test__/protobuf.test.ts +++ b/src/utils/timeParsers/__test__/protobuf.test.ts @@ -34,40 +34,40 @@ describe('Protobuf time parsers', () => { }; describe('parseProtobufTimeObjectToMs', () => { - it('should work with timestamp object values', () => { + test('should work with timestamp object values', () => { expect(parseProtobufTimeObjectToMs(timeObjectWithDays)).toEqual(timeWithDaysInMs); }); - it('should work with timestamp object without seconds', () => { + test('should work with timestamp object without seconds', () => { expect(parseProtobufTimeObjectToMs(timeObjectWithNanoseconds)).toEqual( timeWithNanosecondsInMs, ); }); - it('should work with timestamp object without nanos', () => { + test('should work with timestamp object without nanos', () => { expect(parseProtobufTimeObjectToMs(timeObjectWithSeconds)).toEqual(timeWithSecondsInMs); }); - it('should work with empty object values', () => { + test('should work with empty object values', () => { expect(parseProtobufTimeObjectToMs({})).toEqual(0); }); }); describe('parseProtobufTimestampToMs', () => { - it('should work with string date values', () => { + test('should work with string date values', () => { expect(parseProtobufTimestampToMs(timestamp)).toEqual(timestampInMs); }); - it('should work with timestamp object values', () => { + test('should work with timestamp object values', () => { expect(parseProtobufTimestampToMs(timestampObject)).toEqual(timestampInMs); }); - it('should work with empty object values', () => { + test('should work with empty object values', () => { expect(parseProtobufTimestampToMs({})).toEqual(0); }); }); describe('parseProtobufDurationToMs', () => { - it('should work with string values', () => { + test('should work with string values', () => { expect(parseProtobufDurationToMs(stringDurationSeconds)).toEqual(timeWithSecondsInMs); }); - it('should work with duration object values', () => { + test('should work with duration object values', () => { expect(parseProtobufDurationToMs(timeObjectWithDays)).toEqual(timeWithDaysInMs); }); - it('should work with empty object values', () => { + test('should work with empty object values', () => { expect(parseProtobufDurationToMs({})).toEqual(0); }); });