From 7a27c6376f017923441f2b5e14dedce85f1612aa Mon Sep 17 00:00:00 2001 From: btlqql <2977859784@qq.com> Date: Tue, 14 Jul 2026 21:11:55 +0800 Subject: [PATCH 01/43] fix: align frontend API success response handling (#429) --- web/package-lock.json | 491 ++++++++++++++++++++++++++++++++++++- web/package.json | 5 +- web/src/api/client.test.ts | 109 ++++++++ web/src/api/client.ts | 29 ++- 4 files changed, 628 insertions(+), 6 deletions(-) create mode 100644 web/src/api/client.test.ts diff --git a/web/package-lock.json b/web/package-lock.json index 647e8692..ae22fb1f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,6 +25,7 @@ "@typescript-eslint/parser": "^8.62.1", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.5.2", + "axios-mock-adapter": "^2.1.0", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", @@ -36,7 +37,8 @@ "tailwindcss": "^3.4.19", "typescript": "^5.6.0", "typescript-eslint": "^8.62.1", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^3.2.4" } }, "node_modules/@alloc/quick-lru": { @@ -1725,6 +1727,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2041,6 +2061,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2241,6 +2376,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2296,6 +2441,20 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2392,6 +2551,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2436,6 +2605,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2634,6 +2830,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2723,6 +2929,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3042,6 +3255,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3059,6 +3282,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3479,6 +3712,30 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -3784,6 +4041,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3794,6 +4058,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4067,6 +4341,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5246,6 +5537,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5286,6 +5584,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -5335,6 +5647,26 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", @@ -5447,6 +5779,13 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -5474,6 +5813,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5687,6 +6056,109 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5703,6 +6175,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/web/package.json b/web/package.json index 3c79ec24..1e7248b4 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "test": "vitest run", "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -38,6 +39,7 @@ "@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/parser": "^8.62.1", "@vitejs/plugin-react": "^4.3.0", + "axios-mock-adapter": "^2.1.0", "autoprefixer": "^10.5.2", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", @@ -50,6 +52,7 @@ "tailwindcss": "^3.4.19", "typescript": "^5.6.0", "typescript-eslint": "^8.62.1", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^3.2.4" } } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 00000000..77a504f9 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockAdapter from 'axios-mock-adapter'; +import { message } from 'antd'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; + +vi.mock('antd', () => ({ + message: { + error: vi.fn(), + }, +})); + +const mock = new MockAdapter(client); +const storage = new Map(); + +vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + key: (index: number) => [...storage.keys()][index] ?? null, + get length() { + return storage.size; + }, +}); + +describe('API client response contract', () => { + beforeEach(() => { + mock.reset(); + localStorage.clear(); + vi.mocked(message.error).mockClear(); + }); + + afterEach(() => { + mock.reset(); + }); + + it('accepts the backend Result.ok response code', async () => { + const payload = { code: 200, message: 'success', data: { name: 'cluster-a' } }; + mock.onGet('/clusters').reply(200, payload); + + const response = await client.get('/clusters'); + + expect(response.data).toEqual(payload); + expect(message.error).not.toHaveBeenCalled(); + }); + + it('keeps compatibility with legacy zero success codes', async () => { + const payload = { code: 0, message: 'success', data: ['topic-a'] }; + mock.onGet('/topics').reply(200, payload); + + await expect(client.get('/topics')).resolves.toMatchObject({ data: payload }); + expect(message.error).not.toHaveBeenCalled(); + }); + + it('passes through responses without a business envelope', async () => { + const payload = [{ id: 'broker-a' }]; + mock.onGet('/brokers').reply(200, payload); + + await expect(client.get('/brokers')).resolves.toMatchObject({ data: payload }); + expect(message.error).not.toHaveBeenCalled(); + }); + + it('rejects non-success business codes with the backend message', async () => { + mock.onPost('/topics/create').reply(200, { + code: 400, + message: 'Topic already exists', + data: null, + }); + + await expect(client.post('/topics/create', { name: 'orders' })).rejects.toThrow( + 'Topic already exists', + ); + expect(message.error).toHaveBeenCalledWith('Topic already exists'); + }); + + it('uses a stable fallback for malformed error envelopes', async () => { + mock.onGet('/clusters').reply(200, { code: '500', data: null }); + + await expect(client.get('/clusters')).rejects.toThrow('请求失败'); + expect(message.error).toHaveBeenCalledWith('请求失败'); + }); + + it('attaches the stored bearer token to outgoing requests', async () => { + localStorage.setItem('token', 'test-token'); + mock.onGet('/clusters').reply((config) => { + expect(config.headers?.Authorization).toBe('Bearer test-token'); + return [200, { code: 200, message: 'success', data: [] }]; + }); + + await client.get('/clusters'); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bb341adf..a87d762c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -18,6 +18,27 @@ import axios from 'axios'; import { message } from 'antd'; +const SUCCESS_BUSINESS_CODES = new Set([0, 200]); + +interface BusinessResponse { + code?: unknown; + message?: unknown; +} + +function isBusinessResponse(data: unknown): data is BusinessResponse { + return typeof data === 'object' && data !== null; +} + +function getBusinessError(data: unknown): string | null { + if (!isBusinessResponse(data) || data.code === undefined) { + return null; + } + if (typeof data.code === 'number' && SUCCESS_BUSINESS_CODES.has(data.code)) { + return null; + } + return typeof data.message === 'string' && data.message.trim() ? data.message : '请求失败'; +} + const client = axios.create({ baseURL: '/api', timeout: 30000, @@ -38,10 +59,10 @@ client.interceptors.request.use( // Response interceptor: check business code and handle 401 client.interceptors.response.use( (response) => { - const data = response.data; - if (data.code !== undefined && data.code !== 0) { - message.error(data.message || '请求失败'); - return Promise.reject(new Error(data.message || '请求失败')); + const errorMessage = getBusinessError(response.data); + if (errorMessage) { + message.error(errorMessage); + return Promise.reject(new Error(errorMessage)); } return response; }, From f971efc5c286aaad43925cf34e9ede91cd320574 Mon Sep 17 00:00:00 2001 From: lizhimins <707364882@qq.com> Date: Wed, 15 Jul 2026 16:33:32 +0800 Subject: [PATCH 02/43] ci: add build workflow and PR review skill (#430) --- .claude/skills/pr-review/SKILL.md | 215 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 54 ++++++++ 2 files changed, 269 insertions(+) create mode 100644 .claude/skills/pr-review/SKILL.md create mode 100644 .github/workflows/ci.yml diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md new file mode 100644 index 00000000..91555cf8 --- /dev/null +++ b/.claude/skills/pr-review/SKILL.md @@ -0,0 +1,215 @@ +--- +name: pr-review +description: RocketMQ Studio 的 PR 评审助手。输入一个 GitHub PR 链接,自动用 gh 拉取 PR 及其关联 Issue,切出本地分支,编译前端与后端,用 docker compose 拉起整个项目(不改端口),并产出结构化的 PR 分析总结。当用户提到 评审 PR、review PR、看一下这个 PR、PR 分析、拉 PR 编译、检查 PR、pr review、审查合并请求 等场景时触发;即使用户只贴了一个 GitHub PR 链接,也应触发此 skill。 +--- + +# RocketMQ Studio PR 评审 + +对 RocketMQ Studio(`apache/rocketmq-dashboard`,分支 `rocketmq-studio`)的一个 GitHub PR 做端到端评审:拉取 PR 与关联 Issue → 本地切分支 → 编译前后端 → docker compose 拉起 → 输出结构化分析总结。 + +## 前置条件 + +- 已安装并登录 `gh`(`gh auth status` 确认)。 +- 已安装 `docker`(含 `docker compose`)、`node`(>=20)、`npm`、`mvn`(JDK 21)。 +- 当前工作目录为 `project/rocketmq-studio`(项目根,含 `server/`、`web/`、`deploy/`)。 + +> 端口约定(**禁止修改**):前端 6789(Nginx)、后端 8888(Spring Boot)、NameServer 9876、Broker 10911、Proxy 8080/8081。 + +## 输入 + +一个 PR 链接,例如:`https://github.com/apache/rocketmq-dashboard/pull/123` + +从链接中解析出 PR 编号 ``(URL 最后一段数字)。 + +## 执行步骤 + +### 1. 拉取 PR 元信息 + +```bash +gh pr view --repo apache/rocketmq-dashboard \ + --json number,title,author,state,baseRefName,headRefName,url,body,additions,deletions,changedFiles,labels,commits,files,closingIssuesReferences +``` + +重点关注: +- `baseRefName` 是否为 `rocketmq-studio`(目标分支应为它,否则在总结中提示)。 +- `files` / `changedFiles` / `additions` / `deletions`:变更范围与体量。 +- `closingIssuesReferences`:PR 声明会关闭的 Issue(`Closes #N`)。 + +同时拉取 diff 供后续分析: + +```bash +gh pr diff --repo apache/rocketmq-dashboard > /tmp/pr-.diff +``` + +### 1.5 检查 PR 标题是否规范 + +规范来源:`docs/contributing.md`「提交规范」+ `README.md`「Commit Format」+ 本仓库既有 commit 历史,统一遵循 [Conventional Commits](https://www.conventionalcommits.org/)。 + +**格式**:`: ` + +- **type** 必须为以下之一(小写):`feat`(新功能)、`fix`(修复 Bug)、`docs`(文档)、`refactor`(重构)、`test`(测试)、`chore`(构建/工具)、`perf`(性能)。 +- type 后紧跟半角冒号 `:` 加一个空格,再接 **description**。 +- description 用英文小写祈使句,简洁描述改动,结尾不加句号。 +- 可选 scope:`type(scope): description`(scope 为小写模块名,如 `fix(web):`)。 +- squash 合并后 GitHub 会在标题末尾追加 ` (#PR号)`,属正常现象,检查时应先剥离该后缀。 + +校验正则(先去掉可能存在的 ` (#N)` 尾巴): + +```bash +TITLE=$(gh pr view --repo apache/rocketmq-dashboard --json title -q .title) +CLEAN=$(printf '%s' "$TITLE" | sed -E 's/ \(#[0-9]+\)$//') +if printf '%s' "$CLEAN" | grep -Eq '^(feat|fix|docs|refactor|test|chore|perf)(\([a-z0-9-]+\))?: .+'; then + echo "标题规范 ✅: $TITLE" +else + echo "标题不规范 ❌: $TITLE" +fi +``` + +对照参考(本仓库历史):`fix: align frontend API success response handling (#429)`、`feat: implement backend cluster management module`、`chore: initialize project with license and git config`。 + +不规范时在总结中明确指出问题(type 非法 / 缺冒号或空格 / 用了中文或首字母大写 / 结尾多余标点等)并给出建议标题。 + +### 2. 拉取关联 Issue(若有) + +若 `closingIssuesReferences` 非空,或 PR 正文中出现 `#N` / `Closes #N` / `Fixes #N` 引用,逐个拉取: + +```bash +gh issue view --repo apache/rocketmq-dashboard \ + --json number,title,state,body,labels,url +``` + +用于判断 PR 是否真正解决了 Issue 描述的问题(需求对齐度)。 + +### 3. 本地切出评审分支 + +不要污染当前分支。用 `gh` 直接 checkout PR 分支(会自动创建本地分支): + +```bash +# 记录当前分支以便复原 +git rev-parse --abbrev-ref HEAD +git stash push -u -m "pr-review-stash" 2>/dev/null || true + +gh pr checkout --repo apache/rocketmq-dashboard --branch pr-review- +``` + +若因权限/fork 无法直接 checkout,退化为手动 fetch: + +```bash +git fetch apache pull//head:pr-review- +git checkout pr-review- +``` + +### 4. 编译后端 + +严格对齐 `server/Dockerfile` 的构建方式(`mvn package -DskipTests`,含 checkstyle 校验): + +```bash +cd server +mvn -B -ntp clean package -DskipTests +cd .. +``` + +- 编译失败 → 记录报错(编译错误 / checkstyle 违规),标记后端为 ❌,仍继续后续步骤并如实汇报。 +- 通过 → 标记 ✅。 + +### 5. 编译前端 + +对齐 `web/Dockerfile`(`npm ci && npm run build`,即 `tsc -b && vite build`): + +```bash +cd web +npm ci +npm run build +cd .. +``` + +- 出现 TypeScript 类型错误或构建失败 → 标记前端 ❌,记录关键报错。 +- 通过 → 标记 ✅。 + +### 6. docker compose 拉起项目 + +使用项目自带 compose 文件,**不修改任何端口**: + +```bash +cd deploy +docker compose up -d --build +cd .. +``` + +启动后做健康检查(不改端口): + +```bash +# 前端(Nginx) +curl -fsS -o /dev/null -w "web=%{http_code}\n" http://127.0.0.1:6789/ || echo "web 未就绪" +# 后端 actuator(经前端 nginx 反代或容器内),若暴露则直接探测 +curl -fsS -o /dev/null -w "api=%{http_code}\n" http://127.0.0.1:6789/actuator/health || echo "api 未就绪" +docker compose -f deploy/docker-compose.yml ps +``` + +评审结束后清理(询问用户或默认保留,视会话上下文而定): + +```bash +docker compose -f deploy/docker-compose.yml down +``` + +### 7. 复原工作区 + +评审完成后切回原分支并恢复暂存: + +```bash +git checkout <原分支> +git stash pop 2>/dev/null || true +# 如需删除评审分支:git branch -D pr-review- +``` + +## 输出:PR 分析总结 + +用中文输出一份结构化 Markdown 总结,包含以下部分: + +### 1. 概览 +- 标题、作者、状态、源分支 → 目标分支、PR 链接。 +- **PR 标题规范**:✅/❌(不规范时标出具体问题并附建议标题)。 +- 变更体量:`+additions / -deletions`,改动文件数。 +- 关联 Issue:编号、标题、链接(若有)。 + +### 2. 需求对齐 +- 简述 PR 目标(来自正文)。 +- 若有关联 Issue,逐条比对 Issue 诉求与 PR 实现,判断是否覆盖 / 部分覆盖 / 偏离。 + +### 3. 构建与运行结果 +用表格汇总: + +| 检查项 | 结果 | 说明 | +|---|---|---| +| 后端编译 (`mvn package`) | ✅/❌ | 关键报错摘要 | +| 前端编译 (`npm run build`) | ✅/❌ | 关键报错摘要 | +| docker compose 启动 | ✅/❌ | 容器状态 / 端口 6789、8888 | +| 前端健康检查 | ✅/❌ | HTTP 状态码 | + +### 4. 变更分析 +- 按模块归类改动(前端页面/组件、后端 controller/service/domain、部署、文档等)。 +- 结合六边形架构(server 用 ArchUnit 约束)判断分层是否合理。 +- i18n:新增前端文案是否中英文双语(`web/src/i18n/`)。 +- 提交规范:commit message 是否符合 Conventional Commits。 + +### 5. 风险与建议 +- 潜在逻辑问题、边界情况、安全风险(如凭据明文、公网暴露)。 +- 缺失的测试 / 文档 / i18n。 +- 明确的改进建议(可执行、可定位到文件)。 + +### 6. 评审结论 +给出倾向:**Approve** / **Request Changes** / **Comment**,并用一句话说明理由。 + +## 自由发挥补充能力 + +- **变更规模自适应**:大 PR(改动文件多)先按目录聚合概述再抽样精读核心文件;小 PR 可逐文件过。 +- **checkstyle / lint 单独复核**:后端 `mvn checkstyle:check`,前端 `npm run lint`,把风格问题与逻辑问题分开汇报。 +- **失败即止但汇报完整**:任一步失败不阻断总结,如实记录并继续能做的检查。 +- **可选发布评审意见**:用户明确要求时,可用 `gh pr comment --repo apache/rocketmq-dashboard --body-file ` 或 `gh pr review --comment/--approve/--request-changes` 提交(默认只本地产出,不自动发布)。 + +## 注意事项 + +- **不修改端口**:任何环节都使用既有端口,不得改 compose / nginx / env 中的端口映射。 +- **不污染主分支**:评审在独立分支进行,结束后复原。 +- **只读默认**:默认不向 GitHub 写入评论/评审,除非用户明确要求。 +- **凭据安全**:分析 diff 时若发现 AK/SK、password、token 等明文凭据,作为高风险项在总结中显著标注。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b5f1b59c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +# 检测前端与后端是否都能正确编译 +on: + push: + branches: + - rocketmq-studio + pull_request: + branches: + - rocketmq-studio + +jobs: + backend-build: + name: Backend Build (Java 21) + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 (Dragonwell) + uses: actions/setup-java@v4 + with: + distribution: dragonwell + java-version: "21" + cache: maven + + - name: Build backend + run: mvn -B -ntp clean package -DskipTests + + frontend-build: + name: Frontend Build (Node 20) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build frontend + run: npm run build From a002477b5264423250668a25d054fa8aaaa75c7e Mon Sep 17 00:00:00 2001 From: btlqql <2977859784@qq.com> Date: Fri, 17 Jul 2026 16:18:57 +0800 Subject: [PATCH 03/43] fix: connect K8s certificate page to APIs (#440) --- web/src/api/cluster.test.ts | 83 +++++++++++++++ web/src/api/cluster.ts | 9 +- web/src/pages/cluster/certs.tsx | 165 ++++++++++++++++++++++------- web/src/services/clusterService.ts | 53 ++++++++- 4 files changed, 267 insertions(+), 43 deletions(-) create mode 100644 web/src/api/cluster.test.ts diff --git a/web/src/api/cluster.test.ts b/web/src/api/cluster.test.ts new file mode 100644 index 00000000..49a35e52 --- /dev/null +++ b/web/src/api/cluster.test.ts @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { createK8sCert, deleteK8sCert, listK8sCerts, updateK8sCert } from './cluster'; +import type { K8sCertInfo } from './cluster'; + +const mock = new MockAdapter(client); + +const cert: K8sCertInfo = { + id: 'cert-1', + name: 'rocketmq-tls', + namespace: 'rocketmq', + cluster: 'prod-cluster', + type: 'TLS', + issuer: 'kubernetes-ca', + notBefore: '2026-01-01T00:00:00Z', + notAfter: '2027-01-01T00:00:00Z', + status: 'valid', + daysRemaining: 365, + san: ['broker.example.com'], +}; + +describe('K8s certificate API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads and unwraps certificate records', async () => { + mock.onGet('/k8s-certs').reply(200, { code: 200, message: 'success', data: [cert] }); + + await expect(listK8sCerts()).resolves.toEqual([cert]); + }); + + it('returns the certificate created by the backend', async () => { + mock.onPost('/k8s-certs/create').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ name: cert.name, cluster: cert.cluster }); + return [200, { code: 200, message: 'success', data: cert }]; + }); + + await expect(createK8sCert({ name: cert.name, cluster: cert.cluster })).resolves.toEqual(cert); + }); + + it('returns the updated certificate and sends its id', async () => { + const updated = { ...cert, issuer: 'vault' }; + mock.onPost('/k8s-certs/update').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ id: cert.id, issuer: 'vault' }); + return [200, { code: 200, message: 'success', data: updated }]; + }); + + await expect(updateK8sCert({ id: cert.id, issuer: 'vault' })).resolves.toEqual(updated); + }); + + it('sends the certificate id when deleting', async () => { + mock.onPost('/k8s-certs/delete').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ id: cert.id }); + return [200, { code: 200, message: 'success', data: null }]; + }); + + await expect(deleteK8sCert(cert.id)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/cluster.ts b/web/src/api/cluster.ts index c7d35ef0..007498b2 100644 --- a/web/src/api/cluster.ts +++ b/web/src/api/cluster.ts @@ -155,15 +155,18 @@ export async function listK8sCerts() { } export async function createK8sCert(data: Partial) { - await client.post('/k8s-certs/create', data); + const res = await client.post<{ data: K8sCertInfo }>('/k8s-certs/create', data); + return res.data.data; } export async function updateK8sCert(data: Partial) { - await client.post('/k8s-certs/update', data); + const res = await client.post<{ data: K8sCertInfo }>('/k8s-certs/update', data); + return res.data.data; } export async function renewK8sCert(id: string) { - await client.post('/k8s-certs/renew', { id }); + const res = await client.post<{ data: K8sCertInfo }>('/k8s-certs/renew', { id }); + return res.data.data; } export async function deleteK8sCert(id: string) { diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx index 84493579..e5155467 100644 --- a/web/src/pages/cluster/certs.tsx +++ b/web/src/pages/cluster/certs.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Table, Tag, @@ -33,7 +33,13 @@ import { import type { ColumnsType } from 'antd/es/table'; import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import PageHeader from '../../components/PageHeader'; -import { mockK8sCerts, type K8sCertInfo } from '../../mock/clusters'; +import type { K8sCertInfo } from '../../api/cluster'; +import { + createK8sCert, + deleteK8sCert, + listK8sCerts, + updateK8sCert, +} from '../../services/clusterService'; const { Text } = Typography; @@ -43,14 +49,84 @@ const formatDateTime = (iso: string): string => { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; +const getErrorMessage = (error: unknown): string => + error instanceof Error && error.message ? error.message : '请求失败,请稍后重试'; + const K8sCertsPage = () => { - const [certs, setCerts] = useState(mockK8sCerts); + const [certs, setCerts] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); const [certSearch, setCertSearch] = useState(''); const [certTypeFilter, setCertTypeFilter] = useState(''); const [editModalOpen, setEditModalOpen] = useState(false); const [editingCert, setEditingCert] = useState(null); const [editForm] = Form.useForm(); + useEffect(() => { + let active = true; + listK8sCerts() + .then((data) => { + if (active) setCerts(data); + }) + .catch((error: unknown) => { + if (active) message.error(getErrorMessage(error)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, []); + + const openCreateModal = () => { + setEditingCert(null); + editForm.resetFields(); + editForm.setFieldsValue({ type: 'TLS', namespace: 'default' }); + setEditModalOpen(true); + }; + + const closeEditModal = () => { + setEditModalOpen(false); + setEditingCert(null); + editForm.resetFields(); + }; + + const saveCert = async () => { + const values = await editForm.validateFields(); + const data = { + name: values.name, + namespace: values.namespace, + cluster: values.cluster, + type: values.type, + issuer: values.issuer, + san: values.san + ? String(values.san) + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + : [], + }; + + setSubmitting(true); + try { + if (editingCert) { + const updated = await updateK8sCert({ id: editingCert.id, ...data }); + setCerts((prev) => prev.map((cert) => (cert.id === updated.id ? updated : cert))); + message.success(`证书「${updated.name}」已更新`); + } else { + const created = await createK8sCert(data); + setCerts((prev) => [...prev, created]); + message.success(`证书「${created.name}」已创建`); + } + closeEditModal(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setSubmitting(false); + } + }; + const filteredCerts = certs.filter((cert) => { const matchSearch = !certSearch || @@ -163,6 +239,8 @@ const K8sCertsPage = () => { type: record.type, issuer: record.issuer, namespace: record.namespace, + cluster: record.cluster, + san: record.san.join(', '), }); setEditModalOpen(true); }} @@ -180,9 +258,15 @@ const K8sCertsPage = () => { okText: '确认', cancelText: '取消', okButtonProps: { danger: true }, - onOk: () => { - setCerts((prev) => prev.filter((c) => c.id !== record.id)); - message.success(`证书已删除: ${record.name}`); + onOk: async () => { + try { + await deleteK8sCert(record.id); + setCerts((prev) => prev.filter((c) => c.id !== record.id)); + message.success(`证书已删除: ${record.name}`); + } catch (error) { + message.error(getErrorMessage(error)); + throw error; + } }, }); }} @@ -200,11 +284,7 @@ const K8sCertsPage = () => { title="K8s 证书管理" subtitle={`共 ${filteredCerts.length} 个证书`} extra={ - } @@ -236,6 +316,7 @@ const K8sCertsPage = () => { columns={certColumns} dataSource={filteredCerts} rowKey="id" + loading={loading} pagination={{ pageSize: 20 }} size="small" /> @@ -243,39 +324,36 @@ const K8sCertsPage = () => { {/* Edit Cert Modal */} { - setEditModalOpen(false); - editForm.resetFields(); - }} - onOk={() => { - editForm.validateFields().then((values) => { - if (!editingCert) return; - setCerts((prev) => - prev.map((c) => - c.id === editingCert.id - ? { ...c, issuer: values.issuer, namespace: values.namespace } - : c, - ), - ); - message.success(`证书「${editingCert.name}」已更新`); - setEditModalOpen(false); - editForm.resetFields(); - }); - }} + onCancel={closeEditModal} + onOk={saveCert} + confirmLoading={submitting} okText="保存" cancelText="取消" width={520} >
- - + + + + + - + - + + + +
diff --git a/web/src/services/clusterService.ts b/web/src/services/clusterService.ts index 0e6615a8..a3270e2f 100644 --- a/web/src/services/clusterService.ts +++ b/web/src/services/clusterService.ts @@ -1,7 +1,12 @@ import { USE_MOCK } from '../config'; import * as clusterApi from '../api/cluster'; import type { ClusterInfo, K8sCertInfo } from '../api/cluster'; -import clusters from '../mock/clusters'; +import clusters, { mockK8sCerts } from '../mock/clusters'; + +const mockCertStore: K8sCertInfo[] = mockK8sCerts.map((cert) => ({ + ...cert, + san: [...cert.san], +})); export async function listClusters(): Promise { if (USE_MOCK) { @@ -32,10 +37,54 @@ export async function getCluster(id: string) { } export async function listK8sCerts(): Promise { - if (USE_MOCK) return []; // certs mock not yet defined + if (USE_MOCK) return mockCertStore.map((cert) => ({ ...cert, san: [...cert.san] })); return clusterApi.listK8sCerts(); } +export async function createK8sCert(data: Partial): Promise { + if (USE_MOCK) { + const now = new Date(); + const notAfter = new Date(now); + notAfter.setFullYear(notAfter.getFullYear() + 1); + const cert: K8sCertInfo = { + id: `cert-${Date.now()}`, + name: data.name ?? '', + namespace: data.namespace ?? '', + cluster: data.cluster ?? '', + type: data.type ?? 'TLS', + issuer: data.issuer ?? '', + notBefore: now.toISOString(), + notAfter: notAfter.toISOString(), + status: 'valid', + daysRemaining: 365, + san: data.san ?? [], + }; + mockCertStore.push(cert); + return { ...cert, san: [...cert.san] }; + } + return clusterApi.createK8sCert(data); +} + +export async function updateK8sCert(data: Partial): Promise { + if (USE_MOCK) { + const existing = mockCertStore.find((cert) => cert.id === data.id); + if (!existing) throw new Error(`Certificate not found: ${data.id}`); + Object.assign(existing, data, { san: data.san ?? existing.san }); + return { ...existing, san: [...existing.san] }; + } + return clusterApi.updateK8sCert(data); +} + +export async function deleteK8sCert(id: string): Promise { + if (USE_MOCK) { + const index = mockCertStore.findIndex((cert) => cert.id === id); + if (index < 0) throw new Error(`Certificate not found: ${id}`); + mockCertStore.splice(index, 1); + return; + } + return clusterApi.deleteK8sCert(id); +} + export async function updateClusterConfig(data: { id: string } & Record) { if (USE_MOCK) return; return clusterApi.updateClusterConfig(data); From 6f43bffe4d18231ca81ce56f6521819a4211f59b Mon Sep 17 00:00:00 2001 From: zhaohai <33314633+zhaohai666@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:30:53 +0800 Subject: [PATCH 04/43] feat: extend translation keys & add useLanguage compatible alias in LangContext (#433) --- web/package-lock.json | 1079 +++++++++++++++---- web/package.json | 15 +- web/src/i18n/LangContext.tsx | 6 + web/src/i18n/__tests__/LangContext.test.tsx | 122 +++ web/src/i18n/translations.ts | 484 +++++++++ web/src/test/setup.ts | 6 + web/vite.config.ts | 8 +- 7 files changed, 1517 insertions(+), 203 deletions(-) create mode 100644 web/src/i18n/__tests__/LangContext.test.tsx create mode 100644 web/src/test/setup.ts diff --git a/web/package-lock.json b/web/package-lock.json index ae22fb1f..98a67cb1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -19,6 +19,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^8.62.1", @@ -31,6 +34,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.4.0", "postcss": "^8.5.16", "prettier": "^3.9.4", @@ -38,9 +42,16 @@ "typescript": "^5.6.0", "typescript-eslint": "^8.62.1", "vite": "^6.0.0", - "vitest": "^3.2.4" + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -151,6 +162,57 @@ "react": ">=16.9.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -442,6 +504,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -1011,6 +1226,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1682,6 +1915,111 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2062,39 +2400,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2106,42 +2445,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -2149,28 +2488,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2376,6 +2712,16 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2478,6 +2824,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2551,16 +2907,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2606,32 +2952,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2788,6 +3117,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2807,6 +3157,20 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -2830,15 +3194,12 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/deep-is": { "version": "0.1.4", @@ -2856,6 +3217,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2870,6 +3241,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2898,6 +3277,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2930,9 +3322,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -3650,6 +4042,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3699,6 +4104,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3801,6 +4216,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3824,6 +4246,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4041,13 +4514,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4058,6 +4524,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4077,6 +4554,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4148,6 +4632,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -4248,6 +4742,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -4314,6 +4822,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4348,16 +4869,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4587,6 +5098,55 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -5348,6 +5908,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -5480,6 +6064,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5592,9 +6189,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -5647,26 +6244,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^9.0.1" + "min-indent": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "engines": { + "node": ">=8" } }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", @@ -5709,6 +6299,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -5813,36 +6410,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.0.0" + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" } }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5862,6 +6459,32 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5933,6 +6556,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6056,89 +6689,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -6149,15 +6773,59 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } }, "node_modules/which": { "version": "2.0.2", @@ -6238,6 +6906,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 1e7248b4..025967d3 100644 --- a/web/package.json +++ b/web/package.json @@ -6,12 +6,13 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "test": "vitest run", "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", - "prepare": "cd .. && husky" + "prepare": "cd .. && husky", + "test": "vitest run", + "test:watch": "vitest" }, "lint-staged": { "*.{ts,tsx}": [ @@ -34,18 +35,22 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/parser": "^8.62.1", "@vitejs/plugin-react": "^4.3.0", - "axios-mock-adapter": "^2.1.0", "autoprefixer": "^10.5.2", + "axios-mock-adapter": "^2.1.0", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.4.0", "postcss": "^8.5.16", "prettier": "^3.9.4", @@ -53,6 +58,6 @@ "typescript": "^5.6.0", "typescript-eslint": "^8.62.1", "vite": "^6.0.0", - "vitest": "^3.2.4" + "vitest": "^4.1.10" } -} +} \ No newline at end of file diff --git a/web/src/i18n/LangContext.tsx b/web/src/i18n/LangContext.tsx index 76671569..9a5a1b8b 100644 --- a/web/src/i18n/LangContext.tsx +++ b/web/src/i18n/LangContext.tsx @@ -47,3 +47,9 @@ export const LangProvider = ({ children }: { children: ReactNode }) => { }; export const useLang = () => useContext(LangContext); + +/** + * Alias for useLang – provides the same i18n context. + * Kept for backward compatibility with components that import `useLanguage`. + */ +export const useLanguage = useLang; diff --git a/web/src/i18n/__tests__/LangContext.test.tsx b/web/src/i18n/__tests__/LangContext.test.tsx new file mode 100644 index 00000000..1e3ac571 --- /dev/null +++ b/web/src/i18n/__tests__/LangContext.test.tsx @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LangProvider, useLang, useLanguage } from '../LangContext'; + +/** Helper component that consumes LangContext and exposes its values */ +const LangConsumer = () => { + const { lang, setLang, t } = useLang(); + return ( +
+ {lang} + {t('nav.home')} + {t('nonexistent.key')} + {t('common.total', { count: 42 })} + + +
+ ); +}; + +/** Component that uses the useLanguage alias */ +const LanguageAliasConsumer = () => { + const { lang } = useLanguage(); + return {lang}; +}; + +describe('LangContext', () => { + it('defaults to zh language', () => { + render( + + + , + ); + expect(screen.getByTestId('lang')).toHaveTextContent('zh'); + }); + + it('translates keys in Chinese by default', () => { + render( + + + , + ); + expect(screen.getByTestId('translated')).toHaveTextContent('首页'); + }); + + it('switches to English via setLang', async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByText('switch-en')); + expect(screen.getByTestId('lang')).toHaveTextContent('en'); + expect(screen.getByTestId('translated')).toHaveTextContent('Home'); + }); + + it('switches back to Chinese via setLang', async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByText('switch-en')); + expect(screen.getByTestId('lang')).toHaveTextContent('en'); + await user.click(screen.getByText('switch-zh')); + expect(screen.getByTestId('lang')).toHaveTextContent('zh'); + expect(screen.getByTestId('translated')).toHaveTextContent('首页'); + }); + + it('returns the key itself for missing translations', () => { + render( + + + , + ); + expect(screen.getByTestId('missing')).toHaveTextContent('nonexistent.key'); + }); + + it('interpolates parameters into translation strings', async () => { + const user = userEvent.setup(); + render( + + + , + ); + // In Chinese mode, 'common.total' is '共' — no {count} placeholder in base text, + // but the interpolation logic should still work without error. + // Switch to English to verify a key that might use params. + await user.click(screen.getByText('switch-en')); + // Even if the key doesn't have a {count} placeholder, the t() function + // should not throw — it simply replaces matching placeholders. + expect(screen.getByTestId('param')).toHaveTextContent('Total'); + }); + + it('useLanguage alias provides the same context as useLang', () => { + render( + + + , + ); + expect(screen.getByTestId('alias-lang')).toHaveTextContent('zh'); + }); +}); \ No newline at end of file diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 1916eb09..c07151f6 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -334,10 +334,494 @@ const translations: Record> = { // ─── Settings ─── 'settings.title': { zh: '设置', en: 'Settings' }, + 'settings.subtitle': { zh: '管理应用配置与数据源', en: 'Manage app settings and data sources' }, // ─── User Menu ─── 'user.profile': { zh: '个人中心', en: 'Profile' }, 'user.logout': { zh: '退出登录', en: 'Logout' }, + + // ─── Login ─── + 'login.title': { zh: '登录', en: 'Login' }, + 'login.username': { zh: '用户名', en: 'Username' }, + 'login.password': { zh: '密码', en: 'Password' }, + 'login.usernamePlaceholder': { zh: '请输入用户名', en: 'Enter username' }, + 'login.passwordPlaceholder': { zh: '请输入密码', en: 'Enter password' }, + 'login.usernameRequired': { zh: '用户名为必填项', en: 'Username is required' }, + 'login.passwordRequired': { zh: '密码为必填项', en: 'Password is required' }, + 'login.success': { zh: '登录成功', en: 'Login successful' }, + 'login.failed': { zh: '登录失败', en: 'Login failed' }, + 'login.welcome': { zh: '欢迎使用 RocketMQ 仪表盘', en: 'Welcome to RocketMQ Dashboard' }, + + // ─── Ops ─── + 'ops.title': { zh: '运维', en: 'Ops' }, + 'ops.name': { zh: '名称', en: 'Name' }, + 'ops.value': { zh: '值', en: 'Value' }, + 'ops.description': { zh: '描述', en: 'Description' }, + + // ─── Topic (detailed) ─── + 'topic.subtitle': { + zh: '管理 Topic 的创建、配置与删除', + en: 'Manage Topic creation, configuration and deletion', + }, + 'topic.count': { zh: '共 {n} 个 Topic', en: '{n} Topics' }, + 'topic.add': { zh: '添加主题', en: 'Add Topic' }, + 'topic.config': { zh: '主题配置', en: 'Topic Config' }, + 'topic.change': { zh: '修改主题', en: 'Modify Topic' }, + 'topic.name': { zh: '主题名', en: 'Topic Name' }, + 'topic.type': { zh: '消息类型', en: 'Message Type' }, + 'topic.normal': { zh: '普通消息', en: 'Normal' }, + 'topic.fifo': { zh: '顺序消息', en: 'FIFO' }, + 'topic.delay': { zh: '定时/延时消息', en: 'Delay' }, + 'topic.transaction': { zh: '事务消息', en: 'Transaction' }, + 'topic.unspecified': { zh: '未指定', en: 'Unspecified' }, + 'topic.perm': { zh: '权限', en: 'Permission' }, + 'topic.readQueueNums': { zh: '读队列数量', en: 'Read Queue Num' }, + 'topic.writeQueueNums': { zh: '写队列数量', en: 'Write Queue Num' }, + 'topic.brokerName': { zh: 'Broker 名称', en: 'Broker Name' }, + 'topic.brokerAddr': { zh: 'Broker 地址', en: 'Broker Address' }, + 'topic.clusterName': { zh: '集群名', en: 'Cluster Name' }, + 'topic.selectCluster': { zh: '请选择集群', en: 'Select Cluster' }, + 'topic.selectBroker': { zh: '请选择 Broker', en: 'Select Broker' }, + 'topic.queueData': { zh: '队列信息', en: 'Queue Info' }, + 'topic.minOffset': { zh: '最小位点', en: 'Min Offset' }, + 'topic.maxOffset': { zh: '最大位点', en: 'Max Offset' }, + 'topic.lastUpdateTime': { zh: '上次更新时间', en: 'Last Update Time' }, + 'topic.sendMsg': { zh: '发送消息', en: 'Send Message' }, + 'topic.resetOffset': { zh: '重置位点', en: 'Reset Offset' }, + 'topic.skipAccumulate': { zh: '跳过堆积', en: 'Skip Accumulate' }, + 'topic.delete': { zh: '删除主题', en: 'Delete Topic' }, + 'topic.confirmDelete': { zh: '确认删除此主题?', en: 'Delete this topic?' }, + 'topic.deleteWarning': { zh: '删除后无法恢复,请确认。', en: 'This cannot be undone.' }, + 'topic.fetchFailed': { zh: '获取主题列表失败', en: 'Failed to fetch topic list' }, + 'topic.operationSuccess': { zh: 'Topic 操作成功', en: 'Topic operation successful' }, + 'topic.searchPlaceholder': { zh: '搜索主题名称', en: 'Search topic name' }, + 'topic.filterType': { zh: '消息类型', en: 'Message Type' }, + 'topic.filterAll': { zh: '全部类型', en: 'All Types' }, + + // ─── Group / Consumer (detailed) ─── + 'group.subtitle': { + zh: '管理消费者组的订阅与消费位点', + en: 'Manage consumer group subscriptions and offsets', + }, + 'group.count': { zh: '共 {n} 个消费组', en: '{n} Groups' }, + 'group.name': { zh: '组名称', en: 'Group Name' }, + 'group.consumeEnable': { zh: '启用消费', en: 'Consume Enabled' }, + 'group.orderly': { zh: '有序消费', en: 'Orderly' }, + 'group.broadcast': { zh: '广播消费', en: 'Broadcast' }, + 'group.retryQueues': { zh: '重试队列', en: 'Retry Queues' }, + 'group.maxRetries': { zh: '最大重试次数', en: 'Max Retries' }, + 'group.consumeTimeout': { zh: '消费超时', en: 'Consume Timeout' }, + 'group.minutes': { zh: '分钟', en: 'Minutes' }, + 'group.brokerId': { zh: '代理 ID', en: 'Broker ID' }, + 'group.slowBroker': { zh: '慢消费代理', en: 'Slow Broker' }, + 'group.retryPolicy': { zh: '重试策略', en: 'Retry Policy' }, + 'group.systemFlag': { zh: '系统标志', en: 'System Flag' }, + 'group.selectCluster': { zh: '请选择集群名称', en: 'Select Cluster' }, + 'group.selectBrokers': { zh: '选择代理', en: 'Select Brokers' }, + 'group.selectDeleteBrokers': { + zh: '请选择在哪个 Broker 删除消费者组', + en: 'Select Broker to delete group from', + }, + 'group.deleteGroup': { zh: '删除消费者组', en: 'Delete Consumer Group' }, + 'group.addConsumer': { zh: '添加消费者', en: 'Add Consumer' }, + 'group.consumeDetail': { zh: '消费详情', en: 'Consume Detail' }, + 'group.clientInfo': { zh: '客户端信息', en: 'Client Info' }, + 'group.consumeType': { zh: '消费类型', en: 'Consume Type' }, + 'group.messageModel': { zh: '消息模型', en: 'Message Model' }, + 'group.consumeFromWhere': { zh: '从何处消费', en: 'Consume From' }, + 'group.clientConnections': { zh: '客户端连接', en: 'Client Connections' }, + 'group.clientSubscriptions': { zh: '客户端订阅', en: 'Client Subscriptions' }, + 'group.clientId': { zh: '客户端 ID', en: 'Client ID' }, + 'group.clientAddr': { zh: '客户端地址', en: 'Client Address' }, + 'group.language': { zh: '语言', en: 'Language' }, + 'group.subscriptionExpr': { zh: '订阅表达式', en: 'Subscription Expression' }, + 'group.expressionType': { zh: '表达式类型', en: 'Expression Type' }, + 'group.subVersion': { zh: '订阅版本', en: 'Subscription Version' }, + 'group.brokerOffset': { zh: '代理者位点', en: 'Broker Offset' }, + 'group.consumerOffset': { zh: '消费者位点', en: 'Consumer Offset' }, + 'group.diffTotal': { zh: '差值', en: 'Diff Total' }, + 'group.lastTimestamp': { zh: '上次时间', en: 'Last Timestamp' }, + 'group.lastConsumeTime': { zh: '最后消费时间', en: 'Last Consume Time' }, + 'group.resetOffsetSuccess': { + zh: '{name} 消费位点已重置到 {time}', + en: '{name} offset reset to {time}', + }, + 'group.searchPlaceholder': { zh: '搜索消费组名称', en: 'Search group name' }, + 'group.selectGroup': { zh: '请选择消费者组', en: 'Select Consumer Group' }, + + // ─── Message (detailed) ─── + 'message.subtitle': { + zh: '按 Topic、Key 或 Message ID 检索消息', + en: 'Search by Topic, Key or Message ID', + }, + 'message.detail': { zh: '消息详情', en: 'Message Detail' }, + 'message.body': { zh: '消息主体', en: 'Message Body' }, + 'message.properties': { zh: '消息属性', en: 'Message Properties' }, + 'message.info': { zh: '消息信息', en: 'Message Info' }, + 'message.tracking': { zh: '消息追踪', en: 'Message Tracking' }, + 'message.showAll': { zh: '显示全部内容', en: 'Show All Content' }, + 'message.topicName': { zh: '主题名', en: 'Topic Name' }, + 'message.tag': { zh: '标签', en: 'Tag' }, + 'message.key': { zh: '键', en: 'Key' }, + 'message.id': { zh: '消息 ID', en: 'Message ID' }, + 'message.resend': { zh: '重新发送', en: 'Resend' }, + 'message.viewException': { zh: '查看异常', en: 'View Exception' }, + 'message.selectTopic': { zh: '请选择主题', en: 'Select Topic' }, + 'message.selectTraceTopic': { zh: '请选择消息轨迹主题', en: 'Select Trace Topic' }, + 'message.traceTopicHint': { zh: '消息轨迹主题', en: 'Trace Topic' }, + 'message.onlyReturn64': { zh: '仅返回64条消息', en: 'Only return 64 messages' }, + 'message.idTopicHint': { zh: '消息ID主题', en: 'Message ID Topic' }, + 'message.idAndGroupRequired': { + zh: '消息ID和消费者组为必填项', + en: 'Message ID and Consumer Group are required', + }, + 'message.topicAndKeyRequired': { zh: 'Topic和Key为必填项', en: 'Topic and Key are required' }, + 'message.idRequired': { zh: '消息ID为必填项', en: 'Message ID is required' }, + 'message.resendSuccess': { zh: '重新发送成功', en: 'Resend successful' }, + 'message.noResult': { zh: '无结果', en: 'No Result' }, + 'message.queryFailed': { zh: '查询失败', en: 'Query Failed' }, + 'message.fetchDetailFailed': { zh: '获取消息详情失败', en: 'Failed to fetch message detail' }, + 'message.export': { zh: '导出', en: 'Export' }, + 'message.batchResend': { zh: '批量重发', en: 'Batch Resend' }, + 'message.batchExport': { zh: '批量导出', en: 'Batch Export' }, + 'message.noMatchResult': { zh: '没有查到符合条件的结果', en: 'No matching results' }, + + // ─── DLQ (detailed) ─── + 'dlq.subtitle': { + zh: '管理消费失败进入死信队列的消息', + en: 'Manage messages in dead letter queue', + }, + 'dlq.message': { zh: '死信消息', en: 'DLQ Message' }, + 'dlq.consumerGroup': { zh: '消费者组', en: 'Consumer Group' }, + + // ─── Message Trace ─── + 'trace.title': { zh: '消息轨迹', en: 'Message Trace' }, + 'trace.detail': { zh: '消息轨迹详情', en: 'Trace Detail' }, + 'trace.enable': { zh: '开启消息轨迹', en: 'Enable Message Trace' }, + 'trace.topic': { zh: '消息轨迹主题', en: 'Trace Topic' }, + 'trace.selectTopic': { zh: '选择消息轨迹主题', en: 'Select Trace Topic' }, + + // ─── Broker Detail ─── + 'broker.overview': { zh: 'Broker 概览', en: 'Broker Overview' }, + 'broker.name': { zh: 'Broker 名称', en: 'Broker Name' }, + 'broker.addr': { zh: 'Broker 地址', en: 'Broker Address' }, + 'broker.config': { zh: '代理配置', en: 'Broker Config' }, + 'broker.totalMsgToday': { zh: '今天接收的总消息数', en: 'Total Messages Today' }, + 'broker.putTps': { zh: '写入 TPS', en: 'Put TPS' }, + 'broker.getTps': { zh: '拉取 TPS', en: 'Get TPS' }, + 'broker.putNumsToday': { zh: '写入今日总量', en: 'Put Num Today' }, + 'broker.getNumsToday': { zh: '拉取今日总量', en: 'Get Num Today' }, + 'broker.networkThroughput': { zh: 'Broker 网络吞吐量', en: 'Broker Network Throughput' }, + 'broker.accumulationDepth': { zh: '堆积深度趋势', en: 'Accumulation Depth Trend' }, + 'broker.accumulationTotal': { zh: '堆积总量', en: 'Accumulation Total' }, + 'broker.transactionMetrics': { zh: '事务消息指标', en: 'Transaction Message Metrics' }, + 'broker.storageWriteLatency': { zh: '存储写入延迟', en: 'Storage Write Latency' }, + 'broker.topicPutLatency': { zh: 'CommitLog 写入延迟', en: 'CommitLog Put Latency' }, + 'broker.consumeLatency': { zh: '消费拉取延迟', en: 'Consume Pull Latency' }, + 'broker.replicaSyncLatency': { zh: '主从副本同步延迟', en: 'Replica Sync Latency' }, + 'broker.hotTopicTop10': { zh: '热点 Topic Top10', en: 'Hot Topic Top10' }, + 'broker.jvmDetail': { zh: 'Broker JVM 详情', en: 'Broker JVM Detail' }, + 'broker.gcStats': { zh: 'JVM GC 统计', en: 'JVM GC Stats' }, + 'broker.heapUsage': { zh: '堆内存使用', en: 'Heap Usage' }, + 'broker.threadCount': { zh: '线程数', en: 'Thread Count' }, + 'broker.queryAccumulationFailed': { + zh: '查询堆积数据失败', + en: 'Failed to query accumulation data', + }, + 'broker.queryTransactionFailed': { + zh: '查询事务数据失败', + en: 'Failed to query transaction data', + }, + 'broker.queryStorageLatencyFailed': { + zh: '查询存储延迟数据失败', + en: 'Failed to query storage latency', + }, + 'broker.queryNetworkFailed': { + zh: '查询网络吞吐量数据失败', + en: 'Failed to query network throughput', + }, + 'broker.queryReplicaFailed': { + zh: '查询副本同步数据失败', + en: 'Failed to query replica sync data', + }, + 'broker.queryHotTopicFailed': { + zh: '查询热点 Topic 数据失败', + en: 'Failed to query hot topic data', + }, + 'broker.queryJvmFailed': { zh: '查询 JVM 统计失败', en: 'Failed to query JVM stats' }, + + // ─── Proxy Cluster ─── + 'proxy.title': { zh: 'Proxy 集群', en: 'Proxy Cluster' }, + 'proxy.totalNodes': { zh: '总节点数', en: 'Total Nodes' }, + 'proxy.healthyNodes': { zh: '健康节点', en: 'Healthy Nodes' }, + 'proxy.totalConnections': { zh: '总连接数', en: 'Total Connections' }, + 'proxy.totalTps': { zh: '总 TPS', en: 'Total TPS' }, + 'proxy.addNode': { zh: '添加节点', en: 'Add Node' }, + 'proxy.nodes': { zh: 'Proxy 节点', en: 'Proxy Nodes' }, + 'proxy.viewConfig': { zh: '查看配置', en: 'View Config' }, + 'proxy.remove': { zh: '移除', en: 'Remove' }, + 'proxy.nodeConfig': { zh: '节点配置', en: 'Node Configuration' }, + 'proxy.addProxyNode': { zh: '添加 Proxy 节点', en: 'Add Proxy Node' }, + 'proxy.address': { zh: 'Proxy 地址', en: 'Proxy Address' }, + 'proxy.addressPlaceholder': { zh: '例:127.0.0.1:8081', en: 'e.g., 127.0.0.1:8081' }, + 'proxy.invalidAddress': { + zh: '地址格式无效(例:127.0.0.1:8081)', + en: 'Invalid format (e.g., 127.0.0.1:8081)', + }, + 'proxy.current': { zh: '当前', en: 'Current' }, + 'proxy.connections': { zh: '连接数', en: 'Connections' }, + 'proxy.memory': { zh: '内存', en: 'Memory' }, + 'proxy.uptime': { zh: '运行时间', en: 'Uptime' }, + 'proxy.action': { zh: '操作', en: 'Action' }, + 'proxy.fetchListFailed': { zh: '获取代理列表失败', en: 'Failed to fetch proxy list' }, + 'proxy.addFailed': { zh: '添加代理失败', en: 'Failed to add proxy' }, + 'proxy.addrRequired': { zh: '请输入代理地址', en: 'Proxy address is required' }, + 'proxy.noConfigData': { zh: '无配置数据', en: 'No config data' }, + + // ─── SSL Settings ─── + 'ssl.title': { zh: 'SSL/TLS 设置', en: 'SSL/TLS Settings' }, + 'ssl.info': { zh: 'SSL/TLS 配置', en: 'SSL/TLS Configuration' }, + 'ssl.infoDesc': { + zh: '配置 SSL/TLS 设置以实现安全通信,更改后需重启服务器。', + en: 'Configure SSL/TLS for secure communication. Changes require server restart.', + }, + 'ssl.enabled': { zh: '启用 SSL/TLS', en: 'Enable SSL/TLS' }, + 'ssl.protocol': { zh: 'SSL 协议', en: 'SSL Protocol' }, + 'ssl.selectProtocol': { zh: '请选择协议', en: 'Select protocol' }, + 'ssl.clientAuth': { zh: '客户端认证', en: 'Client Authentication' }, + 'ssl.none': { zh: '无', en: 'None' }, + 'ssl.want': { zh: '可选', en: 'Want' }, + 'ssl.need': { zh: '必需', en: 'Need' }, + 'ssl.keystoreConfig': { zh: 'KeyStore 配置', en: 'KeyStore Configuration' }, + 'ssl.keystoreType': { zh: 'KeyStore 类型', en: 'KeyStore Type' }, + 'ssl.keystorePath': { zh: 'KeyStore 路径', en: 'KeyStore Path' }, + 'ssl.keystorePathPlaceholder': { zh: '请输入 KeyStore 路径', en: 'Enter KeyStore path' }, + 'ssl.keystorePassword': { zh: 'KeyStore 密码', en: 'KeyStore Password' }, + 'ssl.keystorePasswordPlaceholder': { zh: '请输入 KeyStore 密码', en: 'Enter KeyStore password' }, + 'ssl.uploadKeystore': { zh: '上传 KeyStore 文件', en: 'Upload KeyStore File' }, + 'ssl.truststoreConfig': { zh: 'TrustStore 配置', en: 'TrustStore Configuration' }, + 'ssl.truststoreType': { zh: 'TrustStore 类型', en: 'TrustStore Type' }, + 'ssl.truststorePath': { zh: 'TrustStore 路径', en: 'TrustStore Path' }, + 'ssl.truststorePathPlaceholder': { zh: '请输入 TrustStore 路径', en: 'Enter TrustStore path' }, + 'ssl.truststorePassword': { zh: 'TrustStore 密码', en: 'TrustStore Password' }, + 'ssl.truststorePasswordPlaceholder': { + zh: '请输入 TrustStore 密码', + en: 'Enter TrustStore password', + }, + 'ssl.uploadTruststore': { zh: '上传 TrustStore 文件', en: 'Upload TrustStore File' }, + 'ssl.save': { zh: '保存', en: 'Save' }, + 'ssl.upload': { zh: '上传', en: 'Upload' }, + 'ssl.certInfo': { zh: '证书信息', en: 'Certificate Information' }, + 'ssl.issuer': { zh: '颁发者', en: 'Issuer' }, + 'ssl.expiryDate': { zh: '过期日期', en: 'Expiry Date' }, + 'ssl.active': { zh: '有效', en: 'Active' }, + 'ssl.saveSuccess': { zh: 'SSL 配置保存成功', en: 'SSL configuration saved successfully' }, + 'ssl.invalidCertFormat': { zh: '仅允许证书文件!', en: 'Only certificate files are allowed!' }, + 'ssl.certRemoved': { zh: '证书文件已移除', en: 'Certificate file removed' }, + + // ─── Namespace ─── + 'ns.title': { zh: '命名空间管理', en: 'Namespace Management' }, + 'ns.name': { zh: '命名空间', en: 'Namespace' }, + 'ns.displayName': { zh: '显示名称', en: 'Display Name' }, + 'ns.cluster': { zh: '集群', en: 'Cluster' }, + 'ns.description': { zh: '描述', en: 'Description' }, + 'ns.default': { zh: '默认', en: 'Default' }, + 'ns.createTime': { zh: '创建时间', en: 'Create Time' }, + 'ns.topicLimit': { zh: 'Topic 限额', en: 'Topic Limit' }, + 'ns.groupLimit': { zh: 'Group 限额', en: 'Group Limit' }, + 'ns.storage': { zh: '存储 (GB)', en: 'Storage (GB)' }, + 'ns.qpsLimit': { zh: 'QPS 限制', en: 'QPS Limit' }, + 'ns.connLimit': { zh: '连接限制', en: 'Connection Limit' }, + 'ns.total': { zh: '命名空间总数', en: 'Total Namespaces' }, + 'ns.enabled': { zh: '已启用', en: 'Enabled' }, + 'ns.totalTopicQuota': { zh: 'Topic 总配额', en: 'Total Topic Quota' }, + 'ns.totalGroupQuota': { zh: 'Group 总配额', en: 'Total Group Quota' }, + 'ns.list': { zh: '命名空间列表', en: 'Namespace List' }, + 'ns.create': { zh: '创建命名空间', en: 'Create Namespace' }, + 'ns.edit': { zh: '编辑命名空间', en: 'Edit Namespace' }, + 'ns.detail': { zh: '命名空间详情', en: 'Namespace Detail' }, + 'ns.viewDetail': { zh: '查看详情', en: 'View Detail' }, + 'ns.quotaSection': { zh: '配额配置', en: 'Quota Configuration' }, + 'ns.confirmDelete': { zh: '确认删除此命名空间?', en: 'Delete this namespace?' }, + 'ns.nameRequired': { zh: '请输入命名空间名称', en: 'Namespace name is required' }, + 'ns.namePattern': { + zh: '仅支持字母、数字、连字符和下划线', + en: 'Only letters, numbers, hyphens and underscores', + }, + 'ns.namePlaceholder': { zh: '例:production-ns', en: 'e.g., production-ns' }, + 'ns.displayNamePlaceholder': { zh: '例:Production', en: 'e.g., Production' }, + 'ns.clusterPlaceholder': { zh: '例:DefaultCluster', en: 'e.g., DefaultCluster' }, + 'ns.descPlaceholder': { zh: '描述此命名空间...', en: 'Describe this namespace...' }, + 'ns.fetchFailed': { zh: '获取命名空间失败', en: 'Failed to fetch namespaces' }, + 'ns.createSuccess': { zh: '命名空间创建成功', en: 'Namespace created successfully' }, + 'ns.createFailed': { zh: '创建命名空间失败', en: 'Failed to create namespace' }, + 'ns.updateSuccess': { zh: '命名空间更新成功', en: 'Namespace updated successfully' }, + 'ns.updateFailed': { zh: '更新命名空间失败', en: 'Failed to update namespace' }, + 'ns.deleteSuccess': { zh: '命名空间删除成功', en: 'Namespace deleted successfully' }, + 'ns.deleteFailed': { zh: '删除命名空间失败', en: 'Failed to delete namespace' }, + 'ns.notSupported': { zh: '不支持命名空间', en: 'Namespace Not Supported' }, + 'ns.notSupportedDesc': { + zh: '当前集群架构不支持命名空间管理,请切换到 RocketMQ 5.0 Proxy 集群。', + en: 'Current cluster does not support namespace management. Please switch to a RocketMQ 5.0 Proxy cluster.', + }, + + // ─── Alert Management ─── + 'alert.management': { zh: '告警管理', en: 'Alert Management' }, + 'alert.name': { zh: '告警名称', en: 'Alert Name' }, + 'alert.group': { zh: '告警分组', en: 'Group' }, + 'alert.severity': { zh: '严重级别', en: 'Severity' }, + 'alert.team': { zh: '团队', en: 'Team' }, + 'alert.expr': { zh: '表达式', en: 'Expression' }, + 'alert.for': { zh: '持续时间', en: 'For' }, + 'alert.status': { zh: '状态', en: 'Status' }, + 'alert.actions': { zh: '操作', en: 'Actions' }, + 'alert.total': { zh: '总规则数', en: 'Total Rules' }, + 'alert.enabled': { zh: '已启用', en: 'Enabled' }, + 'alert.disabled': { zh: '已禁用', en: 'Disabled' }, + 'alert.critical': { zh: '严重', en: 'Critical' }, + 'alert.warningCount': { zh: '警告', en: 'Warning' }, + 'alert.refresh': { zh: '刷新', en: 'Refresh' }, + 'alert.add': { zh: '新增规则', en: 'Add Rule' }, + 'alert.exportYaml': { zh: '导出 YAML', en: 'Export YAML' }, + 'alert.searchPlaceholder': { + zh: '搜索告警名称、表达式...', + en: 'Search alert name, expression...', + }, + 'alert.allGroups': { zh: '全部分组', en: 'All Groups' }, + 'alert.allSeverity': { zh: '全部级别', en: 'All Severity' }, + 'alert.allStatus': { zh: '全部状态', en: 'All Status' }, + 'alert.rules': { zh: '条规则', en: 'rules' }, + 'alert.edit': { zh: '编辑', en: 'Edit' }, + 'alert.delete': { zh: '删除', en: 'Delete' }, + 'alert.editRule': { zh: '编辑告警规则', en: 'Edit Alert Rule' }, + 'alert.addRule': { zh: '新增告警规则', en: 'Add Alert Rule' }, + 'alert.summary': { zh: '摘要', en: 'Summary' }, + 'alert.description': { zh: '描述', en: 'Description' }, + 'alert.nameRequired': { zh: '告警名称为必填项', en: 'Alert name is required' }, + 'alert.groupRequired': { zh: '告警分组为必填项', en: 'Alert group is required' }, + 'alert.exprRequired': { zh: '表达式为必填项', en: 'Expression is required' }, + 'alert.forRequired': { zh: '持续时间为必填项', en: 'Duration is required' }, + 'alert.summaryRequired': { zh: '摘要为必填项', en: 'Summary is required' }, + 'alert.fetchFailed': { zh: '获取告警规则失败', en: 'Failed to fetch alert rules' }, + 'alert.deleteSuccess': { zh: '告警规则已删除', en: 'Alert rule deleted' }, + 'alert.updateSuccess': { zh: '告警规则已更新', en: 'Alert rule updated' }, + 'alert.createSuccess': { zh: '告警规则已创建', en: 'Alert rule created' }, + 'alert.exportSuccess': { zh: '告警规则已导出', en: 'Alert rules exported' }, + + // ─── LiteTopic ─── + 'liteTopic.title': { zh: 'LiteTopic 管理', en: 'LiteTopic Management' }, + 'liteTopic.quotaOverview': { zh: '配额概览', en: 'Quota Overview' }, + 'liteTopic.topicUsage': { zh: 'Topic 使用', en: 'Topic Usage' }, + 'liteTopic.sessionUsage': { zh: 'Session 使用', en: 'Session Usage' }, + 'liteTopic.creationRate': { zh: '创建速率', en: 'Creation Rate' }, + 'liteTopic.searchPlaceholder': { zh: '按 Topic 模式筛选...', en: 'Filter by topic pattern...' }, + 'liteTopic.namespace': { zh: '命名空间', en: 'Namespace' }, + 'liteTopic.namespacePlaceholder': { zh: '选择命名空间', en: 'Select namespace' }, + 'liteTopic.allNamespaces': { zh: '全部命名空间', en: 'All Namespaces' }, + 'liteTopic.pattern': { zh: '模式', en: 'Pattern' }, + 'liteTopic.topicCount': { zh: 'Topic 数', en: 'Topic Count' }, + 'liteTopic.consumers': { zh: '消费者', en: 'Consumers' }, + 'liteTopic.backlog': { zh: '积压', en: 'Backlog' }, + 'liteTopic.avgTtl': { zh: '平均 TTL', en: 'Avg TTL' }, + 'liteTopic.lastActive': { zh: '最后活跃', en: 'Last Active' }, + 'liteTopic.status': { zh: '状态', en: 'Status' }, + 'liteTopic.actions': { zh: '操作', en: 'Actions' }, + 'liteTopic.extendTtl': { zh: '延长 TTL', en: 'Extend TTL' }, + 'liteTopic.viewSessions': { zh: '查看会话', en: 'View Sessions' }, + 'liteTopic.sessionDetail': { zh: '会话详情', en: 'Session Detail' }, + 'liteTopic.sessionId': { zh: '会话 ID', en: 'Session ID' }, + 'liteTopic.clientId': { zh: '客户端 ID', en: 'Client ID' }, + 'liteTopic.clientAddress': { zh: '客户端地址', en: 'Client Address' }, + 'liteTopic.parentTopic': { zh: '父 Topic', en: 'Parent Topic' }, + 'liteTopic.consumerGroup': { zh: '消费者组', en: 'Consumer Group' }, + 'liteTopic.createTime': { zh: '创建时间', en: 'Create Time' }, + 'liteTopic.ttl': { zh: 'TTL', en: 'TTL' }, + 'liteTopic.ttlRemaining': { zh: 'TTL 剩余', en: 'TTL Remaining' }, + 'liteTopic.totalMessages': { zh: '总消息数', en: 'Total Messages' }, + 'liteTopic.consumedMessages': { zh: '已消费消息', en: 'Consumed Messages' }, + 'liteTopic.pendingMessages': { zh: '待消费消息', en: 'Pending Messages' }, + 'liteTopic.consumptionRate': { zh: '消费速率', en: 'Consumption Rate' }, + 'liteTopic.active': { zh: '活跃', en: 'Active' }, + 'liteTopic.expiringSoon': { zh: '即将过期', en: 'Expiring Soon' }, + 'liteTopic.expired': { zh: '已过期', en: 'Expired' }, + 'liteTopic.unknown': { zh: '未知', en: 'Unknown' }, + 'liteTopic.notSupported': { + zh: '当前集群不支持 LiteTopic,请升级到 RocketMQ 5.x', + en: 'LiteTopic is not supported on the current cluster. Please upgrade to RocketMQ 5.x', + }, + 'liteTopic.fetchListFailed': { + zh: '获取 LiteTopic 列表失败', + en: 'Failed to fetch LiteTopic list', + }, + 'liteTopic.fetchQuotaFailed': { zh: '获取配额信息失败', en: 'Failed to fetch quota information' }, + 'liteTopic.fetchSessionFailed': { zh: '获取会话详情失败', en: 'Failed to fetch session detail' }, + 'liteTopic.extendTtlSuccess': { zh: 'TTL 延长成功', en: 'TTL extended successfully' }, + 'liteTopic.extendTtlFailed': { zh: 'TTL 延长失败', en: 'Failed to extend TTL' }, + 'liteTopic.total': { zh: '共 {total} 条记录', en: 'Total {total} records' }, + + // ─── Producer ─── + 'producer.title': { zh: '生产者', en: 'Producer' }, + 'producer.group': { zh: '生产组', en: 'Producer Group' }, + + // ─── Common (additional) ─── + 'common.loading': { zh: '加载中', en: 'Loading' }, + 'common.refresh': { zh: '刷新', en: 'Refresh' }, + 'common.logout': { zh: '退出', en: 'Logout' }, + 'common.submit': { zh: '提交', en: 'Submit' }, + 'common.add': { zh: '新增', en: 'Add' }, + 'common.update': { zh: '更新', en: 'Update' }, + 'common.modify': { zh: '修改', en: 'Modify' }, + 'common.view': { zh: '查看', en: 'View' }, + 'common.confirmDelete': { zh: '确认删除', en: 'Confirm Delete' }, + 'common.areYouSureToDelete': { zh: '您确定要删除吗?', en: 'Are you sure to delete?' }, + 'common.deleteSuccess': { zh: '删除成功', en: 'Delete Successful' }, + 'common.operationFailed': { zh: '操作失败', en: 'Operation Failed' }, + 'common.formValidationFailed': { zh: '表单验证失败', en: 'Form Validation Failed' }, + 'common.fetchDataFailed': { zh: '获取数据失败', en: 'Failed to fetch data' }, + 'common.refreshSuccess': { zh: '刷新成功', en: 'Refresh Successful' }, + 'common.refreshFailed': { zh: '刷新失败', en: 'Refresh Failed' }, + 'common.cannotBeEmpty': { zh: '不能为空', en: 'Cannot be empty' }, + 'common.pleaseInputNumber': { zh: '请输入数字', en: 'Please input a number' }, + 'common.pleaseSelect': { zh: '请选择', en: 'Please select' }, + 'common.pleaseInput': { zh: '请输入', en: 'Please input' }, + 'common.endTimeLaterThanBegin': { + zh: '结束时间应晚于开始时间', + en: 'End time should be later than begin time', + }, + 'common.synchronize': { zh: '同步', en: 'Synchronize' }, + 'common.show': { zh: '显示', en: 'Show' }, + 'common.hide': { zh: '隐藏', en: 'Hide' }, + 'common.readMore': { zh: '阅读更多', en: 'Read More' }, + 'common.queue': { zh: '队列', en: 'Queue' }, + 'common.result': { zh: '结果', en: 'Result' }, + 'common.date': { zh: '日期', en: 'Date' }, + 'common.begin': { zh: '开始', en: 'Begin' }, + 'common.end': { zh: '结束', en: 'End' }, + 'common.selectProxy': { zh: '选择代理', en: 'Select Proxy' }, + 'common.enableProxy': { zh: '启用代理', en: 'Enable Proxy' }, + 'common.proxyDisabled': { zh: '代理禁用', en: 'Proxy Disabled' }, + 'common.proxyEnabled': { zh: '代理启用', en: 'Proxy Enabled' }, + 'common.menu': { zh: '菜单', en: 'Menu' }, + 'common.cluster': { zh: '集群', en: 'Cluster' }, + 'common.broker': { zh: 'Broker', en: 'Broker' }, + 'common.proxy': { zh: '代理', en: 'Proxy' }, + 'common.topic': { zh: '主题', en: 'Topic' }, + 'common.consumer': { zh: '消费者', en: 'Consumer' }, + 'common.producer': { zh: '生产者', en: 'Producer' }, + 'common.message': { zh: '消息', en: 'Message' }, + 'common.config': { zh: '配置', en: 'Config' }, + 'common.operation': { zh: '操作', en: 'Operation' }, + 'common.manage': { zh: '管理', en: 'Manage' }, + 'common.router': { zh: '路由', en: 'Router' }, + 'common.quantity': { zh: '数量', en: 'Quantity' }, + 'common.mode': { zh: '模式', en: 'Mode' }, + 'common.delay': { zh: '延迟', en: 'Delay' }, + 'common.trend': { zh: '趋势', en: 'Trend' }, + 'common.split': { zh: '分片', en: 'Split' }, + 'common.instance': { zh: '实例', en: 'Instance' }, + 'common.warning': { zh: '警告', en: 'Warning' }, + 'common.na': { zh: 'N/A', en: 'N/A' }, + 'common.default': { zh: '默认', en: 'Default' }, }; export default translations; diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 00000000..3bcbc099 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,6 @@ +/// +import '@testing-library/jest-dom/vitest'; + +beforeEach(() => { + localStorage.clear(); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index 8aef6686..f8d5b64a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ @@ -12,4 +12,10 @@ export default defineConfig({ }, }, }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: true, + }, }); From b5f7da4c399668e9c68bb80d70a584b51d6239e7 Mon Sep 17 00:00:00 2001 From: btlqql <2977859784@qq.com> Date: Fri, 17 Jul 2026 16:40:10 +0800 Subject: [PATCH 05/43] fix: validate audit query and cleanup parameters (#442) --- .../studio/ops/audit/AuditService.java | 19 +++++++- .../studio/ops/audit/AuditServiceTest.java | 43 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/com/rocketmq/studio/ops/audit/AuditService.java b/server/src/main/java/com/rocketmq/studio/ops/audit/AuditService.java index f5968b8c..e1d98b89 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/audit/AuditService.java +++ b/server/src/main/java/com/rocketmq/studio/ops/audit/AuditService.java @@ -17,6 +17,7 @@ package com.rocketmq.studio.ops.audit; import com.rocketmq.studio.common.domain.PageResult; +import com.rocketmq.studio.common.exception.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -38,6 +39,7 @@ public class AuditService { public PageResult queryLogs(int page, int pageSize, String search, String operationType, String startDate, String endDate, String result) { + validatePagination(page, pageSize); log.info("Querying audit logs, page={}, pageSize={}, search={}, operationType={}, result={}", page, pageSize, search, operationType, result); @@ -47,8 +49,9 @@ public PageResult queryLogs(int page, int pageSize, String search List allRecords = auditRepository.findAll(search, operationType, start, end, result); long total = allRecords.size(); - int fromIndex = Math.min((page - 1) * pageSize, allRecords.size()); - int toIndex = Math.min(fromIndex + pageSize, allRecords.size()); + long offset = (long) (page - 1) * pageSize; + int fromIndex = (int) Math.min(offset, allRecords.size()); + int toIndex = (int) Math.min((long) fromIndex + pageSize, allRecords.size()); List pageRecords = allRecords.subList(fromIndex, toIndex); return PageResult.of(pageRecords, total, page, pageSize); @@ -56,11 +59,23 @@ public PageResult queryLogs(int page, int pageSize, String search public int cleanupLogs(int beforeDays) { + if (beforeDays <= 0) { + throw new BusinessException(400, "beforeDays must be greater than 0"); + } log.info("Cleaning up audit logs older than {} days", beforeDays); LocalDateTime cutoff = LocalDateTime.now().minusDays(beforeDays); return auditRepository.deleteBefore(cutoff); } + private void validatePagination(int page, int pageSize) { + if (page <= 0) { + throw new BusinessException(400, "page must be greater than 0"); + } + if (pageSize <= 0) { + throw new BusinessException(400, "pageSize must be greater than 0"); + } + } + private LocalDateTime parseDate(String dateStr, boolean startOfDay) { if (dateStr == null || dateStr.isEmpty()) { return null; diff --git a/server/src/test/java/com/rocketmq/studio/ops/audit/AuditServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/audit/AuditServiceTest.java index e0446328..2abcb631 100644 --- a/server/src/test/java/com/rocketmq/studio/ops/audit/AuditServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/ops/audit/AuditServiceTest.java @@ -17,6 +17,7 @@ package com.rocketmq.studio.ops.audit; import com.rocketmq.studio.common.domain.PageResult; +import com.rocketmq.studio.common.exception.BusinessException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -30,6 +31,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; @@ -107,6 +109,35 @@ void queryLogsShouldReturnEmptyWhenPageExceedsTotal() { assertThat(result.getTotal()).isEqualTo(1); } + @Test + void queryLogsShouldRejectNonPositivePage() { + assertThatThrownBy(() -> auditService.queryLogs(0, 10, null, null, null, null, null)) + .isInstanceOf(BusinessException.class) + .hasMessage("page must be greater than 0") + .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400)); + } + + @Test + void queryLogsShouldRejectNonPositivePageSize() { + assertThatThrownBy(() -> auditService.queryLogs(1, 0, null, null, null, null, null)) + .isInstanceOf(BusinessException.class) + .hasMessage("pageSize must be greater than 0") + .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400)); + } + + @Test + void queryLogsShouldAvoidOffsetOverflow() { + AuditRecordVO record = AuditRecordVO.builder().operationType("CREATE").build(); + when(auditRepository.findAll(isNull(), isNull(), isNull(), isNull(), isNull())) + .thenReturn(List.of(record)); + + PageResult result = auditService.queryLogs( + Integer.MAX_VALUE, Integer.MAX_VALUE, null, null, null, null, null); + + assertThat(result.getItems()).isEmpty(); + assertThat(result.getTotal()).isEqualTo(1); + } + @Test void queryLogsShouldPassSearchFilterToRepository() { when(auditRepository.findAll(eq("topic-a"), isNull(), isNull(), isNull(), isNull())) @@ -191,6 +222,18 @@ void cleanupLogsShouldReturnZeroWhenNoOldRecords() { assertThat(result).isZero(); } + @Test + void cleanupLogsShouldRejectNonPositiveRetention() { + assertThatThrownBy(() -> auditService.cleanupLogs(0)) + .isInstanceOf(BusinessException.class) + .hasMessage("beforeDays must be greater than 0") + .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400)); + + assertThatThrownBy(() -> auditService.cleanupLogs(-1)) + .isInstanceOf(BusinessException.class) + .hasMessage("beforeDays must be greater than 0"); + } + @Test void queryLogsShouldHandleAllFiltersTogether() { when(auditRepository.findAll(eq("admin"), eq("DELETE"), any(LocalDateTime.class), From 23f20a22a1802f8d8e33fcce249e30bc780f8f3a Mon Sep 17 00:00:00 2001 From: terrancesli <3519981@qq.com> Date: Fri, 17 Jul 2026 16:59:44 +0800 Subject: [PATCH 06/43] chore: fix Dockerfile missing style/ copy and standardize pr-review skill (#444) - Add COPY style ./style to server/Dockerfile so Maven checkstyle can find style/rmq_checkstyle.xml during docker compose build - Rewrite pr-review SKILL.md with an 8-stage pipeline checklist, Dockerfile pre-flight fix step, and [Studio] title format spec --- .claude/skills/pr-review/SKILL.md | 92 ++++++++++++++++++++++++------- server/Dockerfile | 1 + 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index 91555cf8..c92aea68 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -11,7 +11,8 @@ description: RocketMQ Studio 的 PR 评审助手。输入一个 GitHub PR 链接 - 已安装并登录 `gh`(`gh auth status` 确认)。 - 已安装 `docker`(含 `docker compose`)、`node`(>=20)、`npm`、`mvn`(JDK 21)。 -- 当前工作目录为 `project/rocketmq-studio`(项目根,含 `server/`、`web/`、`deploy/`)。 +- 当前工作目录为项目根目录(含 `server/`、`web/`、`deploy/`)。 +- 后端构建需要 JDK 21(`JAVA_HOME` 指向 JDK 21,或使用 `mvn -B -ntp` 配合系统 JDK 21)。 > 端口约定(**禁止修改**):前端 6789(Nginx)、后端 8888(Spring Boot)、NameServer 9876、Broker 10911、Proxy 8080/8081。 @@ -21,9 +22,28 @@ description: RocketMQ Studio 的 PR 评审助手。输入一个 GitHub PR 链接 从链接中解析出 PR 编号 ``(URL 最后一段数字)。 -## 执行步骤 +## 标准流程(Pipeline) -### 1. 拉取 PR 元信息 +评审按以下 8 个阶段顺序执行。每个阶段独立产出结果,任一阶段失败不阻断后续步骤,但需在总结中标记 ❌。 + +``` +Stage 1 拉取元信息 gh pr view → JSON + diff +Stage 2 标题规范检查 正则校验 [Studio] type: description +Stage 3 关联 Issue gh issue view(若有 Closes/Fixes 引用) +Stage 4 切出评审分支 gh pr checkout → pr-review- +Stage 5 预检 & 修复 Dockerfile style/ 目录修复(已知问题) +Stage 6 编译后端 mvn package -DskipTests(含 checkstyle) +Stage 7 编译前端 npm ci && npm run build(tsc + vite) +Stage 8 Docker 部署 docker compose up -d --build + 健康检查 +``` + +**完成后**:复原工作区(切回原分支 + stash pop)。 + +--- + +## 各阶段详细步骤 + +### Stage 1: 拉取 PR 元信息 ```bash gh pr view --repo apache/rocketmq-dashboard \ @@ -41,16 +61,16 @@ gh pr view --repo apache/rocketmq-dashboard \ gh pr diff --repo apache/rocketmq-dashboard > /tmp/pr-.diff ``` -### 1.5 检查 PR 标题是否规范 +### Stage 2: 检查 PR 标题是否规范 -规范来源:`docs/contributing.md`「提交规范」+ `README.md`「Commit Format」+ 本仓库既有 commit 历史,统一遵循 [Conventional Commits](https://www.conventionalcommits.org/)。 +规范来源:`docs/contributing.md`「提交规范」+ `README.md`「Commit Format」+ 本仓库既有 PR/commit 历史,基于 [Conventional Commits](https://www.conventionalcommits.org/) 并带项目前缀。 -**格式**:`: ` +**格式**:`[Studio] : ` +- **`[Studio]`** 为项目前缀(必须,大小写敏感,首字母大写 `Studio`)。 - **type** 必须为以下之一(小写):`feat`(新功能)、`fix`(修复 Bug)、`docs`(文档)、`refactor`(重构)、`test`(测试)、`chore`(构建/工具)、`perf`(性能)。 -- type 后紧跟半角冒号 `:` 加一个空格,再接 **description**。 +- `[Studio]` 与 `type` 之间有一个空格;type 后紧跟半角冒号 `:` 加一个空格,再接 **description**。 - description 用英文小写祈使句,简洁描述改动,结尾不加句号。 -- 可选 scope:`type(scope): description`(scope 为小写模块名,如 `fix(web):`)。 - squash 合并后 GitHub 会在标题末尾追加 ` (#PR号)`,属正常现象,检查时应先剥离该后缀。 校验正则(先去掉可能存在的 ` (#N)` 尾巴): @@ -58,18 +78,23 @@ gh pr diff --repo apache/rocketmq-dashboard > /tmp/pr-.diff ```bash TITLE=$(gh pr view --repo apache/rocketmq-dashboard --json title -q .title) CLEAN=$(printf '%s' "$TITLE" | sed -E 's/ \(#[0-9]+\)$//') -if printf '%s' "$CLEAN" | grep -Eq '^(feat|fix|docs|refactor|test|chore|perf)(\([a-z0-9-]+\))?: .+'; then +if printf '%s' "$CLEAN" | grep -Eq '^\[Studio\] (feat|fix|docs|refactor|test|chore|perf): .+'; then echo "标题规范 ✅: $TITLE" else echo "标题不规范 ❌: $TITLE" fi ``` -对照参考(本仓库历史):`fix: align frontend API success response handling (#429)`、`feat: implement backend cluster management module`、`chore: initialize project with license and git config`。 +对照参考(本仓库历史): +- `[Studio] fix: validate audit query and cleanup parameters` ✅ +- `[Studio] fix: connect K8s certificate page to backend APIs` ✅ +- `[Studio] feat: add i18n language context with useLanguage alias` ✅ +- `[Studio][fix] Connect K8s certificate page to backend APIs` ❌(type 应在冒号前,不应使用 `[fix]` 方括号) +- `[studio] feat: extend translation keys` ❌(`studio` 应为 `Studio`,首字母大写) -不规范时在总结中明确指出问题(type 非法 / 缺冒号或空格 / 用了中文或首字母大写 / 结尾多余标点等)并给出建议标题。 +不规范时在总结中明确指出问题并给出建议标题。 -### 2. 拉取关联 Issue(若有) +### Stage 3: 拉取关联 Issue(若有) 若 `closingIssuesReferences` 非空,或 PR 正文中出现 `#N` / `Closes #N` / `Fixes #N` 引用,逐个拉取: @@ -80,13 +105,13 @@ gh issue view --repo apache/rocketmq-dashboard \ 用于判断 PR 是否真正解决了 Issue 描述的问题(需求对齐度)。 -### 3. 本地切出评审分支 +### Stage 4: 本地切出评审分支 不要污染当前分支。用 `gh` 直接 checkout PR 分支(会自动创建本地分支): ```bash # 记录当前分支以便复原 -git rev-parse --abbrev-ref HEAD +ORIGINAL_BRANCH=$(git rev-parse --abbrev-ref HEAD) git stash push -u -m "pr-review-stash" 2>/dev/null || true gh pr checkout --repo apache/rocketmq-dashboard --branch pr-review- @@ -95,11 +120,29 @@ gh pr checkout --repo apache/rocketmq-dashboard --branch pr-review- 若因权限/fork 无法直接 checkout,退化为手动 fetch: ```bash -git fetch apache pull//head:pr-review- +git fetch origin pull//head:pr-review- git checkout pr-review- ``` -### 4. 编译后端 +### Stage 5: 预检 & 修复(Dockerfile style/ 目录) + +**已知问题**:`server/Dockerfile` 在基准分支上缺少 `COPY style ./style`,导致 Maven checkstyle 插件在构建阶段找不到 `style/rmq_checkstyle.xml` 而报错,docker compose 无法启动。 + +每次评审前检查并修复: + +```bash +if ! grep -q 'COPY style' server/Dockerfile; then + # 在 "COPY src ./src" 后插入 "COPY style ./style" + sed -i '/^COPY src \.\/src$/a COPY style ./style' server/Dockerfile + echo "✅ 已修复 Dockerfile: 添加 COPY style ./style" +else + echo "✅ Dockerfile 已包含 style/ 目录复制" +fi +``` + +> 此修复仅用于本地评审,不影响 PR diff。若 PR 本身已修复此问题,此步为 no-op。 + +### Stage 6: 编译后端 严格对齐 `server/Dockerfile` 的构建方式(`mvn package -DskipTests`,含 checkstyle 校验): @@ -112,7 +155,7 @@ cd .. - 编译失败 → 记录报错(编译错误 / checkstyle 违规),标记后端为 ❌,仍继续后续步骤并如实汇报。 - 通过 → 标记 ✅。 -### 5. 编译前端 +### Stage 7: 编译前端 对齐 `web/Dockerfile`(`npm ci && npm run build`,即 `tsc -b && vite build`): @@ -126,12 +169,13 @@ cd .. - 出现 TypeScript 类型错误或构建失败 → 标记前端 ❌,记录关键报错。 - 通过 → 标记 ✅。 -### 6. docker compose 拉起项目 +### Stage 8: Docker 部署 使用项目自带 compose 文件,**不修改任何端口**: ```bash cd deploy +docker compose down 2>/dev/null || true docker compose up -d --build cd .. ``` @@ -152,16 +196,20 @@ docker compose -f deploy/docker-compose.yml ps docker compose -f deploy/docker-compose.yml down ``` -### 7. 复原工作区 +--- + +## 复原工作区 评审完成后切回原分支并恢复暂存: ```bash -git checkout <原分支> +git checkout "$ORIGINAL_BRANCH" git stash pop 2>/dev/null || true # 如需删除评审分支:git branch -D pr-review- ``` +--- + ## 输出:PR 分析总结 用中文输出一份结构化 Markdown 总结,包含以下部分: @@ -190,7 +238,7 @@ git stash pop 2>/dev/null || true - 按模块归类改动(前端页面/组件、后端 controller/service/domain、部署、文档等)。 - 结合六边形架构(server 用 ArchUnit 约束)判断分层是否合理。 - i18n:新增前端文案是否中英文双语(`web/src/i18n/`)。 -- 提交规范:commit message 是否符合 Conventional Commits。 +- 提交规范:commit message 是否符合 `[Studio] type: description` 格式。 ### 5. 风险与建议 - 潜在逻辑问题、边界情况、安全风险(如凭据明文、公网暴露)。 @@ -200,6 +248,8 @@ git stash pop 2>/dev/null || true ### 6. 评审结论 给出倾向:**Approve** / **Request Changes** / **Comment**,并用一句话说明理由。 +--- + ## 自由发挥补充能力 - **变更规模自适应**:大 PR(改动文件多)先按目录聚合概述再抽样精读核心文件;小 PR 可逐文件过。 diff --git a/server/Dockerfile b/server/Dockerfile index 057cb91e..d11ef498 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src +COPY style ./style RUN mvn package -DskipTests # Stage 2: Runtime From decb079026f6cdff921d4c02e7aef32b5628682b Mon Sep 17 00:00:00 2001 From: zhaohai <33314633+zhaohai666@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:09:39 +0800 Subject: [PATCH 07/43] feat: add centralized ThemeContext and useTheme hook for dark mode management (#435) --- web/package.json | 6 +- web/src/layouts/MainLayout.tsx | 24 +-- web/src/main.tsx | 18 +- web/src/test/setup.ts | 1 + web/src/theme/ThemeContext.tsx | 68 ++++++++ web/src/theme/__tests__/ThemeContext.test.tsx | 156 ++++++++++++++++++ 6 files changed, 249 insertions(+), 24 deletions(-) create mode 100644 web/src/theme/ThemeContext.tsx create mode 100644 web/src/theme/__tests__/ThemeContext.test.tsx diff --git a/web/package.json b/web/package.json index 025967d3..307eab84 100644 --- a/web/package.json +++ b/web/package.json @@ -7,12 +7,12 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", - "prepare": "cd .. && husky", - "test": "vitest run", - "test:watch": "vitest" + "prepare": "cd .. && husky" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index b214bbeb..de7dc55b 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -23,8 +23,6 @@ import { Avatar, Dropdown, Input, - ConfigProvider, - theme, Modal, } from 'antd'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; @@ -49,6 +47,7 @@ import { Notebook, } from '@phosphor-icons/react'; import { useLang } from '../i18n/LangContext'; +import { useTheme } from '../theme/ThemeContext'; const { Sider, Content } = Layout; @@ -57,7 +56,7 @@ const iconSize = 18; const MainLayout = () => { const navigate = useNavigate(); const location = useLocation(); - const [darkMode, setDarkMode] = useState(false); + const { darkMode, toggleTheme } = useTheme(); const [searchOpen, setSearchOpen] = useState(false); const [searchText, setSearchText] = useState(''); const { lang, setLang, t } = useLang(); @@ -152,20 +151,7 @@ const MainLayout = () => { const logoColor = darkMode ? '#e5e5e5' : '#1b1b1a'; return ( - + <> { {/* Theme toggle */}
setDarkMode(!darkMode)} + onClick={toggleTheme} style={{ cursor: 'pointer', display: 'flex', @@ -393,7 +379,7 @@ const MainLayout = () => { ))}
-
+ ); }; diff --git a/web/src/main.tsx b/web/src/main.tsx index 73c687e6..4415ac46 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -18,25 +18,37 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; -import { ConfigProvider } from 'antd'; +import { ConfigProvider, theme } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import enUS from 'antd/locale/en_US'; import { LangProvider, useLang } from './i18n/LangContext'; +import { ThemeProvider, useTheme } from './theme/ThemeContext'; import App from './App'; import './index.css'; const ThemedApp = () => { const { lang } = useLang(); + const { darkMode } = useTheme(); return ( { ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 3bcbc099..a9f1da51 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1,6 +1,7 @@ /// import '@testing-library/jest-dom/vitest'; +// Clean up localStorage between tests beforeEach(() => { localStorage.clear(); }); diff --git a/web/src/theme/ThemeContext.tsx b/web/src/theme/ThemeContext.tsx new file mode 100644 index 00000000..6bac20e2 --- /dev/null +++ b/web/src/theme/ThemeContext.tsx @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; + +interface ThemeContextType { + darkMode: boolean; + setDarkMode: (dark: boolean) => void; + toggleTheme: () => void; +} + +const THEME_STORAGE_KEY = 'rocketmq-studio-theme'; + +const ThemeContext = createContext({ + darkMode: false, + setDarkMode: () => {}, + toggleTheme: () => {}, +}); + +/** Read initial theme preference from localStorage, fall back to system preference. */ +const getInitialDarkMode = (): boolean => { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (stored !== null) return stored === 'dark'; + } catch { + // localStorage unavailable + } + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +}; + +export const ThemeProvider = ({ children }: { children: ReactNode }) => { + const [darkMode, setDarkMode] = useState(getInitialDarkMode); + + // Persist theme choice to localStorage + useEffect(() => { + try { + localStorage.setItem(THEME_STORAGE_KEY, darkMode ? 'dark' : 'light'); + } catch { + // localStorage unavailable + } + }, [darkMode]); + + const toggleTheme = () => setDarkMode((prev) => !prev); + + return ( + + {children} + + ); +}; + +export const useTheme = () => useContext(ThemeContext); + +export default ThemeContext; \ No newline at end of file diff --git a/web/src/theme/__tests__/ThemeContext.test.tsx b/web/src/theme/__tests__/ThemeContext.test.tsx new file mode 100644 index 00000000..3feb0cc6 --- /dev/null +++ b/web/src/theme/__tests__/ThemeContext.test.tsx @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider, useTheme } from '../ThemeContext'; + +/** Helper component that displays theme state and provides toggle button. */ +const ThemeConsumer = () => { + const { darkMode, toggleTheme, setDarkMode } = useTheme(); + return ( +
+ {darkMode ? 'dark' : 'light'} + + + +
+ ); +}; + +describe('ThemeContext', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('defaults to light mode when no stored preference', () => { + // jsdom does not implement matchMedia – define it to return light preference + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + }); + + it('reads dark mode from localStorage', () => { + localStorage.setItem('rocketmq-studio-theme', 'dark'); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('dark'); + }); + + it('reads light mode from localStorage', () => { + localStorage.setItem('rocketmq-studio-theme', 'light'); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + }); + + it('toggles from light to dark', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + + await user.click(screen.getByText('toggle')); + expect(screen.getByTestId('mode')).toHaveTextContent('dark'); + }); + + it('toggles from dark back to light', async () => { + localStorage.setItem('rocketmq-studio-theme', 'dark'); + const user = userEvent.setup(); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('dark'); + + await user.click(screen.getByText('toggle')); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + }); + + it('persists theme to localStorage after toggle', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByText('toggle')); + expect(localStorage.getItem('rocketmq-studio-theme')).toBe('dark'); + }); + + it('setDarkMode(true) switches to dark', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + + await user.click(screen.getByText('set-dark')); + expect(screen.getByTestId('mode')).toHaveTextContent('dark'); + }); + + it('setDarkMode(false) switches to light', async () => { + localStorage.setItem('rocketmq-studio-theme', 'dark'); + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByText('set-light')); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + }); +}); \ No newline at end of file From d9a1ac3e96432d305afa7e590322d8cc98f37081 Mon Sep 17 00:00:00 2001 From: btlqql <2977859784@qq.com> Date: Fri, 17 Jul 2026 17:15:31 +0800 Subject: [PATCH 08/43] fix: preserve SSE framing for AI chat (#438) --- web/src/api/ai.test.ts | 97 ++++++++++++++++++++++++++++++++++++++++++ web/src/api/ai.ts | 69 ++++++++++++++++++++++++------ 2 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 web/src/api/ai.test.ts diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts new file mode 100644 index 00000000..1b03825f --- /dev/null +++ b/web/src/api/ai.test.ts @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { chatStream } from './ai'; + +const encoder = new TextEncoder(); + +function streamResponse(chunks: string[]): Response { + const body = new ReadableStream({ + start(controller) { + chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))); + controller.close(); + }, + }); + return new Response(body, { status: 200 }); +} + +describe('AI chat SSE stream', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reassembles an event split across network chunks', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + streamResponse([ + 'event: message\r\ndata: {"text":"hel', + 'lo"}\r\n\r\nevent: done\r\ndata: [DONE]\r\n\r\n', + ]), + ), + ); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue('token') }); + const chunks: string[] = []; + + await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => + chunks.push(text), + ); + + expect(chunks).toEqual(['hello']); + }); + + it('dispatches multiple events delivered in one network chunk', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + streamResponse([ + 'data: {"content":"first"}\n\ndata: {"content":"second"}\n\ndata: [DONE]\n\n', + ]), + ), + ); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + const chunks: string[] = []; + + await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => + chunks.push(text), + ); + + expect(chunks).toEqual(['first', 'second']); + }); + + it('supports multiline and raw SSE data at end of stream', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(streamResponse(['data: {"content":\ndata: "hello"}\n\ndata: raw text'])), + ); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + const chunks: string[] = []; + + await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => + chunks.push(text), + ); + + expect(chunks).toEqual(['hello', 'raw text']); + }); +}); diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts index f38bb029..547b3b44 100644 --- a/web/src/api/ai.ts +++ b/web/src/api/ai.ts @@ -31,6 +31,49 @@ export interface AiExecuteRequest { tools?: string[]; } +interface AiStreamPayload { + content?: unknown; + text?: unknown; +} + +function getEventBoundary(buffer: string): { index: number; length: number } | null { + const match = /\r\n\r\n|\n\n|\r\r/.exec(buffer); + return match ? { index: match.index, length: match[0].length } : null; +} + +function getEventData(event: string): string | null { + const dataLines = event + .split(/\r\n|\r|\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => { + const value = line.slice(5); + return value.startsWith(' ') ? value.slice(1) : value; + }); + + return dataLines.length ? dataLines.join('\n') : null; +} + +function emitEvent(event: string, onChunk: (text: string) => void): boolean { + const payload = getEventData(event); + if (payload === null) return false; + if (payload === '[DONE]') return true; + + try { + const parsed = JSON.parse(payload) as AiStreamPayload; + const text = + typeof parsed.content === 'string' + ? parsed.content + : typeof parsed.text === 'string' + ? parsed.text + : null; + if (text !== null) onChunk(text); + } catch { + onChunk(payload); + } + + return false; +} + // ─── AI ───────────────────────────────────────────────────────── export async function chatStream( data: AiExecuteRequest, @@ -53,24 +96,24 @@ export async function chatStream( const reader = response.body.getReader(); const decoder = new TextDecoder(); + let buffer = ''; + while (true) { const { done, value } = await reader.read(); if (done) break; - const chunk = decoder.decode(value, { stream: true }); - // SSE format: "data: ..." - const lines = chunk.split('\n').filter((l) => l.startsWith('data: ')); - for (const line of lines) { - const payload = line.slice(6); - if (payload === '[DONE]') return; - try { - const parsed = JSON.parse(payload); - if (parsed.content) onChunk(parsed.content); - } catch { - // raw text chunk - onChunk(payload); - } + + buffer += decoder.decode(value, { stream: true }); + let boundary = getEventBoundary(buffer); + while (boundary) { + const event = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary.length); + if (emitEvent(event, onChunk)) return; + boundary = getEventBoundary(buffer); } } + + buffer += decoder.decode(); + if (buffer && emitEvent(buffer, onChunk)) return; } export async function executeAiCommand(data: AiExecuteRequest) { From 407fe5158f5066241016cbe5e9a8050b1930aa7a Mon Sep 17 00:00:00 2001 From: wizcraft_kris <99409434+Kris20030907@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:30:51 +0800 Subject: [PATCH 09/43] feat: add Prometheus range query adapter (#432) Add a real Prometheus /api/v1/query_range adapter as the foundation for observability (#431): configurable base URL, timeouts, Basic/Bearer auth, error mapping, and tests. --- docs/api-spec.md | 76 ++++- .../studio/cluster/metrics/MetricDataVO.java | 35 +- .../cluster/metrics/MetricQueryDTO.java | 23 ++ .../cluster/metrics/MetricsController.java | 17 +- .../cluster/metrics/MetricsService.java | 4 +- .../cluster/metrics/PrometheusException.java | 34 ++ .../metrics/PrometheusMetricsSource.java | 260 ++++++++++++-- .../cluster/metrics/PrometheusProperties.java | 37 ++ .../exception/GlobalExceptionHandler.java | 28 ++ server/src/main/resources/application.yml | 10 + .../metrics/MetricsControllerTest.java | 55 +++ .../cluster/metrics/MetricsServiceTest.java | 80 +++-- .../metrics/PrometheusMetricsSourceTest.java | 321 ++++++++++++++++++ web/src/api/metrics.ts | 44 ++- 14 files changed, 954 insertions(+), 70 deletions(-) create mode 100644 server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusException.java create mode 100644 server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusProperties.java create mode 100644 server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsControllerTest.java create mode 100644 server/src/test/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java diff --git a/docs/api-spec.md b/docs/api-spec.md index 581e8d0a..2b0bd143 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -1652,17 +1652,83 @@ POST /api/metrics/query | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `metric` | `string` | 是 | 指标名称(如 `tps_in`、`tps_out`、`message_count`、`disk_usage`) | +| `metric` | `string` | 是 | PromQL 表达式,最大 4096 个字符 | | `start` | `number` | 是 | 起始时间(Unix 时间戳,秒) | | `end` | `number` | 是 | 结束时间(Unix 时间戳,秒) | -| `step` | `string` | 否 | 采样步长(如 `"60s"`、`"5m"`、`"1h"`) | +| `step` | `string` | 是 | 查询分辨率,可以是持续时间或秒数(如 `"30s"`、`"5m"`、`"1h"`) | -**Response `data`:** `MetricsResult` +**Request 示例:** + +```json +{ + "metric": "sum(rate(rocketmq_messages_in_total[1m])) by (node_id)", + "start": 1784112606, + "end": 1784114406, + "step": "30s" +} +``` + +**Response `data`:** `MetricData` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `resultType` | `string` | Prometheus 结果类型;范围查询通常为 `matrix` | +| `series` | `MetricSeries[]` | 查询返回的时间序列 | +| `warnings` | `string[]` | Prometheus 返回的非致命警告,没有警告时为空数组 | + +**MetricSeries:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `labels` | `object` | 序列的完整标签集合,包括可能存在的 `__name__` | +| `values` | `MetricSample[]` | 浮点样本;没有浮点样本时为空数组 | +| `histograms` | `MetricHistogramSample[]` | Native Histogram 样本;没有 Histogram 样本时为空数组 | + +同一序列可能只有 `values`、只有 `histograms`,或同时包含两者。 + +**MetricSample:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `timestamp` | `number` | Unix 时间戳,可能包含小数秒 | +| `value` | `string` | Prometheus 样本原始字符串,保留小数精度以及 `NaN`、`+Inf`、`-Inf` | + +**MetricHistogramSample:** | 字段 | 类型 | 说明 | |------|------|------| -| `metric` | `string` | 指标名称 | -| `values` | `[number, number][]` | 数据点数组,每项为 `[timestamp, value]` | +| `timestamp` | `number` | Unix 时间戳,可能包含小数秒 | +| `histogram` | `object` | Prometheus Native Histogram 原始对象,包含 `count`、`sum` 和 `buckets` | + +**Prometheus 配置:** + +```yaml +studio: + metrics: + prometheus: + base-url: ${STUDIO_METRICS_PROMETHEUS_BASE_URL:} + connect-timeout: ${STUDIO_METRICS_PROMETHEUS_CONNECT_TIMEOUT:3s} + read-timeout: ${STUDIO_METRICS_PROMETHEUS_READ_TIMEOUT:10s} + username: ${STUDIO_METRICS_PROMETHEUS_USERNAME:} + password: ${STUDIO_METRICS_PROMETHEUS_PASSWORD:} + bearer-token: ${STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN:} +``` + +- `base-url` 是 Prometheus 或 Prometheus-compatible 服务的 URL 前缀;服务会在其后追加 `/api/v1/query_range`。 +- 未配置 `base-url` 时,查询接口返回 HTTP 503。 +- `connect-timeout` 默认 3 秒,`read-timeout` 默认 10 秒。 +- Bearer Token 的优先级高于 Basic Auth;Basic Auth 必须同时配置 `username` 和 `password`。 +- 密码和 Token 等敏感配置应通过环境变量或其他外部化配置传入,不应提交到代码仓库。 + +**错误响应:** + +| HTTP 状态 | 场景 | +|-----------|------| +| `400` | JSON 无法解析、字段类型错误、请求校验失败或 PromQL 参数错误 | +| `422` | Prometheus 无法执行 PromQL 表达式 | +| `502` | 无法连接 Prometheus,或 Prometheus 返回非法响应 | +| `503` | Prometheus 未配置或暂时不可用 | +| `504` | Prometheus 查询超时 | --- diff --git a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricDataVO.java b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricDataVO.java index 06fbf70c..29846dd1 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricDataVO.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricDataVO.java @@ -16,11 +16,13 @@ */ package com.rocketmq.studio.cluster.metrics; +import com.fasterxml.jackson.databind.JsonNode; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.Map; import java.util.List; @Data @@ -28,6 +30,35 @@ @NoArgsConstructor @AllArgsConstructor public class MetricDataVO { - private String metric; - private List values; + private String resultType; + private List series; + private List warnings; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MetricSeriesVO { + private Map labels; + private List values; + private List histograms; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MetricSampleVO { + private double timestamp; + private String value; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MetricHistogramSampleVO { + private double timestamp; + private JsonNode histogram; + } } diff --git a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricQueryDTO.java b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricQueryDTO.java index 0884ea48..de032a07 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricQueryDTO.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricQueryDTO.java @@ -16,6 +16,10 @@ */ package com.rocketmq.studio.cluster.metrics; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -25,9 +29,28 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(description = "Prometheus range query") public class MetricQueryDTO { + @Schema(description = "PromQL expression evaluated by Prometheus", + example = "sum(rate(rocketmq_messages_in_total[1m])) by (node_id)", minLength = 1, + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "Metric query is required") + @Size(max = 4096, message = "Metric query must not exceed 4096 characters") private String metric; + + @Schema(description = "Range start as a Unix timestamp in seconds", example = "1784112606", + requiredMode = Schema.RequiredMode.REQUIRED) + @Positive(message = "Metric query start must be positive") private long start; + + @Schema(description = "Range end as a Unix timestamp in seconds", example = "1784114406", + requiredMode = Schema.RequiredMode.REQUIRED) + @Positive(message = "Metric query end must be positive") private long end; + + @Schema(description = "Prometheus query resolution step as a duration or number of seconds", example = "30s", + minLength = 1, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "Metric query step is required") + @Size(max = 32, message = "Metric query step must not exceed 32 characters") private String step; } diff --git a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsController.java b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsController.java index 9015c010..6ee8334f 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsController.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsController.java @@ -17,6 +17,10 @@ package com.rocketmq.studio.cluster.metrics; import com.rocketmq.studio.common.domain.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -30,8 +34,19 @@ public class MetricsController { private final MetricsService metricsService; + @Operation(summary = "Query Prometheus range metrics", + description = "Executes a PromQL range query against the configured Prometheus server") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Range query completed successfully", + useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "Invalid request or PromQL expression"), + @ApiResponse(responseCode = "422", description = "Prometheus could not execute the expression"), + @ApiResponse(responseCode = "502", description = "Prometheus connection or response failure"), + @ApiResponse(responseCode = "503", description = "Prometheus is unavailable or not configured"), + @ApiResponse(responseCode = "504", description = "Prometheus query timed out") + }) @PostMapping("/query") - public Result query(@RequestBody MetricQueryDTO query) { + public Result query(@Valid @RequestBody MetricQueryDTO query) { return Result.ok(metricsService.query(query)); } } diff --git a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsService.java b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsService.java index 0a381275..65bbe77f 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsService.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsService.java @@ -28,8 +28,8 @@ public class MetricsService { private final MetricsSource metricsSource; public MetricDataVO query(MetricQueryDTO query) { - log.info("Querying metrics: metric={}, start={}, end={}, step={}", - query.getMetric(), query.getStart(), query.getEnd(), query.getStep()); + log.debug("Querying metrics: start={}, end={}, step={}", + query.getStart(), query.getEnd(), query.getStep()); return metricsSource.query(query); } } diff --git a/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusException.java b/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusException.java new file mode 100644 index 00000000..e286c66e --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusException.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.cluster.metrics; + +import lombok.Getter; + +@Getter +public class PrometheusException extends RuntimeException { + private final int statusCode; + + public PrometheusException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public PrometheusException(int statusCode, String message, Throwable cause) { + super(message, cause); + this.statusCode = statusCode; + } +} diff --git a/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java b/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java index cc48fe5a..701bab1a 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java @@ -16,53 +16,263 @@ */ package com.rocketmq.studio.cluster.metrics; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; -import java.util.ArrayList; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.stream.StreamSupport; @Slf4j @Component public class PrometheusMetricsSource implements MetricsSource { + private static final String QUERY_RANGE_PATH = "/api/v1/query_range"; + + private final RestClient restClient; + private final ObjectMapper objectMapper; + private final PrometheusProperties properties; + + public PrometheusMetricsSource(RestClient.Builder restClientBuilder, ObjectMapper objectMapper, + PrometheusProperties properties) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.getConnectTimeout()); + requestFactory.setReadTimeout(properties.getReadTimeout()); + this.restClient = restClientBuilder.requestFactory(requestFactory).build(); + this.objectMapper = objectMapper; + this.properties = properties; + } + @Override public MetricDataVO query(MetricQueryDTO query) { - log.info("Querying Prometheus: metric={}, start={}, end={}, step={}", - query.getMetric(), query.getStart(), query.getEnd(), query.getStep()); + validateQuery(query); + URI queryRangeUri = queryRangeUri(); + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("query", query.getMetric()); + form.add("start", Long.toString(query.getStart())); + form.add("end", Long.toString(query.getEnd())); + form.add("step", query.getStep()); + + log.debug("Querying Prometheus range: start={}, end={}, step={}", + query.getStart(), query.getEnd(), query.getStep()); + + try { + JsonNode response = restClient.post() + .uri(queryRangeUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .headers(this::applyAuthentication) + .body(form) + .retrieve() + .body(JsonNode.class); + return parseResponse(response); + } catch (PrometheusException exception) { + throw exception; + } catch (RestClientResponseException exception) { + throw responseException(exception); + } catch (ResourceAccessException exception) { + if (hasCause(exception, SocketTimeoutException.class)) { + throw new PrometheusException(HttpStatus.GATEWAY_TIMEOUT.value(), + "Prometheus query timed out", exception); + } + throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(), + "Failed to connect to Prometheus", exception); + } catch (RestClientException exception) { + if (hasCause(exception, SocketTimeoutException.class)) { + throw new PrometheusException(HttpStatus.GATEWAY_TIMEOUT.value(), + "Prometheus query timed out", exception); + } + throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(), + "Prometheus query failed", exception); + } + } + + private void validateQuery(MetricQueryDTO query) { + if (query == null) { + throw new PrometheusException(HttpStatus.BAD_REQUEST.value(), "Metric query is required"); + } + if (query.getEnd() < query.getStart()) { + throw new PrometheusException(HttpStatus.BAD_REQUEST.value(), + "Metric query end must not be earlier than start"); + } + } - // Stub: generate sample data points - List values = new ArrayList<>(); - long stepSeconds = parseStep(query.getStep()); - long start = query.getStart(); - long end = query.getEnd(); + private URI queryRangeUri() { + if (!StringUtils.hasText(properties.getBaseUrl())) { + throw new PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(), + "Prometheus base URL is not configured"); + } + try { + String baseUrl = properties.getBaseUrl().strip(); + while (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + URI uri = URI.create(baseUrl + QUERY_RANGE_PATH); + if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) { + throw new IllegalArgumentException("Unsupported Prometheus URL scheme"); + } + return uri; + } catch (IllegalArgumentException exception) { + throw new PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(), + "Prometheus base URL is invalid", exception); + } + } + + private void applyAuthentication(HttpHeaders headers) { + if (StringUtils.hasText(properties.getBearerToken())) { + headers.setBearerAuth(properties.getBearerToken()); + return; + } + boolean hasUsername = StringUtils.hasText(properties.getUsername()); + boolean hasPassword = StringUtils.hasText(properties.getPassword()); + if (hasUsername != hasPassword) { + throw new PrometheusException(HttpStatus.SERVICE_UNAVAILABLE.value(), + "Prometheus basic authentication is incomplete"); + } + if (hasUsername) { + headers.setBasicAuth(properties.getUsername(), properties.getPassword()); + } + } - for (long ts = start; ts <= end; ts += stepSeconds) { - values.add(new long[]{ts, (long) (Math.random() * 100)}); + private MetricDataVO parseResponse(JsonNode response) { + if (response == null || !"success".equals(response.path("status").asText())) { + throw responseBodyException(response, HttpStatus.BAD_GATEWAY.value()); } + JsonNode data = response.path("data"); + JsonNode result = data.path("result"); + if (!data.isObject() || !result.isArray() || !StringUtils.hasText(data.path("resultType").asText())) { + throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(), + "Prometheus returned a malformed response"); + } + + List series = StreamSupport.stream(result.spliterator(), false) + .map(this::parseSeries) + .toList(); + List warnings = parseWarnings(response.path("warnings")); + return MetricDataVO.builder() - .metric(query.getMetric()) - .values(values) + .resultType(data.path("resultType").asText()) + .series(series) + .warnings(warnings) + .build(); + } + + private MetricDataVO.MetricSeriesVO parseSeries(JsonNode seriesNode) { + JsonNode metric = seriesNode.path("metric"); + JsonNode values = seriesNode.path("values"); + JsonNode histograms = seriesNode.path("histograms"); + boolean hasValues = values.isArray(); + boolean hasHistograms = histograms.isArray(); + boolean hasSamples = hasValues || hasHistograms; + boolean invalidValues = !values.isMissingNode() && !hasValues; + boolean invalidHistograms = !histograms.isMissingNode() && !hasHistograms; + if (!metric.isObject() || invalidValues || invalidHistograms || !hasSamples) { + throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(), + "Prometheus returned a malformed time series"); + } + + Map labels = new LinkedHashMap<>(); + Iterator> fields = metric.fields(); + fields.forEachRemaining(entry -> labels.put(entry.getKey(), entry.getValue().asText())); + + List samples = hasValues + ? StreamSupport.stream(values.spliterator(), false).map(this::parseSample).toList() + : List.of(); + List histogramSamples = hasHistograms + ? StreamSupport.stream(histograms.spliterator(), false).map(this::parseHistogramSample).toList() + : List.of(); + return MetricDataVO.MetricSeriesVO.builder() + .labels(labels) + .values(samples) + .histograms(histogramSamples) + .build(); + } + + private MetricDataVO.MetricSampleVO parseSample(JsonNode sampleNode) { + if (!sampleNode.isArray() || sampleNode.size() != 2 || !sampleNode.get(0).isNumber()) { + throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(), + "Prometheus returned a malformed sample"); + } + return MetricDataVO.MetricSampleVO.builder() + .timestamp(sampleNode.get(0).asDouble()) + .value(sampleNode.get(1).asText()) .build(); } - private long parseStep(String step) { - if (step == null || step.isEmpty()) { - return 60; + private MetricDataVO.MetricHistogramSampleVO parseHistogramSample(JsonNode sampleNode) { + if (!sampleNode.isArray() || sampleNode.size() != 2 + || !sampleNode.get(0).isNumber() || !sampleNode.get(1).isObject()) { + throw new PrometheusException(HttpStatus.BAD_GATEWAY.value(), + "Prometheus returned a malformed histogram sample"); } + return MetricDataVO.MetricHistogramSampleVO.builder() + .timestamp(sampleNode.get(0).asDouble()) + .histogram(sampleNode.get(1)) + .build(); + } + + private List parseWarnings(JsonNode warningsNode) { + if (!warningsNode.isArray()) { + return List.of(); + } + return StreamSupport.stream(warningsNode.spliterator(), false) + .map(JsonNode::asText) + .toList(); + } + + private PrometheusException responseException(RestClientResponseException exception) { + JsonNode response = null; try { - if (step.endsWith("s")) { - return Long.parseLong(step.substring(0, step.length() - 1)); - } else if (step.endsWith("m")) { - return Long.parseLong(step.substring(0, step.length() - 1)) * 60; - } else if (step.endsWith("h")) { - return Long.parseLong(step.substring(0, step.length() - 1)) * 3600; + response = objectMapper.readTree(exception.getResponseBodyAsString()); + } catch (IOException ignored) { + log.debug("Failed to parse Prometheus error response"); + } + int upstreamStatus = exception.getStatusCode().value(); + int statusCode = switch (upstreamStatus) { + case 400, 422, 503 -> upstreamStatus; + default -> HttpStatus.BAD_GATEWAY.value(); + }; + return responseBodyException(response, statusCode); + } + + private PrometheusException responseBodyException(JsonNode response, int statusCode) { + String errorType = response == null ? "" : response.path("errorType").asText(); + String error = response == null ? "" : response.path("error").asText(); + if (StringUtils.hasText(error)) { + String message = StringUtils.hasText(errorType) + ? "Prometheus query failed (" + errorType + "): " + error + : "Prometheus query failed: " + error; + return new PrometheusException(statusCode, message); + } + return new PrometheusException(statusCode, "Prometheus query failed"); + } + + private boolean hasCause(Throwable throwable, Class causeType) { + Throwable current = throwable; + while (current != null) { + if (causeType.isInstance(current)) { + return true; } - return Long.parseLong(step); - } catch (NumberFormatException e) { - log.warn("Failed to parse step '{}', defaulting to 60s", step); - return 60; + current = current.getCause(); } + return false; } } diff --git a/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusProperties.java b/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusProperties.java new file mode 100644 index 00000000..e37e5826 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusProperties.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.cluster.metrics; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "studio.metrics.prometheus") +public class PrometheusProperties { + private String baseUrl; + private Duration connectTimeout = Duration.ofSeconds(3); + private Duration readTimeout = Duration.ofSeconds(10); + private String username; + private String password; + private String bearerToken; +} diff --git a/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java b/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java index 3bc1c19e..26cffb94 100644 --- a/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java +++ b/server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java @@ -16,10 +16,14 @@ */ package com.rocketmq.studio.common.exception; +import com.rocketmq.studio.cluster.metrics.PrometheusException; import com.rocketmq.studio.common.domain.Result; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -36,6 +40,30 @@ public Result handleBusinessException(BusinessException ex) { return Result.error(ex.getCode(), ex.getMessage()); } + @ExceptionHandler(PrometheusException.class) + public ResponseEntity> handlePrometheusException(PrometheusException ex) { + log.warn("Prometheus exception: status={}, message={}", ex.getStatusCode(), ex.getMessage()); + return ResponseEntity.status(ex.getStatusCode()) + .body(Result.error(ex.getStatusCode(), ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage() == null ? "Invalid request" : error.getDefaultMessage()) + .orElse("Invalid request"); + return Result.error(HttpStatus.BAD_REQUEST.value(), message); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + log.warn("Invalid request body"); + return Result.error(HttpStatus.BAD_REQUEST.value(), "Invalid request body"); + } + @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public Result handleException(Exception ex) { diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 3c98b4c6..a213118d 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -12,3 +12,13 @@ springdoc: path: /api-docs swagger-ui: path: /swagger-ui.html + +studio: + metrics: + prometheus: + base-url: ${STUDIO_METRICS_PROMETHEUS_BASE_URL:} + connect-timeout: ${STUDIO_METRICS_PROMETHEUS_CONNECT_TIMEOUT:3s} + read-timeout: ${STUDIO_METRICS_PROMETHEUS_READ_TIMEOUT:10s} + username: ${STUDIO_METRICS_PROMETHEUS_USERNAME:} + password: ${STUDIO_METRICS_PROMETHEUS_PASSWORD:} + bearer-token: ${STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN:} diff --git a/server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsControllerTest.java b/server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsControllerTest.java new file mode 100644 index 00000000..70b64bcf --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsControllerTest.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.cluster.metrics; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MetricsController.class) +@AutoConfigureMockMvc(addFilters = false) +class MetricsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private MetricsService metricsService; + + @Test + void queryShouldReturnBadRequestWhenFieldTypeIsInvalid() throws Exception { + mockMvc.perform(post("/api/metrics/query") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"metric":"up","start":"abc","end":123,"step":"30s"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.message").value("Invalid request body")); + + verifyNoInteractions(metricsService); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsServiceTest.java b/server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsServiceTest.java index b6897758..a8f7118a 100644 --- a/server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsServiceTest.java @@ -22,9 +22,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -48,22 +48,21 @@ void queryShouldReturnMetricData() { .end(1700003600L) .step("1m") .build(); - List values = Arrays.asList( - new long[]{1700000000L, 45}, - new long[]{1700000060L, 52}, - new long[]{1700000120L, 48} + List values = List.of( + sample(1700000000L, "45.5"), + sample(1700000060L, "52"), + sample(1700000120L, "48") ); - MetricDataVO data = MetricDataVO.builder().metric("cpu_usage").values(values).build(); + MetricDataVO data = metricData("cpu_usage", values); when(metricsSource.query(query)).thenReturn(data); MetricDataVO result = metricsService.query(query); - assertThat(result.getMetric()).isEqualTo("cpu_usage"); - assertThat(result.getValues()).hasSize(3); - assertThat(result.getValues().get(0)[0]).isEqualTo(1700000000L); - assertThat(result.getValues().get(0)[1]).isEqualTo(45); - assertThat(result.getValues().get(1)[1]).isEqualTo(52); - assertThat(result.getValues().get(2)[1]).isEqualTo(48); + assertThat(result.getSeries()).hasSize(1); + assertThat(result.getSeries().get(0).getLabels()).containsEntry("__name__", "cpu_usage"); + assertThat(result.getSeries().get(0).getValues()).hasSize(3); + assertThat(result.getSeries().get(0).getValues().get(0).getTimestamp()).isEqualTo(1700000000D); + assertThat(result.getSeries().get(0).getValues().get(0).getValue()).isEqualTo("45.5"); verify(metricsSource).query(query); } @@ -75,13 +74,12 @@ void queryShouldReturnEmptyValuesWhenNoData() { .end(1700003600L) .step("5m") .build(); - MetricDataVO data = MetricDataVO.builder().metric("disk_io").values(Collections.emptyList()).build(); + MetricDataVO data = emptyMetricData(); when(metricsSource.query(query)).thenReturn(data); MetricDataVO result = metricsService.query(query); - assertThat(result.getMetric()).isEqualTo("disk_io"); - assertThat(result.getValues()).isEmpty(); + assertThat(result.getSeries()).isEmpty(); } @Test @@ -92,7 +90,7 @@ void queryShouldPassQueryDirectlyToSource() { .end(1700086400L) .step("1h") .build(); - MetricDataVO data = MetricDataVO.builder().metric("tps").values(Collections.emptyList()).build(); + MetricDataVO data = emptyMetricData(); when(metricsSource.query(any(MetricQueryDTO.class))).thenReturn(data); metricsService.query(query); @@ -104,7 +102,7 @@ void queryShouldPassQueryDirectlyToSource() { void queryShouldHandleVariousStepSizes() { MetricQueryDTO query15s = MetricQueryDTO.builder().metric("cpu").start(1L).end(2L).step("15s").build(); MetricQueryDTO query1h = MetricQueryDTO.builder().metric("cpu").start(1L).end(2L).step("1h").build(); - MetricDataVO data = MetricDataVO.builder().metric("cpu").values(Collections.emptyList()).build(); + MetricDataVO data = emptyMetricData(); when(metricsSource.query(any(MetricQueryDTO.class))).thenReturn(data); MetricDataVO result15s = metricsService.query(query15s); @@ -124,20 +122,19 @@ void queryShouldReturnMultipleDataPoints() { .end(1700003600L) .step("1m") .build(); - List values = Arrays.asList( - new long[]{1700000000L, 72}, - new long[]{1700000060L, 73}, - new long[]{1700000120L, 71}, - new long[]{1700000180L, 74}, - new long[]{1700000240L, 75} + List values = List.of( + sample(1700000000L, "72"), + sample(1700000060L, "73"), + sample(1700000120L, "71"), + sample(1700000180L, "74"), + sample(1700000240L, "75") ); - MetricDataVO data = MetricDataVO.builder().metric("memory_usage").values(values).build(); + MetricDataVO data = metricData("memory_usage", values); when(metricsSource.query(query)).thenReturn(data); MetricDataVO result = metricsService.query(query); - assertThat(result.getMetric()).isEqualTo("memory_usage"); - assertThat(result.getValues()).hasSize(5); + assertThat(result.getSeries().get(0).getValues()).hasSize(5); } @Test @@ -145,12 +142,39 @@ void queryShouldPreserveMetricName() { String[] metrics = {"rocketmq_tps", "rocketmq_latency_p99", "broker_disk_usage", "consumer_lag"}; for (String metricName : metrics) { MetricQueryDTO query = MetricQueryDTO.builder().metric(metricName).start(1L).end(2L).step("1m").build(); - MetricDataVO data = MetricDataVO.builder().metric(metricName).values(Collections.emptyList()).build(); + MetricDataVO data = metricData(metricName, Collections.emptyList()); when(metricsSource.query(query)).thenReturn(data); MetricDataVO result = metricsService.query(query); - assertThat(result.getMetric()).isEqualTo(metricName); + assertThat(result.getSeries().get(0).getLabels()).containsEntry("__name__", metricName); } } + + private MetricDataVO emptyMetricData() { + return MetricDataVO.builder() + .resultType("matrix") + .series(Collections.emptyList()) + .warnings(Collections.emptyList()) + .build(); + } + + private MetricDataVO metricData(String metricName, List values) { + MetricDataVO.MetricSeriesVO series = MetricDataVO.MetricSeriesVO.builder() + .labels(Map.of("__name__", metricName)) + .values(values) + .build(); + return MetricDataVO.builder() + .resultType("matrix") + .series(List.of(series)) + .warnings(Collections.emptyList()) + .build(); + } + + private MetricDataVO.MetricSampleVO sample(double timestamp, String value) { + return MetricDataVO.MetricSampleVO.builder() + .timestamp(timestamp) + .value(value) + .build(); + } } diff --git a/server/src/test/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java b/server/src/test/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java new file mode 100644 index 00000000..3d2fbb26 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java @@ -0,0 +1,321 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.cluster.metrics; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PrometheusMetricsSourceTest { + + private HttpServer server; + private String baseUrl; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + server.start(); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + @Test + void queryShouldPreserveSeriesLabelsDecimalValuesAndWarnings() { + AtomicReference requestBody = new AtomicReference<>(); + AtomicReference requestMethod = new AtomicReference<>(); + AtomicReference contentType = new AtomicReference<>(); + server.createContext("/api/v1/query_range", exchange -> { + requestMethod.set(exchange.getRequestMethod()); + contentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + respond(exchange, 200, """ + { + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": {"node_id": "broker-a", "cluster": "cluster-a"}, + "values": [[1784107658, "0.30000000000000004"], [1784107688, "NaN"]] + }, + { + "metric": {"node_id": "broker-b", "cluster": "cluster-a"}, + "values": [[1784107658.5, "1.25"]] + } + ] + }, + "warnings": ["partial response"] + } + """); + }); + + PrometheusMetricsSource source = source(Duration.ofSeconds(2)); + MetricDataVO result = source.query(query()); + + assertThat(result.getResultType()).isEqualTo("matrix"); + assertThat(result.getSeries()).hasSize(2); + assertThat(result.getSeries().get(0).getLabels()) + .containsEntry("node_id", "broker-a") + .containsEntry("cluster", "cluster-a"); + assertThat(result.getSeries().get(0).getValues().get(0).getValue()) + .isEqualTo("0.30000000000000004"); + assertThat(result.getSeries().get(0).getValues().get(1).getValue()).isEqualTo("NaN"); + assertThat(result.getSeries().get(1).getValues().get(0).getTimestamp()).isEqualTo(1784107658.5D); + assertThat(result.getWarnings()).containsExactly("partial response"); + + assertThat(requestMethod.get()).isEqualTo("POST"); + assertThat(contentType.get()).startsWith("application/x-www-form-urlencoded"); + String decodedBody = URLDecoder.decode(requestBody.get(), StandardCharsets.UTF_8); + assertThat(decodedBody).contains("query=sum(rate(rocketmq_messages_in_total[1m]))"); + assertThat(decodedBody).contains("start=1784107658"); + assertThat(decodedBody).contains("end=1784108558"); + assertThat(decodedBody).contains("step=30s"); + } + + @Test + void queryShouldPreserveHistogramOnlySeries() { + server.createContext("/api/v1/query_range", exchange -> respond(exchange, 200, """ + { + "status": "success", + "data": { + "resultType": "matrix", + "result": [{ + "metric": {"__name__": "rocketmq_rpc_latency"}, + "histograms": [[1784107658, { + "count": "12", + "sum": "3.5", + "buckets": [[3, "-0.5", "0.5", "4"], [0, "0.5", "+Inf", "8"]] + }]] + }] + } + } + """)); + + MetricDataVO.MetricSeriesVO series = source(Duration.ofSeconds(2)).query(query()).getSeries().get(0); + + assertThat(series.getValues()).isEmpty(); + assertThat(series.getHistograms()).hasSize(1); + assertThat(series.getHistograms().get(0).getTimestamp()).isEqualTo(1784107658D); + assertThat(series.getHistograms().get(0).getHistogram().path("count").asText()).isEqualTo("12"); + assertThat(series.getHistograms().get(0).getHistogram().path("sum").asText()).isEqualTo("3.5"); + assertThat(series.getHistograms().get(0).getHistogram().path("buckets")).hasSize(2); + } + + @Test + void queryShouldPreserveFloatAndHistogramSamplesInSameSeries() { + server.createContext("/api/v1/query_range", exchange -> respond(exchange, 200, """ + { + "status": "success", + "data": { + "resultType": "matrix", + "result": [{ + "metric": {"__name__": "request_duration_seconds"}, + "values": [[1784107658, "1.25"]], + "histograms": [[1784107688, { + "count": "2", + "sum": "1.5", + "buckets": [[3, "-0.5", "0.5", "2"]] + }]] + }] + } + } + """)); + + MetricDataVO.MetricSeriesVO series = source(Duration.ofSeconds(2)).query(query()).getSeries().get(0); + + assertThat(series.getValues()).hasSize(1); + assertThat(series.getValues().get(0).getValue()).isEqualTo("1.25"); + assertThat(series.getHistograms()).hasSize(1); + assertThat(series.getHistograms().get(0).getHistogram().path("count").asText()).isEqualTo("2"); + } + + @Test + void queryShouldApplyBasicAuthentication() { + AtomicReference authorization = new AtomicReference<>(); + server.createContext("/api/v1/query_range", exchange -> { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, successResponse()); + }); + PrometheusProperties properties = properties(Duration.ofSeconds(2)); + properties.setUsername("studio"); + properties.setPassword("secret"); + + source(properties).query(query()); + + String credentials = Base64.getEncoder().encodeToString("studio:secret".getBytes(StandardCharsets.UTF_8)); + assertThat(authorization.get()).isEqualTo("Basic " + credentials); + } + + @Test + void queryShouldPreferBearerAuthentication() { + AtomicReference authorization = new AtomicReference<>(); + server.createContext("/api/v1/query_range", exchange -> { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, successResponse()); + }); + PrometheusProperties properties = properties(Duration.ofSeconds(2)); + properties.setBearerToken("test-token"); + properties.setUsername("ignored-user"); + properties.setPassword("ignored-password"); + + source(properties).query(query()); + + assertThat(authorization.get()).isEqualTo("Bearer test-token"); + } + + @Test + void queryShouldExposePrometheusErrorDetails() { + server.createContext("/api/v1/query_range", exchange -> respond(exchange, 422, """ + {"status":"error","errorType":"execution","error":"invalid expression"} + """)); + + PrometheusMetricsSource source = source(Duration.ofSeconds(2)); + + assertThatThrownBy(() -> source.query(query())) + .isInstanceOf(PrometheusException.class) + .satisfies(exception -> { + PrometheusException prometheusException = (PrometheusException) exception; + assertThat(prometheusException.getStatusCode()).isEqualTo(422); + assertThat(prometheusException.getMessage()) + .isEqualTo("Prometheus query failed (execution): invalid expression"); + }); + } + + @Test + void queryShouldRejectMalformedPrometheusResponse() { + server.createContext("/api/v1/query_range", exchange -> respond(exchange, 200, """ + {"status":"success","data":{"resultType":"matrix","result":{}}} + """)); + + assertThatThrownBy(() -> source(Duration.ofSeconds(2)).query(query())) + .isInstanceOf(PrometheusException.class) + .satisfies(exception -> assertThat(((PrometheusException) exception).getStatusCode()) + .isEqualTo(HttpStatus.BAD_GATEWAY.value())) + .hasMessage("Prometheus returned a malformed response"); + } + + @Test + void queryShouldRejectEndEarlierThanStart() { + MetricQueryDTO invalidQuery = MetricQueryDTO.builder() + .metric("up") + .start(2L) + .end(1L) + .step("30s") + .build(); + + assertThatThrownBy(() -> source(Duration.ofSeconds(2)).query(invalidQuery)) + .isInstanceOf(PrometheusException.class) + .satisfies(exception -> assertThat(((PrometheusException) exception).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST.value())) + .hasMessage("Metric query end must not be earlier than start"); + } + + @Test + void queryShouldFailLoudWhenPrometheusIsNotConfigured() { + PrometheusProperties properties = new PrometheusProperties(); + PrometheusMetricsSource source = new PrometheusMetricsSource( + RestClient.builder(), new ObjectMapper(), properties); + + assertThatThrownBy(() -> source.query(query())) + .isInstanceOf(PrometheusException.class) + .satisfies(exception -> assertThat(((PrometheusException) exception).getStatusCode()) + .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE.value())) + .hasMessage("Prometheus base URL is not configured"); + } + + @Test + void queryShouldReportReadTimeout() { + server.createContext("/api/v1/query_range", exchange -> { + try { + Thread.sleep(300); + respond(exchange, 200, "{\"status\":\"success\",\"data\":{\"resultType\":\"matrix\",\"result\":[]}}"); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + // The client closes the exchange after the expected timeout. + } + }); + + PrometheusMetricsSource source = source(Duration.ofMillis(50)); + + assertThatThrownBy(() -> source.query(query())) + .isInstanceOf(PrometheusException.class) + .satisfies(exception -> { + PrometheusException prometheusException = (PrometheusException) exception; + assertThat(prometheusException.getStatusCode()) + .isEqualTo(HttpStatus.GATEWAY_TIMEOUT.value()); + }) + .hasMessage("Prometheus query timed out"); + } + + private PrometheusMetricsSource source(Duration readTimeout) { + return source(properties(readTimeout)); + } + + private PrometheusMetricsSource source(PrometheusProperties properties) { + return new PrometheusMetricsSource(RestClient.builder(), new ObjectMapper(), properties); + } + + private PrometheusProperties properties(Duration readTimeout) { + PrometheusProperties properties = new PrometheusProperties(); + properties.setBaseUrl(baseUrl); + properties.setConnectTimeout(Duration.ofSeconds(1)); + properties.setReadTimeout(readTimeout); + return properties; + } + + private MetricQueryDTO query() { + return MetricQueryDTO.builder() + .metric("sum(rate(rocketmq_messages_in_total[1m]))") + .start(1784107658L) + .end(1784108558L) + .step("30s") + .build(); + } + + private void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] response = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } + + private String successResponse() { + return "{\"status\":\"success\",\"data\":{\"resultType\":\"matrix\",\"result\":[]}}"; + } +} diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts index ddc0cc18..d0b71a92 100644 --- a/web/src/api/metrics.ts +++ b/web/src/api/metrics.ts @@ -40,6 +40,41 @@ export interface DashboardData { clusters: ClusterOverview[]; } +export interface MetricSample { + timestamp: number; + value: string; +} + +export interface MetricHistogram { + count: string; + sum: string; + buckets: [number, string, string, string][]; +} + +export interface MetricHistogramSample { + timestamp: number; + histogram: MetricHistogram; +} + +export interface MetricSeries { + labels: Record; + values: MetricSample[]; + histograms: MetricHistogramSample[]; +} + +export interface MetricData { + resultType: string; + series: MetricSeries[]; + warnings: string[]; +} + +export interface MetricQuery { + metric: string; + start: number; + end: number; + step: string; +} + // ─── Dashboard ────────────────────────────────────────────────── export async function getDashboard() { const res = await client.get<{ data: DashboardData }>('/dashboard'); @@ -47,12 +82,7 @@ export async function getDashboard() { } // ─── Metrics ──────────────────────────────────────────────────── -export async function queryMetrics(query: { - metric: string; - start: number; - end: number; - step?: string; -}) { - const res = await client.post<{ data: unknown }>('/metrics/query', query); +export async function queryMetrics(query: MetricQuery) { + const res = await client.post<{ data: MetricData }>('/metrics/query', query); return res.data.data; } From 6c853c4c731cf4a5449b206bf283790bc08e3afb Mon Sep 17 00:00:00 2001 From: zhaohai <33314633+zhaohai666@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:09:38 +0800 Subject: [PATCH 10/43] feat: implement login page, auth & AI modules, simplify theme management (#473) Add login page with full auth workflow, Auth/AI API modules, Zustand state, and simplified theme management. --- web/package.json | 2 +- web/src/App.tsx | 2 + web/src/api/ai.test.ts | 191 ++++++++++++------ web/src/api/auth.test.ts | 74 +++++++ web/src/layouts/MainLayout.tsx | 10 +- web/src/pages/login/index.tsx | 102 ++++++++++ web/src/theme/ThemeContext.tsx | 2 +- web/src/theme/__tests__/ThemeContext.test.tsx | 2 +- 8 files changed, 316 insertions(+), 69 deletions(-) create mode 100644 web/src/api/auth.test.ts create mode 100644 web/src/pages/login/index.tsx diff --git a/web/package.json b/web/package.json index 307eab84..b9dc0ecf 100644 --- a/web/package.json +++ b/web/package.json @@ -60,4 +60,4 @@ "vite": "^6.0.0", "vitest": "^4.1.10" } -} \ No newline at end of file +} diff --git a/web/src/App.tsx b/web/src/App.tsx index eac2795c..a75edbbf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,10 +33,12 @@ import SystemAlertsPage from './pages/ops/systemAlerts'; import AuditPage from './pages/ops/audit'; import AiPage from './pages/ai'; import SettingsPage from './pages/settings'; +import LoginPage from './pages/login'; function App() { return ( + } /> }> } /> } /> diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts index 1b03825f..58250ebb 100644 --- a/web/src/api/ai.test.ts +++ b/web/src/api/ai.test.ts @@ -15,9 +15,12 @@ * limitations under the License. */ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { chatStream } from './ai'; +import MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { chatStream, executeAiCommand, listTools, type AiExecuteRequest, type McpTool } from './ai'; +const mock = new MockAdapter(client); const encoder = new TextEncoder(); function streamResponse(chunks: string[]): Response { @@ -30,68 +33,142 @@ function streamResponse(chunks: string[]): Response { return new Response(body, { status: 200 }); } -describe('AI chat SSE stream', () => { +describe('AI API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { + getItem: vi.fn().mockReturnValue('test-token'), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + }); + afterEach(() => { + mock.reset(); vi.unstubAllGlobals(); }); - it('reassembles an event split across network chunks', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValue( - streamResponse([ - 'event: message\r\ndata: {"text":"hel', - 'lo"}\r\n\r\nevent: done\r\ndata: [DONE]\r\n\r\n', - ]), - ), - ); - vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue('token') }); - const chunks: string[] = []; - - await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => - chunks.push(text), - ); - - expect(chunks).toEqual(['hello']); + describe('chatStream (SSE)', () => { + it('reassembles an event split across network chunks', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + streamResponse([ + 'event: message\r\ndata: {"text":"hel', + 'lo"}\r\n\r\nevent: done\r\ndata: [DONE]\r\n\r\n', + ]), + ), + ); + const chunks: string[] = []; + + await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => + chunks.push(text), + ); + + expect(chunks).toEqual(['hello']); + }); + + it('dispatches multiple events delivered in one network chunk', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + streamResponse([ + 'data: {"content":"first"}\n\ndata: {"content":"second"}\n\ndata: [DONE]\n\n', + ]), + ), + ); + const chunks: string[] = []; + + await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => + chunks.push(text), + ); + + expect(chunks).toEqual(['first', 'second']); + }); + + it('supports multiline and raw SSE data at end of stream', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + streamResponse(['data: {"content":\ndata: "hello"}\n\ndata: raw text']), + ), + ); + const chunks: string[] = []; + + await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => + chunks.push(text), + ); + + expect(chunks).toEqual(['hello', 'raw text']); + }); }); - it('dispatches multiple events delivered in one network chunk', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValue( - streamResponse([ - 'data: {"content":"first"}\n\ndata: {"content":"second"}\n\ndata: [DONE]\n\n', - ]), - ), - ); - vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); - const chunks: string[] = []; - - await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => - chunks.push(text), - ); - - expect(chunks).toEqual(['first', 'second']); + describe('executeAiCommand', () => { + it('should post AI command and return result with tool calls', async () => { + const request: AiExecuteRequest = { + message: 'list topics', + mode: 'agent', + model: 'gpt-4', + }; + const mockResult = { result: 'Found 5 topics', toolCalls: [{ name: 'listTopics' }] }; + mock.onPost('/ai/execute', request).reply(200, { data: mockResult }); + + const result = await executeAiCommand(request); + expect(result.result).toBe('Found 5 topics'); + expect(result.toolCalls).toHaveLength(1); + expect((result.toolCalls[0] as { name: string }).name).toBe('listTopics'); + }); + + it('should handle empty tool calls', async () => { + mock.onPost('/ai/execute').reply(200, { data: { result: 'Hi!', toolCalls: [] } }); + + const result = await executeAiCommand({ + message: 'hello', + mode: 'chat', + model: 'gpt-4', + }); + expect(result.result).toBe('Hi!'); + expect(result.toolCalls).toEqual([]); + }); + + it('should handle server error', async () => { + mock.onPost('/ai/execute').reply(500); + await expect( + executeAiCommand({ message: 'test', mode: 'chat', model: 'gpt-4' }), + ).rejects.toThrow(); + }); }); - it('supports multiline and raw SSE data at end of stream', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValue(streamResponse(['data: {"content":\ndata: "hello"}\n\ndata: raw text'])), - ); - vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); - const chunks: string[] = []; - - await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) => - chunks.push(text), - ); - - expect(chunks).toEqual(['hello', 'raw text']); + describe('listTools', () => { + it('should return list of MCP tools', async () => { + const mockTools: McpTool[] = [ + { name: 'listTopics', description: 'List all topics', parameters: {} }, + { name: 'createTopic', description: 'Create a topic', parameters: { type: 'object' } }, + ]; + mock.onGet('/ai/tools').reply(200, { data: mockTools }); + + const result = await listTools(); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('listTopics'); + expect(result[1].name).toBe('createTopic'); + }); + + it('should return empty list when no tools available', async () => { + mock.onGet('/ai/tools').reply(200, { data: [] }); + + const result = await listTools(); + expect(result).toEqual([]); + }); + + it('should handle server error', async () => { + mock.onGet('/ai/tools').reply(500); + await expect(listTools()).rejects.toThrow(); + }); }); }); diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts new file mode 100644 index 00000000..6db27b12 --- /dev/null +++ b/web/src/api/auth.test.ts @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { login, logout } from './auth'; + +const mock = new MockAdapter(client); + +describe('Auth API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('login should post credentials and return token data', async () => { + const mockResponse = { + token: 'jwt-token-123', + username: 'admin', + role: 'ADMIN', + }; + mock.onPost('/auth/login', { username: 'admin', password: 'secret' }).reply(200, { + data: mockResponse, + }); + + const result = await login('admin', 'secret'); + expect(result).toEqual(mockResponse); + expect(result.token).toBe('jwt-token-123'); + expect(result.username).toBe('admin'); + expect(result.role).toBe('ADMIN'); + }); + + it('login should handle error response', async () => { + mock.onPost('/auth/login').reply(401, { message: 'Invalid credentials' }); + + await expect(login('wrong', 'creds')).rejects.toThrow(); + }); + + it('logout should post to logout endpoint', async () => { + mock.onPost('/auth/logout').reply(200); + + await expect(logout()).resolves.toBeUndefined(); + }); + + it('logout should handle server error', async () => { + mock.onPost('/auth/logout').reply(500); + + await expect(logout()).rejects.toThrow(); + }); +}); diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index de7dc55b..b656db82 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -16,15 +16,7 @@ */ import { useState } from 'react'; -import { - Layout, - Menu, - Breadcrumb, - Avatar, - Dropdown, - Input, - Modal, -} from 'antd'; +import { Layout, Menu, Breadcrumb, Avatar, Dropdown, Input, Modal } from 'antd'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { House, diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx new file mode 100644 index 00000000..715201c1 --- /dev/null +++ b/web/src/pages/login/index.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import { Button, Form, Input, Typography, App } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { useLang } from '../../i18n/LangContext'; +import useAuthStore from '../../stores/authStore'; +import { login as loginApi } from '../../api/auth'; + +const { Title } = Typography; + +interface LoginFormValues { + username: string; + password: string; +} + +const LoginPage = () => { + const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + const { t } = useLang(); + const { message } = App.useApp(); + const navigate = useNavigate(); + const authLogin = useAuthStore((s) => s.login); + + const onFinish = async (values: LoginFormValues) => { + setLoading(true); + try { + const data = await loginApi(values.username, values.password); + authLogin(data.token, data.username); + localStorage.setItem('userrole', data.role); + message.success(t('login.success')); + navigate('/', { replace: true }); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : t('login.failed'); + message.error(errorMsg); + } finally { + setLoading(false); + } + }; + + return ( +
+ + {t('login.welcome')} + +
+ + + + + + + + + + + +
+
+ ); +}; + +export default LoginPage; diff --git a/web/src/theme/ThemeContext.tsx b/web/src/theme/ThemeContext.tsx index 6bac20e2..48d250eb 100644 --- a/web/src/theme/ThemeContext.tsx +++ b/web/src/theme/ThemeContext.tsx @@ -65,4 +65,4 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { export const useTheme = () => useContext(ThemeContext); -export default ThemeContext; \ No newline at end of file +export default ThemeContext; diff --git a/web/src/theme/__tests__/ThemeContext.test.tsx b/web/src/theme/__tests__/ThemeContext.test.tsx index 3feb0cc6..95b919eb 100644 --- a/web/src/theme/__tests__/ThemeContext.test.tsx +++ b/web/src/theme/__tests__/ThemeContext.test.tsx @@ -153,4 +153,4 @@ describe('ThemeContext', () => { await user.click(screen.getByText('set-light')); expect(screen.getByTestId('mode')).toHaveTextContent('light'); }); -}); \ No newline at end of file +}); From 13bef2d11a29c10887aef8ed1a329b65afcf7217 Mon Sep 17 00:00:00 2001 From: zhaohai <33314633+zhaohai666@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:12:59 +0800 Subject: [PATCH 11/43] feat: add Ops page (NameServer management, VIPChannel, TLS) (#474) Add Ops management page for NameServer address management, VIP channel toggle, and TLS switch. --- web/src/App.tsx | 2 + web/src/api/ops.test.ts | 248 +++++++++++++++++++++++++++++++++++ web/src/api/ops.ts | 29 ++++ web/src/i18n/translations.ts | 9 ++ web/src/pages/studio/Ops.tsx | 199 ++++++++++++++++++++++++++++ 5 files changed, 487 insertions(+) create mode 100644 web/src/api/ops.test.ts create mode 100644 web/src/pages/studio/Ops.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index a75edbbf..faba2783 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts'; import AuditPage from './pages/ops/audit'; import AiPage from './pages/ai'; import SettingsPage from './pages/settings'; +import OpsPage from './pages/studio/Ops'; import LoginPage from './pages/login'; function App() { @@ -56,6 +57,7 @@ function App() { } /> } /> } /> + } /> } />
diff --git a/web/src/api/ops.test.ts b/web/src/api/ops.test.ts new file mode 100644 index 00000000..573a351a --- /dev/null +++ b/web/src/api/ops.test.ts @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { + queryOpsHomePage, + updateNameSvrAddr, + addNameSvrAddr, + updateIsVIPChannel, + updateUseTLS, + listAlertRules, + createAlertRule, + updateAlertRule, + toggleAlertRule, + deleteAlertRule, + listSystemAlerts, + acknowledgeAlert, + clearAcknowledgedAlerts, + listAuditRecords, + cleanupAuditLogs, +} from './ops'; + +const mock = new MockAdapter(client); + +describe('Ops API - NameServer operations', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('queries ops home page data', async () => { + const data = { + namesvrAddrList: ['127.0.0.1:9876', '127.0.0.1:9877'], + useVIPChannel: true, + useTLS: false, + currentNamesrv: '127.0.0.1:9876', + }; + mock.onGet('/ops/homePage').reply(200, { code: 200, data }); + + const result = await queryOpsHomePage(); + expect(result.namesvrAddrList).toHaveLength(2); + expect(result.useVIPChannel).toBe(true); + expect(result.useTLS).toBe(false); + }); + + it('updates NameServer address', async () => { + mock.onPost('/ops/updateNameSvrAddr').reply((config) => { + const body = JSON.parse(config.data); + expect(body.namesrvAddr).toBe('10.0.0.1:9876'); + return [200, { code: 200 }]; + }); + + await updateNameSvrAddr('10.0.0.1:9876'); + }); + + it('adds a NameServer address', async () => { + mock.onPost('/ops/addNameSvrAddr').reply((config) => { + const body = JSON.parse(config.data); + expect(body.namesrvAddr).toBe('10.0.0.2:9876'); + return [200, { code: 200 }]; + }); + + await addNameSvrAddr('10.0.0.2:9876'); + }); + + it('updates VIP channel setting', async () => { + mock.onPost('/ops/updateIsVIPChannel').reply((config) => { + const body = JSON.parse(config.data); + expect(body.useVIPChannel).toBe(false); + return [200, { code: 200 }]; + }); + + await updateIsVIPChannel(false); + }); + + it('updates TLS setting', async () => { + mock.onPost('/ops/updateUseTLS').reply((config) => { + const body = JSON.parse(config.data); + expect(body.useTLS).toBe(true); + return [200, { code: 200 }]; + }); + + await updateUseTLS(true); + }); +}); + +describe('Ops API - Alert Rules', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('lists alert rules', async () => { + const rules = [ + { + id: '1', + name: 'HighCPU', + metric: 'cpu', + operator: '>', + threshold: 80, + thresholdUnit: '%', + duration: '5m', + channels: ['email'], + enabled: true, + lastTriggered: null, + description: 'CPU alert', + }, + ]; + mock.onGet('/alert-rules').reply(200, { code: 200, data: rules }); + + const result = await listAlertRules(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('HighCPU'); + }); + + it('creates an alert rule', async () => { + mock.onPost('/alert-rules/create').reply(200, { code: 200 }); + await createAlertRule({ name: 'TestAlert', metric: 'memory', operator: '>', threshold: 90 }); + }); + + it('updates an alert rule', async () => { + mock.onPost('/alert-rules/update').reply((config) => { + const body = JSON.parse(config.data); + expect(body.id).toBe('1'); + expect(body.threshold).toBe(95); + return [200, { code: 200 }]; + }); + await updateAlertRule({ id: '1', threshold: 95 }); + }); + + it('toggles an alert rule', async () => { + mock.onPost('/alert-rules/toggle').reply((config) => { + const body = JSON.parse(config.data); + expect(body.id).toBe('1'); + expect(body.enabled).toBe(false); + return [200, { code: 200 }]; + }); + await toggleAlertRule('1', false); + }); + + it('deletes an alert rule', async () => { + mock.onPost('/alert-rules/delete').reply((config) => { + const body = JSON.parse(config.data); + expect(body.id).toBe('1'); + return [200, { code: 200 }]; + }); + await deleteAlertRule('1'); + }); +}); + +describe('Ops API - System Alerts & Audit', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('lists system alerts', async () => { + mock.onGet('/system-alerts').reply(200, { + code: 200, + data: [ + { + id: 'a1', + level: 'critical', + title: 'Disk Full', + description: 'Disk usage > 95%', + time: '2026-01-01', + acknowledged: false, + }, + ], + }); + const result = await listSystemAlerts(); + expect(result[0].level).toBe('critical'); + }); + + it('acknowledges an alert', async () => { + mock.onPost('/system-alerts/acknowledge').reply((config) => { + expect(JSON.parse(config.data).id).toBe('a1'); + return [200, { code: 200 }]; + }); + await acknowledgeAlert('a1'); + }); + + it('clears acknowledged alerts', async () => { + mock.onPost('/system-alerts/clear-acknowledged').reply(200, { code: 200 }); + await clearAcknowledgedAlerts(); + }); + + it('lists audit records with params', async () => { + mock.onGet('/audit-logs').reply(200, { + code: 200, + data: { + list: [ + { + id: 'r1', + timestamp: '2026-01-01', + operator: 'admin', + operationType: 'CREATE', + target: 'topic', + detail: 'Created topic', + ipAddress: '127.0.0.1', + result: 'SUCCESS', + }, + ], + total: 1, + }, + }); + const result = await listAuditRecords({ page: 1 }); + expect(result.list).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('cleans up audit logs', async () => { + mock.onPost('/audit-logs/cleanup').reply((config) => { + expect(JSON.parse(config.data).beforeDays).toBe(30); + return [200, { code: 200 }]; + }); + await cleanupAuditLogs(30); + }); +}); diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts index 8f199962..28ab04ea 100644 --- a/web/src/api/ops.ts +++ b/web/src/api/ops.ts @@ -84,3 +84,32 @@ export async function listAuditRecords(params?: Record) { export async function cleanupAuditLogs(beforeDays: number) { await client.post('/audit-logs/cleanup', { beforeDays }); } + +// ─── NameServer Operations ────────────────────────────────────── +export interface OpsHomeData { + namesvrAddrList: string[]; + useVIPChannel: boolean; + useTLS: boolean; + currentNamesrv: string; +} + +export async function queryOpsHomePage(): Promise { + const res = await client.get<{ data: OpsHomeData }>('/ops/homePage'); + return res.data.data; +} + +export async function updateNameSvrAddr(namesrvAddr: string): Promise { + await client.post('/ops/updateNameSvrAddr', { namesrvAddr }); +} + +export async function addNameSvrAddr(namesrvAddr: string): Promise { + await client.post('/ops/addNameSvrAddr', { namesrvAddr }); +} + +export async function updateIsVIPChannel(useVIPChannel: boolean): Promise { + await client.post('/ops/updateIsVIPChannel', { useVIPChannel }); +} + +export async function updateUseTLS(useTLS: boolean): Promise { + await client.post('/ops/updateUseTLS', { useTLS }); +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index c07151f6..47ba4549 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -357,6 +357,15 @@ const translations: Record> = { 'ops.name': { zh: '名称', en: 'Name' }, 'ops.value': { zh: '值', en: 'Value' }, 'ops.description': { zh: '描述', en: 'Description' }, + 'ops.nameServerAddressList': { zh: 'NameServer 地址列表', en: 'NameServer Address List' }, + 'ops.isUseVIPChannel': { zh: '是否使用 VIP 通道', en: 'Is Use VIP Channel' }, + 'ops.useTLS': { zh: '使用 TLS', en: 'Use TLS' }, + 'ops.selectNamesrv': { zh: '请选择 NameServer 地址', en: 'Please select a NameServer address' }, + 'ops.inputNamesrvAddr': { + zh: '请输入新的 NameServer 地址', + en: 'Please input a new NameServer address', + }, + 'ops.fetchFailed': { zh: '获取运维数据失败', en: 'Failed to fetch ops data' }, // ─── Topic (detailed) ─── 'topic.subtitle': { diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx new file mode 100644 index 00000000..bbda058c --- /dev/null +++ b/web/src/pages/studio/Ops.tsx @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useRef, useState } from 'react'; +import { App, Button, Input, Select, Space, Switch, Typography } from 'antd'; +import { FloppyDisk, Plus } from '@phosphor-icons/react'; +import { useLang } from '../../i18n/LangContext'; +import { + addNameSvrAddr, + queryOpsHomePage, + updateIsVIPChannel, + updateNameSvrAddr, + updateUseTLS, +} from '../../api/ops'; + +const OpsPage: React.FC = () => { + const { t } = useLang(); + const { message } = App.useApp(); + + const [namesrvAddrList, setNamesrvAddrList] = useState([]); + const [selectedNamesrv, setSelectedNamesrv] = useState(''); + const [newNamesrvAddr, setNewNamesrvAddr] = useState(''); + const [useVIPChannel, setUseVIPChannel] = useState(false); + const [useTLS, setUseTLS] = useState(false); + const [writeOperationEnabled, setWriteOperationEnabled] = useState(true); + + // One-time initialization (ESLint-compliant: no useEffect+setState) + const initialized = useRef(null); + if (initialized.current == null) { + initialized.current = true; + const loadOpsData = async () => { + try { + const userRole = sessionStorage.getItem('userrole'); + setWriteOperationEnabled(userRole === null || userRole === '1'); + + const data = await queryOpsHomePage(); + setNamesrvAddrList(data.namesvrAddrList); + setUseVIPChannel(data.useVIPChannel); + setUseTLS(data.useTLS); + setSelectedNamesrv(data.currentNamesrv); + } catch { + message.error(t('ops.fetchFailed')); + } + }; + loadOpsData(); + } + + const handleUpdateNameSvrAddr = async () => { + if (!selectedNamesrv) { + message.warning(t('ops.selectNamesrv')); + return; + } + try { + await updateNameSvrAddr(selectedNamesrv); + message.success(t('common.success')); + } catch { + message.error(t('common.failure')); + } + }; + + const handleAddNameSvrAddr = async () => { + const addr = newNamesrvAddr.trim(); + if (!addr) { + message.warning(t('ops.inputNamesrvAddr')); + return; + } + try { + await addNameSvrAddr(addr); + if (!namesrvAddrList.includes(addr)) { + setNamesrvAddrList([...namesrvAddrList, addr]); + } + setNewNamesrvAddr(''); + message.success(t('common.success')); + } catch { + message.error(t('common.failure')); + } + }; + + const handleUpdateIsVIPChannel = async (checked: boolean) => { + setUseVIPChannel(checked); + try { + await updateIsVIPChannel(checked); + message.success(t('common.success')); + } catch { + message.error(t('common.failure')); + setUseVIPChannel(!checked); + } + }; + + const handleUpdateUseTLS = async (checked: boolean) => { + setUseTLS(checked); + try { + await updateUseTLS(checked); + message.success(t('common.success')); + } catch { + message.error(t('common.failure')); + setUseTLS(!checked); + } + }; + + return ( +
+ {/* NameServer Address List */} +
+ {t('ops.nameServerAddressList')} + + setNewNamesrvAddr(e.target.value)} + /> + + + )} + +
+ + {/* VIP Channel */} +
+ {t('ops.isUseVIPChannel')} + + + {writeOperationEnabled && ( + + )} + +
+ + {/* Use TLS */} +
+ {t('ops.useTLS')} + + + {writeOperationEnabled && ( + + )} + +
+
+ ); +}; + +export default OpsPage; From 4c24628b67bab56e4a44609772a468d51a07f2c9 Mon Sep 17 00:00:00 2001 From: zhaohai <33314633+zhaohai666@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:20:03 +0800 Subject: [PATCH 12/43] feat: add Producer page (#475) Add a page to query producer client connections by Topic and Producer Group. --- web/src/App.tsx | 2 + web/src/api/producer.test.ts | 84 +++++++++++++++++ web/src/api/producer.ts | 45 +++++++++ web/src/i18n/translations.ts | 16 +++- web/src/pages/studio/Producer.tsx | 148 ++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 web/src/api/producer.test.ts create mode 100644 web/src/api/producer.ts create mode 100644 web/src/pages/studio/Producer.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index faba2783..3959a7c9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts'; import AuditPage from './pages/ops/audit'; import AiPage from './pages/ai'; import SettingsPage from './pages/settings'; +import ProducerPage from './pages/studio/Producer'; import OpsPage from './pages/studio/Ops'; import LoginPage from './pages/login'; @@ -57,6 +58,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts new file mode 100644 index 00000000..c87fa070 --- /dev/null +++ b/web/src/api/producer.test.ts @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { fetchTopicList, queryProducerConnection } from './producer'; + +const mock = new MockAdapter(client); + +describe('Producer API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('fetches topic list sorted alphabetically', async () => { + mock.onGet('/topics').reply(200, { + topicList: ['order-events', 'user-signup', 'batch-process'], + }); + + const result = await fetchTopicList(); + expect(result).toEqual(['batch-process', 'order-events', 'user-signup']); + }); + + it('handles empty topic list', async () => { + mock.onGet('/topics').reply(200, { topicList: [] }); + + const result = await fetchTopicList(); + expect(result).toEqual([]); + }); + + it('queries producer connections by topic and group', async () => { + const connections = [ + { + clientId: 'producer-1', + clientAddr: '192.168.1.10', + language: 'JAVA', + versionDesc: '5.1.0', + }, + { + clientId: 'producer-2', + clientAddr: '192.168.1.11', + language: 'JAVA', + versionDesc: '5.1.0', + }, + ]; + mock.onGet('/producer/connection').reply((config) => { + expect(config.params.topic).toBe('order-events'); + expect(config.params.producerGroup).toBe('order-producer'); + return [200, { connectionSet: connections }]; + }); + + const result = await queryProducerConnection('order-events', 'order-producer'); + expect(result).toHaveLength(2); + expect(result[0].clientId).toBe('producer-1'); + }); + + it('handles empty producer connections', async () => { + mock.onGet('/producer/connection').reply(200, { connectionSet: [] }); + + const result = await queryProducerConnection('topic', 'group'); + expect(result).toEqual([]); + }); +}); diff --git a/web/src/api/producer.ts b/web/src/api/producer.ts new file mode 100644 index 00000000..80e76f58 --- /dev/null +++ b/web/src/api/producer.ts @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import client from './client'; + +// ─── Types ────────────────────────────────────────────────────── +export interface ProducerConnection { + clientId: string; + clientAddr: string; + language: string; + versionDesc: string; +} + +// ─── API ──────────────────────────────────────────────────────── + +/** Fetch all topic names */ +export async function fetchTopicList(): Promise { + const res = await client.get<{ topicList: string[] }>('/topics'); + return (res.data?.topicList ?? []).sort(); +} + +/** Query producer connections by topic and group */ +export async function queryProducerConnection( + topic: string, + producerGroup: string, +): Promise { + const res = await client.get<{ connectionSet: ProducerConnection[] }>('/producer/connection', { + params: { topic, producerGroup }, + }); + return res.data?.connectionSet ?? []; +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 47ba4549..b3d0930a 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -628,6 +628,18 @@ const translations: Record> = { 'ssl.invalidCertFormat': { zh: '仅允许证书文件!', en: 'Only certificate files are allowed!' }, 'ssl.certRemoved': { zh: '证书文件已移除', en: 'Certificate file removed' }, + // ─── Producer ─── + 'producer.title': { zh: '生产者连接', en: 'Producer Connection' }, + 'producer.language': { zh: '语言', en: 'Language' }, + 'producer.selectTopic': { zh: '请选择 Topic', en: 'Select a topic' }, + 'producer.inputGroup': { zh: '请输入生产者组', en: 'Input producer group' }, + 'producer.fetchTopicFailed': { zh: '获取 Topic 列表失败', en: 'Failed to fetch topic list' }, + 'producer.fetchConnectionFailed': { + zh: '获取生产者连接失败', + en: 'Failed to fetch producer connections', + }, + 'producer.noConnections': { zh: '暂无生产者连接', en: 'No producer connections found' }, + // ─── Namespace ─── 'ns.title': { zh: '命名空间管理', en: 'Namespace Management' }, 'ns.name': { zh: '命名空间', en: 'Namespace' }, @@ -768,10 +780,6 @@ const translations: Record> = { 'liteTopic.extendTtlFailed': { zh: 'TTL 延长失败', en: 'Failed to extend TTL' }, 'liteTopic.total': { zh: '共 {total} 条记录', en: 'Total {total} records' }, - // ─── Producer ─── - 'producer.title': { zh: '生产者', en: 'Producer' }, - 'producer.group': { zh: '生产组', en: 'Producer Group' }, - // ─── Common (additional) ─── 'common.loading': { zh: '加载中', en: 'Loading' }, 'common.refresh': { zh: '刷新', en: 'Refresh' }, diff --git a/web/src/pages/studio/Producer.tsx b/web/src/pages/studio/Producer.tsx new file mode 100644 index 00000000..241adffc --- /dev/null +++ b/web/src/pages/studio/Producer.tsx @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, useRef } from 'react'; +import { Button, Form, Input, Select, Table, Card, App } from 'antd'; +import { MagnifyingGlass } from '@phosphor-icons/react'; +import { useLang } from '../../i18n/LangContext'; +import { + fetchTopicList, + queryProducerConnection, + type ProducerConnection, +} from '../../api/producer'; + +const ProducerPage = () => { + const [form] = Form.useForm(); + const [topicList, setTopicList] = useState([]); + const [connectionList, setConnectionList] = useState([]); + const [loading, setLoading] = useState(false); + const { t } = useLang(); + const { message } = App.useApp(); + + // Load topic list on mount (once) + const initialized = useRef(null); + if (initialized.current == null) { + initialized.current = true; + const loadTopics = async () => { + try { + const topics = await fetchTopicList(); + setTopicList(topics); + } catch { + message.error(t('producer.fetchTopicFailed')); + } + }; + loadTopics(); + } + + const onFinish = async (values: { selectedTopic: string; producerGroup: string }) => { + setLoading(true); + try { + const connections = await queryProducerConnection(values.selectedTopic, values.producerGroup); + setConnectionList(connections); + if (connections.length === 0) { + message.info(t('producer.noConnections')); + } + } catch { + message.error(t('producer.fetchConnectionFailed')); + } finally { + setLoading(false); + } + }; + + const columns = [ + { title: 'Client ID', dataIndex: 'clientId', key: 'clientId', align: 'center' as const }, + { + title: t('common.address'), + dataIndex: 'clientAddr', + key: 'clientAddr', + align: 'center' as const, + }, + { + title: t('producer.language'), + dataIndex: 'language', + key: 'language', + align: 'center' as const, + }, + { + title: t('brokerCluster.version'), + dataIndex: 'versionDesc', + key: 'versionDesc', + align: 'center' as const, + }, + ]; + + return ( +
+
+

{t('producer.title')}

+
+ + +
+ + + + + + +
+ + + + + ); +}; + +export default ProducerPage; From 428c00b34883dc57a88c923fa27e18efc9969e96 Mon Sep 17 00:00:00 2001 From: zhaohai <33314633+zhaohai666@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:21:11 +0800 Subject: [PATCH 13/43] feat: add AlertManagement page for alert rule operations (#476) Add alert rule management page: parse/render Prometheus AlertManager YAML rules with search, toggle, and CRUD. --- web/src/App.tsx | 2 + web/src/api/alertManagement.test.ts | 52 ++ web/src/api/alertManagement.ts | 27 + web/src/i18n/translations.ts | 36 ++ web/src/pages/studio/AlertManagement.tsx | 674 +++++++++++++++++++++++ 5 files changed, 791 insertions(+) create mode 100644 web/src/api/alertManagement.test.ts create mode 100644 web/src/api/alertManagement.ts create mode 100644 web/src/pages/studio/AlertManagement.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 3959a7c9..e21f40c5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts'; import AuditPage from './pages/ops/audit'; import AiPage from './pages/ai'; import SettingsPage from './pages/settings'; +import AlertManagementPage from './pages/studio/AlertManagement'; import ProducerPage from './pages/studio/Producer'; import OpsPage from './pages/studio/Ops'; import LoginPage from './pages/login'; @@ -58,6 +59,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/alertManagement.test.ts b/web/src/api/alertManagement.test.ts new file mode 100644 index 00000000..b8ac8929 --- /dev/null +++ b/web/src/api/alertManagement.test.ts @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { queryAlertRules } from './alertManagement'; + +const mock = new MockAdapter(client); + +describe('AlertManagement API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('queries alert rules data', async () => { + const rulesYaml = + 'groups:\n - name: test\n rules:\n - alert: HighCPU\n expr: cpu > 80'; + mock.onGet('/alert/rules').reply(200, { code: 200, data: { rules: rulesYaml } }); + + const result = await queryAlertRules(); + expect(result.rules).toBe(rulesYaml); + expect(result.rules).toContain('HighCPU'); + }); + + it('handles empty alert rules', async () => { + mock.onGet('/alert/rules').reply(200, { code: 200, data: { rules: '' } }); + + const result = await queryAlertRules(); + expect(result.rules).toBe(''); + }); +}); diff --git a/web/src/api/alertManagement.ts b/web/src/api/alertManagement.ts new file mode 100644 index 00000000..469b5f91 --- /dev/null +++ b/web/src/api/alertManagement.ts @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import client from './client'; + +export interface AlertRuleData { + rules: string; +} + +export async function queryAlertRules(): Promise { + const res = await client.get<{ data: AlertRuleData }>('/alert/rules'); + return res.data.data; +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index b3d0930a..7928b720 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -367,6 +367,42 @@ const translations: Record> = { }, 'ops.fetchFailed': { zh: '获取运维数据失败', en: 'Failed to fetch ops data' }, + // ─── Alert Management ─── + 'alertMgmt.title': { zh: '告警规则管理', en: 'Alert Management' }, + 'alertMgmt.alertName': { zh: '告警名称', en: 'Alert Name' }, + 'alertMgmt.group': { zh: '规则组', en: 'Group' }, + 'alertMgmt.severity': { zh: '严重级别', en: 'Severity' }, + 'alertMgmt.team': { zh: '团队', en: 'Team' }, + 'alertMgmt.expression': { zh: '表达式 (PromQL)', en: 'Expression (PromQL)' }, + 'alertMgmt.forDuration': { zh: '持续时间', en: 'For Duration' }, + 'alertMgmt.summary': { zh: '摘要', en: 'Summary' }, + 'alertMgmt.description': { zh: '描述', en: 'Description' }, + 'alertMgmt.totalRules': { zh: '规则总数', en: 'Total Rules' }, + 'alertMgmt.enabled': { zh: '已启用', en: 'Enabled' }, + 'alertMgmt.disabled': { zh: '已禁用', en: 'Disabled' }, + 'alertMgmt.critical': { zh: '严重', en: 'Critical' }, + 'alertMgmt.warningCount': { zh: '警告', en: 'Warning' }, + 'alertMgmt.addRule': { zh: '添加规则', en: 'Add Rule' }, + 'alertMgmt.editRule': { zh: '编辑规则', en: 'Edit Rule' }, + 'alertMgmt.exportYaml': { zh: '导出 YAML', en: 'Export YAML' }, + 'alertMgmt.searchPlaceholder': { + zh: '搜索告警名称、表达式...', + en: 'Search alert name, expression...', + }, + 'alertMgmt.allGroups': { zh: '所有组', en: 'All Groups' }, + 'alertMgmt.allSeverity': { zh: '所有级别', en: 'All Severity' }, + 'alertMgmt.allStatus': { zh: '所有状态', en: 'All Status' }, + 'alertMgmt.fetchFailed': { zh: '获取告警规则失败', en: 'Failed to fetch alert rules' }, + 'alertMgmt.deleteSuccess': { zh: '告警规则已删除', en: 'Alert rule deleted' }, + 'alertMgmt.updateSuccess': { zh: '告警规则已更新', en: 'Alert rule updated' }, + 'alertMgmt.createSuccess': { zh: '告警规则已创建', en: 'Alert rule created' }, + 'alertMgmt.exportSuccess': { zh: '告警规则已导出', en: 'Alert rules exported' }, + 'alertMgmt.alertNameRequired': { zh: '告警名称为必填项', en: 'Alert name is required' }, + 'alertMgmt.groupRequired': { zh: '规则组为必填项', en: 'Group is required' }, + 'alertMgmt.expressionRequired': { zh: '表达式为必填项', en: 'Expression is required' }, + 'alertMgmt.forDurationRequired': { zh: '持续时间为必填项', en: 'Duration is required' }, + 'alertMgmt.summaryRequired': { zh: '摘要为必填项', en: 'Summary is required' }, + // ─── Topic (detailed) ─── 'topic.subtitle': { zh: '管理 Topic 的创建、配置与删除', diff --git a/web/src/pages/studio/AlertManagement.tsx b/web/src/pages/studio/AlertManagement.tsx new file mode 100644 index 00000000..dbdd6df3 --- /dev/null +++ b/web/src/pages/studio/AlertManagement.tsx @@ -0,0 +1,674 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo, useRef, useState } from 'react'; +import { + App, + Badge, + Button, + Card, + Col, + Form, + Input, + Modal, + Popconfirm, + Row, + Select, + Space, + Statistic, + Switch, + Table, + Tag, + Tooltip, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + ArrowClockwise, + DownloadSimple, + MagnifyingGlass, + Pencil, + Plus, + Trash, + Warning, +} from '@phosphor-icons/react'; +import { useLang } from '../../i18n/LangContext'; +import { queryAlertRules } from '../../api/alertManagement'; + +const { TextArea } = Input; + +// ─── Types ──────────────────────────────────────────────────────── +interface AlertRule { + key: string; + index: number; + alert: string; + group: string; + expr: string; + for: string; + severity: string; + team: string; + summary: string; + description: string; + enabled: boolean; +} + +// ─── Constants ──────────────────────────────────────────────────── +const SEVERITY_COLORS: Record = { + critical: 'red', + warning: 'orange', + info: 'blue', +}; + +const TEAM_COLORS: Record = { + broker: 'purple', + topic: 'cyan', + consumer: 'green', + client: 'geekblue', + proxy: 'magenta', + security: 'volcano', + reliability: 'gold', +}; + +const GROUP_OPTIONS = [ + 'rocketmq-broker.rules', + 'rocketmq-topic.rules', + 'rocketmq-consumer.rules', + 'rocketmq-client.rules', + 'rocketmq-proxy.rules', + 'rocketmq-errors.rules', + 'rocketmq-broker-extended.rules', +]; + +const TEAM_OPTIONS = ['broker', 'topic', 'consumer', 'client', 'proxy', 'security', 'reliability']; + +// ─── YAML Parser ────────────────────────────────────────────────── +function parseYamlRules(yamlStr: string, disabledRules: Record): AlertRule[] { + const rules: AlertRule[] = []; + if (!yamlStr) return rules; + + const groupBlocks = yamlStr.split(/\n(?=\s*- name:)/); + let ruleIndex = 0; + + for (const block of groupBlocks) { + const groupNameMatch = block.match(/- name:\s*(.+)/); + if (!groupNameMatch) continue; + const groupName = groupNameMatch[1].trim(); + + const ruleBlocks = block.split(/\n\s*#\s*Rule\s+\d+:/); + for (let i = 1; i < ruleBlocks.length; i++) { + const ruleBlock = ruleBlocks[i]; + const alertMatch = ruleBlock.match(/alert:\s*(.+)/); + const exprMatch = ruleBlock.match(/expr:\s*(.+)/); + const forMatch = ruleBlock.match(/for:\s*(.+)/); + const severityMatch = ruleBlock.match(/severity:\s*(.+)/); + const teamMatch = ruleBlock.match(/team:\s*(.+)/); + const summaryMatch = ruleBlock.match(/summary:\s*"(.+)"/); + const descMatch = ruleBlock.match(/description:\s*"(.+)"/); + + if (alertMatch) { + ruleIndex++; + const alertName = alertMatch[1].trim(); + rules.push({ + key: alertName, + index: ruleIndex, + alert: alertName, + group: groupName, + expr: exprMatch ? exprMatch[1].trim() : '', + for: forMatch ? forMatch[1].trim() : '', + severity: severityMatch ? severityMatch[1].trim() : 'warning', + team: teamMatch ? teamMatch[1].trim() : '', + summary: summaryMatch ? summaryMatch[1].trim() : '', + description: descMatch ? descMatch[1].trim() : '', + enabled: !disabledRules[alertName], + }); + } + } + } + return rules; +} + +// ─── Component ──────────────────────────────────────────────────── +const AlertManagementPage: React.FC = () => { + const { t } = useLang(); + const { message } = App.useApp(); + const [form] = Form.useForm(); + + const [alertRules, setAlertRules] = useState([]); + const [loading, setLoading] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [searchText, setSearchText] = useState(''); + const [filterGroup, setFilterGroup] = useState('all'); + const [filterSeverity, setFilterSeverity] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [disabledRules, setDisabledRules] = useState>(() => { + try { + const saved = localStorage.getItem('alertDisabledRules'); + return saved ? JSON.parse(saved) : {}; + } catch { + return {}; + } + }); + + // One-time initialization (ESLint-compliant) + const initialized = useRef(null); + if (initialized.current == null) { + initialized.current = true; + const loadRules = async () => { + setLoading(true); + try { + const data = await queryAlertRules(); + const yamlStr = data.rules || ''; + setAlertRules(parseYamlRules(yamlStr, disabledRules)); + } catch { + message.error(t('alertMgmt.fetchFailed')); + } finally { + setLoading(false); + } + }; + loadRules(); + } + + const fetchAlertRules = async () => { + setLoading(true); + try { + const data = await queryAlertRules(); + const yamlStr = data.rules || ''; + setAlertRules(parseYamlRules(yamlStr, disabledRules)); + } catch { + message.error(t('alertMgmt.fetchFailed')); + } finally { + setLoading(false); + } + }; + + const handleToggleRule = (ruleKey: string) => { + const updated = { ...disabledRules, [ruleKey]: !disabledRules[ruleKey] }; + setDisabledRules(updated); + localStorage.setItem('alertDisabledRules', JSON.stringify(updated)); + setAlertRules((prev) => + prev.map((rule) => (rule.key === ruleKey ? { ...rule, enabled: !rule.enabled } : rule)), + ); + }; + + const handleAddRule = () => { + setEditingRule(null); + form.resetFields(); + form.setFieldsValue({ severity: 'warning', for: '5m', team: 'broker', enabled: true }); + setModalVisible(true); + }; + + const handleEditRule = (rule: AlertRule) => { + setEditingRule(rule); + form.setFieldsValue({ + alert: rule.alert, + group: rule.group, + expr: rule.expr, + for: rule.for, + severity: rule.severity, + team: rule.team, + summary: rule.summary, + description: rule.description, + enabled: rule.enabled, + }); + setModalVisible(true); + }; + + const handleDeleteRule = (ruleKey: string) => { + setAlertRules((prev) => prev.filter((rule) => rule.key !== ruleKey)); + const updated = { ...disabledRules }; + delete updated[ruleKey]; + setDisabledRules(updated); + localStorage.setItem('alertDisabledRules', JSON.stringify(updated)); + message.success(t('alertMgmt.deleteSuccess')); + }; + + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + if (editingRule) { + setAlertRules((prev) => + prev.map((rule) => + rule.key === editingRule.key + ? { + ...rule, + alert: values.alert, + group: values.group, + expr: values.expr, + for: values.for, + severity: values.severity, + team: values.team, + summary: values.summary || '', + description: values.description || '', + enabled: values.enabled !== false, + } + : rule, + ), + ); + message.success(t('alertMgmt.updateSuccess')); + } else { + const newRule: AlertRule = { + key: values.alert, + index: alertRules.length + 1, + alert: values.alert, + group: values.group, + expr: values.expr, + for: values.for, + severity: values.severity, + team: values.team, + summary: values.summary || '', + description: values.description || '', + enabled: values.enabled !== false, + }; + setAlertRules((prev) => [...prev, newRule]); + message.success(t('alertMgmt.createSuccess')); + } + setModalVisible(false); + form.resetFields(); + } catch { + // validation failed + } + }; + + const handleExportYaml = () => { + const enabledRules = alertRules.filter((r) => r.enabled); + const groups: Record = {}; + for (const rule of enabledRules) { + if (!groups[rule.group]) groups[rule.group] = []; + groups[rule.group].push(rule); + } + + let yaml = '# ==============================================================\n'; + yaml += '# RocketMQ 5.x Monitoring — Alert Rules (Exported from Dashboard)\n'; + yaml += '# Compatible with Prometheus / VictoriaMetrics / Thanos alerting\n'; + yaml += '# ==============================================================\n\n'; + yaml += 'groups:\n'; + + for (const [groupName, groupRules] of Object.entries(groups)) { + yaml += ` - name: ${groupName}\n`; + yaml += ' rules:\n'; + for (const rule of groupRules) { + yaml += ` - alert: ${rule.alert}\n`; + yaml += ` expr: ${rule.expr}\n`; + yaml += ` for: ${rule.for}\n`; + yaml += ' labels:\n'; + yaml += ` severity: ${rule.severity}\n`; + yaml += ` team: ${rule.team}\n`; + yaml += ' annotations:\n'; + yaml += ` summary: "${rule.summary}"\n`; + if (rule.description) { + yaml += ` description: "${rule.description}"\n`; + } + yaml += '\n'; + } + } + + const blob = new Blob([yaml], { type: 'text/yaml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'rocketmq-alert-rules.yaml'; + a.click(); + URL.revokeObjectURL(url); + message.success(t('alertMgmt.exportSuccess')); + }; + + // ─── Derived data ───────────────────────────────────────────── + const filteredRules = useMemo(() => { + return alertRules.filter((rule) => { + const matchesSearch = + !searchText || + rule.alert.toLowerCase().includes(searchText.toLowerCase()) || + rule.expr.toLowerCase().includes(searchText.toLowerCase()) || + rule.summary.toLowerCase().includes(searchText.toLowerCase()); + const matchesGroup = filterGroup === 'all' || rule.group === filterGroup; + const matchesSeverity = filterSeverity === 'all' || rule.severity === filterSeverity; + const matchesStatus = + filterStatus === 'all' || + (filterStatus === 'enabled' && rule.enabled) || + (filterStatus === 'disabled' && !rule.enabled); + return matchesSearch && matchesGroup && matchesSeverity && matchesStatus; + }); + }, [alertRules, searchText, filterGroup, filterSeverity, filterStatus]); + + const groups = useMemo(() => Array.from(new Set(alertRules.map((r) => r.group))), [alertRules]); + + const stats = useMemo(() => { + const total = alertRules.length; + const enabled = alertRules.filter((r) => r.enabled).length; + const critical = alertRules.filter((r) => r.severity === 'critical').length; + const warning = alertRules.filter((r) => r.severity === 'warning').length; + return { total, enabled, disabled: total - enabled, critical, warning }; + }, [alertRules]); + + // ─── Table columns ───────────────────────────────────────────── + const columns: ColumnsType = [ + { + title: '#', + dataIndex: 'index', + width: 50, + render: (text: number) => {text}, + }, + { + title: t('alertMgmt.alertName'), + dataIndex: 'alert', + width: 260, + render: (text: string, record: AlertRule) => ( + {text} + ), + }, + { + title: t('alertMgmt.group'), + dataIndex: 'group', + width: 200, + render: (text: string) => ( + {text.replace('rocketmq-', '').replace('.rules', '')} + ), + }, + { + title: t('alertMgmt.severity'), + dataIndex: 'severity', + width: 100, + render: (text: string) => ( + {text.toUpperCase()} + ), + }, + { + title: t('alertMgmt.team'), + dataIndex: 'team', + width: 100, + render: (text: string) => {text}, + }, + { + title: t('alertMgmt.expression'), + dataIndex: 'expr', + ellipsis: true, + render: (text: string, record: AlertRule) => ( + + + {text} + + + ), + }, + { + title: t('alertMgmt.forDuration'), + dataIndex: 'for', + width: 80, + render: (text: string) => {text}, + }, + { + title: t('common.status'), + dataIndex: 'enabled', + width: 80, + render: (enabled: boolean, record: AlertRule) => ( + handleToggleRule(record.key)} /> + ), + }, + { + title: t('common.actions'), + width: 120, + render: (_: unknown, record: AlertRule) => ( + + + + } + /> + + + + + + + + + + + + + + {/* Main Card */} + + + {t('alertMgmt.title')} + + + } + extra={ + + + + + + } + > + {/* Filters */} +
+ } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + style={{ width: 280 }} + allowClear + /> + +
`${t('common.total')} ${total}`, + }} + scroll={{ x: 1200 }} + /> + + + {/* Add/Edit Modal */} + { + setModalVisible(false); + form.resetFields(); + }} + width={720} + destroyOnClose + > +
+ +
+ + + + + + +