diff --git a/docs/open-pr-report-20260722.md b/docs/open-pr-report-20260722.md new file mode 100644 index 000000000..b5d70bc64 --- /dev/null +++ b/docs/open-pr-report-20260722.md @@ -0,0 +1,539 @@ +# RocketMQ Studio (Dashboard) 待合并 PR 报告 + +> 数据来源: `apache/rocketmq-dashboard`,筛选条件 `state:open created:>=2026-06-22`,共 **45** 个 PR。 +> 生成日期: 2026-07-22 + +## 总览 + +- **PR 总数**: 45 +- **改动文件合计**: 203 个文件,`+14042 / -846` 行 +- **主要贡献者**: + - `Loyal-Young`: 30 个 PR + - `zhaohai666`: 14 个 PR + - `Kris20030907`: 1 个 PR + +### 主题分类 + +- **新增前端页面 (zhaohai666)**: 以 `feat: add ... page` 为主,多为全新页面,改动大、几乎无删除。覆盖 LLM 设置、Proxy 管理、lite topic、消费组、broker 概览、namespace、SSL、告警、Producer、Ops、登录鉴权、公共基础模块等。 +- **前后端 API 对接与修复 (Loyal-Young)**: 以 `fix: connect ... to APIs` / `feat: ...` 为主,将各页面接入后端接口,并做无障碍、主题跟随、多语言、会话恢复等体验优化。 +- **监控适配 (Kris20030907)**: Prometheus range query adapter。 + +## PR 明细 + +### #488 [Studio] feat: add skip navigation link + +- 作者: `Loyal-Young` | 文件: 1 | 改动: `+24 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/488 +- 说明: Summary: adds a keyboard-visible skip link so users can move past navigation directly to the main content. Validation: git diff --check +- 改动文件 (1): + - `web/src/layouts/MainLayout.tsx` `+24/-0` + +### #487 [Studio] fix: use semantic page headings + +- 作者: `Loyal-Young` | 文件: 1 | 改动: `+3 / -2` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/487 +- 说明: Summary: use an h1 for page headers by default while preserving the established visual size and allowing nested pages to opt into another level. Validation: git diff --check +- 改动文件 (1): + - `web/src/components/PageHeader.tsx` `+3/-2` + +### #486 [Studio] fix: announce status badge labels + +- 作者: `Loyal-Young` | 文件: 1 | 改动: `+2 / -2` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/486 +- 说明: Summary: exposes status labels to screen readers and hides the decorative color dot. Validation: git diff --check +- 改动文件 (1): + - `web/src/components/StatusBadge.tsx` `+2/-2` + +### #485 [Studio] fix: handle empty mini bar data + +- 作者: `Loyal-Young` | 文件: 1 | 改动: `+8 / -1` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/485 +- 说明: Summary: renders a safe empty state for an empty series and adds an accessible trend label. Validation: git diff --check +- 改动文件 (1): + - `web/src/components/MiniBar.tsx` `+8/-1` + +### #484 [Studio] feat: configure API endpoint from env + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+28 / -16` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/484 +- 说明: - support configurable browser API base URLs and Vite development proxy targets - retain `/api` and `http://localhost:8888` as backwards-compatible defaults - document the new environment variables for reverse-proxy and container deployments - `npm.cmd test -- client.test.ts` (6 +- 改动文件 (4): + - `web/.env` `+4/-0` + - `web/src/api/client.ts` `+2/-1` + - `web/src/config.ts` `+3/-0` + - `web/vite.config.ts` `+19/-15` + +### #483 [Studio] feat: add LLM Settings configuration page + +- 作者: `zhaohai666` | 文件: 5 | 改动: `+1085 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/483 +- 说明: `[studio] feat: add LLM Settings configuration page` ```markdown - Branch: feature/studio-llmsettings-page (target repo: rocketmq-studio) - Total commits: 3 - Code changes: 5 files, +1086 lines | File | Line Changes | Description | |------|--------------|-------------| | llm.ts | +- 改动文件 (5): + - `web/src/App.tsx` `+2/-0` + - `web/src/api/llm.test.ts` `+135/-0` + - `web/src/api/llm.ts` `+67/-0` + - `web/src/i18n/translations.ts` `+100/-0` + - `web/src/pages/studio/LlmSettings.tsx` `+781/-0` + +### #482 [Studio] feat: add Proxy management page + +- 作者: `zhaohai666` | 文件: 5 | 改动: `+568 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/482 +- 说明: `[studio] feat: add Proxy management page` ```markdown - Branch: feature/studio-proxy-page (target repo: rocketmq-studio) - Total changed files: 5, +568 lines - Total commits: 3 | File | Line Changes | Description | |------|--------------|-------------| | proxy.ts | +53 | Proxy A +- 改动文件 (5): + - `web/src/App.tsx` `+2/-0` + - `web/src/api/proxy.test.ts` `+76/-0` + - `web/src/api/proxy.ts` `+53/-0` + - `web/src/i18n/translations.ts` `+6/-0` + - `web/src/pages/studio/Proxy.tsx` `+431/-0` + +### #481 [Studio] feat: add lite topic management page + +- 作者: `zhaohai666` | 文件: 5 | 改动: `+1020 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/481 +- 说明: - Branch: feature/studio-litetopic-page (target repo: rocketmq-studio) - Total commits: 3 1. 4004284 [studio] feat: add LiteTopic page 2. 39a75da [studio] test: add LiteTopic API unit tests 3. 7bcda6b [studio] fix: remove unsupported icon prop from PageHeader in LiteTopic - Code +- 改动文件 (5): + - `web/src/App.tsx` `+2/-0` + - `web/src/api/liteTopic.test.ts` `+156/-0` + - `web/src/api/liteTopic.ts` `+105/-0` + - `web/src/i18n/translations.ts` `+14/-0` + - `web/src/pages/studio/LiteTopic.tsx` `+743/-0` + +### #480 [Studio] feat: add consumer group management page + +- 作者: `zhaohai666` | 文件: 4 | 改动: `+709 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/480 +- 说明: - Branch: feature/studio-group-management-page (target repo: rocketmq-studio) - Total commits: 2 - 9fd182f [studio] feat: add GroupManagement page - b05f6d7 [studio] test: add GroupManagement page component tests - Code changes: 4 files changed, +709 lines | File | Changes | Desc +- 改动文件 (4): + - `web/src/App.tsx` `+2/-0` + - `web/src/i18n/translations.ts` `+59/-0` + - `web/src/pages/studio/GroupManagement.tsx` `+551/-0` + - `web/src/pages/studio/__tests__/GroupManagement.test.tsx` `+97/-0` + +### #479 [Studio] feat: add broker cluster overview page + +- 作者: `zhaohai666` | 文件: 4 | 改动: `+690 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/479 +- 说明: `[studio] feat: add broker cluster overview page` Adds a new Broker Cluster overview page with tab-based management for NameServer, Broker, and Proxy nodes. The page provides visualized cluster status, resource usage statistics and basic operation entries. This implementation cur +- 改动文件 (4): + - `web/src/App.tsx` `+2/-0` + - `web/src/i18n/translations.ts` `+25/-0` + - `web/src/pages/studio/BrokerCluster.tsx` `+551/-0` + - `web/src/pages/studio/__tests__/BrokerCluster.test.tsx` `+112/-0` + +### #478 [Studio] feat: add namespace management page + +- 作者: `zhaohai666` | 文件: 4 | 改动: `+711 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/478 +- 说明: Branch: feature/studio-namespace-page (target repo: rocketmq-studio) Total commits: 2 1. 9d7ca83 [studio] feat: add Namespace page 2. 28e69b5 [studio] test: add Namespace API unit tests Total 4 files modified, +711 lines of code | File | Line Changes | Description | |------|----- +- 改动文件 (4): + - `web/src/App.tsx` `+2/-0` + - `web/src/api/namespace.test.ts` `+116/-0` + - `web/src/api/namespace.ts` `+68/-0` + - `web/src/pages/studio/Namespace.tsx` `+525/-0` + +### #477 [Studio] feat: add SslSettings page for SSL/TLS configuration management + +- 作者: `zhaohai666` | 文件: 3 | 改动: `+427 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/477 +- 说明: Add an SSL/TLS configuration management page with route `/studio/ssl-settings`. It supports SSL toggle, TLS protocol version selection, KeyStore & TrustStore configuration, client authentication mode setup and certificate file upload. | File | Change Type | Description | |------| +- 改动文件 (3): + - `web/src/App.tsx` `+2/-0` + - `web/src/pages/studio/SslSettings.tsx` `+327/-0` + - `web/src/pages/studio/__tests__/SslSettings.test.tsx` `+98/-0` + +### #476 [Studio] feat: add AlertManagement page for alert rule operations + +- 作者: `zhaohai666` | 文件: 5 | 改动: `+800 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/476 +- 说明: Add an alert rule management page accessible via route `/studio/alert`. The page supports parsing and rendering Prometheus AlertManager YAML rules, with search & filter, enable/disable toggle, create/edit/delete operations and YAML export capabilities. | File | Change Type | Desc +- 改动文件 (5): + - `web/src/App.tsx` `+2/-0` + - `web/src/api/alertManagement.test.ts` `+52/-0` + - `web/src/api/alertManagement.ts` `+27/-0` + - `web/src/i18n/translations.ts` `+45/-0` + - `web/src/pages/studio/AlertManagement.tsx` `+674/-0` + +### #475 [Studio] feat: add Producer page + +- 作者: `zhaohai666` | 文件: 5 | 改动: `+291 / -4` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/475 +- 说明: Add a new page to query producer client connections, supporting lookup by specified Topic and Producer Group. The page route is `/studio/producer`. | File | Change Type | Description | |------|-------------|-------------| | web/src/pages/studio/Producer.tsx | New | Producer conne +- 改动文件 (5): + - `web/src/App.tsx` `+2/-0` + - `web/src/api/producer.test.ts` `+84/-0` + - `web/src/api/producer.ts` `+45/-0` + - `web/src/i18n/translations.ts` `+12/-4` + - `web/src/pages/studio/Producer.tsx` `+148/-0` + +### #474 [Studio] feat: add Ops page (NameServer management, VIPChannel, TLS) + +- 作者: `zhaohai666` | 文件: 5 | 改动: `+487 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/474 +- 说明: Add a new Ops management page accessible via route `/studio/ops`. This page supports NameServer address management, VIP channel toggle and TLS switch configurations. | File | Change Type | Description | |------|-------------|-------------| | web/src/pages/studio/Ops.tsx | New | O +- 改动文件 (5): + - `web/src/App.tsx` `+2/-0` + - `web/src/api/ops.test.ts` `+248/-0` + - `web/src/api/ops.ts` `+29/-0` + - `web/src/i18n/translations.ts` `+9/-0` + - `web/src/pages/studio/Ops.tsx` `+199/-0` + +### #473 [Studio] feat: Implement login page, auth & AI modules, simplify theme management + +- 作者: `zhaohai666` | 文件: 8 | 改动: `+316 / -69` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/473 +- 说明: Branch: feature/studio-login-page Implement the login page for RocketMQ Studio, including full user authentication workflow, Auth API module, AI API module, and Zustand state management integration. Meanwhile, remove ThemeContext to simplify global theme handling logic. | File | +- 改动文件 (8): + - `web/package.json` `+1/-1` + - `web/src/App.tsx` `+2/-0` + - `web/src/api/ai.test.ts` `+134/-57` + - `web/src/api/auth.test.ts` `+74/-0` + - `web/src/layouts/MainLayout.tsx` `+1/-9` + - `web/src/pages/login/index.tsx` `+102/-0` + - `web/src/theme/ThemeContext.tsx` `+1/-1` + - `web/src/theme/__tests__/ThemeContext.test.tsx` `+1/-1` + +### #472 [Studio] fix: restore persisted auth sessions + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+115 / -6` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/472 +- 说明: - persist both authentication token and username, then restore the session when the application reloads - clear the complete persisted session from the user action and from API 401 handling - centralize session-storage behavior and cover persistence, orphaned data, and cleanup - +- 改动文件 (4): + - `web/src/api/client.ts` `+3/-2` + - `web/src/stores/authStorage.test.ts` `+51/-0` + - `web/src/stores/authStorage.ts` `+54/-0` + - `web/src/stores/authStore.ts` `+7/-4` + +### #471 [Studio] feat: pass home prompts to AI chat + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+94 / -2` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/471 +- 说明: - preserve a question typed on the home page when navigating to AI Chat - forward the selected model with the prompt, focus the AI input, and clear one-time router state after consumption - make the home input controlled so Enter and the send button share the same behavior - `npm +- 改动文件 (4): + - `web/src/pages/ai/chatDraft.test.ts` `+34/-0` + - `web/src/pages/ai/chatDraft.ts` `+32/-0` + - `web/src/pages/ai/index.tsx` `+18/-0` + - `web/src/pages/home/index.tsx` `+10/-2` + +### #470 [Studio] feat: add navigation search shortcut + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+114 / -13` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/470 +- 说明: - make the advertised Command/Control-K navigation search shortcut functional - support Enter to navigate to the first match and show an explicit empty state for unmatched queries - isolate and test search filtering and shortcut recognition logic - `npm.cmd test -- navigationSear +- 改动文件 (3): + - `web/src/layouts/MainLayout.tsx` `+34/-13` + - `web/src/layouts/navigationSearch.test.ts` `+38/-0` + - `web/src/layouts/navigationSearch.ts` `+42/-0` + +### #469 [Studio] feat: add cluster operation services + +- 作者: `Loyal-Young` | 文件: 2 | 改动: `+125 / -1` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/469 +- 说明: - add service-layer access to NameServer restart, upgrade, create, update, and delete operations - add Proxy restart support and realistic in-memory behavior for every operation in mock mode - cover request payloads for all new backend operation endpoints - `npm.cmd test -- clust +- 改动文件 (2): + - `web/src/api/cluster.test.ts` `+45/-1` + - `web/src/services/clusterService.ts` `+80/-0` + +### #468 [Studio] feat: complete consumer group service APIs + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+133 / -8` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/468 +- 说明: - expose consumer-group detail, typed offset-reset, and created-record responses through the frontend service layer - make mock-mode creation produce a realistic, immediately queryable group - add request-contract coverage for group detail, create, and reset-offset operations - ` +- 改动文件 (3): + - `web/src/api/metadata.test.ts` `+78/-0` + - `web/src/api/metadata.ts` `+14/-5` + - `web/src/services/consumerService.ts` `+41/-3` + +### #467 [Studio] fix: implement namespace listing + +- 作者: `Loyal-Young` | 文件: 2 | 改动: `+54 / -1` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/467 +- 说明: - implement the previously unsupported namespace listing operation behind `GET /api/namespaces` - derive unique namespaces from topic metadata while retaining cluster identity and skipping blank namespaces - return namespace records in deterministic name and cluster order, with s +- 改动文件 (2): + - `server/src/main/java/com/rocketmq/studio/instance/topic/MetadataService.java` `+23/-1` + - `server/src/test/java/com/rocketmq/studio/instance/topic/MetadataServiceTest.java` `+31/-0` + +### #466 [Studio] feat: follow system theme changes + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+117 / -24` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/466 +- 说明: - follow operating-system theme changes when the user has not explicitly chosen a theme - preserve explicit light or dark choices and stop automatic synchronization after a manual toggle - isolate browser-storage helpers and cover stored preference validation - `npm.cmd test -- t +- 改动文件 (3): + - `web/src/theme/ThemeContext.tsx` `+33/-24` + - `web/src/theme/themePreference.test.ts` `+43/-0` + - `web/src/theme/themePreference.ts` `+41/-0` + +### #465 [Studio] feat: persist language preference + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+82 / -1` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/465 +- 说明: - persist the user's Chinese or English selection in browser storage - restore only supported language values and safely default to Chinese for missing, invalid, or unavailable storage - add focused unit coverage for preference restoration and persistence - `npm.cmd test -- langu +- 改动文件 (3): + - `web/src/i18n/LangContext.tsx` `+7/-1` + - `web/src/i18n/languagePreference.test.ts` `+38/-0` + - `web/src/i18n/languagePreference.ts` `+37/-0` + +### #464 [Studio] feat: stream AI chat responses + +- 作者: `Loyal-Young` | 文件: 2 | 改动: `+74 / -95` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/464 +- 说明: - replace the AI page's simulated delayed reply with the backend SSE chat stream - append streamed chunks to the in-progress assistant response and retain a stable conversation identifier - add a stop action, cleanup on unmount, and user-visible failure feedback - align the chat +- 改动文件 (2): + - `web/src/api/ai.ts` `+8/-1` + - `web/src/pages/ai/index.tsx` `+66/-94` + +### #463 [Studio] fix: connect user logout to API + +- 作者: `Loyal-Young` | 文件: 2 | 改动: `+76 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/463 +- 说明: - wire the account dropdown's logout action to the existing backend logout endpoint - clear the local authentication state even when the backend is unreachable, preventing a stale client session - make the profile menu entry open the settings page and add auth request-contract co +- 改动文件 (2): + - `web/src/api/auth.test.ts` `+54/-0` + - `web/src/layouts/MainLayout.tsx` `+22/-0` + +### #462 [Studio] feat: add K8s certificate renewal + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+63 / -3` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/462 +- 说明: - expose the existing K8s certificate renewal endpoint in the certificate management page - refresh the renewed record in place and provide confirmation, progress, and failure feedback - implement deterministic renewal behavior for the frontend mock service - add API-contract cov +- 改动文件 (3): + - `web/src/api/cluster.test.ts` `+11/-1` + - `web/src/pages/cluster/certs.tsx` `+34/-2` + - `web/src/services/clusterService.ts` `+18/-0` + +### #461 [Studio] fix: show dashboard loading failures + +- 作者: `Loyal-Young` | 文件: 2 | 改动: `+112 / -5` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/461 +- 说明: - keep the dashboard visible while data is loading instead of rendering a blank page - provide a clear error state with a retry action when the dashboard request fails - add request-contract coverage for the dashboard and metrics endpoints - `npm.cmd test -- metrics.test.ts` - `n +- 改动文件 (2): + - `web/src/api/metrics.test.ts` `+67/-0` + - `web/src/pages/home/dashboard.tsx` `+45/-5` + +### #460 [Studio] fix: connect instance management to APIs + +- 作者: `Loyal-Young` | 文件: 2 | 改动: `+130 / -113` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/460 +- 说明: - replace the instance management page's local fixture state with the existing instance service - add loading, failure, and submission feedback for list, create, update, and delete operations - cover the instance API request and response contracts with focused tests - `npm.cmd te +- 改动文件 (2): + - `web/src/api/instance.test.ts` `+55/-0` + - `web/src/pages/instance/index.tsx` `+75/-113` + +### #459 [Studio] fix: connect data sources to APIs + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+180 / -69` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/459 +- 说明: - replace mock-only data-source management with the existing settings APIs - persist create, edit, delete, and connection-test operations with loading and error feedback - align the deletion request with the controller's `key` query parameter and add API contract tests - `npm.cmd +- 改动文件 (3): + - `web/src/api/dataSources.test.ts` `+61/-0` + - `web/src/api/settings.ts` `+9/-7` + - `web/src/pages/settings/index.tsx` `+110/-62` + +### #458 [Studio] fix: connect general settings to APIs + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+108 / -18` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/458 +- 说明: - load the general-settings form from `/api/settings/general` and persist changes through `/api/settings/general/save` - align the frontend settings type with `GeneralSettingsVO` - show loading, saving, and request-error feedback; add API contract coverage - `npm.cmd test -- gene +- 改动文件 (3): + - `web/src/api/generalSettings.test.ts` `+63/-0` + - `web/src/api/settings.ts` `+8/-5` + - `web/src/pages/settings/index.tsx` `+37/-13` + +### #457 [Studio] feat: add cluster broker overview page + +- 作者: `zhaohai666` | 文件: 12 | 改动: `+1141 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/457 +- 说明: Implement cluster and broker management module, providing core APIs including cluster list query, cluster detail retrieval, cluster configuration update, and broker restart. The module adopts a layered architecture (Controller → Service → Repository/Provider), with in-memory stub +- 改动文件 (12): + - `server/src/main/java/com/rocketmq/studio/cluster/broker/BrokerVO.java` `+37/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/broker/ClusterController.java` `+64/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/broker/ClusterProvider.java` `+27/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/broker/ClusterProviderStub.java` `+85/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/broker/ClusterRepository.java` `+31/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/broker/ClusterRepositoryImpl.java` `+192/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/broker/ClusterService.java` `+157/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/broker/ClusterVO.java` `+53/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/config/ClusterConfigVO.java` `+40/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/config/UpdateConfigDTO.java` `+38/-0` + - `server/src/test/java/com/rocketmq/studio/cluster/broker/ClusterControllerTest.java` `+165/-0` + - `server/src/test/java/com/rocketmq/studio/cluster/broker/ClusterServiceTest.java` `+252/-0` + +### #456 [Studio] feat: add login and auth page + +- 作者: `zhaohai666` | 文件: 7 | 改动: `+427 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/456 +- 说明: Add login and authentication module for RocketMQ Studio, providing user login/logout REST API with JWT token support and Spring Security configuration placeholder. 7 files modified, total 427 line insertions | File | Lines | Description | |------|-------|-------------| | auth/Aut +- 改动文件 (7): + - `server/src/main/java/com/rocketmq/studio/auth/AuthController.java` `+44/-0` + - `server/src/main/java/com/rocketmq/studio/auth/AuthService.java` `+60/-0` + - `server/src/main/java/com/rocketmq/studio/auth/LoginDTO.java` `+26/-0` + - `server/src/main/java/com/rocketmq/studio/auth/LoginVO.java` `+42/-0` + - `server/src/main/java/com/rocketmq/studio/auth/SecurityConfig.java` `+25/-0` + - `server/src/test/java/com/rocketmq/studio/auth/AuthControllerTest.java` `+115/-0` + - `server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java` `+115/-0` + +### #455 [Studio] feat: add core common foundation module with unified response, base entity, enums and global exception handling + +- 作者: `zhaohai666` | 文件: 31 | 改动: `+1328 / -0` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/455 +- 说明: `[studio] feat: add core common foundation module with unified response, base entity, enums and global exception handling` This module serves as the foundational common component of the project, delivering standardized response wrappers (`Result` / `PageResult`), base entity `Bas +- 改动文件 (31): + - `server/src/main/java/com/rocketmq/studio/common/config/CorsConfig.java` `+33/-0` + - `server/src/main/java/com/rocketmq/studio/common/config/WebConfig.java` `+25/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/BaseEntity.java` `+27/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/PageResult.java` `+47/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/Result.java` `+47/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/AlertLevel.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/BrokerStatus.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/CertStatus.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/CertType.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/ClientLanguage.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/ClientType.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/ClusterStatus.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/ClusterType.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/ConsumeType.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/DeliveryStatus.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/FlushDiskType.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/InstanceType.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/Protocol.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/SubscriptionMode.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/TopicPerm.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/domain/enums/TopicType.java` `+22/-0` + - `server/src/main/java/com/rocketmq/studio/common/exception/BusinessException.java` `+29/-0` + - `server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java` `+45/-0` + - `server/src/test/java/com/rocketmq/studio/common/config/CorsConfigTest.java` `+51/-0` + - `server/src/test/java/com/rocketmq/studio/common/config/WebConfigTest.java` `+45/-0` + - `server/src/test/java/com/rocketmq/studio/common/domain/BaseEntityTest.java` `+73/-0` + - `server/src/test/java/com/rocketmq/studio/common/domain/PageResultTest.java` `+75/-0` + - `server/src/test/java/com/rocketmq/studio/common/domain/ResultTest.java` `+87/-0` + - `server/src/test/java/com/rocketmq/studio/common/domain/enums/EnumsTest.java` `+274/-0` + - `server/src/test/java/com/rocketmq/studio/common/exception/BusinessExceptionTest.java` `+54/-0` + - `server/src/test/java/com/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java` `+64/-0` + +### #454 [Studio] fix: align consumer group API contract + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+150 / -19` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/454 +- 说明: - align consumer-group query parameters and detail fields with `ConsumerGroupController` and its DTOs - return created groups and expose detail/reset-offset operations through the service layer - use numeric epoch milliseconds for reset-offset requests and add API contract tests +- 改动文件 (3): + - `web/src/api/consumerGroups.test.ts` `+73/-0` + - `web/src/api/metadata.ts` `+24/-9` + - `web/src/services/consumerService.ts` `+53/-10` + +### #453 [Studio] fix: align ACL API contract + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+111 / -37` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/453 +- 说明: - align ACL rule query fields and ACL version types with `AclController` and `AclRuleVO` - return persisted ACL rules and users from creation APIs, including in mock mode - add request and response contract coverage for ACL endpoints - `npm.cmd test -- acl.test.ts` - `npm.cmd run +- 改动文件 (3): + - `web/src/api/acl.test.ts` `+55/-0` + - `web/src/api/acl.ts` `+11/-8` + - `web/src/services/aclService.ts` `+45/-29` + +### #452 [Studio] fix: align cluster API response contract + +- 作者: `Loyal-Young` | 文件: 3 | 改动: `+81 / -25` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/452 +- 说明: - align the frontend cluster API types with the backend `ClusterVO` response - preserve broker, proxy, NameServer, configuration, and aggregate fields in mock mode as well - add a contract test for both cluster list and detail responses - `npm.cmd test -- clusterContract.test.ts` +- 改动文件 (3): + - `web/src/api/cluster.ts` `+12/-19` + - `web/src/api/clusterContract.test.ts` `+60/-0` + - `web/src/services/clusterService.ts` `+9/-6` + +### #451 [Studio] fix: connect topic page to APIs + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+187 / -47` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/451 +- 说明: - replace the Topic page's mock-only state with existing Topic APIs - persist create, delete, batch delete, and message sending; load routes and consumers on demand - align Topic query and create field names with the backend contract and add API tests - `npm.cmd test -- metadata. +- 改动文件 (4): + - `web/src/api/metadata.test.ts` `+79/-0` + - `web/src/api/metadata.ts` `+9/-2` + - `web/src/pages/instance/topic.tsx` `+90/-34` + - `web/src/services/topicService.ts` `+9/-11` + +### #450 [Studio] fix: connect message query page to APIs + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+149 / -55` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/450 +- 说明: - replace mock-only message searches and traces with `/api/messages` requests - align query timestamps and message/trace response types with the backend's epoch-millisecond contract - provide request loading and failure feedback, with API contract coverage - `npm.cmd test -- mess +- 改动文件 (4): + - `web/src/api/message.test.ts` `+75/-0` + - `web/src/api/message.ts` `+16/-17` + - `web/src/pages/instance/message.tsx` `+55/-33` + - `web/src/services/messageService.ts` `+3/-5` + +### #449 [Studio] fix: connect clients page to APIs + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+102 / -29` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/449 +- 说明: - load the clients page from the existing `/api/clients` endpoint instead of direct mock imports - derive cluster filters from the returned connection data and show request failures in the UI - align the API wrapper and mock service with the backend's supported `clusterId` and `t +- 改动文件 (4): + - `web/src/api/connections.test.ts` `+58/-0` + - `web/src/api/connections.ts` `+5/-4` + - `web/src/pages/cluster/clients.tsx` `+34/-10` + - `web/src/services/connectionsService.ts` `+5/-15` + +### #448 [Studio] fix: connect DLQ page to APIs + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+120 / -14` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/448 +- 说明: - load DLQ group data from the existing `/api/dlq` endpoint instead of direct mock imports - submit resends to `/api/dlq/resend`, refresh the list after success, and report request failures - align resend timestamps with the backend's epoch-millisecond contract and cover it with +- 改动文件 (4): + - `web/src/api/dlq.test.ts` `+65/-0` + - `web/src/api/message.ts` `+2/-2` + - `web/src/pages/instance/dlq.tsx` `+51/-10` + - `web/src/services/messageService.ts` `+2/-2` + +### #447 [Studio] fix: connect alert rules page to APIs + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+246 / -30` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/447 +- 说明: - replace the alert-rules page's mock-only state with the existing alert-rules API - persist rule creation, editing, enabling/disabling, and deletion; display API failures to the operator - return the server's canonical rule after mutations and add API contract coverage - `npm.cm +- 改动文件 (4): + - `web/src/api/alertRules.test.ts` `+90/-0` + - `web/src/api/ops.ts` `+7/-4` + - `web/src/pages/ops/alerts.tsx` `+113/-16` + - `web/src/services/opsService.ts` `+36/-10` + +### #446 [Studio] fix: connect audit logs page to APIs + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+191 / -55` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/446 +- 说明: - load audit records from the existing `/api/audit-logs` endpoint, including server-side filters and pagination - use the backend `PageResult` (`items`, `total`, `page`, `size`) and cleanup response (`deleted`) contracts - persist cleanup operations through `/api/audit-logs/clean +- 改动文件 (4): + - `web/src/api/audit.test.ts` `+52/-0` + - `web/src/api/ops.ts` `+21/-3` + - `web/src/pages/ops/audit.tsx` `+80/-46` + - `web/src/services/opsService.ts` `+38/-6` + +### #445 [Studio] fix: connect system alerts page to APIs + +- 作者: `Loyal-Young` | 文件: 4 | 改动: `+109 / -12` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/445 +- 说明: - load system alerts through the existing `opsService` instead of page-level mock data; - persist acknowledgement and clearing actions through `/api/system-alerts`; - preserve the existing mock-mode workflow while adding loading and request-failure states; - add an API contract t +- 改动文件 (4): + - `web/src/api/ops.test.ts` `+39/-0` + - `web/src/api/ops.ts` `+2/-1` + - `web/src/pages/ops/systemAlerts.tsx` `+57/-11` + - `web/src/services/opsService.ts` `+11/-0` + +### #432 [ISSUE #431] Add Prometheus range query adapter + +- 作者: `Kris20030907` | 文件: 14 | 改动: `+954 / -70` +- 链接: https://github.com/apache/rocketmq-dashboard/pull/432 +- 说明: RocketMQ Studio currently returns generated sample data from its metrics API, so it cannot be used to query real RocketMQ monitoring data. This change introduces a real Prometheus-compatible range query adapter as the foundation for the observability features proposed in #431. It +- 改动文件 (14): + - `docs/api-spec.md` `+71/-5` + - `server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricDataVO.java` `+33/-2` + - `server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricQueryDTO.java` `+23/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsController.java` `+16/-1` + - `server/src/main/java/com/rocketmq/studio/cluster/metrics/MetricsService.java` `+2/-2` + - `server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusException.java` `+34/-0` + - `server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSource.java` `+235/-25` + - `server/src/main/java/com/rocketmq/studio/cluster/metrics/PrometheusProperties.java` `+37/-0` + - `server/src/main/java/com/rocketmq/studio/common/exception/GlobalExceptionHandler.java` `+28/-0` + - `server/src/main/resources/application.yml` `+10/-0` + - `server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsControllerTest.java` `+55/-0` + - `server/src/test/java/com/rocketmq/studio/cluster/metrics/MetricsServiceTest.java` `+52/-28` + - `server/src/test/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java` `+321/-0` + - `web/src/api/metrics.ts` `+37/-7` diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java index 275884374..a5ead164f 100644 --- a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java +++ b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java @@ -80,7 +80,7 @@ private void validatePagination(int page, int pageSize) { } } - private LocalDateTime parseDate(String dateStr, boolean startOfDay, String parameterName) { +private LocalDateTime parseDate(String dateStr, boolean startOfDay, String parameterName) { if (dateStr == null || dateStr.isEmpty()) { return null; } 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 000000000..70b64bcf5 --- /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/PrometheusMetricsSourceTest.java b/server/src/test/java/com/rocketmq/studio/cluster/metrics/PrometheusMetricsSourceTest.java new file mode 100644 index 000000000..3d2fbb267 --- /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/.env.production b/web/.env.production index 3e8279849..850317378 100644 --- a/web/.env.production +++ b/web/.env.production @@ -1,3 +1,3 @@ -# Backend not ready yet - use mock data -# Set to "false" when real API is available -VITE_USE_MOCK=true +# Production: use real API +# Set to "true" only if backend is unavailable +VITE_USE_MOCK=false \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index 7e9e25d83..bf2535c46 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +coverage/ *.local .env.local .env.*.local diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts index b175df54a..05df8b0cb 100644 --- a/web/src/api/ai.test.ts +++ b/web/src/api/ai.test.ts @@ -118,12 +118,14 @@ describe('AI API', () => { it('throws structured errors from SSE error events', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue( - streamResponse([ - 'event: error\n', - 'data: {"status":400,"code":"llm.config.incomplete","message":"LLM provider is not configured or enabled","hint":"Configure and enable an LLM provider."}\n\n', - ]), - ), + vi + .fn() + .mockResolvedValue( + streamResponse([ + 'event: error\n', + 'data: {"status":400,"code":"llm.config.incomplete","message":"LLM provider is not configured or enabled","hint":"Configure and enable an LLM provider."}\n\n', + ]), + ), ); await expect( diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 5abeb8071..42adde9ef 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -26,6 +26,7 @@ export interface LoginRequest { export interface LoginResponse { token: string; expiresIn: number; + role: string; user: { username: string; admin: boolean; diff --git a/web/src/api/message.ts b/web/src/api/message.ts index 0c879543d..7e92fd6ef 100644 --- a/web/src/api/message.ts +++ b/web/src/api/message.ts @@ -70,7 +70,9 @@ export async function queryMessages(params: MessageQuery) { } export async function getMessageTrace(msgId: string) { - const res = await client.get<{ data: TraceRecord }>(`/messages/${encodeURIComponent(msgId)}/trace`); + const res = await client.get<{ data: TraceRecord }>( + `/messages/${encodeURIComponent(msgId)}/trace`, + ); return res.data.data; } diff --git a/web/src/components/MiniLine.tsx b/web/src/components/MiniLine.tsx index 8a483ba84..ed31c195a 100644 --- a/web/src/components/MiniLine.tsx +++ b/web/src/components/MiniLine.tsx @@ -15,12 +15,23 @@ * limitations under the License. */ +import { useMemo, useId } from 'react'; + interface MiniLineProps { data: number[]; color?: string; height?: number; width?: number; + /** Fill area under the curve */ fill?: boolean; + /** Stroke width */ + strokeWidth?: number; + /** Show dot on last data point */ + showDot?: boolean; + /** Animate on mount */ + animated?: boolean; + /** Make SVG responsive (width=100%, preserves aspect ratio) */ + responsive?: boolean; } const MiniLine = ({ @@ -29,44 +40,113 @@ const MiniLine = ({ height = 32, width = 120, fill = true, + strokeWidth = 2, + showDot = true, + animated = true, + responsive = false, }: MiniLineProps) => { - if (data.length < 2) return null; - const max = Math.max(...data, 1); const min = Math.min(...data, 0); const range = max - min || 1; - const padding = 2; - const innerW = width - padding * 2; - const innerH = height - padding * 2; + const pad = 4; + const innerW = width - pad * 2; + const innerH = height - pad * 2; - const points = data.map((v, i) => { - const x = padding + (i / (data.length - 1)) * innerW; - const y = padding + innerH - ((v - min) / range) * innerH; - return `${x},${y}`; - }); + const uid = useId(); + const gradientId = useMemo(() => `ml-grad-${uid}`, [uid]); + const glowId = useMemo(() => `ml-glow-${uid}`, [uid]); + + if (data.length < 2) return null; - const linePath = `M${points.join(' L')}`; - const areaPath = `${linePath} L${padding + innerW},${height - padding} L${padding},${height - padding} Z`; + // Build smooth Catmull-Rom → Bezier control points + const points = data.map((v, i) => ({ + x: pad + (i / (data.length - 1)) * innerW, + y: pad + innerH - ((v - min) / range) * innerH, + })); + + // Convert points to a smooth SVG path using cardinal spline + const smoothPath = (() => { + if (points.length < 2) return ''; + let d = `M${points[0].x},${points[0].y}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(0, i - 1)]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[Math.min(points.length - 1, i + 2)]; + const tension = 0.3; + const cp1x = p1.x + (p2.x - p0.x) * tension; + const cp1y = p1.y + (p2.y - p0.y) * tension; + const cp2x = p2.x - (p3.x - p1.x) * tension; + const cp2y = p2.y - (p3.y - p1.y) * tension; + d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`; + } + return d; + })(); + + const areaPath = `${smoothPath} L${pad + innerW},${height - pad} L${pad},${height - pad} Z`; + + const lastPoint = points[points.length - 1]; return ( - - {fill && } + + {' '} + + + + + + + + + + + + + + {fill && } - {/* Last point dot */} - + {showDot && ( + <> + + + + )} + ); }; diff --git a/web/src/components/__tests__/MiniLine.test.tsx b/web/src/components/__tests__/MiniLine.test.tsx new file mode 100644 index 000000000..0a1769031 --- /dev/null +++ b/web/src/components/__tests__/MiniLine.test.tsx @@ -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 { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import MiniLine from '../MiniLine'; + +describe('MiniLine', () => { + const sampleData = [10, 20, 15, 30, 25, 40, 35]; + + it('renders an SVG element', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders with default width and height', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width')).toBe('120'); + expect(svg?.getAttribute('height')).toBe('32'); + }); + + it('renders with custom width and height', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width')).toBe('200'); + expect(svg?.getAttribute('height')).toBe('50'); + }); + + it('renders responsive SVG when responsive is true', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg?.getAttribute('width')).toBe('100%'); + expect(svg?.getAttribute('viewBox')).toBe('0 0 120 32'); + expect(svg?.getAttribute('preserveAspectRatio')).toBe('none'); + }); + + it('renders a stroke path for the line', () => { + const { container } = render(); + const paths = container.querySelectorAll('path'); + // At least one path for the line stroke (plus optional fill area) + expect(paths.length).toBeGreaterThanOrEqual(1); + }); + + it('renders fill area when fill is true (default)', () => { + const { container } = render(); + const paths = container.querySelectorAll('path'); + // Should have both fill path and stroke path + expect(paths.length).toBeGreaterThanOrEqual(2); + }); + + it('does not render fill area when fill is false', () => { + const { container } = render(); + const paths = container.querySelectorAll('path'); + // Only the stroke path, no fill path + expect(paths.length).toBe(1); + }); + + it('renders a dot on the last data point when showDot is true (default)', () => { + const { container } = render(); + const circles = container.querySelectorAll('circle'); + // Two circles: outer glow + inner dot + expect(circles.length).toBe(2); + }); + + it('does not render dot when showDot is false', () => { + const { container } = render(); + const circles = container.querySelectorAll('circle'); + expect(circles.length).toBe(0); + }); + + it('returns null for data with less than 2 points', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('uses custom color', () => { + const { container } = render(); + // The stroke path is the one with a stroke attribute (second path when fill is enabled) + const paths = container.querySelectorAll('path'); + const strokePath = Array.from(paths).find((p) => p.getAttribute('stroke')); + expect(strokePath?.getAttribute('stroke')).toBe('#ff0000'); + }); + + it('renders gradient and glow filter definitions', () => { + const { container } = render(); + const defs = container.querySelector('defs'); + expect(defs).toBeInTheDocument(); + const linearGradient = defs?.querySelector('linearGradient'); + const filter = defs?.querySelector('filter'); + expect(linearGradient).toBeInTheDocument(); + expect(filter).toBeInTheDocument(); + }); +}); diff --git a/web/src/index.css b/web/src/index.css index d9074ce16..d5eccd416 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -30,6 +30,11 @@ body, } body { + margin: 0; + padding: 0; + background: #e8e8ed; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; overscroll-behavior: none; } @@ -45,37 +50,27 @@ body { --oneday-home-mute: #7c7670; --oneday-home-motion-duration: 520ms; --oneday-home-motion-ease: cubic-bezier(0.22, 1, 0.36, 1); + --studio-primary: #1677ff; + --studio-sidebar-width: 220px; + --studio-sidebar-collapsed: 64px; + --studio-titlebar-h: 38px; + --studio-addressbar-h: 36px; + --studio-topbar-h: 40px; } -/* === Keyframe Animations === */ -@keyframes oneday-orb-drift-a { - 0% { - transform: translate3d(0, 0, 0) scale(1) rotate(0deg); - } - 25% { - transform: translate3d(35%, 18%, 0) scale(1.25) rotate(8deg); - } - 50% { - transform: translate3d(48%, 38%, 0) scale(1.4) rotate(-4deg); - } - 75% { - transform: translate3d(20%, 42%, 0) scale(1.1) rotate(6deg); - } - 100% { - transform: translate3d(0, 0, 0) scale(1) rotate(0deg); - } -} - -@keyframes oneday-bg-drift { - 0%, - 100% { - filter: hue-rotate(0deg) saturate(1); - } - 50% { - filter: hue-rotate(-18deg) saturate(1.15); - } +/* ═══════════════════════════════════════════════════════════════ + Gradient text + ═══════════════════════════════════════════════════════════════ */ +.gradient-text { + background: linear-gradient(135deg, #7c3aed 0%, #1677ff 50%, #f97316 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } +/* ═══════════════════════════════════════════════════════════════ + Keyframe Animations + ═══════════════════════════════════════════════════════════════ */ @keyframes wave { 0%, 100% { @@ -91,7 +86,6 @@ body { transform: rotate(15deg); } } - @keyframes float-in { from { opacity: 0; @@ -103,17 +97,9 @@ body { } } -@keyframes pulse-ring { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.3); - } - 50% { - box-shadow: 0 0 0 8px rgba(139, 92, 246, 0); - } -} - -/* === Utility Classes === */ +/* ═══════════════════════════════════════════════════════════════ + Utility Classes + ═══════════════════════════════════════════════════════════════ */ .scrollbar-hide::-webkit-scrollbar { display: none; } @@ -122,7 +108,6 @@ body { scrollbar-width: none; } -/* === Template Cards === */ .template-card { border-radius: 12px; overflow: hidden; @@ -136,7 +121,6 @@ body { box-shadow: 0 12px 40px -12px rgba(80, 90, 180, 0.2); } -/* === Tool Buttons === */ .tool-btn { display: inline-flex; align-items: center; @@ -155,7 +139,6 @@ body { background: #f3f4f6; } -/* === Mode Toggle === */ .mode-btn { display: flex; align-items: center; @@ -178,7 +161,6 @@ body { color: #7c3aed; } -/* === Chat Input === */ .chat-input { width: 100%; padding: 16px 56px 16px 51px; diff --git a/web/src/main.tsx b/web/src/main.tsx index d9575877f..13bbe052f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -55,6 +55,12 @@ const ThemedApp = () => { Table: { borderRadius: 8 }, Button: { borderRadius: 8 }, Tag: { borderRadiusSM: 6 }, + Menu: { + itemBorderRadius: 6, + itemMarginInline: 8, + itemHeight: 36, + subMenuItemBorderRadius: 6, + }, }, }} > diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx index ee0429212..3d1080c94 100644 --- a/web/src/pages/ai/index.tsx +++ b/web/src/pages/ai/index.tsx @@ -363,6 +363,10 @@ const AiPage = () => { conversationIdRef.current = `conversation-${Date.now()}`; } + if (!conversationIdRef.current) { + conversationIdRef.current = `conversation-${Date.now()}`; + } + const userMsg: Message = { id: `user-${Date.now()}`, role: 'user', @@ -408,9 +412,7 @@ const AiPage = () => { const errorHint = error instanceof AiStreamError && error.hint ? error.hint : ''; const summary = errorHint ? `${errorMessage}\n\n> ${errorHint}` : errorMessage; setMessages((prev) => - prev.map((item) => - item.id === responseId ? { ...item, summary } : item, - ), + prev.map((item) => (item.id === responseId ? { ...item, summary } : item)), ); message.error(errorMessage); } diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx deleted file mode 100644 index ad3577bd1..000000000 --- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { App } from 'antd'; -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type React from 'react'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { LangProvider } from '../../../i18n/LangContext'; -import ClusterPage from '../index'; - -beforeAll(() => { - 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(), - })), - }); -}); - -const renderWithProviders = (ui: React.ReactElement) => - render( - - {ui} - , - ); - -describe('Cluster page', () => { - it('opens proxy detail dialog from the proxy table', async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole('tab', { name: /Proxy 管理/ })); - const proxyRow = screen.getByRole('row', { name: /10\.101\.2\.21:8081/ }); - await user.click(within(proxyRow).getByRole('button', { name: /详情/ })); - - const dialog = await screen.findByRole('dialog', { name: /Proxy 详情 - 10\.101\.2\.21:8081/ }); - expect(within(dialog).getByText('rocketmq-prod')).toBeInTheDocument(); - expect(within(dialog).getByText('ns-prod')).toBeInTheDocument(); - expect(within(dialog).getAllByText('10.101.2.21:8081')).toHaveLength(1); - expect(within(dialog).getByText('1,842')).toBeInTheDocument(); - expect(within(dialog).getByText('8081')).toBeInTheDocument(); - expect(within(dialog).getByText('8080')).toBeInTheDocument(); - }); -}); diff --git a/web/src/pages/cluster/clients.tsx b/web/src/pages/cluster/clients.tsx index 8071e5fa0..4f085a5fe 100644 --- a/web/src/pages/cluster/clients.tsx +++ b/web/src/pages/cluster/clients.tsx @@ -89,6 +89,25 @@ const ClientsPage = () => { }; }, []); + useEffect(() => { + let cancelled = false; + + void listConnections() + .then((nextConnections) => { + if (!cancelled) setConnections(nextConnections); + }) + .catch(() => { + if (!cancelled) message.error('客户端连接加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + /* ─── Cluster options using nsClusterName ─── */ const clusterOptions = useMemo(() => { const clusterNames = [ diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx index a83e30956..c007b7939 100644 --- a/web/src/pages/home/index.tsx +++ b/web/src/pages/home/index.tsx @@ -29,7 +29,6 @@ import { ArrowUp, MagnifyingGlass, CaretDown, - MegaphoneSimple, Database, } from '@phosphor-icons/react'; import { useLang } from '../../i18n/LangContext'; @@ -44,7 +43,7 @@ function getGreetingKey(): string { return 'home.greeting.evening'; } -/* ─── Mode definitions (keys only, labels resolved via t()) ─── */ +/* ─── Mode definitions ─── */ const modes = [ { key: 'query', labelKey: 'home.mode.query', icon: MagnifyingGlass }, { key: 'diagnose', labelKey: 'home.mode.diagnose', icon: Stethoscope }, @@ -52,7 +51,7 @@ const modes = [ { key: 'chat', labelKey: 'home.mode.chat', icon: ChatCircleDots }, ]; -/* ─── Model option values ─── */ +/* ─── Model options ─── */ const modelOptions = [ { value: 'qwen3.7-max', recommended: true }, { value: 'qwen3.7-plus', recommended: false }, @@ -60,9 +59,22 @@ const modelOptions = [ { value: 'gpt-5.4', recommended: false }, ]; -/* ═══════════════════════════════════════════════════════ +/* ─── Quick action capsules ── */ +const quickActions = [ + { + key: '/instance/message', + icon: MagnifyingGlass, + labelKey: 'home.mode.query', + color: '#1677ff', + }, + { key: '/cluster', icon: Stethoscope, labelKey: 'home.mode.diagnose', color: '#722ed1' }, + { key: '/instance', icon: Database, labelKey: 'home.mode.manage', color: '#52c41a' }, + { key: '/ai', icon: ChatCircleDots, labelKey: 'home.mode.chat', color: '#fa8c16' }, +]; + +/* ═══════════════════════════════════════════════════════════════ HomePage Component - ═══════════════════════════════════════════════════════ */ + ═══════════════════════════════════════════════════════════════ */ const HomePage = () => { const [activeMode, setActiveMode] = useState('query'); const [selectedModel, setSelectedModel] = useState('qwen3.7-max'); @@ -73,21 +85,16 @@ const HomePage = () => { const navigate = useNavigate(); const { t, lang } = useLang(); - /* ─── Mode switch handler ─── */ const handleModeSwitch = useCallback((key: string, btn: HTMLButtonElement) => { setActiveMode(key); const parent = modeBarRef.current; if (parent) { const rect = btn.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect(); - setIndicatorStyle({ - width: rect.width, - left: rect.left - parentRect.left - 6, - }); + setIndicatorStyle({ width: rect.width, left: rect.left - parentRect.left - 6 }); } }, []); - /* ─── Fix indicator on mount and language/mode change ─── */ useEffect(() => { const parent = modeBarRef.current; if (!parent) return; @@ -95,14 +102,10 @@ const HomePage = () => { if (activeBtn) { const rect = activeBtn.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect(); - setIndicatorStyle({ - width: rect.width, - left: rect.left - parentRect.left - 6, - }); + setIndicatorStyle({ width: rect.width, left: rect.left - parentRect.left - 6 }); } }, [lang, activeMode]); - /* ─── Auto-resize textarea ─── */ useEffect(() => { const ta = textareaRef.current; if (!ta) return; @@ -114,7 +117,6 @@ const HomePage = () => { return () => ta.removeEventListener('input', handler); }, []); - /* ─── Keyboard shortcut: Enter to send ─── */ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -132,85 +134,29 @@ const HomePage = () => {
- {/* ── Animated Orbs Background ── */} -
-