diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md new file mode 100644 index 00000000..c92aea68 --- /dev/null +++ b/.claude/skills/pr-review/SKILL.md @@ -0,0 +1,265 @@ +--- +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)。 +- 当前工作目录为项目根目录(含 `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。 + +## 输入 + +一个 PR 链接,例如:`https://github.com/apache/rocketmq-dashboard/pull/123` + +从链接中解析出 PR 编号 ``(URL 最后一段数字)。 + +## 标准流程(Pipeline) + +评审按以下 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 \ + --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 +``` + +### Stage 2: 检查 PR 标题是否规范 + +规范来源:`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`(性能)。 +- `[Studio]` 与 `type` 之间有一个空格;type 后紧跟半角冒号 `:` 加一个空格,再接 **description**。 +- description 用英文小写祈使句,简洁描述改动,结尾不加句号。 +- 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 '^\[Studio\] (feat|fix|docs|refactor|test|chore|perf): .+'; then + echo "标题规范 ✅: $TITLE" +else + echo "标题不规范 ❌: $TITLE" +fi +``` + +对照参考(本仓库历史): +- `[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`,首字母大写) + +不规范时在总结中明确指出问题并给出建议标题。 + +### Stage 3: 拉取关联 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 描述的问题(需求对齐度)。 + +### Stage 4: 本地切出评审分支 + +不要污染当前分支。用 `gh` 直接 checkout PR 分支(会自动创建本地分支): + +```bash +# 记录当前分支以便复原 +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- +``` + +若因权限/fork 无法直接 checkout,退化为手动 fetch: + +```bash +git fetch origin pull//head:pr-review- +git checkout pr-review- +``` + +### 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 校验): + +```bash +cd server +mvn -B -ntp clean package -DskipTests +cd .. +``` + +- 编译失败 → 记录报错(编译错误 / checkstyle 违规),标记后端为 ❌,仍继续后续步骤并如实汇报。 +- 通过 → 标记 ✅。 + +### Stage 7: 编译前端 + +对齐 `web/Dockerfile`(`npm ci && npm run build`,即 `tsc -b && vite build`): + +```bash +cd web +npm ci +npm run build +cd .. +``` + +- 出现 TypeScript 类型错误或构建失败 → 标记前端 ❌,记录关键报错。 +- 通过 → 标记 ✅。 + +### Stage 8: Docker 部署 + +使用项目自带 compose 文件,**不修改任何端口**: + +```bash +cd deploy +docker compose down 2>/dev/null || true +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 +``` + +--- + +## 复原工作区 + +评审完成后切回原分支并恢复暂存: + +```bash +git checkout "$ORIGINAL_BRANCH" +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 是否符合 `[Studio] type: description` 格式。 + +### 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..1a765df1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +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 + + frontend-docker-build: + name: Frontend Docker Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build frontend Docker image + run: docker build -t rocketmq-studio-web-ci ./web diff --git a/deploy/deploy.sh b/deploy/deploy.sh index a67c8108..754335e9 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -10,6 +10,18 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# ─── 颜色输出 ─── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${GREEN}[✓]${NC} $*"; } +info() { echo -e "${CYAN}[→]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +err() { echo -e "${RED}[✗]${NC} $*"; exit 1; } + # 加载配置 if [[ -f "$SCRIPT_DIR/.env" ]]; then set -a @@ -26,18 +38,6 @@ TMP_DIR="/tmp/rocketmq-studio-deploy" TARGET="${1:-all}" # all | server | web -# ─── 颜色输出 ─── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -NC='\033[0m' - -log() { echo -e "${GREEN}[✓]${NC} $*"; } -info() { echo -e "${CYAN}[→]${NC} $*"; } -warn() { echo -e "${YELLOW}[!]${NC} $*"; } -err() { echo -e "${RED}[✗]${NC} $*"; exit 1; } - # ─── 前置检查 ─── check_prereqs() { info "检查前置条件..." diff --git a/docs/api-spec.md b/docs/api-spec.md index 581e8d0a..e75158f9 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -1483,7 +1483,7 @@ GET /api/settings/general | `sessionTimeout` | `number` | 会话超时(分钟,5-1440) | | `requireLogin` | `boolean` | 是否需要登录 | | `llmProvider` | `string` | LLM 提供商: `openai` / `azure` / `ollama` / `qwen` | -| `apiKey` | `string` | API Key | +| `apiKeyConfigured` | `boolean` | 是否已配置 API Key;响应不会返回密钥内容 | | `model` | `string` | 模型名称 | | `baseUrl` | `string` | Base URL | @@ -1493,7 +1493,21 @@ GET /api/settings/general POST /api/settings/general/save ``` -**Request Body:** 同 14.1 响应格式 +**Request Body:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `theme` | `string` | 是 | 主题: `light` / `dark` / `system` | +| `compact` | `boolean` | 是 | 紧凑模式 | +| `desktopNotify` | `boolean` | 是 | 桌面通知 | +| `notifySound` | `boolean` | 是 | 通知声音 | +| `sessionTimeout` | `number` | 是 | 会话超时(分钟,5-1440) | +| `requireLogin` | `boolean` | 是 | 是否需要登录 | +| `llmProvider` | `string` | 是 | LLM 提供商 | +| `apiKey` | `string` | 否 | 新 API Key;省略或传空值时保留现有密钥 | +| `clearApiKey` | `boolean` | 否 | 传 `true` 时显式清除现有密钥,优先级高于 `apiKey` | +| `model` | `string` | 是 | 模型名称 | +| `baseUrl` | `string` | 是 | Base URL | **Response `data`:** `null` @@ -1652,17 +1666,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"`) | + +**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` | -**Response `data`:** `MetricsResult` +**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/docs/open-pr-report-20260722.md b/docs/open-pr-report-20260722.md new file mode 100644 index 00000000..b5d70bc6 --- /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/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 diff --git a/server/pom.xml b/server/pom.xml index 55e3e94d..6450dcc0 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -30,6 +30,15 @@ org.springframework.boot spring-boot-starter-validation + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.networknt + json-schema-validator + 2.0.4 + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/server/src/main/java/com/rocketmq/studio/cluster/client/ClientConnectionVO.java b/server/src/main/java/com/rocketmq/studio/cluster/client/ClientConnectionVO.java index b9ef5a30..537b7e54 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/client/ClientConnectionVO.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/client/ClientConnectionVO.java @@ -34,6 +34,7 @@ public class ClientConnectionVO { private String clientId; private ClientType type; private String groupOrTopic; + private String producerGroup; private Protocol protocol; private String address; private ClientLanguage language; diff --git a/server/src/main/java/com/rocketmq/studio/cluster/client/ClientProviderStub.java b/server/src/main/java/com/rocketmq/studio/cluster/client/ClientProviderStub.java index 27aa8483..7b22d47d 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/client/ClientProviderStub.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/client/ClientProviderStub.java @@ -36,6 +36,7 @@ public class ClientProviderStub implements ClientProvider { .clientId("producer-001") .type(ClientType.Producer) .groupOrTopic("order-topic") + .producerGroup("pg-order") .protocol(Protocol.gRPC) .address("192.168.1.10:56789") .language(ClientLanguage.Java) diff --git a/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java new file mode 100644 index 00000000..9c6934f7 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionResultVO.java @@ -0,0 +1,30 @@ +/* + * 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.client; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProducerConnectionResultVO { + private List connectionSet; +} diff --git a/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionService.java b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionService.java new file mode 100644 index 00000000..b5d2b776 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionService.java @@ -0,0 +1,63 @@ +/* + * 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.client; + +import com.rocketmq.studio.common.domain.enums.ClientType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProducerConnectionService { + + private final ClientService clientService; + + public List listConnections(String topic, String producerGroup) { + log.info("Listing producer connections, topic={}, producerGroup={}", topic, producerGroup); + return clientService.listConnections(null, ClientType.Producer.name()).stream() + .filter(connection -> matchesFilter(connection, topic, producerGroup)) + .map(this::toProducerConnection) + .toList(); + } + + private boolean matchesFilter(ClientConnectionVO connection, String topic, String producerGroup) { + if (hasText(topic) && !topic.equals(connection.getGroupOrTopic())) { + return false; + } + if (hasText(producerGroup) && !producerGroup.equals(connection.getProducerGroup())) { + return false; + } + return true; + } + + private ProducerConnectionVO toProducerConnection(ClientConnectionVO connection) { + return ProducerConnectionVO.builder() + .clientId(connection.getClientId()) + .clientAddr(connection.getAddress()) + .language(connection.getLanguage() == null ? null : connection.getLanguage().name()) + .versionDesc(connection.getVersion()) + .build(); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionVO.java b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionVO.java new file mode 100644 index 00000000..aa4c2139 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerConnectionVO.java @@ -0,0 +1,33 @@ +/* + * 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.client; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProducerConnectionVO { + private String clientId; + private String clientAddr; + private String language; + private String versionDesc; +} diff --git a/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerController.java b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerController.java new file mode 100644 index 00000000..32d9611f --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/client/ProducerController.java @@ -0,0 +1,38 @@ +/* + * 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.client; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/producer") +@RequiredArgsConstructor +public class ProducerController { + + private final ProducerConnectionService producerConnectionService; + + @GetMapping("/connection") + public ProducerConnectionResultVO listConnections( + @RequestParam(required = false) String topic, + @RequestParam(required = false) String producerGroup) { + return new ProducerConnectionResultVO(producerConnectionService.listConnections(topic, producerGroup)); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/cluster/k8s/K8sCertService.java b/server/src/main/java/com/rocketmq/studio/cluster/k8s/K8sCertService.java index c55f9c00..c66517ce 100644 --- a/server/src/main/java/com/rocketmq/studio/cluster/k8s/K8sCertService.java +++ b/server/src/main/java/com/rocketmq/studio/cluster/k8s/K8sCertService.java @@ -69,49 +69,51 @@ public K8sCertVO createCert(CreateCertDTO command) { public K8sCertVO updateCert(UpdateCertDTO command) { log.info("Updating K8s certificate: {}", command.getId()); - K8sCertVO cert = k8sCertRepository.findById(command.getId()) + K8sCertVO existing = k8sCertRepository.findById(command.getId()) .orElseThrow(() -> new BusinessException(404, "Certificate not found: " + command.getId())); + K8sCertVO updated = copyOf(existing); if (command.getName() != null) { - cert.setName(command.getName()); + updated.setName(command.getName()); } if (command.getNamespace() != null) { - cert.setNamespace(command.getNamespace()); + updated.setNamespace(command.getNamespace()); } if (command.getCluster() != null) { - cert.setCluster(command.getCluster()); + updated.setCluster(command.getCluster()); } if (command.getType() != null) { - cert.setType(CertType.valueOf(command.getType())); + updated.setType(CertType.valueOf(command.getType())); } if (command.getIssuer() != null) { - cert.setIssuer(command.getIssuer()); + updated.setIssuer(command.getIssuer()); } if (command.getSan() != null) { - cert.setSan(command.getSan()); + updated.setSan(command.getSan()); } - cert.setUpdatedAt(LocalDateTime.now()); + updated.setUpdatedAt(LocalDateTime.now()); - K8sCertVO saved = k8sCertRepository.save(cert); + K8sCertVO saved = k8sCertRepository.save(updated); log.info("K8s certificate updated: {} (id={})", saved.getName(), saved.getId()); return saved; } public K8sCertVO renewCert(RenewCertDTO command) { log.info("Renewing K8s certificate: {}", command.getId()); - K8sCertVO cert = k8sCertRepository.findById(command.getId()) + K8sCertVO existing = k8sCertRepository.findById(command.getId()) .orElseThrow(() -> new BusinessException(404, "Certificate not found: " + command.getId())); LocalDateTime now = LocalDateTime.now(); LocalDateTime notAfter = now.plusYears(1); - cert.setNotBefore(now); - cert.setNotAfter(notAfter); - cert.setStatus(CertStatus.valid); - cert.setDaysRemaining((int) ChronoUnit.DAYS.between(now, notAfter)); - cert.setUpdatedAt(now); + K8sCertVO renewed = copyOf(existing); + renewed.setNotBefore(now); + renewed.setNotAfter(notAfter); + renewed.setStatus(CertStatus.valid); + renewed.setDaysRemaining((int) ChronoUnit.DAYS.between(now, notAfter)); + renewed.setUpdatedAt(now); - K8sCertVO saved = k8sCertRepository.save(cert); + K8sCertVO saved = k8sCertRepository.save(renewed); log.info("K8s certificate renewed: {} (id={}), new expiry: {}", saved.getName(), saved.getId(), notAfter); return saved; } @@ -123,4 +125,23 @@ public void deleteCert(DeleteCertDTO command) { k8sCertRepository.deleteById(command.getId()); log.info("K8s certificate deleted: {}", command.getId()); } + + private K8sCertVO copyOf(K8sCertVO cert) { + K8sCertVO copy = K8sCertVO.builder() + .name(cert.getName()) + .namespace(cert.getNamespace()) + .cluster(cert.getCluster()) + .type(cert.getType()) + .issuer(cert.getIssuer()) + .notBefore(cert.getNotBefore()) + .notAfter(cert.getNotAfter()) + .status(cert.getStatus()) + .daysRemaining(cert.getDaysRemaining()) + .san(cert.getSan()) + .build(); + copy.setId(cert.getId()); + copy.setCreatedAt(cert.getCreatedAt()); + copy.setUpdatedAt(cert.getUpdatedAt()); + return copy; + } } 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/cluster/proxy/ProxyAddressService.java b/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyAddressService.java new file mode 100644 index 00000000..d2e30a56 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyAddressService.java @@ -0,0 +1,58 @@ +/* + * 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.proxy; + +import com.rocketmq.studio.common.exception.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Service +public class ProxyAddressService { + + private final Set proxyAddrs = new LinkedHashSet<>(List.of("127.0.0.1:8081")); + private String currentProxyAddr = "127.0.0.1:8081"; + + public synchronized ProxyHomeVO getHomePage() { + return ProxyHomeVO.builder() + .proxyAddrList(new ArrayList<>(proxyAddrs)) + .currentProxyAddr(currentProxyAddr) + .build(); + } + + public synchronized void addProxyAddr(String newProxyAddr) { + String normalized = normalizeProxyAddr(newProxyAddr); + proxyAddrs.add(normalized); + if (currentProxyAddr == null || currentProxyAddr.isBlank()) { + currentProxyAddr = normalized; + } + log.info("Added Proxy address {}", normalized); + } + + private String normalizeProxyAddr(String proxyAddr) { + if (proxyAddr == null || proxyAddr.trim().isEmpty()) { + throw new BusinessException(400, "newProxyAddr is required"); + } + return proxyAddr.trim(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyCompatController.java b/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyCompatController.java new file mode 100644 index 00000000..48c15445 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyCompatController.java @@ -0,0 +1,46 @@ +/* + * 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.proxy; + +import com.rocketmq.studio.common.domain.Result; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/proxy") +@RequiredArgsConstructor +public class ProxyCompatController { + + private final ProxyAddressService proxyAddressService; + + @GetMapping("/homePage.query") + public Result homePage() { + return Result.ok(proxyAddressService.getHomePage()); + } + + @PostMapping(value = "/addProxyAddr.do", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public Result addProxyAddr(@RequestParam String newProxyAddr) { + proxyAddressService.addProxyAddr(newProxyAddr); + return Result.ok(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyHomeVO.java b/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyHomeVO.java new file mode 100644 index 00000000..426f65e1 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/cluster/proxy/ProxyHomeVO.java @@ -0,0 +1,30 @@ +/* + * 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.proxy; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class ProxyHomeVO { + private List proxyAddrList; + private String currentProxyAddr; +} 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..79eefd1b 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; @@ -30,10 +34,34 @@ public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(BusinessException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public Result handleBusinessException(BusinessException ex) { + public ResponseEntity> handleBusinessException(BusinessException ex) { log.warn("Business exception: {}", ex.getMessage()); - return Result.error(ex.getCode(), ex.getMessage()); + return ResponseEntity.status(ex.getCode()) + .body(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) diff --git a/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java b/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java index 3d1d83cb..9c35cd6b 100644 --- a/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java +++ b/server/src/main/java/com/rocketmq/studio/instance/acl/AclController.java @@ -47,6 +47,11 @@ public Result createRule(@RequestBody AclRuleVO rule) { return Result.ok(aclService.createRule(rule)); } + @PostMapping("/rules/update") + public Result updateRule(@RequestBody AclRuleVO rule) { + return Result.ok(aclService.updateRule(rule)); + } + @PostMapping("/rules/delete") public Result deleteRule(@RequestBody Map request) { aclService.deleteRule(request.get("id")); @@ -63,6 +68,11 @@ public Result createUser(@RequestBody AclUserVO user) { return Result.ok(aclService.createUser(user)); } + @PostMapping("/users/update") + public Result updateUser(@RequestBody AclUserVO user) { + return Result.ok(aclService.updateUser(user)); + } + @PostMapping("/users/delete") public Result deleteUser(@RequestBody Map request) { aclService.deleteUser(request.get("id")); diff --git a/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java b/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java index 537b81e8..ceff7680 100644 --- a/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java +++ b/server/src/main/java/com/rocketmq/studio/instance/acl/AclService.java @@ -16,6 +16,7 @@ */ package com.rocketmq.studio.instance.acl; +import com.rocketmq.studio.common.exception.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -45,6 +46,16 @@ public AclRuleVO createRule(AclRuleVO rule) { return aclRepository.saveRule(rule); } + public AclRuleVO updateRule(AclRuleVO rule) { + if (isBlank(rule.getId())) { + throw new BusinessException(400, "ACL rule id is required"); + } + log.info("Updating ACL rule id={}, principal={}", rule.getId(), rule.getPrincipal()); + if (rule.getCreatedAt() == null) { + rule.setCreatedAt(LocalDateTime.now()); + } + return aclRepository.saveRule(rule); + } public void deleteRule(String id) { log.info("Deleting ACL rule id={}", id); @@ -67,9 +78,23 @@ public AclUserVO createUser(AclUserVO user) { return aclRepository.saveUser(user); } + public AclUserVO updateUser(AclUserVO user) { + if (isBlank(user.getId())) { + throw new BusinessException(400, "ACL user id is required"); + } + log.info("Updating ACL user id={}, username={}", user.getId(), user.getUsername()); + if (user.getCreatedAt() == null) { + user.setCreatedAt(LocalDateTime.now()); + } + return aclRepository.saveUser(user); + } public void deleteUser(String id) { log.info("Deleting ACL user id={}", id); aclRepository.deleteUser(id); } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } } diff --git a/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsProvider.java b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsProvider.java new file mode 100644 index 00000000..cf843487 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsProvider.java @@ -0,0 +1,22 @@ +/* + * 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.instance.group; + +public interface ConsumerDiagnosticsProvider { + ConsumerStackTraceVO getConsumerStack(String groupName, String clientId); +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStub.java b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStub.java new file mode 100644 index 00000000..34f7f286 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStub.java @@ -0,0 +1,41 @@ +/* + * 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.instance.group; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +public class ConsumerDiagnosticsProviderStub implements ConsumerDiagnosticsProvider { + + @Override + public ConsumerStackTraceVO getConsumerStack(String groupName, String clientId) { + log.warn("ConsumerDiagnosticsProviderStub.getConsumerStack called - returning empty stack"); + return ConsumerStackTraceVO.builder() + .groupName(groupName) + .clientId(clientId) + .capturedAt(LocalDateTime.now()) + .threadCount(0) + .threads(List.of()) + .build(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsService.java b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsService.java new file mode 100644 index 00000000..8bf35f85 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsService.java @@ -0,0 +1,41 @@ +/* + * 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.instance.group; + +import com.rocketmq.studio.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class ConsumerDiagnosticsService { + + private final ConsumerDiagnosticsProvider diagnosticsProvider; + + public ConsumerStackTraceVO getConsumerStack(String groupName, String clientId) { + if (!StringUtils.hasText(groupName)) { + throw new BusinessException(HttpStatus.BAD_REQUEST.value(), "groupName is required"); + } + if (!StringUtils.hasText(clientId)) { + throw new BusinessException(HttpStatus.BAD_REQUEST.value(), "clientId is required"); + } + return diagnosticsProvider.getConsumerStack(groupName, clientId); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerGroupController.java b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerGroupController.java index 395744f9..9ac96f85 100644 --- a/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerGroupController.java +++ b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerGroupController.java @@ -37,6 +37,7 @@ public class ConsumerGroupController { private final MetadataService metadataService; + private final ConsumerDiagnosticsService consumerDiagnosticsService; @GetMapping public Result> listConsumerGroups( @@ -60,6 +61,13 @@ public Result> getGroupSubscriptions(@PathVariable Str return Result.ok(metadataService.getGroupSubscriptions(name)); } + @GetMapping("/{name}/instances/{clientId}/stack") + public Result getConsumerStack( + @PathVariable String name, + @PathVariable String clientId) { + return Result.ok(consumerDiagnosticsService.getConsumerStack(name, clientId)); + } + @PostMapping("/create") public Result createConsumerGroup(@RequestBody ConsumerGroupVO group) { return Result.ok(metadataService.createConsumerGroup(group)); diff --git a/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerStackTraceVO.java b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerStackTraceVO.java new file mode 100644 index 00000000..6ed26af7 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerStackTraceVO.java @@ -0,0 +1,38 @@ +/* + * 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.instance.group; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConsumerStackTraceVO { + private String groupName; + private String clientId; + private LocalDateTime capturedAt; + private int threadCount; + private List threads; +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerThreadStackVO.java b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerThreadStackVO.java new file mode 100644 index 00000000..3af17b34 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/group/ConsumerThreadStackVO.java @@ -0,0 +1,38 @@ +/* + * 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.instance.group; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConsumerThreadStackVO { + private String threadName; + private long threadId; + private String state; + private long blockedTime; + private long waitedTime; + private List stackTrace; +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicCapabilityVO.java b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicCapabilityVO.java new file mode 100644 index 00000000..c31ba621 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicCapabilityVO.java @@ -0,0 +1,29 @@ +/* + * 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.instance.topic; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LiteTopicCapabilityVO { + private boolean supported; +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicController.java b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicController.java new file mode 100644 index 00000000..b4ce5235 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicController.java @@ -0,0 +1,66 @@ +/* + * 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.instance.topic; + +import com.rocketmq.studio.common.domain.Result; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/liteTopic") +@RequiredArgsConstructor +public class LiteTopicController { + + private final LiteTopicService liteTopicService; + + @GetMapping("/list") + public Result> listLiteTopics( + @RequestParam(required = false) String pattern, + @RequestParam(required = false) String namespace) { + return Result.ok(liteTopicService.listLiteTopics(pattern, namespace)); + } + + @GetMapping("/session/{sessionId}") + public Result getSession(@PathVariable String sessionId) { + return Result.ok(liteTopicService.getSession(sessionId)); + } + + @PostMapping("/extendTTL") + public Result extendTTL(@RequestBody LiteTopicTTLUpdateDTO request) { + liteTopicService.extendTTL(request.getTopicPattern(), request.getNewTTL()); + return Result.ok(); + } + + @GetMapping("/quota") + public Result getQuota(@RequestParam(required = false) String namespace) { + return Result.ok(liteTopicService.getQuota(namespace)); + } + + @GetMapping("/capability") + public Result getCapability() { + return Result.ok(liteTopicService.getCapability()); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicItemVO.java b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicItemVO.java new file mode 100644 index 00000000..4b156074 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicItemVO.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.instance.topic; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class LiteTopicItemVO { + private String topicPattern; + private String namespace; + private Integer topicCount; + private Integer consumerCount; + private Long totalBacklog; + private Long averageTTL; + private String ttlStatus; + private Long lastActiveTime; + private List sessionIds; +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicQuotaVO.java b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicQuotaVO.java new file mode 100644 index 00000000..976f9d8c --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicQuotaVO.java @@ -0,0 +1,38 @@ +/* + * 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.instance.topic; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class LiteTopicQuotaVO { + private Integer currentTopicCount; + private Integer maxTopicCount; + private Integer currentSessionCount; + private Integer maxSessionCount; + private Integer currentCreationRate; + private Integer maxCreationRate; + private Double usageRate; + private Double sessionUsageRate; + private Long defaultTTL; + private Long maxTTL; + private Integer remainingQuota; + private Double consumerDensity; +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicService.java b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicService.java new file mode 100644 index 00000000..4e48ff74 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicService.java @@ -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. + */ + +package com.rocketmq.studio.instance.topic; + +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Locale; + +@Service +public class LiteTopicService { + + public List listLiteTopics(String pattern, String namespace) { + return sampleItems().stream() + .filter(item -> matches(pattern, item.getTopicPattern())) + .filter(item -> matches(namespace, item.getNamespace())) + .toList(); + } + + public LiteTopicSessionVO getSession(String sessionId) { + long now = System.currentTimeMillis(); + return LiteTopicSessionVO.builder() + .sessionId(sessionId) + .clientId("grpc-client-" + sessionId) + .clientAddress("192.168.1.10:8081") + .parentTopic("chat/{sessionId}") + .consumerGroup("cg-chat-session") + .createTime(now - 3_600_000L) + .lastActiveTime(now - 30_000L) + .ttl(3_600_000L) + .ttlRemaining(1_800_000L) + .status("ACTIVE") + .totalMessages(5_000L) + .consumedMessages(4_800L) + .pendingMessages(200L) + .popProgress(96) + .liteTopicCreationCount(2) + .liteTopics(List.of( + new LiteTopicSessionVO.SessionLiteTopic("chat/" + sessionId, "ACTIVE", 1_800_000L), + new LiteTopicSessionVO.SessionLiteTopic("agent/" + sessionId, "ACTIVE", 1_200_000L))) + .build(); + } + + public void extendTTL(String topicPattern, Long newTTL) { + if (topicPattern == null || topicPattern.isBlank()) { + throw new IllegalArgumentException("topicPattern is required"); + } + if (newTTL == null || newTTL <= 0) { + throw new IllegalArgumentException("newTTL must be positive"); + } + } + + public LiteTopicQuotaVO getQuota(String namespace) { + return LiteTopicQuotaVO.builder() + .currentTopicCount(128) + .maxTopicCount(1_000_000) + .currentSessionCount(32) + .maxSessionCount(100_000) + .currentCreationRate(12) + .maxCreationRate(1_000) + .usageRate(0.000128) + .sessionUsageRate(0.00032) + .defaultTTL(3_600_000L) + .maxTTL(86_400_000L) + .remainingQuota(999_872) + .consumerDensity(0.25) + .build(); + } + + public LiteTopicCapabilityVO getCapability() { + return new LiteTopicCapabilityVO(true); + } + + private List sampleItems() { + long now = System.currentTimeMillis(); + return List.of( + LiteTopicItemVO.builder() + .topicPattern("chat/{sessionId}") + .namespace("default") + .topicCount(96) + .consumerCount(12) + .totalBacklog(1_200L) + .averageTTL(3_600_000L) + .ttlStatus("ACTIVE") + .lastActiveTime(now - 60_000L) + .sessionIds(List.of("sess-001", "sess-002")) + .build(), + LiteTopicItemVO.builder() + .topicPattern("agent/{sessionId}") + .namespace("ai") + .topicCount(32) + .consumerCount(4) + .totalBacklog(180L) + .averageTTL(1_800_000L) + .ttlStatus("EXPIRING_SOON") + .lastActiveTime(now - 120_000L) + .sessionIds(List.of("agent-001")) + .build()); + } + + private boolean matches(String filter, String value) { + if (filter == null || filter.isBlank()) { + return true; + } + return value != null && value.toLowerCase(Locale.ROOT).contains(filter.toLowerCase(Locale.ROOT)); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicSessionVO.java b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicSessionVO.java new file mode 100644 index 00000000..ae6aa497 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicSessionVO.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.instance.topic; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +public class LiteTopicSessionVO { + private String sessionId; + private String clientId; + private String clientAddress; + private String parentTopic; + private String consumerGroup; + private Long createTime; + private Long lastActiveTime; + private Long ttl; + private Long ttlRemaining; + private String status; + private Long totalMessages; + private Long consumedMessages; + private Long pendingMessages; + private Integer popProgress; + private Integer liteTopicCreationCount; + private List liteTopics; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SessionLiteTopic { + private String topicName; + private String status; + private Long ttlRemaining; + } +} diff --git a/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicTTLUpdateDTO.java b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicTTLUpdateDTO.java new file mode 100644 index 00000000..2a445d4d --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/instance/topic/LiteTopicTTLUpdateDTO.java @@ -0,0 +1,26 @@ +/* + * 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.instance.topic; + +import lombok.Data; + +@Data +public class LiteTopicTTLUpdateDTO { + private String topicPattern; + private Long newTTL; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/OpsController.java b/server/src/main/java/com/rocketmq/studio/ops/OpsController.java new file mode 100644 index 00000000..5bfb8a23 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/OpsController.java @@ -0,0 +1,63 @@ +/* + * 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.ops; + +import com.rocketmq.studio.common.domain.Result; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/ops") +@RequiredArgsConstructor +public class OpsController { + + private final OpsService opsService; + + @GetMapping("/homePage") + public Result homePage() { + return Result.ok(opsService.getHomePage()); + } + + @PostMapping("/updateNameSvrAddr") + public Result updateNameSvrAddr(@RequestBody OpsNameServerDTO request) { + opsService.updateNameServer(request.getNamesrvAddr()); + return Result.ok(); + } + + @PostMapping("/addNameSvrAddr") + public Result addNameSvrAddr(@RequestBody OpsNameServerDTO request) { + opsService.addNameServer(request.getNamesrvAddr()); + return Result.ok(); + } + + @PostMapping("/updateIsVIPChannel") + public Result updateIsVIPChannel(@RequestBody OpsVipChannelDTO request) { + opsService.updateVipChannel(request.isUseVIPChannel()); + return Result.ok(); + } + + @PostMapping("/updateUseTLS") + public Result updateUseTLS(@RequestBody OpsTlsDTO request) { + opsService.updateUseTLS(request.isUseTLS()); + return Result.ok(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/OpsHomeVO.java b/server/src/main/java/com/rocketmq/studio/ops/OpsHomeVO.java new file mode 100644 index 00000000..ec7893a1 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/OpsHomeVO.java @@ -0,0 +1,32 @@ +/* + * 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.ops; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class OpsHomeVO { + private List namesvrAddrList; + private boolean useVIPChannel; + private boolean useTLS; + private String currentNamesrv; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/OpsNameServerDTO.java b/server/src/main/java/com/rocketmq/studio/ops/OpsNameServerDTO.java new file mode 100644 index 00000000..a520cd75 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/OpsNameServerDTO.java @@ -0,0 +1,25 @@ +/* + * 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.ops; + +import lombok.Data; + +@Data +public class OpsNameServerDTO { + private String namesrvAddr; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/OpsService.java b/server/src/main/java/com/rocketmq/studio/ops/OpsService.java new file mode 100644 index 00000000..aa7e7123 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/OpsService.java @@ -0,0 +1,76 @@ +/* + * 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.ops; + +import com.rocketmq.studio.common.exception.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Service +public class OpsService { + + private final Set namesrvAddrs = new LinkedHashSet<>(List.of("127.0.0.1:9876")); + private String currentNamesrv = "127.0.0.1:9876"; + private boolean useVIPChannel = true; + private boolean useTLS; + + public synchronized OpsHomeVO getHomePage() { + return OpsHomeVO.builder() + .namesvrAddrList(new ArrayList<>(namesrvAddrs)) + .currentNamesrv(currentNamesrv) + .useVIPChannel(useVIPChannel) + .useTLS(useTLS) + .build(); + } + + public synchronized void updateNameServer(String namesrvAddr) { + String normalized = normalizeNameServer(namesrvAddr); + namesrvAddrs.add(normalized); + currentNamesrv = normalized; + log.info("Updated current NameServer address to {}", normalized); + } + + public synchronized void addNameServer(String namesrvAddr) { + String normalized = normalizeNameServer(namesrvAddr); + namesrvAddrs.add(normalized); + log.info("Added NameServer address {}", normalized); + } + + public synchronized void updateVipChannel(boolean enabled) { + useVIPChannel = enabled; + log.info("Updated VIP channel setting to {}", enabled); + } + + public synchronized void updateUseTLS(boolean enabled) { + useTLS = enabled; + log.info("Updated TLS setting to {}", enabled); + } + + private String normalizeNameServer(String namesrvAddr) { + if (namesrvAddr == null || namesrvAddr.trim().isEmpty()) { + throw new BusinessException(400, "namesrvAddr is required"); + } + return namesrvAddr.trim(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/OpsTlsDTO.java b/server/src/main/java/com/rocketmq/studio/ops/OpsTlsDTO.java new file mode 100644 index 00000000..1ed42ee2 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/OpsTlsDTO.java @@ -0,0 +1,25 @@ +/* + * 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.ops; + +import lombok.Data; + +@Data +public class OpsTlsDTO { + private boolean useTLS; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/OpsVipChannelDTO.java b/server/src/main/java/com/rocketmq/studio/ops/OpsVipChannelDTO.java new file mode 100644 index 00000000..612b8f38 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/OpsVipChannelDTO.java @@ -0,0 +1,25 @@ +/* + * 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.ops; + +import lombok.Data; + +@Data +public class OpsVipChannelDTO { + private boolean useVIPChannel; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java b/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java index 27ce1461..3ddd7c8b 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/AiController.java @@ -19,14 +19,19 @@ import com.rocketmq.studio.common.domain.Result; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.util.Collections; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/ai") @@ -46,7 +51,25 @@ public Result execute(@RequestBody AiCommandDTO command) { } @GetMapping("/tools") - public Result> listTools() { - return Result.ok(aiService.listTools()); + public ResponseEntity>> listTools( + @RequestParam(required = false) String cluster) { + List tools = cluster == null + ? aiService.listTools() + : aiService.listTools(cluster); + return ResponseEntity.ok() + .header("X-RMQ-Catalog-Version", aiService.catalogVersion()) + .header("X-RMQ-Catalog-Digest", aiService.catalogDigest()) + .header("X-RMQ-Minimum-Client-Version", aiService.minimumClientVersion()) + .body(Result.ok(tools)); + } + + @PostMapping("/tools/{name}/execute") + public Result executeTool( + @PathVariable String name, + @RequestBody(required = false) Map input) { + Map normalizedInput = input == null + ? Collections.emptyMap() + : input; + return Result.ok(aiService.executeTool(name, normalizedInput)); } } diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java b/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java index b14479a7..3196f417 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/AiService.java @@ -22,6 +22,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.List; +import java.util.Map; @Slf4j @Service @@ -60,4 +61,26 @@ public List listTools() { log.debug("Listing available AI tools"); return mcpServerRegistry.listTools(); } + + public List listTools(String clusterId) { + log.debug("Listing available AI tools for cluster: {}", clusterId); + return mcpServerRegistry.listTools(clusterId); + } + + public Object executeTool(String name, Map input) { + log.info("Executing registered AI tool: {}", name); + return mcpServerRegistry.execute(name, input); + } + + public String catalogVersion() { + return mcpServerRegistry.catalogVersion(); + } + + public String catalogDigest() { + return mcpServerRegistry.catalogDigest(); + } + + public String minimumClientVersion() { + return mcpServerRegistry.minimumClientVersion(); + } } diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java index fc2dbe5c..3683ff44 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/AiToolVO.java @@ -21,6 +21,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @Builder @NoArgsConstructor @@ -29,4 +31,11 @@ public class AiToolVO { private String name; private String description; private Object parameters; + private String riskLevel; + private String permission; + private List requiredCapabilities; + private Object outputSchema; + private String viewHint; + private boolean deprecated; + private String replacement; } diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigService.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigService.java new file mode 100644 index 00000000..2134f2b9 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigService.java @@ -0,0 +1,168 @@ +/* + * 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.ops.ai; + +import com.rocketmq.studio.settings.GeneralSettingsVO; +import com.rocketmq.studio.settings.SettingsService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class LlmConfigService { + + private static final String OPENAI = "openai"; + private static final Map> PROVIDER_MODELS = Map.of( + OPENAI, List.of( + new LlmModelItemVO("gpt-4o", "GPT-4o"), + new LlmModelItemVO("gpt-4-turbo", "GPT-4 Turbo"), + new LlmModelItemVO("gpt-4", "GPT-4")), + "azure", List.of( + new LlmModelItemVO("gpt-4o", "GPT-4o"), + new LlmModelItemVO("gpt-4", "GPT-4")), + "deepseek", List.of( + new LlmModelItemVO("deepseek-chat", "DeepSeek Chat"), + new LlmModelItemVO("deepseek-reasoner", "DeepSeek Reasoner")), + "tongyi", List.of( + new LlmModelItemVO("qwen-max", "Qwen Max"), + new LlmModelItemVO("qwen-plus", "Qwen Plus"), + new LlmModelItemVO("qwen-turbo", "Qwen Turbo")), + "ollama", List.of( + new LlmModelItemVO("llama3", "Llama 3"), + new LlmModelItemVO("mistral", "Mistral"), + new LlmModelItemVO("qwen2.5", "Qwen 2.5")), + "bedrock", List.of( + new LlmModelItemVO("anthropic.claude-3-sonnet", "Claude 3 Sonnet"), + new LlmModelItemVO("anthropic.claude-3-haiku", "Claude 3 Haiku"), + new LlmModelItemVO("meta.llama3-70b", "Llama 3 70B"))); + + private final SettingsService settingsService; + private LlmConfigVO overrides; + + public synchronized LlmConfigVO getConfig() { + if (overrides != null) { + return copy(overrides); + } + return fromGeneralSettings(settingsService.getGeneralSettings()); + } + + public synchronized void saveConfig(LlmConfigVO config) { + LlmConfigVO normalized = normalize(config); + overrides = copy(normalized); + GeneralSettingsVO current = settingsService.getGeneralSettings(); + settingsService.saveGeneralSettings(GeneralSettingsVO.builder() + .theme(current.getTheme()) + .compact(current.isCompact()) + .desktopNotify(current.isDesktopNotify()) + .notifySound(current.isNotifySound()) + .sessionTimeout(current.getSessionTimeout()) + .requireLogin(current.isRequireLogin()) + .llmProvider(normalized.getProvider()) + .apiKey(normalized.getApiKey()) + .model(normalized.getModel()) + .baseUrl(normalized.getApiBase()) + .build()); + } + + public LlmOperationResultVO testConfig(LlmConfigVO config) { + LlmConfigVO normalized = normalize(config); + String provider = normalized.getProvider(); + boolean keyRequired = !"ollama".equals(provider); + if (keyRequired && isBlank(normalized.getApiKey())) { + return LlmOperationResultVO.failure("API Key is required"); + } + if ("azure".equals(provider) && isBlank(normalized.getDeploymentName())) { + return LlmOperationResultVO.failure("Deployment name is required"); + } + if (isBlank(normalized.getModel())) { + return LlmOperationResultVO.failure("Model is required"); + } + return LlmOperationResultVO.success("Configuration accepted"); + } + + public synchronized LlmModelsResultVO listModels() { + String provider = getConfig().getProvider(); + List models = PROVIDER_MODELS.getOrDefault(provider, PROVIDER_MODELS.get(OPENAI)); + return new LlmModelsResultVO(0, models); + } + + private LlmConfigVO fromGeneralSettings(GeneralSettingsVO settings) { + String provider = defaultString(settings.getLlmProvider(), OPENAI); + return LlmConfigVO.builder() + .provider(provider) + .apiKey(defaultString(settings.getApiKey(), "")) + .apiBase(defaultString(settings.getBaseUrl(), defaultApiBase(provider))) + .model(defaultString(settings.getModel(), defaultModel(provider))) + .maxTokens(4096) + .temperature(0.7) + .enabled(!isBlank(settings.getApiKey())) + .apiVersion("2024-02-15-preview") + .awsRegion("us-east-1") + .build(); + } + + private LlmConfigVO normalize(LlmConfigVO config) { + String provider = normalizeProvider(config == null ? null : config.getProvider()); + return LlmConfigVO.builder() + .provider(provider) + .apiKey(defaultString(config == null ? null : config.getApiKey(), "")) + .apiBase(defaultString(config == null ? null : config.getApiBase(), defaultApiBase(provider))) + .model(defaultString(config == null ? null : config.getModel(), defaultModel(provider))) + .maxTokens(config == null || config.getMaxTokens() <= 0 ? 4096 : config.getMaxTokens()) + .temperature(config == null ? 0.7 : config.getTemperature()) + .enabled(config != null && config.isEnabled()) + .deploymentName(defaultString(config == null ? null : config.getDeploymentName(), "")) + .apiVersion(defaultString(config == null ? null : config.getApiVersion(), "2024-02-15-preview")) + .awsRegion(defaultString(config == null ? null : config.getAwsRegion(), "us-east-1")) + .build(); + } + + private LlmConfigVO copy(LlmConfigVO config) { + return normalize(config); + } + + private String normalizeProvider(String provider) { + String normalized = defaultString(provider, OPENAI).toLowerCase(Locale.ROOT); + return PROVIDER_MODELS.containsKey(normalized) ? normalized : OPENAI; + } + + private String defaultModel(String provider) { + return PROVIDER_MODELS.getOrDefault(provider, PROVIDER_MODELS.get(OPENAI)).get(0).getId(); + } + + private String defaultApiBase(String provider) { + return switch (provider) { + case "deepseek" -> "https://api.deepseek.com/v1"; + case "tongyi" -> "https://dashscope.aliyuncs.com/compatible-mode/v1"; + case "ollama" -> "http://localhost:11434/v1"; + default -> "https://api.openai.com/v1"; + }; + } + + private String defaultString(String value, String fallback) { + return isBlank(value) ? fallback : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigVO.java new file mode 100644 index 00000000..85d6460c --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmConfigVO.java @@ -0,0 +1,40 @@ +/* + * 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.ops.ai; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LlmConfigVO { + private String provider; + private String apiKey; + private String apiBase; + private String model; + private int maxTokens; + private double temperature; + private boolean enabled; + private String deploymentName; + private String apiVersion; + private String awsRegion; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmController.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmController.java new file mode 100644 index 00000000..8f7e08bc --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmController.java @@ -0,0 +1,54 @@ +/* + * 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.ops.ai; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/llm") +@RequiredArgsConstructor +public class LlmController { + + private final LlmConfigService llmConfigService; + + @GetMapping("/config") + public LlmConfigVO getConfig() { + return llmConfigService.getConfig(); + } + + @PostMapping("/config") + public LlmOperationResultVO saveConfig(@RequestBody LlmConfigVO config) { + llmConfigService.saveConfig(config); + return LlmOperationResultVO.success("saved"); + } + + @PostMapping("/config/test") + public LlmOperationResultVO testConfig(@RequestBody LlmConfigVO config) { + return llmConfigService.testConfig(config); + } + + @GetMapping("/models") + public LlmModelsResultVO listModels() { + return llmConfigService.listModels(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelItemVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelItemVO.java new file mode 100644 index 00000000..0a49758f --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelItemVO.java @@ -0,0 +1,30 @@ +/* + * 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.ops.ai; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LlmModelItemVO { + private String id; + private String name; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelsResultVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelsResultVO.java new file mode 100644 index 00000000..0e95658f --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmModelsResultVO.java @@ -0,0 +1,32 @@ +/* + * 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.ops.ai; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LlmModelsResultVO { + private int status; + private List data; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/LlmOperationResultVO.java b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmOperationResultVO.java new file mode 100644 index 00000000..2fcfebf6 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/LlmOperationResultVO.java @@ -0,0 +1,39 @@ +/* + * 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.ops.ai; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LlmOperationResultVO { + private int status; + private String msg; + private String errMsg; + + public static LlmOperationResultVO success(String message) { + return new LlmOperationResultVO(0, message, null); + } + + public static LlmOperationResultVO failure(String message) { + return new LlmOperationResultVO(1, null, message); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java index 50aaa45f..d079e5e7 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerImpl.java @@ -16,49 +16,48 @@ */ package com.rocketmq.studio.ops.ai; -import lombok.extern.slf4j.Slf4j; +import com.rocketmq.studio.ops.ai.tool.ToolCatalog; +import com.rocketmq.studio.ops.ai.tool.ToolGatewayService; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; -@Slf4j @Component +@RequiredArgsConstructor public class McpServerImpl implements McpServerRegistry { + private final ToolGatewayService toolGatewayService; + private final ToolCatalog toolCatalog; + @Override public List listTools() { - log.debug("Listing available MCP tools (stub)"); + return toolGatewayService.discover(null); + } - Map queryParams = new HashMap<>(); - queryParams.put("type", "object"); - queryParams.put("properties", Collections.singletonMap("query", - Collections.singletonMap("type", "string"))); + @Override + public List listTools(String clusterId) { + return toolGatewayService.discover(clusterId); + } - Map brokerParams = new HashMap<>(); - brokerParams.put("type", "object"); - brokerParams.put("properties", Collections.singletonMap("brokerName", - Collections.singletonMap("type", "string"))); + @Override + public Object execute(String name, Map input) { + return toolGatewayService.execute(name, input); + } - return Arrays.asList( - AiToolVO.builder() - .name("query_metrics") - .description("Query RocketMQ metrics from Prometheus") - .parameters(queryParams) - .build(), - AiToolVO.builder() - .name("list_brokers") - .description("List all RocketMQ brokers in the cluster") - .parameters(Collections.emptyMap()) - .build(), - AiToolVO.builder() - .name("diagnose_broker") - .description("Diagnose issues with a specific broker") - .parameters(brokerParams) - .build() - ); + @Override + public String catalogVersion() { + return toolCatalog.getVersion(); + } + + @Override + public String catalogDigest() { + return toolCatalog.getDigest(); + } + + @Override + public String minimumClientVersion() { + return toolCatalog.getMinimumClientVersion(); } } diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java index 24b5fff4..8dfcb81e 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/McpServerRegistry.java @@ -18,8 +18,19 @@ import java.util.List; +import java.util.Map; public interface McpServerRegistry { List listTools(); + + List listTools(String clusterId); + + Object execute(String name, Map input); + + String catalogVersion(); + + String catalogDigest(); + + String minimumClientVersion(); } diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.java new file mode 100644 index 00000000..706087cd --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilitiesToolHandler.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.ops.ai.tool; + +import com.rocketmq.studio.cluster.broker.ClusterService; +import com.rocketmq.studio.cluster.broker.ClusterVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class CapabilitiesToolHandler implements ToolHandler { + + private static final String NAME = "rmq.capabilities"; + + private final ClusterService clusterService; + private final CapabilityResolver capabilityResolver; + + @Override + public String name() { + return NAME; + } + + @Override + public Object execute(Map input) { + String clusterId = (String) input.get("cluster"); + ClusterVO cluster = clusterService.getCluster(clusterId); + List capabilities = capabilityResolver.resolve(cluster); + + Map result = new LinkedHashMap<>(); + result.put("cluster", cluster.getId()); + result.put("type", cluster.getType().name()); + result.put("version", cluster.getVersion()); + result.put("capabilities", capabilities); + return result; + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilityResolver.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilityResolver.java new file mode 100644 index 00000000..58254984 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/CapabilityResolver.java @@ -0,0 +1,64 @@ +/* + * 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.ops.ai.tool; + +import com.rocketmq.studio.cluster.broker.ClusterService; +import com.rocketmq.studio.cluster.broker.ClusterVO; +import com.rocketmq.studio.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class CapabilityResolver { + + private final ClusterService clusterService; + + public List resolve(String clusterId) { + return resolve(clusterService.getCluster(clusterId)); + } + + List resolve(ClusterVO cluster) { + if (cluster.getType() == null) { + throw new BusinessException( + 400, "Cluster type is unavailable: " + cluster.getId()); + } + return switch (cluster.getType()) { + case V4_DIRECT -> List.of( + "REMOTING", + "ROCKETMQ_4"); + case V5_PROXY_LOCAL -> List.of( + "ACL_V2", + "GRPC", + "LITE_TOPIC", + "LOCAL_PROXY", + "POP", + "REMOTING", + "ROCKETMQ_5"); + case V5_PROXY_CLUSTER -> List.of( + "ACL_V2", + "CLUSTER_PROXY", + "GRPC", + "LITE_TOPIC", + "POP", + "REMOTING", + "ROCKETMQ_5"); + }; + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java new file mode 100644 index 00000000..47c313f5 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ClusterListToolHandler.java @@ -0,0 +1,64 @@ +/* + * 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.ops.ai.tool; + +import com.rocketmq.studio.cluster.broker.ClusterService; +import com.rocketmq.studio.cluster.broker.ClusterVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class ClusterListToolHandler implements ToolHandler { + + private static final String NAME = "rmq.cluster.list"; + + private final ClusterService clusterService; + + @Override + public String name() { + return NAME; + } + + @Override + public Object execute(Map input) { + return clusterService.listClusters().stream() + .map(ClusterListToolHandler::safeProjection) + .toList(); + } + + private static Map safeProjection(ClusterVO cluster) { + Map result = new LinkedHashMap<>(); + result.put("id", cluster.getId()); + result.put("name", cluster.getName()); + result.put("type", requiredEnumName(cluster.getType(), "type", cluster.getId())); + result.put("status", requiredEnumName(cluster.getStatus(), "status", cluster.getId())); + result.put("version", cluster.getVersion()); + return result; + } + + private static String requiredEnumName(Enum value, String field, String clusterId) { + if (value == null) { + throw new IllegalStateException( + "Cluster " + field + " is unavailable: " + clusterId); + } + return value.name(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolCatalog.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolCatalog.java new file mode 100644 index 00000000..3e81e983 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolCatalog.java @@ -0,0 +1,183 @@ +/* + * 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.ops.ai.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.networknt.schema.Error; +import com.networknt.schema.InputFormat; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +public class ToolCatalog { + + static final String CATALOG_RESOURCE = "classpath:tool-catalog/rmq-tools.yaml"; + static final String SCHEMA_RESOURCE = "classpath:tool-catalog/rmq-tools.schema.json"; + + private static final String CLUSTER_LIST_TOOL = "rmq.cluster.list"; + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + private final String version; + private final String minimumClientVersion; + private final String digest; + private final List definitions; + private final Map definitionsByName; + + @Autowired + public ToolCatalog(ResourceLoader resourceLoader) { + ToolCatalog loaded = load( + resourceLoader.getResource(CATALOG_RESOURCE), + resourceLoader.getResource(SCHEMA_RESOURCE)); + this.version = loaded.version; + this.minimumClientVersion = loaded.minimumClientVersion; + this.digest = loaded.digest; + this.definitions = loaded.definitions; + this.definitionsByName = loaded.definitionsByName; + } + + private ToolCatalog( + String version, + String minimumClientVersion, + String digest, + List definitions, + Map definitionsByName) { + this.version = version; + this.minimumClientVersion = minimumClientVersion; + this.digest = digest; + this.definitions = definitions; + this.definitionsByName = definitionsByName; + } + + static ToolCatalog load(Resource catalogResource, Resource schemaResource) { + try { + byte[] catalogBytes = catalogResource.getContentAsByteArray(); + String catalogYaml = new String(catalogBytes, StandardCharsets.UTF_8); + String schemaJson = schemaResource.getContentAsString(StandardCharsets.UTF_8); + + SchemaRegistry registry = SchemaRegistry.withDefaultDialect( + SpecificationVersion.DRAFT_2020_12); + Schema schema = registry.getSchema(schemaJson, InputFormat.JSON); + List validationErrors = new ArrayList<>( + schema.validate(catalogYaml, InputFormat.YAML)); + if (!validationErrors.isEmpty()) { + validationErrors.sort(Comparator.comparing( + error -> error.getInstanceLocation().toString())); + throw new IllegalStateException( + "Tool catalog schema validation failed: " + validationErrors); + } + + CatalogDocument document = YAML_MAPPER.readValue(catalogBytes, CatalogDocument.class); + return validatedCatalog(document, sha256(catalogBytes)); + } catch (IOException e) { + throw new IllegalStateException("Unable to load RocketMQ tool catalog", e); + } + } + + private static ToolCatalog validatedCatalog(CatalogDocument document, String digest) { + int catalogMajor = majorVersion(document.version()); + int minimumClientMajor = majorVersion(document.minimumClientVersion()); + if (catalogMajor != minimumClientMajor) { + throw new IllegalStateException( + "Catalog and minimum client major versions must match"); + } + + Map byName = new LinkedHashMap<>(); + for (ToolDefinition definition : document.tools()) { + if (byName.putIfAbsent(definition.name(), definition) != null) { + throw new IllegalStateException( + "Tool catalog contains duplicate tool name: " + definition.name()); + } + validateClusterConvention(definition); + } + + List immutableDefinitions = List.copyOf(byName.values()); + return new ToolCatalog( + document.version(), + document.minimumClientVersion(), + digest, + immutableDefinitions, + Map.copyOf(byName)); + } + + private static void validateClusterConvention(ToolDefinition definition) { + if (CLUSTER_LIST_TOOL.equals(definition.name())) { + return; + } + Object required = definition.inputSchema().get("required"); + if (!(required instanceof List requiredFields) || !requiredFields.contains("cluster")) { + throw new IllegalStateException( + "Remote tool must require cluster: " + definition.name()); + } + } + + private static int majorVersion(String version) { + return Integer.parseInt(version.substring(0, version.indexOf('.'))); + } + + private static String sha256(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + public String getVersion() { + return version; + } + + public String getMinimumClientVersion() { + return minimumClientVersion; + } + + public String getDigest() { + return digest; + } + + public List list() { + return definitions; + } + + public Optional find(String name) { + return Optional.ofNullable(definitionsByName.get(name)); + } + + private record CatalogDocument( + String version, + String minimumClientVersion, + List tools) { + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolDefinition.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolDefinition.java new file mode 100644 index 00000000..493e02ed --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolDefinition.java @@ -0,0 +1,75 @@ +/* + * 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.ops.ai.tool; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public record ToolDefinition( + String name, + Cli cli, + String description, + String riskLevel, + String permission, + List requiredCapabilities, + Map inputSchema, + Map outputSchema, + String viewHint, + boolean deprecated, + String replacement) { + + public ToolDefinition { + requiredCapabilities = List.copyOf(requiredCapabilities); + inputSchema = immutableMap(inputSchema); + outputSchema = immutableMap(outputSchema); + } + + public String getName() { + return name; + } + + private static Map immutableMap(Map source) { + Map copy = new LinkedHashMap<>(); + source.forEach((key, value) -> copy.put(key, immutableValue(value))); + return Collections.unmodifiableMap(copy); + } + + private static Object immutableValue(Object value) { + if (value instanceof Map nestedMap) { + Map copy = new LinkedHashMap<>(); + nestedMap.forEach((key, nestedValue) -> { + if (!(key instanceof String stringKey)) { + throw new IllegalArgumentException("JSON Schema keys must be strings"); + } + copy.put(stringKey, immutableValue(nestedValue)); + }); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List nestedList) { + List copy = new ArrayList<>(nestedList.size()); + nestedList.forEach(item -> copy.add(immutableValue(item))); + return Collections.unmodifiableList(copy); + } + return value; + } + + public record Cli(String resource, String verb) { + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayService.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayService.java new file mode 100644 index 00000000..c8de44b9 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayService.java @@ -0,0 +1,224 @@ +/* + * 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.ops.ai.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.Error; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import com.networknt.schema.dialect.Dialects; +import com.rocketmq.studio.common.exception.BusinessException; +import com.rocketmq.studio.ops.ai.AiToolVO; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class ToolGatewayService { + + private static final String L1 = "L1"; + + private final ToolCatalog catalog; + private final CapabilityResolver capabilityResolver; + private final ObjectMapper objectMapper; + private final Map handlers; + private final Map inputSchemas; + private final Map outputSchemas; + + public ToolGatewayService( + ToolCatalog catalog, + CapabilityResolver capabilityResolver, + ObjectMapper objectMapper, + List handlers) { + this.catalog = catalog; + this.capabilityResolver = capabilityResolver; + this.objectMapper = objectMapper; + this.handlers = registerHandlers(catalog, handlers); + + SchemaRegistry registry = SchemaRegistry.withDefaultDialect( + SpecificationVersion.DRAFT_2020_12); + SchemaRegistry metaSchemaRegistry = SchemaRegistry.withDialect( + Dialects.getDraft202012()); + Schema metaSchema = metaSchemaRegistry.getSchema( + SchemaLocation.of(Dialects.getDraft202012().getId())); + this.inputSchemas = compileSchemas(catalog, registry, metaSchema, true); + this.outputSchemas = compileSchemas(catalog, registry, metaSchema, false); + } + + public List discover(String clusterId) { + boolean clusterSelected = clusterId != null && !clusterId.isBlank(); + Set capabilities = clusterSelected + ? Set.copyOf(capabilityResolver.resolve(clusterId)) + : Collections.emptySet(); + + return catalog.list().stream() + .filter(definition -> clusterSelected || !requiresCluster(definition)) + .filter(definition -> capabilities.containsAll( + definition.requiredCapabilities())) + .map(ToolGatewayService::toView) + .toList(); + } + + public Object execute(String name, Map input) { + ToolDefinition definition = catalog.find(name) + .orElseThrow(() -> new BusinessException(404, "Tool not found: " + name)); + ToolHandler handler = handlers.get(name); + Map normalizedInput = input == null + ? Collections.emptyMap() + : input; + + validateInput(definition, normalizedInput); + if (!L1.equals(definition.riskLevel())) { + throw new BusinessException( + 400, "Execution rejected; only L1 tools are enabled: " + name); + } + enforceCapabilities(definition, normalizedInput); + + Object output = handler.execute(normalizedInput); + validateOutput(definition, output); + return output; + } + + private void validateInput(ToolDefinition definition, Map input) { + List errors = sortedErrors( + inputSchemas.get(definition.name()).validate(objectMapper.valueToTree(input))); + if (!errors.isEmpty()) { + throw new BusinessException( + 400, + "Tool input validation failed for " + definition.name() + ": " + errors); + } + } + + private void enforceCapabilities( + ToolDefinition definition, + Map input) { + if (definition.requiredCapabilities().isEmpty()) { + return; + } + Object cluster = input.get("cluster"); + if (!(cluster instanceof String clusterId) || clusterId.isBlank()) { + throw new BusinessException( + 400, "Tool requires a cluster for capability checks: " + definition.name()); + } + Set capabilities = Set.copyOf(capabilityResolver.resolve(clusterId)); + if (!capabilities.containsAll(definition.requiredCapabilities())) { + throw new BusinessException( + 400, "Cluster does not support tool: " + definition.name()); + } + } + + private void validateOutput(ToolDefinition definition, Object output) { + JsonNode outputNode = objectMapper.valueToTree(output); + List errors = sortedErrors( + outputSchemas.get(definition.name()).validate(outputNode)); + if (!errors.isEmpty()) { + throw new IllegalStateException( + "Tool output validation failed for " + definition.name() + ": " + errors); + } + } + + private static Map registerHandlers( + ToolCatalog catalog, + List handlers) { + Map registered = new LinkedHashMap<>(); + for (ToolHandler handler : handlers) { + if (registered.putIfAbsent(handler.name(), handler) != null) { + throw new IllegalStateException( + "Tool gateway contains duplicate handler: " + handler.name()); + } + if (catalog.find(handler.name()).isEmpty()) { + throw new IllegalStateException( + "Tool handler is absent from catalog: " + handler.name()); + } + } + + List missing = catalog.list().stream() + .map(ToolDefinition::name) + .filter(name -> !registered.containsKey(name)) + .toList(); + if (!missing.isEmpty()) { + throw new IllegalStateException( + "Tool catalog contains missing handler: " + missing); + } + return Collections.unmodifiableMap(registered); + } + + private Map compileSchemas( + ToolCatalog catalog, + SchemaRegistry registry, + Schema metaSchema, + boolean input) { + String schemaKind = input ? "input" : "output"; + Map compiled = new LinkedHashMap<>(); + for (ToolDefinition definition : catalog.list()) { + JsonNode schemaNode = objectMapper.valueToTree( + input ? definition.inputSchema() : definition.outputSchema()); + List metaSchemaErrors = sortedErrors(metaSchema.validate(schemaNode)); + if (!metaSchemaErrors.isEmpty()) { + throw new IllegalStateException( + "Tool " + schemaKind + " schema is invalid for " + + definition.name() + ": " + metaSchemaErrors); + } + + try { + Schema schema = registry.getSchema(schemaNode); + schema.initializeValidators(); + compiled.put(definition.name(), schema); + } catch (RuntimeException ex) { + throw new IllegalStateException( + "Tool " + schemaKind + " schema is invalid for " + + definition.name(), ex); + } + } + return Collections.unmodifiableMap(compiled); + } + + private static boolean requiresCluster(ToolDefinition definition) { + Object required = definition.inputSchema().get("required"); + return required instanceof List fields && fields.contains("cluster"); + } + + private static AiToolVO toView(ToolDefinition definition) { + return AiToolVO.builder() + .name(definition.name()) + .description(definition.description()) + .parameters(definition.inputSchema()) + .riskLevel(definition.riskLevel()) + .permission(definition.permission()) + .requiredCapabilities(definition.requiredCapabilities()) + .outputSchema(definition.outputSchema()) + .viewHint(definition.viewHint()) + .deprecated(definition.deprecated()) + .replacement(definition.replacement()) + .build(); + } + + private static List sortedErrors(List errors) { + List sorted = new ArrayList<>(errors); + sorted.sort(Comparator.comparing(error -> error.getInstanceLocation().toString())); + return sorted; + } +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolHandler.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolHandler.java new file mode 100644 index 00000000..3a6f8af2 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/ToolHandler.java @@ -0,0 +1,26 @@ +/* + * 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.ops.ai.tool; + +import java.util.Map; + +public interface ToolHandler { + + String name(); + + Object execute(Map input); +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/alert/AlertRulesYamlVO.java b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertRulesYamlVO.java new file mode 100644 index 00000000..472b6a57 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertRulesYamlVO.java @@ -0,0 +1,28 @@ +/* + * 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.ops.alert; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AlertRulesYamlVO { + private String rules; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java index 7336481e..9de4f2c5 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java +++ b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java @@ -20,6 +20,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -36,6 +37,32 @@ public List listRules() { return alertRepository.findAllRules(); } + public String exportPrometheusRulesYaml() { + List rules = alertRepository.findAllRules(); + List prometheusRules = rules.isEmpty() + ? defaultPrometheusRules() + : rules.stream().map(this::toPrometheusRule).toList(); + + StringBuilder yaml = new StringBuilder(); + yaml.append("groups:\n"); + int index = 1; + for (PrometheusAlertRule rule : prometheusRules) { + yaml.append(" - name: ").append(rule.group()).append('\n'); + yaml.append(" rules:\n"); + yaml.append(" # Rule ").append(index++).append(": ").append(rule.alert()).append('\n'); + yaml.append(" - alert: ").append(rule.alert()).append('\n'); + yaml.append(" expr: ").append(rule.expr()).append('\n'); + yaml.append(" for: ").append(rule.duration()).append('\n'); + yaml.append(" labels:\n"); + yaml.append(" severity: ").append(rule.severity()).append('\n'); + yaml.append(" team: ").append(rule.team()).append('\n'); + yaml.append(" annotations:\n"); + yaml.append(" summary: \"").append(escapeYaml(rule.summary())).append("\"\n"); + yaml.append(" description: \"").append(escapeYaml(rule.description())).append("\"\n"); + } + return yaml.toString(); + } + public AlertRuleVO createRule(AlertRuleVO rule) { log.info("Creating alert rule: {}", rule.getName()); @@ -90,4 +117,132 @@ public int clearAcknowledged() { log.info("Clearing acknowledged system alerts"); return alertRepository.deleteAcknowledgedAlerts(); } + + private List defaultPrometheusRules() { + List rules = new ArrayList<>(); + rules.add(rule("rocketmq-broker.rules", "RocketMQBrokerDown", "up{job=~\".*rocketmq.*\"} == 0", "1m", + "critical", "broker", "RocketMQ broker is down", + "A RocketMQ broker scrape target has been unavailable for more than 1 minute.")); + rules.add(rule("rocketmq-consumer.rules", "RocketMQConsumerLagHigh", + "rocketmq_consumer_lag_messages > 100000", "5m", + "warning", "consumer", "Consumer lag is high", + "A consumer group has accumulated more than 100000 messages.")); + rules.add(rule("rocketmq-consumer.rules", "RocketMQConsumerLagCritical", + "rocketmq_consumer_lag_messages > 1000000", "5m", + "critical", "consumer", "Consumer lag is critical", + "A consumer group has accumulated more than 1000000 messages.")); + rules.add(rule("rocketmq-client.rules", "RocketMQProducerSendLatencyHigh", + "rocketmq_producer_send_to_back_rt > 1000", "5m", + "warning", "client", "Producer send latency is high", + "Producer send-to-broker latency has stayed above 1000 ms.")); + rules.add(rule("rocketmq-broker.rules", "RocketMQProcessorWatermarkHigh", + "rocketmq_processor_watermark > 80", "5m", + "warning", "broker", "Processor watermark is high", + "Broker processor watermark is above 80 percent.")); + rules.add(rule("rocketmq-topic.rules", "RocketMQMessageInDrop", + "rate(rocketmq_messages_in_total[5m]) == 0", "10m", + "info", "topic", "No incoming messages", + "No incoming messages have been observed for 10 minutes.")); + rules.add(rule("rocketmq-consumer.rules", "RocketMQMessageOutDrop", + "rate(rocketmq_messages_out_total[5m]) == 0", "10m", + "info", "consumer", "No outgoing messages", + "No outgoing messages have been observed for 10 minutes.")); + return rules; + } + + private PrometheusAlertRule rule(String group, String alert, String expr, String duration, + String severity, String team, String summary, String description) { + return new PrometheusAlertRule(group, alert, expr, duration, severity, team, summary, description); + } + + private PrometheusAlertRule toPrometheusRule(AlertRuleVO rule) { + String team = inferTeam(rule.getMetric()); + return new PrometheusAlertRule( + groupName(team), + alertName(rule), + expression(rule), + duration(rule), + "warning", + team, + summary(rule), + description(rule)); + } + + private String groupName(String team) { + if ("client".equals(team)) { + return "rocketmq-client.rules"; + } + if ("consumer".equals(team)) { + return "rocketmq-consumer.rules"; + } + if ("topic".equals(team)) { + return "rocketmq-topic.rules"; + } + return "rocketmq-broker.rules"; + } + + private String alertName(AlertRuleVO rule) { + return hasText(rule.getName()) ? rule.getName().replaceAll("[^A-Za-z0-9_]", "") : "RocketMQAlert"; + } + + private String expression(AlertRuleVO rule) { + String metric = hasText(rule.getMetric()) ? rule.getMetric() : "rocketmq_consumer_lag_messages"; + String operator = hasText(rule.getOperator()) ? rule.getOperator() : ">"; + return metric + " " + operator + " " + formatThreshold(rule.getThreshold()); + } + + private String formatThreshold(double threshold) { + if (threshold == Math.rint(threshold)) { + return Long.toString((long) threshold); + } + return Double.toString(threshold); + } + + private String duration(AlertRuleVO rule) { + return hasText(rule.getDuration()) ? rule.getDuration() : "5m"; + } + + private String inferTeam(String metric) { + if (!hasText(metric)) { + return "broker"; + } + if (metric.contains("consumer") || metric.contains("lag")) { + return "consumer"; + } + if (metric.contains("producer") || metric.contains("client")) { + return "client"; + } + if (metric.contains("topic") || metric.contains("messages_in") || metric.contains("messages_out")) { + return "topic"; + } + return "broker"; + } + + private String summary(AlertRuleVO rule) { + String description = rule.getDescription(); + if (hasText(description) && description.contains(" - ")) { + return description.substring(0, description.indexOf(" - ")); + } + return hasText(rule.getName()) ? rule.getName() : "RocketMQ alert"; + } + + private String description(AlertRuleVO rule) { + String description = rule.getDescription(); + if (hasText(description) && description.contains(" - ")) { + return description.substring(description.indexOf(" - ") + 3); + } + return hasText(description) ? description : "RocketMQ alert condition matched."; + } + + private String escapeYaml(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private record PrometheusAlertRule(String group, String alert, String expr, String duration, + String severity, String team, String summary, String description) { + } } diff --git a/server/src/main/java/com/rocketmq/studio/ops/alert/LegacyAlertController.java b/server/src/main/java/com/rocketmq/studio/ops/alert/LegacyAlertController.java new file mode 100644 index 00000000..88d24d80 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/alert/LegacyAlertController.java @@ -0,0 +1,36 @@ +/* + * 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.ops.alert; + +import com.rocketmq.studio.common.domain.Result; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/alert") +@RequiredArgsConstructor +public class LegacyAlertController { + + private final AlertService alertService; + + @GetMapping("/rules") + public Result getRules() { + return Result.ok(new AlertRulesYamlVO(alertService.exportPrometheusRulesYaml())); + } +} 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/main/java/com/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java new file mode 100644 index 00000000..fbfea401 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java @@ -0,0 +1,73 @@ +/* + * 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.settings; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GeneralSettingsUpdateDTO { + @NotBlank + private String theme; + @NotNull + private Boolean compact; + @NotNull + private Boolean desktopNotify; + @NotNull + private Boolean notifySound; + @NotNull + @Min(5) + @Max(1440) + private Integer sessionTimeout; + @NotNull + private Boolean requireLogin; + @NotBlank + private String llmProvider; + @ToString.Exclude + private String apiKey; + private boolean clearApiKey; + @NotBlank + private String model; + @NotNull + private String baseUrl; + + public GeneralSettingsVO toSettings() { + return GeneralSettingsVO.builder() + .theme(theme) + .compact(compact) + .desktopNotify(desktopNotify) + .notifySound(notifySound) + .sessionTimeout(sessionTimeout) + .requireLogin(requireLogin) + .llmProvider(llmProvider) + .apiKey(apiKey) + .clearApiKey(clearApiKey) + .model(model) + .baseUrl(baseUrl) + .build(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java index 8dd3bd6e..bfb60833 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java +++ b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java @@ -16,10 +16,13 @@ */ package com.rocketmq.studio.settings; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; +import org.springframework.util.StringUtils; @Data @Builder @@ -33,7 +36,16 @@ public class GeneralSettingsVO { private int sessionTimeout; private boolean requireLogin; private String llmProvider; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @ToString.Exclude private String apiKey; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private boolean clearApiKey; private String model; private String baseUrl; + + @JsonProperty(value = "apiKeyConfigured", access = JsonProperty.Access.READ_ONLY) + public boolean isApiKeyConfigured() { + return StringUtils.hasText(apiKey); + } } diff --git a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java index a9a91234..a84923de 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java +++ b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java @@ -17,6 +17,7 @@ package com.rocketmq.studio.settings; import com.rocketmq.studio.common.domain.Result; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -40,8 +41,8 @@ public Result getGeneralSettings() { } @PostMapping("/general/save") - public Result saveGeneralSettings(@RequestBody GeneralSettingsVO settings) { - settingsService.saveGeneralSettings(settings); + public Result saveGeneralSettings(@Valid @RequestBody GeneralSettingsUpdateDTO request) { + settingsService.saveGeneralSettings(request.toSettings()); return Result.ok(); } diff --git a/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java b/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java index 1f128769..2c2db875 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java +++ b/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java @@ -19,8 +19,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.util.List; +import java.util.UUID; @Slf4j @Service @@ -36,8 +38,15 @@ public GeneralSettingsVO getGeneralSettings() { } - public void saveGeneralSettings(GeneralSettingsVO settings) { + public synchronized void saveGeneralSettings(GeneralSettingsVO settings) { log.info("Saving general settings"); + GeneralSettingsVO currentSettings = settingsRepository.loadGeneralSettings(); + if (settings.isClearApiKey()) { + settings.setApiKey(""); + } else if (!StringUtils.hasText(settings.getApiKey()) && currentSettings != null) { + settings.setApiKey(currentSettings.getApiKey()); + } + settings.setClearApiKey(false); settingsRepository.saveGeneralSettings(settings); } @@ -50,6 +59,7 @@ public List listDataSources() { public DataSourceVO createDataSource(DataSourceVO dataSource) { log.info("Creating data source: {}", dataSource.getName()); + dataSource.setKey(UUID.randomUUID().toString()); return settingsRepository.saveDataSource(dataSource); } 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/main/resources/tool-catalog/rmq-tools.schema.json b/server/src/main/resources/tool-catalog/rmq-tools.schema.json new file mode 100644 index 00000000..f192ffc0 --- /dev/null +++ b/server/src/main/resources/tool-catalog/rmq-tools.schema.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://rocketmq.apache.org/schemas/rmq-tools.schema.json", + "title": "RocketMQ Studio tool catalog", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "minimumClientVersion", + "tools" + ], + "properties": { + "version": { + "$ref": "#/$defs/semanticVersion" + }, + "minimumClientVersion": { + "$ref": "#/$defs/semanticVersion" + }, + "tools": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/tool" + } + } + }, + "$defs": { + "semanticVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "toolName": { + "type": "string", + "pattern": "^rmq\\.[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$" + }, + "cli": { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "verb" + ], + "properties": { + "resource": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "verb": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + } + }, + "tool": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "cli", + "description", + "riskLevel", + "permission", + "requiredCapabilities", + "inputSchema", + "outputSchema", + "viewHint", + "deprecated" + ], + "properties": { + "name": { + "$ref": "#/$defs/toolName" + }, + "cli": { + "$ref": "#/$defs/cli" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "riskLevel": { + "enum": [ + "L1", + "L2", + "L3" + ] + }, + "permission": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$" + }, + "requiredCapabilities": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + }, + "inputSchema": { + "type": "object" + }, + "outputSchema": { + "type": "object" + }, + "viewHint": { + "enum": [ + "object", + "table", + "text", + "timeline", + "topology" + ] + }, + "deprecated": { + "type": "boolean" + }, + "replacement": { + "$ref": "#/$defs/toolName" + } + }, + "allOf": [ + { + "if": { + "required": [ + "replacement" + ] + }, + "then": { + "properties": { + "deprecated": { + "const": true + } + } + } + } + ] + } + } +} diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml b/server/src/main/resources/tool-catalog/rmq-tools.yaml new file mode 100644 index 00000000..4327667c --- /dev/null +++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml @@ -0,0 +1,76 @@ +version: 1.0.0 +minimumClientVersion: 1.0.0 +tools: + - name: rmq.cluster.list + cli: + resource: cluster + verb: list + description: List RocketMQ clusters available in Studio. + riskLevel: L1 + permission: cluster:read + requiredCapabilities: [] + inputSchema: + type: object + additionalProperties: false + outputSchema: + type: array + items: + type: object + required: + - id + - name + - type + - status + - version + additionalProperties: false + properties: + id: + type: string + name: + type: string + type: + type: string + status: + type: string + version: + type: string + viewHint: table + deprecated: false + - name: rmq.capabilities + cli: + resource: capabilities + verb: get + description: Describe the capabilities of one RocketMQ cluster. + riskLevel: L1 + permission: cluster:read + requiredCapabilities: [] + inputSchema: + type: object + required: + - cluster + additionalProperties: false + properties: + cluster: + type: string + minLength: 1 + outputSchema: + type: object + required: + - cluster + - type + - version + - capabilities + additionalProperties: false + properties: + cluster: + type: string + type: + type: string + version: + type: string + capabilities: + type: array + items: + type: string + viewHint: object + deprecated: false diff --git a/server/src/test/java/com/rocketmq/studio/StudioApplicationTest.java b/server/src/test/java/com/rocketmq/studio/StudioApplicationTest.java new file mode 100644 index 00000000..3f67d482 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/StudioApplicationTest.java @@ -0,0 +1,41 @@ +/* + * 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; + +import com.rocketmq.studio.ops.ai.tool.ToolCatalog; +import com.rocketmq.studio.ops.ai.tool.ToolGatewayService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class StudioApplicationTest { + + @Autowired + private ToolCatalog toolCatalog; + + @Autowired + private ToolGatewayService toolGatewayService; + + @Test + void applicationContextLoadsWithToolGateway() { + assertThat(toolCatalog.getVersion()).isEqualTo("1.0.0"); + assertThat(toolGatewayService.discover(null)).isNotEmpty(); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/cluster/client/ProducerConnectionServiceTest.java b/server/src/test/java/com/rocketmq/studio/cluster/client/ProducerConnectionServiceTest.java new file mode 100644 index 00000000..c3d700f1 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/cluster/client/ProducerConnectionServiceTest.java @@ -0,0 +1,111 @@ +/* + * 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.client; + +import com.rocketmq.studio.common.domain.enums.ClientLanguage; +import com.rocketmq.studio.common.domain.enums.ClientType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProducerConnectionServiceTest { + + @Mock + private ClientService clientService; + + @InjectMocks + private ProducerConnectionService producerConnectionService; + + @Test + void listConnectionsShouldProjectProducerClientsByTopic() { + ClientConnectionVO producer = ClientConnectionVO.builder() + .clientId("producer-1") + .type(ClientType.Producer) + .groupOrTopic("order-topic") + .producerGroup("pg-order") + .address("10.0.0.1:38888") + .language(ClientLanguage.Java) + .version("5.1.0") + .build(); + ClientConnectionVO otherProducer = ClientConnectionVO.builder() + .clientId("producer-2") + .type(ClientType.Producer) + .groupOrTopic("payment-topic") + .producerGroup("pg-payment") + .address("10.0.0.2:38888") + .language(ClientLanguage.Go) + .version("5.0.0") + .build(); + when(clientService.listConnections(null, ClientType.Producer.name())) + .thenReturn(List.of(producer, otherProducer)); + + List result = producerConnectionService.listConnections("order-topic", "pg-order"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getClientId()).isEqualTo("producer-1"); + assertThat(result.get(0).getClientAddr()).isEqualTo("10.0.0.1:38888"); + assertThat(result.get(0).getLanguage()).isEqualTo("Java"); + assertThat(result.get(0).getVersionDesc()).isEqualTo("5.1.0"); + verify(clientService).listConnections(null, ClientType.Producer.name()); + } + + @Test + void listConnectionsShouldFallbackToProducerGroupWhenTopicIsMissing() { + ClientConnectionVO producer = ClientConnectionVO.builder() + .clientId("producer-1") + .type(ClientType.Producer) + .groupOrTopic("order-topic") + .producerGroup("pg-order") + .address("10.0.0.1:38888") + .language(ClientLanguage.Java) + .version("5.1.0") + .build(); + when(clientService.listConnections(null, ClientType.Producer.name())).thenReturn(List.of(producer)); + + List result = producerConnectionService.listConnections(null, "pg-order"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getClientId()).isEqualTo("producer-1"); + } + + @Test + void listConnectionsShouldRequireProducerGroupWhenBothFiltersAreProvided() { + ClientConnectionVO producer = ClientConnectionVO.builder() + .clientId("producer-1") + .type(ClientType.Producer) + .groupOrTopic("order-topic") + .producerGroup("pg-order") + .address("10.0.0.1:38888") + .language(ClientLanguage.Java) + .version("5.1.0") + .build(); + when(clientService.listConnections(null, ClientType.Producer.name())).thenReturn(List.of(producer)); + + List result = producerConnectionService.listConnections("order-topic", "wrong-group"); + + assertThat(result).isEmpty(); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/cluster/client/ProducerControllerTest.java b/server/src/test/java/com/rocketmq/studio/cluster/client/ProducerControllerTest.java new file mode 100644 index 00000000..c05cf3b9 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/cluster/client/ProducerControllerTest.java @@ -0,0 +1,67 @@ +/* + * 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.client; + +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.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ProducerController.class) +@AutoConfigureMockMvc(addFilters = false) +class ProducerControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ProducerConnectionService producerConnectionService; + + @Test + void listConnectionsShouldReturnLegacyConnectionSetPayload() throws Exception { + ProducerConnectionVO connection = ProducerConnectionVO.builder() + .clientId("producer-1") + .clientAddr("10.0.0.1:38888") + .language("Java") + .versionDesc("5.1.0") + .build(); + when(producerConnectionService.listConnections("order-topic", "pg-order")) + .thenReturn(List.of(connection)); + + mockMvc.perform(get("/api/producer/connection") + .param("topic", "order-topic") + .param("producerGroup", "pg-order")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.connectionSet").isArray()) + .andExpect(jsonPath("$.connectionSet[0].clientId").value("producer-1")) + .andExpect(jsonPath("$.connectionSet[0].clientAddr").value("10.0.0.1:38888")) + .andExpect(jsonPath("$.connectionSet[0].language").value("Java")) + .andExpect(jsonPath("$.connectionSet[0].versionDesc").value("5.1.0")); + + verify(producerConnectionService).listConnections("order-topic", "pg-order"); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java b/server/src/test/java/com/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java index d886c0e6..525e832e 100644 --- a/server/src/test/java/com/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java @@ -65,6 +65,8 @@ void setUp() { .san(Arrays.asList("rocketmq.example.com", "*.rocketmq.example.com")) .build(); sampleCert.setId("cert-1"); + sampleCert.setCreatedAt(LocalDateTime.of(2024, 12, 1, 0, 0)); + sampleCert.setUpdatedAt(LocalDateTime.of(2025, 1, 2, 0, 0)); } @Test @@ -175,7 +177,13 @@ void updateCertShouldUpdateFieldsWhenFound() { assertThat(result.getType()).isEqualTo(CertType.mTLS); assertThat(result.getIssuer()).isEqualTo("new-issuer"); assertThat(result.getSan()).containsExactly("new.example.com"); - assertThat(result.getUpdatedAt()).isNotNull(); + assertThat(result.getId()).isEqualTo("cert-1"); + assertThat(result.getCreatedAt()).isEqualTo(LocalDateTime.of(2024, 12, 1, 0, 0)); + assertThat(result.getUpdatedAt()).isAfter(sampleCert.getUpdatedAt()); + assertThat(result).isNotSameAs(sampleCert); + assertThat(sampleCert.getName()).isEqualTo("rocketmq-tls"); + assertThat(sampleCert.getType()).isEqualTo(CertType.TLS); + assertThat(sampleCert.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 1, 2, 0, 0)); verify(k8sCertRepository).save(any(K8sCertVO.class)); } @@ -213,10 +221,31 @@ void updateCertShouldThrowWhenNotFound() { .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(404)); } + @Test + void updateCertShouldNotMutateStoredCertWhenSaveFails() { + when(k8sCertRepository.findById("cert-1")).thenReturn(Optional.of(sampleCert)); + when(k8sCertRepository.save(any(K8sCertVO.class))).thenThrow(new IllegalStateException("save failed")); + UpdateCertDTO command = UpdateCertDTO.builder() + .id("cert-1") + .name("should-not-persist") + .type("mTLS") + .build(); + + assertThatThrownBy(() -> k8sCertService.updateCert(command)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("save failed"); + + assertThat(sampleCert.getName()).isEqualTo("rocketmq-tls"); + assertThat(sampleCert.getType()).isEqualTo(CertType.TLS); + assertThat(sampleCert.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 1, 2, 0, 0)); + } + @Test void renewCertShouldRenewCertValidity() { sampleCert.setStatus(CertStatus.expired); sampleCert.setDaysRemaining(0); + LocalDateTime originalNotBefore = sampleCert.getNotBefore(); + LocalDateTime originalNotAfter = sampleCert.getNotAfter(); when(k8sCertRepository.findById("cert-1")).thenReturn(Optional.of(sampleCert)); when(k8sCertRepository.save(any(K8sCertVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -230,9 +259,39 @@ void renewCertShouldRenewCertValidity() { assertThat(result.getNotBefore()).isNotNull(); assertThat(result.getNotAfter()).isAfter(result.getNotBefore()); assertThat(result.getUpdatedAt()).isNotNull(); + assertThat(result.getId()).isEqualTo("cert-1"); + assertThat(result.getCreatedAt()).isEqualTo(sampleCert.getCreatedAt()); + assertThat(result).isNotSameAs(sampleCert); + assertThat(sampleCert.getStatus()).isEqualTo(CertStatus.expired); + assertThat(sampleCert.getDaysRemaining()).isZero(); + assertThat(sampleCert.getNotBefore()).isEqualTo(originalNotBefore); + assertThat(sampleCert.getNotAfter()).isEqualTo(originalNotAfter); verify(k8sCertRepository).save(any(K8sCertVO.class)); } + @Test + void renewCertShouldNotMutateStoredCertWhenSaveFails() { + sampleCert.setStatus(CertStatus.expired); + sampleCert.setDaysRemaining(0); + LocalDateTime originalNotBefore = sampleCert.getNotBefore(); + LocalDateTime originalNotAfter = sampleCert.getNotAfter(); + + when(k8sCertRepository.findById("cert-1")).thenReturn(Optional.of(sampleCert)); + when(k8sCertRepository.save(any(K8sCertVO.class))).thenThrow(new IllegalStateException("save failed")); + + RenewCertDTO command = RenewCertDTO.builder().id("cert-1").build(); + + assertThatThrownBy(() -> k8sCertService.renewCert(command)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("save failed"); + + assertThat(sampleCert.getStatus()).isEqualTo(CertStatus.expired); + assertThat(sampleCert.getDaysRemaining()).isZero(); + assertThat(sampleCert.getNotBefore()).isEqualTo(originalNotBefore); + assertThat(sampleCert.getNotAfter()).isEqualTo(originalNotAfter); + assertThat(sampleCert.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025, 1, 2, 0, 0)); + } + @Test void renewCertShouldThrowWhenNotFound() { when(k8sCertRepository.findById("nonexistent")).thenReturn(Optional.empty()); 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/server/src/test/java/com/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java b/server/src/test/java/com/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java new file mode 100644 index 00000000..44b7e72e --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.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.proxy; + +import com.rocketmq.studio.common.exception.BusinessException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProxyAddressServiceTest { + + private final ProxyAddressService proxyAddressService = new ProxyAddressService(); + + @Test + void homePageShouldReturnDefaultProxyAddress() { + ProxyHomeVO home = proxyAddressService.getHomePage(); + + assertThat(home.getProxyAddrList()).containsExactly("127.0.0.1:8081"); + assertThat(home.getCurrentProxyAddr()).isEqualTo("127.0.0.1:8081"); + } + + @Test + void addProxyAddrShouldTrimAndKeepUniqueAddresses() { + proxyAddressService.addProxyAddr(" 10.0.0.1:8081 "); + proxyAddressService.addProxyAddr("10.0.0.1:8081"); + + ProxyHomeVO home = proxyAddressService.getHomePage(); + assertThat(home.getProxyAddrList()).containsExactly("127.0.0.1:8081", "10.0.0.1:8081"); + assertThat(home.getCurrentProxyAddr()).isEqualTo("127.0.0.1:8081"); + } + + @Test + void addProxyAddrShouldRejectBlankAddress() { + assertThatThrownBy(() -> proxyAddressService.addProxyAddr(" ")) + .isInstanceOf(BusinessException.class) + .hasMessage("newProxyAddr is required") + .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400)); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/cluster/proxy/ProxyCompatControllerTest.java b/server/src/test/java/com/rocketmq/studio/cluster/proxy/ProxyCompatControllerTest.java new file mode 100644 index 00000000..1268fbb9 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/cluster/proxy/ProxyCompatControllerTest.java @@ -0,0 +1,73 @@ +/* + * 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.proxy; + +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 java.util.List; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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(ProxyCompatController.class) +@AutoConfigureMockMvc(addFilters = false) +class ProxyCompatControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ProxyAddressService proxyAddressService; + + @Test + void homePageShouldReturnProxyAddressState() throws Exception { + ProxyHomeVO home = ProxyHomeVO.builder() + .proxyAddrList(List.of("127.0.0.1:8081", "10.0.0.1:8081")) + .currentProxyAddr("127.0.0.1:8081") + .build(); + when(proxyAddressService.getHomePage()).thenReturn(home); + + mockMvc.perform(get("/api/proxy/homePage.query")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.proxyAddrList[0]").value("127.0.0.1:8081")) + .andExpect(jsonPath("$.data.currentProxyAddr").value("127.0.0.1:8081")); + } + + @Test + void addProxyAddrShouldAcceptFormEncodedPayload() throws Exception { + mockMvc.perform(post("/api/proxy/addProxyAddr.do") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("newProxyAddr", "10.0.0.1:8081")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(proxyAddressService).addProxyAddr(eq("10.0.0.1:8081")); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java b/server/src/test/java/com/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..3b5f525c --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,67 @@ +/* + * 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.common.exception; + +import com.rocketmq.studio.common.domain.Result; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class GlobalExceptionHandlerTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new FailingController()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + void preservesNotFoundBusinessStatusAndEnvelope() throws Exception { + mockMvc.perform(get("/test/business/404")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.message").value("failure-404")); + } + + @Test + void preservesBadRequestBusinessStatusAndEnvelope() throws Exception { + mockMvc.perform(get("/test/business/400")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.message").value("failure-400")); + } + + @RestController + static class FailingController { + + @GetMapping("/test/business/{code}") + Result fail(@PathVariable int code) { + throw new BusinessException(code, "failure-" + code); + } + } +} diff --git a/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java b/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java index 15250820..3db19851 100644 --- a/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java +++ b/server/src/test/java/com/rocketmq/studio/instance/acl/AclControllerTest.java @@ -116,6 +116,27 @@ void createRuleShouldReturnCreatedRule() throws Exception { .andExpect(jsonPath("$.data.principal").value("user1")); } + @Test + void updateRuleShouldReturnUpdatedRule() throws Exception { + AclRuleVO input = AclRuleVO.builder() + .id("rule-1") + .principal("user1") + .resource("topic-1") + .resourceType("TOPIC") + .decision("DENY") + .build(); + + when(aclService.updateRule(any(AclRuleVO.class))).thenReturn(input); + + mockMvc.perform(post("/api/acl/rules/update") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(input))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.id").value("rule-1")) + .andExpect(jsonPath("$.data.decision").value("DENY")); + } + @Test void listUsersShouldReturnAllUsers() throws Exception { AclUserVO user = AclUserVO.builder() @@ -137,4 +158,25 @@ void listUsersShouldReturnAllUsers() throws Exception { .andExpect(jsonPath("$.data[0].username").value("admin")) .andExpect(jsonPath("$.data[0].admin").value(true)); } + + @Test + void updateUserShouldReturnUpdatedUser() throws Exception { + AclUserVO input = AclUserVO.builder() + .id("user-1") + .username("admin") + .accessKey("ak123") + .secretKey("sk456") + .admin(false) + .build(); + + when(aclService.updateUser(any(AclUserVO.class))).thenReturn(input); + + mockMvc.perform(post("/api/acl/users/update") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(input))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.id").value("user-1")) + .andExpect(jsonPath("$.data.admin").value(false)); + } } diff --git a/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java b/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java index c24a7a23..c27008cb 100644 --- a/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/instance/acl/AclServiceTest.java @@ -26,6 +26,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.Mockito.verify; import static org.mockito.Mockito.when; @@ -91,6 +92,36 @@ void deleteRuleShouldDelegateToRepository() { verify(aclRepository).deleteRule("rule-1"); } + @Test + void updateRuleShouldRequireId() { + AclRuleVO input = AclRuleVO.builder() + .principal("user1") + .resource("topic-1") + .build(); + + assertThatThrownBy(() -> aclService.updateRule(input)) + .hasMessage("ACL rule id is required"); + } + + @Test + void updateRuleShouldSaveExistingRule() { + AclRuleVO input = AclRuleVO.builder() + .id("rule-1") + .principal("user1") + .resource("topic-1") + .decision("DENY") + .build(); + + when(aclRepository.saveRule(any(AclRuleVO.class))).thenAnswer(inv -> inv.getArgument(0)); + + AclRuleVO result = aclService.updateRule(input); + + assertThat(result.getId()).isEqualTo("rule-1"); + assertThat(result.getCreatedAt()).isNotNull(); + assertThat(result.getDecision()).isEqualTo("DENY"); + verify(aclRepository).saveRule(any(AclRuleVO.class)); + } + @Test void listUsersShouldReturnAllUsers() { List users = List.of( @@ -133,4 +164,34 @@ void deleteUserShouldDelegateToRepository() { verify(aclRepository).deleteUser("user-1"); } + + @Test + void updateUserShouldRequireId() { + AclUserVO input = AclUserVO.builder() + .username("newuser") + .build(); + + assertThatThrownBy(() -> aclService.updateUser(input)) + .hasMessage("ACL user id is required"); + } + + @Test + void updateUserShouldSaveExistingUser() { + AclUserVO input = AclUserVO.builder() + .id("user-1") + .username("newuser") + .accessKey("ak") + .secretKey("sk") + .admin(true) + .build(); + + when(aclRepository.saveUser(any(AclUserVO.class))).thenAnswer(inv -> inv.getArgument(0)); + + AclUserVO result = aclService.updateUser(input); + + assertThat(result.getId()).isEqualTo("user-1"); + assertThat(result.getCreatedAt()).isNotNull(); + assertThat(result.isAdmin()).isTrue(); + verify(aclRepository).saveUser(any(AclUserVO.class)); + } } diff --git a/server/src/test/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsServiceTest.java b/server/src/test/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsServiceTest.java new file mode 100644 index 00000000..7ca5720b --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/instance/group/ConsumerDiagnosticsServiceTest.java @@ -0,0 +1,76 @@ +/* + * 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.instance.group; + +import com.rocketmq.studio.common.exception.BusinessException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConsumerDiagnosticsServiceTest { + + @Mock + private ConsumerDiagnosticsProvider diagnosticsProvider; + + @InjectMocks + private ConsumerDiagnosticsService diagnosticsService; + + @Test + void getConsumerStackShouldDelegateToProvider() { + ConsumerStackTraceVO stackTrace = ConsumerStackTraceVO.builder() + .groupName("cg-orders") + .clientId("client-1") + .capturedAt(LocalDateTime.now()) + .threadCount(0) + .threads(List.of()) + .build(); + + when(diagnosticsProvider.getConsumerStack("cg-orders", "client-1")).thenReturn(stackTrace); + + ConsumerStackTraceVO result = diagnosticsService.getConsumerStack("cg-orders", "client-1"); + + assertThat(result.getGroupName()).isEqualTo("cg-orders"); + assertThat(result.getClientId()).isEqualTo("client-1"); + verify(diagnosticsProvider).getConsumerStack("cg-orders", "client-1"); + } + + @Test + void getConsumerStackShouldRejectBlankGroupName() { + assertThatThrownBy(() -> diagnosticsService.getConsumerStack(" ", "client-1")) + .isInstanceOf(BusinessException.class) + .hasMessage("groupName is required"); + } + + @Test + void getConsumerStackShouldRejectBlankClientId() { + assertThatThrownBy(() -> diagnosticsService.getConsumerStack("cg-orders", " ")) + .isInstanceOf(BusinessException.class) + .hasMessage("clientId is required"); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java b/server/src/test/java/com/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java new file mode 100644 index 00000000..be454532 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java @@ -0,0 +1,79 @@ +/* + * 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.instance.group; + +import com.rocketmq.studio.instance.topic.MetadataService; +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.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ConsumerGroupController.class) +@AutoConfigureMockMvc(addFilters = false) +class ConsumerGroupControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private MetadataService metadataService; + + @MockBean + private ConsumerDiagnosticsService consumerDiagnosticsService; + + @Test + void getConsumerStackShouldReturnStackTrace() throws Exception { + ConsumerThreadStackVO thread = ConsumerThreadStackVO.builder() + .threadName("ConsumeMessageThread_1") + .threadId(42L) + .state("RUNNABLE") + .blockedTime(0L) + .waitedTime(5L) + .stackTrace(List.of("org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService.run")) + .build(); + ConsumerStackTraceVO stackTrace = ConsumerStackTraceVO.builder() + .groupName("cg-orders") + .clientId("client-1") + .capturedAt(LocalDateTime.of(2026, 7, 23, 12, 0)) + .threadCount(1) + .threads(List.of(thread)) + .build(); + + when(consumerDiagnosticsService.getConsumerStack("cg-orders", "client-1")).thenReturn(stackTrace); + + mockMvc.perform(get("/api/groups/cg-orders/instances/client-1/stack")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.groupName").value("cg-orders")) + .andExpect(jsonPath("$.data.clientId").value("client-1")) + .andExpect(jsonPath("$.data.threadCount").value(1)) + .andExpect(jsonPath("$.data.threads[0].threadName").value("ConsumeMessageThread_1")) + .andExpect(jsonPath("$.data.threads[0].stackTrace[0]") + .value("org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService.run")); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/instance/topic/LiteTopicControllerTest.java b/server/src/test/java/com/rocketmq/studio/instance/topic/LiteTopicControllerTest.java new file mode 100644 index 00000000..9d018193 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/instance/topic/LiteTopicControllerTest.java @@ -0,0 +1,127 @@ +/* + * 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.instance.topic; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 java.util.List; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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(LiteTopicController.class) +@AutoConfigureMockMvc(addFilters = false) +class LiteTopicControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private LiteTopicService liteTopicService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void listLiteTopicsShouldPassFilters() throws Exception { + LiteTopicItemVO item = LiteTopicItemVO.builder() + .topicPattern("chat/{sessionId}") + .namespace("default") + .topicCount(96) + .build(); + + when(liteTopicService.listLiteTopics("chat", "default")).thenReturn(List.of(item)); + + mockMvc.perform(get("/api/liteTopic/list") + .param("pattern", "chat") + .param("namespace", "default")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[0].topicPattern").value("chat/{sessionId}")); + + verify(liteTopicService).listLiteTopics("chat", "default"); + } + + @Test + void getQuotaShouldReturnQuota() throws Exception { + LiteTopicQuotaVO quota = LiteTopicQuotaVO.builder() + .currentTopicCount(128) + .maxTopicCount(1_000_000) + .remainingQuota(999_872) + .build(); + + when(liteTopicService.getQuota("default")).thenReturn(quota); + + mockMvc.perform(get("/api/liteTopic/quota").param("namespace", "default")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.currentTopicCount").value(128)) + .andExpect(jsonPath("$.data.remainingQuota").value(999872)); + } + + @Test + void getCapabilityShouldReturnSupportedFlag() throws Exception { + when(liteTopicService.getCapability()).thenReturn(new LiteTopicCapabilityVO(true)); + + mockMvc.perform(get("/api/liteTopic/capability")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.supported").value(true)); + } + + @Test + void getSessionShouldReturnSession() throws Exception { + LiteTopicSessionVO session = LiteTopicSessionVO.builder() + .sessionId("sess-001") + .clientId("grpc-client-sess-001") + .popProgress(96) + .build(); + + when(liteTopicService.getSession("sess-001")).thenReturn(session); + + mockMvc.perform(get("/api/liteTopic/session/sess-001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sessionId").value("sess-001")) + .andExpect(jsonPath("$.data.popProgress").value(96)); + } + + @Test + void extendTTLShouldDelegateToService() throws Exception { + LiteTopicTTLUpdateDTO request = new LiteTopicTTLUpdateDTO(); + request.setTopicPattern("chat/{sessionId}"); + request.setNewTTL(7_200_000L); + + mockMvc.perform(post("/api/liteTopic/extendTTL") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(liteTopicService).extendTTL(eq("chat/{sessionId}"), eq(7_200_000L)); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/instance/topic/LiteTopicServiceTest.java b/server/src/test/java/com/rocketmq/studio/instance/topic/LiteTopicServiceTest.java new file mode 100644 index 00000000..25f5fc99 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/instance/topic/LiteTopicServiceTest.java @@ -0,0 +1,66 @@ +/* + * 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.instance.topic; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LiteTopicServiceTest { + + private final LiteTopicService liteTopicService = new LiteTopicService(); + + @Test + void listLiteTopicsShouldFilterByPatternAndNamespace() { + List result = liteTopicService.listLiteTopics("chat", "default"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTopicPattern()).isEqualTo("chat/{sessionId}"); + assertThat(result.get(0).getNamespace()).isEqualTo("default"); + } + + @Test + void getQuotaShouldReturnDefaultLimits() { + LiteTopicQuotaVO quota = liteTopicService.getQuota("default"); + + assertThat(quota.getCurrentTopicCount()).isEqualTo(128); + assertThat(quota.getMaxTopicCount()).isEqualTo(1_000_000); + assertThat(quota.getRemainingQuota()).isEqualTo(999_872); + } + + @Test + void getSessionShouldReturnRequestedSessionId() { + LiteTopicSessionVO session = liteTopicService.getSession("sess-001"); + + assertThat(session.getSessionId()).isEqualTo("sess-001"); + assertThat(session.getLiteTopics()).extracting("topicName").contains("chat/sess-001"); + } + + @Test + void extendTTLShouldRejectInvalidInput() { + assertThatThrownBy(() -> liteTopicService.extendTTL("", 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("topicPattern is required"); + assertThatThrownBy(() -> liteTopicService.extendTTL("chat/{sessionId}", 0L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("newTTL must be positive"); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/OpsControllerTest.java b/server/src/test/java/com/rocketmq/studio/ops/OpsControllerTest.java new file mode 100644 index 00000000..cbc4582b --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/OpsControllerTest.java @@ -0,0 +1,112 @@ +/* + * 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.ops; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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(OpsController.class) +@AutoConfigureMockMvc(addFilters = false) +class OpsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private OpsService opsService; + + @Test + void homePageShouldReturnOpsSettings() throws Exception { + OpsHomeVO home = OpsHomeVO.builder() + .namesvrAddrList(List.of("127.0.0.1:9876", "10.0.0.1:9876")) + .currentNamesrv("127.0.0.1:9876") + .useVIPChannel(true) + .useTLS(false) + .build(); + when(opsService.getHomePage()).thenReturn(home); + + mockMvc.perform(get("/api/ops/homePage")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.namesvrAddrList[0]").value("127.0.0.1:9876")) + .andExpect(jsonPath("$.data.currentNamesrv").value("127.0.0.1:9876")) + .andExpect(jsonPath("$.data.useVIPChannel").value(true)) + .andExpect(jsonPath("$.data.useTLS").value(false)); + } + + @Test + void updateNameSvrAddrShouldDelegateToService() throws Exception { + mockMvc.perform(post("/api/ops/updateNameSvrAddr") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("namesrvAddr", "10.0.0.1:9876")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(opsService).updateNameServer(eq("10.0.0.1:9876")); + } + + @Test + void addNameSvrAddrShouldDelegateToService() throws Exception { + mockMvc.perform(post("/api/ops/addNameSvrAddr") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("namesrvAddr", "10.0.0.2:9876")))) + .andExpect(status().isOk()); + + verify(opsService).addNameServer(eq("10.0.0.2:9876")); + } + + @Test + void updateVipChannelShouldDelegateToService() throws Exception { + mockMvc.perform(post("/api/ops/updateIsVIPChannel") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("useVIPChannel", false)))) + .andExpect(status().isOk()); + + verify(opsService).updateVipChannel(false); + } + + @Test + void updateUseTlsShouldDelegateToService() throws Exception { + mockMvc.perform(post("/api/ops/updateUseTLS") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("useTLS", true)))) + .andExpect(status().isOk()); + + verify(opsService).updateUseTLS(true); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/OpsServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/OpsServiceTest.java new file mode 100644 index 00000000..dd2dc23c --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/OpsServiceTest.java @@ -0,0 +1,72 @@ +/* + * 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.ops; + +import com.rocketmq.studio.common.exception.BusinessException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OpsServiceTest { + + private final OpsService opsService = new OpsService(); + + @Test + void getHomePageShouldReturnDefaultSettings() { + OpsHomeVO home = opsService.getHomePage(); + + assertThat(home.getNamesvrAddrList()).containsExactly("127.0.0.1:9876"); + assertThat(home.getCurrentNamesrv()).isEqualTo("127.0.0.1:9876"); + assertThat(home.isUseVIPChannel()).isTrue(); + assertThat(home.isUseTLS()).isFalse(); + } + + @Test + void addAndUpdateNameServerShouldMaintainUniqueAddressList() { + opsService.addNameServer(" 10.0.0.1:9876 "); + opsService.addNameServer("10.0.0.1:9876"); + opsService.updateNameServer("10.0.0.2:9876"); + + OpsHomeVO home = opsService.getHomePage(); + assertThat(home.getNamesvrAddrList()) + .containsExactly("127.0.0.1:9876", "10.0.0.1:9876", "10.0.0.2:9876"); + assertThat(home.getCurrentNamesrv()).isEqualTo("10.0.0.2:9876"); + } + + @Test + void togglesShouldUpdateHomePageSettings() { + opsService.updateVipChannel(false); + opsService.updateUseTLS(true); + + OpsHomeVO home = opsService.getHomePage(); + assertThat(home.isUseVIPChannel()).isFalse(); + assertThat(home.isUseTLS()).isTrue(); + } + + @Test + void nameServerOperationsShouldRejectBlankAddress() { + assertThatThrownBy(() -> opsService.addNameServer(" ")) + .isInstanceOf(BusinessException.class) + .hasMessage("namesrvAddr is required") + .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(400)); + assertThatThrownBy(() -> opsService.updateNameServer(null)) + .isInstanceOf(BusinessException.class) + .hasMessage("namesrvAddr is required"); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/AiControllerTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/AiControllerTest.java new file mode 100644 index 00000000..7b9e0277 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/AiControllerTest.java @@ -0,0 +1,128 @@ +/* + * 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.ops.ai; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AiControllerTest { + + private static final String DIGEST = "a".repeat(64); + + private AiService aiService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + aiService = mock(AiService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new AiController(aiService)).build(); + + when(aiService.catalogVersion()).thenReturn("1.0.0"); + when(aiService.catalogDigest()).thenReturn(DIGEST); + when(aiService.minimumClientVersion()).thenReturn("1.0.0"); + } + + @Test + void listToolsKeepsTheExistingBodyAndAddsCatalogHeaders() throws Exception { + AiToolVO tool = AiToolVO.builder() + .name("rmq.cluster.list") + .description("List clusters") + .parameters(Map.of("type", "object")) + .riskLevel("L1") + .permission("cluster:read") + .requiredCapabilities(Collections.emptyList()) + .outputSchema(Map.of("type", "array")) + .viewHint("table") + .deprecated(false) + .build(); + when(aiService.listTools()).thenReturn(List.of(tool)); + + mockMvc.perform(get("/api/ai/tools")) + .andExpect(status().isOk()) + .andExpect(header().string("X-RMQ-Catalog-Version", "1.0.0")) + .andExpect(header().string("X-RMQ-Catalog-Digest", DIGEST)) + .andExpect(header().string("X-RMQ-Minimum-Client-Version", "1.0.0")) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.message").value("success")) + .andExpect(jsonPath("$.data[0].name").value("rmq.cluster.list")) + .andExpect(jsonPath("$.data[0].parameters.type").value("object")) + .andExpect(jsonPath("$.data[0].riskLevel").value("L1")) + .andExpect(jsonPath("$.data[0].permission").value("cluster:read")) + .andExpect(jsonPath("$.data[0].viewHint").value("table")); + } + + @Test + void listToolsDelegatesTheSelectedCluster() throws Exception { + when(aiService.listTools("cluster-001")).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/ai/tools").queryParam("cluster", "cluster-001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + + verify(aiService).listTools("cluster-001"); + } + + @Test + void executeToolPreservesStructuredInputAndDottedName() throws Exception { + Map input = Map.of("cluster", "cluster-001"); + Map output = Map.of( + "cluster", "cluster-001", + "capabilities", List.of("GRPC")); + when(aiService.executeTool("rmq.capabilities", input)).thenReturn(output); + + mockMvc.perform(post("/api/ai/tools/rmq.capabilities/execute") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"cluster":"cluster-001"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.cluster").value("cluster-001")) + .andExpect(jsonPath("$.data.capabilities[0]").value("GRPC")); + + verify(aiService).executeTool("rmq.capabilities", input); + } + + @Test + void executeClusterListNormalizesAnAbsentBody() throws Exception { + when(aiService.executeTool("rmq.cluster.list", Collections.emptyMap())) + .thenReturn(Collections.emptyList()); + + mockMvc.perform(post("/api/ai/tools/rmq.cluster.list/execute") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()); + + verify(aiService).executeTool("rmq.cluster.list", Collections.emptyMap()); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java index ac46c7d1..a7f46a48 100644 --- a/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/AiServiceTest.java @@ -181,4 +181,26 @@ void listToolsShouldIncludeParameters() { assertThat(result.get(0).getParameters()).isNotNull(); assertThat(result.get(0).getParameters()).isInstanceOf(Map.class); } + + @Test + void listToolsForClusterDelegatesClusterSelection() { + when(mcpServerRegistry.listTools("cluster-001")).thenReturn(Collections.emptyList()); + + List result = aiService.listTools("cluster-001"); + + assertThat(result).isEmpty(); + verify(mcpServerRegistry).listTools("cluster-001"); + } + + @Test + void executeToolDelegatesStructuredInput() { + Map input = Map.of("cluster", "cluster-001"); + Map output = Map.of("cluster", "cluster-001"); + when(mcpServerRegistry.execute("rmq.capabilities", input)).thenReturn(output); + + Object result = aiService.executeTool("rmq.capabilities", input); + + assertThat(result).isSameAs(output); + verify(mcpServerRegistry).execute("rmq.capabilities", input); + } } diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/LlmConfigServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmConfigServiceTest.java new file mode 100644 index 00000000..a916c5dd --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmConfigServiceTest.java @@ -0,0 +1,129 @@ +/* + * 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.ops.ai; + +import com.rocketmq.studio.settings.GeneralSettingsVO; +import com.rocketmq.studio.settings.SettingsService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LlmConfigServiceTest { + + private SettingsService settingsService; + private LlmConfigService llmConfigService; + + @BeforeEach + void setUp() { + settingsService = mock(SettingsService.class); + when(settingsService.getGeneralSettings()).thenReturn(GeneralSettingsVO.builder() + .theme("dark") + .compact(true) + .desktopNotify(true) + .notifySound(false) + .sessionTimeout(45) + .requireLogin(true) + .llmProvider("openai") + .apiKey("sk-test") + .model("gpt-4o") + .baseUrl("https://api.openai.com/v1") + .build()); + llmConfigService = new LlmConfigService(settingsService); + } + + @Test + void getConfigShouldMapGeneralSettingsToLlmConfig() { + LlmConfigVO config = llmConfigService.getConfig(); + + assertThat(config.getProvider()).isEqualTo("openai"); + assertThat(config.getApiKey()).isEqualTo("sk-test"); + assertThat(config.getApiBase()).isEqualTo("https://api.openai.com/v1"); + assertThat(config.getModel()).isEqualTo("gpt-4o"); + assertThat(config.isEnabled()).isTrue(); + } + + @Test + void saveConfigShouldPreserveGeneralSettingsAndStoreLlmFields() { + LlmConfigVO config = LlmConfigVO.builder() + .provider("deepseek") + .apiKey("sk-deepseek") + .apiBase("https://api.deepseek.com/v1") + .model("deepseek-chat") + .maxTokens(8192) + .temperature(0.2) + .enabled(true) + .build(); + + llmConfigService.saveConfig(config); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GeneralSettingsVO.class); + verify(settingsService).saveGeneralSettings(captor.capture()); + GeneralSettingsVO saved = captor.getValue(); + assertThat(saved.getTheme()).isEqualTo("dark"); + assertThat(saved.isCompact()).isTrue(); + assertThat(saved.getLlmProvider()).isEqualTo("deepseek"); + assertThat(saved.getApiKey()).isEqualTo("sk-deepseek"); + assertThat(saved.getModel()).isEqualTo("deepseek-chat"); + assertThat(saved.getBaseUrl()).isEqualTo("https://api.deepseek.com/v1"); + assertThat(llmConfigService.getConfig().getProvider()).isEqualTo("deepseek"); + } + + @Test + void testConfigShouldRejectMissingRequiredApiKey() { + LlmOperationResultVO result = llmConfigService.testConfig(LlmConfigVO.builder() + .provider("openai") + .apiKey("") + .model("gpt-4o") + .build()); + + assertThat(result.getStatus()).isEqualTo(1); + assertThat(result.getErrMsg()).isEqualTo("API Key is required"); + } + + @Test + void testConfigShouldAllowOllamaWithoutApiKey() { + LlmOperationResultVO result = llmConfigService.testConfig(LlmConfigVO.builder() + .provider("ollama") + .apiBase("http://localhost:11434/v1") + .model("llama3") + .build()); + + assertThat(result.getStatus()).isZero(); + assertThat(result.getMsg()).isEqualTo("Configuration accepted"); + } + + @Test + void listModelsShouldUseSavedProvider() { + llmConfigService.saveConfig(LlmConfigVO.builder() + .provider("tongyi") + .apiKey("dashscope-key") + .model("qwen-plus") + .enabled(true) + .build()); + + LlmModelsResultVO result = llmConfigService.listModels(); + + assertThat(result.getStatus()).isZero(); + assertThat(result.getData()).extracting("id").contains("qwen-max", "qwen-plus"); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/LlmControllerTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmControllerTest.java new file mode 100644 index 00000000..e5b2d8fc --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/LlmControllerTest.java @@ -0,0 +1,119 @@ +/* + * 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.ops.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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(LlmController.class) +@AutoConfigureMockMvc(addFilters = false) +class LlmControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private LlmConfigService llmConfigService; + + @Test + void getConfigShouldReturnFrontendContractShape() throws Exception { + when(llmConfigService.getConfig()).thenReturn(LlmConfigVO.builder() + .provider("openai") + .apiKey("sk-test") + .apiBase("https://api.openai.com/v1") + .model("gpt-4o") + .maxTokens(4096) + .temperature(0.7) + .enabled(true) + .build()); + + mockMvc.perform(get("/api/llm/config")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.provider").value("openai")) + .andExpect(jsonPath("$.apiBase").value("https://api.openai.com/v1")) + .andExpect(jsonPath("$.model").value("gpt-4o")) + .andExpect(jsonPath("$.enabled").value(true)); + } + + @Test + void saveConfigShouldReturnStatusZero() throws Exception { + LlmConfigVO config = LlmConfigVO.builder() + .provider("openai") + .apiKey("sk-test") + .model("gpt-4o") + .enabled(true) + .build(); + + mockMvc.perform(post("/api/llm/config") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(config))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(0)) + .andExpect(jsonPath("$.msg").value("saved")); + + verify(llmConfigService).saveConfig(any(LlmConfigVO.class)); + } + + @Test + void testConfigShouldReturnOperationResult() throws Exception { + when(llmConfigService.testConfig(any(LlmConfigVO.class))) + .thenReturn(LlmOperationResultVO.success("Configuration accepted")); + + mockMvc.perform(post("/api/llm/config/test") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(LlmConfigVO.builder() + .provider("ollama") + .model("llama3") + .build()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(0)) + .andExpect(jsonPath("$.msg").value("Configuration accepted")); + } + + @Test + void listModelsShouldReturnStatusAndData() throws Exception { + when(llmConfigService.listModels()).thenReturn(new LlmModelsResultVO(0, List.of( + new LlmModelItemVO("gpt-4o", "GPT-4o"), + new LlmModelItemVO("gpt-4", "GPT-4")))); + + mockMvc.perform(get("/api/llm/models")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(0)) + .andExpect(jsonPath("$.data[0].id").value("gpt-4o")) + .andExpect(jsonPath("$.data[1].name").value("GPT-4")); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java new file mode 100644 index 00000000..285965b8 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java @@ -0,0 +1,158 @@ +/* + * 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.ops.ai.tool; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ToolCatalogTest { + + @Test + void loadsAndIndexesTheCanonicalCatalog() { + ToolCatalog catalog = ToolCatalog.load(canonicalCatalog(), canonicalSchema()); + + assertThat(catalog.getVersion()).isEqualTo("1.0.0"); + assertThat(catalog.getMinimumClientVersion()).isEqualTo("1.0.0"); + assertThat(catalog.getDigest()).matches("[0-9a-f]{64}"); + assertThat(catalog.list()).extracting(ToolDefinition::getName) + .containsExactly("rmq.cluster.list", "rmq.capabilities"); + assertThat(catalog.find("rmq.cluster.list")).isPresent(); + assertThat(catalog.find("rmq.unknown")).isEmpty(); + } + + @Test + void rejectsCatalogThatDoesNotMatchItsJsonSchema() { + Resource invalid = utf8Resource(""" + version: 1.0.0 + minimumClientVersion: 1.0.0 + tools: not-a-list + """); + + assertThatThrownBy(() -> ToolCatalog.load(invalid, canonicalSchema())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("catalog schema validation failed"); + } + + @Test + void rejectsDuplicateToolNames() { + Resource duplicate = catalog( + tool("rmq.cluster.list", false), + tool("rmq.cluster.list", false)); + + assertThatThrownBy(() -> ToolCatalog.load(duplicate, canonicalSchema())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate tool name"); + } + + @Test + void requiresClusterForEveryRemoteToolExceptClusterList() { + Resource missingCluster = catalog(tool("rmq.capabilities", false)); + + assertThatThrownBy(() -> ToolCatalog.load(missingCluster, canonicalSchema())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must require cluster"); + } + + @Test + void rejectsIncompatibleMinimumClientMajorVersion() { + Resource incompatible = utf8Resource(""" + version: 2.0.0 + minimumClientVersion: 1.0.0 + tools: + %s + """.formatted(tool("rmq.cluster.list", false))); + + assertThatThrownBy(() -> ToolCatalog.load(incompatible, canonicalSchema())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("major versions must match"); + } + + @Test + @SuppressWarnings("unchecked") + void definitionsAreDeeplyImmutableAfterStartupValidation() { + ToolDefinition capabilities = ToolCatalog.load(canonicalCatalog(), canonicalSchema()) + .find("rmq.capabilities") + .orElseThrow(); + Map properties = + (Map) capabilities.inputSchema().get("properties"); + List required = + (List) capabilities.inputSchema().get("required"); + + assertThatThrownBy(() -> properties.put("endpoint", Map.of("type", "string"))) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> required.add("endpoint")) + .isInstanceOf(UnsupportedOperationException.class); + } + + private static Resource canonicalCatalog() { + return new ClassPathResource("tool-catalog/rmq-tools.yaml"); + } + + private static Resource canonicalSchema() { + return new ClassPathResource("tool-catalog/rmq-tools.schema.json"); + } + + private static Resource catalog(String... tools) { + return utf8Resource(""" + version: 1.0.0 + minimumClientVersion: 1.0.0 + tools: + %s + """.formatted(String.join("\n", tools))); + } + + private static String tool(String name, boolean clusterRequired) { + String required = clusterRequired ? """ + required: + - cluster + properties: + cluster: + type: string + minLength: 1 + """ : ""; + return """ + - name: %s + cli: + resource: cluster + verb: list + description: Test tool. + riskLevel: L1 + permission: cluster:read + requiredCapabilities: [] + inputSchema: + type: object + %s additionalProperties: false + outputSchema: + type: object + viewHint: object + deprecated: false + """.formatted(name, required); + } + + private static Resource utf8Resource(String value) { + return new ByteArrayResource(value.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java new file mode 100644 index 00000000..7d7cb37c --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java @@ -0,0 +1,326 @@ +/* + * 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.ops.ai.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rocketmq.studio.cluster.broker.ClusterService; +import com.rocketmq.studio.cluster.broker.ClusterVO; +import com.rocketmq.studio.common.domain.enums.ClusterStatus; +import com.rocketmq.studio.common.domain.enums.ClusterType; +import com.rocketmq.studio.common.exception.BusinessException; +import com.rocketmq.studio.ops.ai.AiToolVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ToolGatewayServiceTest { + + private ToolCatalog catalog; + private ClusterService clusterService; + private CapabilityResolver capabilityResolver; + private ClusterListToolHandler clusterListHandler; + private CapabilitiesToolHandler capabilitiesHandler; + private ToolGatewayService gateway; + + @BeforeEach + void setUp() { + catalog = canonicalCatalog(); + clusterService = mock(ClusterService.class); + capabilityResolver = new CapabilityResolver(clusterService); + clusterListHandler = new ClusterListToolHandler(clusterService); + capabilitiesHandler = new CapabilitiesToolHandler(clusterService, capabilityResolver); + gateway = gateway(catalog, clusterListHandler, capabilitiesHandler); + } + + @Test + void discoveryWithoutClusterOnlyExposesClusterList() { + assertThat(gateway.discover(null)) + .extracting(AiToolVO::getName) + .containsExactly("rmq.cluster.list"); + verifyNoInteractions(clusterService); + } + + @Test + void discoveryWithClusterExposesRegisteredSupportedTools() { + when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER)); + + assertThat(gateway.discover("cluster-v5")) + .extracting(AiToolVO::getName) + .containsExactly("rmq.cluster.list", "rmq.capabilities"); + } + + @Test + void executesClusterListThroughADataMinimizingProjection() { + ClusterVO cluster = cluster(ClusterType.V5_PROXY_CLUSTER); + cluster.setEndpoint("do-not-expose.example:9876"); + when(clusterService.listClusters()).thenReturn(List.of(cluster)); + + Object output = gateway.execute("rmq.cluster.list", Map.of()); + + assertThat(output).isEqualTo(List.of(Map.of( + "id", "cluster-v5", + "name", "test", + "type", "V5_PROXY_CLUSTER", + "status", "healthy", + "version", "5.2.0"))); + assertThat(output.toString()).doesNotContain("do-not-expose"); + } + + @Test + void executesCapabilitiesWithAStableSortedCapabilityList() { + when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER)); + + Object output = gateway.execute( + "rmq.capabilities", Map.of("cluster", "cluster-v5")); + + assertThat(output).isEqualTo(Map.of( + "cluster", "cluster-v5", + "type", "V5_PROXY_CLUSTER", + "version", "5.2.0", + "capabilities", List.of( + "ACL_V2", + "CLUSTER_PROXY", + "GRPC", + "LITE_TOPIC", + "POP", + "REMOTING", + "ROCKETMQ_5"))); + } + + @Test + void resolvesCapabilitiesForEveryExistingClusterType() { + when(clusterService.getCluster("v4")).thenReturn(cluster("v4", ClusterType.V4_DIRECT)); + when(clusterService.getCluster("v5-local")) + .thenReturn(cluster("v5-local", ClusterType.V5_PROXY_LOCAL)); + when(clusterService.getCluster("v5-cluster")) + .thenReturn(cluster("v5-cluster", ClusterType.V5_PROXY_CLUSTER)); + + assertThat(capabilityResolver.resolve("v4")) + .containsExactly("REMOTING", "ROCKETMQ_4"); + assertThat(capabilityResolver.resolve("v5-local")) + .containsExactly( + "ACL_V2", + "GRPC", + "LITE_TOPIC", + "LOCAL_PROXY", + "POP", + "REMOTING", + "ROCKETMQ_5"); + assertThat(capabilityResolver.resolve("v5-cluster")) + .containsExactly( + "ACL_V2", + "CLUSTER_PROXY", + "GRPC", + "LITE_TOPIC", + "POP", + "REMOTING", + "ROCKETMQ_5"); + } + + @Test + void rejectsClusterWithMissingType() { + when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null)); + + assertThatThrownBy(() -> capabilityResolver.resolve("unknown")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("Cluster type is unavailable"); + } + + @Test + void rejectsCapabilitiesExecutionWhenClusterTypeIsMissing() { + when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null)); + + assertThatThrownBy(() -> gateway.execute( + "rmq.capabilities", Map.of("cluster", "unknown"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("Cluster type is unavailable"); + } + + @Test + void rejectsClusterListEntriesWithMissingTypeUsingAStableError() { + when(clusterService.listClusters()).thenReturn(List.of(cluster("unknown", null))); + + assertThatThrownBy(() -> gateway.execute("rmq.cluster.list", Map.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cluster type is unavailable"); + } + + @Test + void rejectsCapabilitiesWithoutRequiredClusterBeforeHandlerRuns() { + assertThatThrownBy(() -> gateway.execute("rmq.capabilities", Map.of())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("input validation failed"); + verifyNoInteractions(clusterService); + } + + @Test + void rejectsUnexpectedInputPropertiesBeforeHandlerRuns() { + assertThatThrownBy(() -> gateway.execute( + "rmq.cluster.list", Map.of("endpoint", "attacker.example"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("input validation failed"); + verifyNoInteractions(clusterService); + } + + @Test + void rejectsUnknownTools() { + assertThatThrownBy(() -> gateway.execute("rmq.unknown", Map.of())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("Tool not found"); + } + + @Test + void refusesNonL1CatalogEntriesEvenWhenAHandlerIsRegistered() throws IOException { + String yaml = canonicalCatalogText().replaceFirst("riskLevel: L1", "riskLevel: L2"); + ToolCatalog l2Catalog = ToolCatalog.load( + new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)), + new ClassPathResource("tool-catalog/rmq-tools.schema.json")); + ToolGatewayService l2Gateway = gateway(l2Catalog, clusterListHandler, capabilitiesHandler); + + assertThatThrownBy(() -> l2Gateway.execute("rmq.cluster.list", Map.of())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("only L1 tools are enabled"); + verifyNoInteractions(clusterService); + } + + @Test + void failsStartupForDuplicateHandlerNames() { + assertThatThrownBy(() -> gateway( + catalog, clusterListHandler, clusterListHandler, capabilitiesHandler)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate handler"); + } + + @Test + void failsStartupWhenCatalogAndHandlersDoNotMatch() { + assertThatThrownBy(() -> gateway(catalog, clusterListHandler)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing handler"); + } + + @Test + void failsStartupWhenToolSchemaContainsAnUnresolvedReference() throws IOException { + String yaml = canonicalCatalogText().replace( + """ + inputSchema: + type: object + additionalProperties: false + """, + """ + inputSchema: + $ref: '#/missing' + """); + ToolCatalog invalidCatalog = ToolCatalog.load( + new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)), + new ClassPathResource("tool-catalog/rmq-tools.schema.json")); + + assertThatThrownBy(() -> gateway( + invalidCatalog, clusterListHandler, capabilitiesHandler)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("input schema") + .hasMessageContaining("rmq.cluster.list"); + } + + @Test + void failsStartupWhenToolSchemaViolatesTheJsonSchemaMetaSchema() throws IOException { + String yaml = canonicalCatalogText().replace( + """ + inputSchema: + type: object + """, + """ + inputSchema: + type: unsupported + """); + ToolCatalog invalidCatalog = ToolCatalog.load( + new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)), + new ClassPathResource("tool-catalog/rmq-tools.schema.json")); + + assertThatThrownBy(() -> gateway( + invalidCatalog, clusterListHandler, capabilitiesHandler)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("input schema") + .hasMessageContaining("rmq.cluster.list"); + } + + @Test + void validatesHandlerOutputAgainstTheCatalog() { + ToolHandler invalidClusterListHandler = new ToolHandler() { + @Override + public String name() { + return "rmq.cluster.list"; + } + + @Override + public Object execute(Map input) { + return Map.of("bad", "shape"); + } + }; + ToolGatewayService invalidGateway = gateway( + catalog, invalidClusterListHandler, capabilitiesHandler); + + assertThatThrownBy(() -> invalidGateway.execute("rmq.cluster.list", Map.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("output validation failed"); + } + + private ToolGatewayService gateway(ToolCatalog toolCatalog, ToolHandler... handlers) { + return new ToolGatewayService( + toolCatalog, + capabilityResolver, + new ObjectMapper(), + List.of(handlers)); + } + + private static ToolCatalog canonicalCatalog() { + return ToolCatalog.load( + new ClassPathResource("tool-catalog/rmq-tools.yaml"), + new ClassPathResource("tool-catalog/rmq-tools.schema.json")); + } + + private static String canonicalCatalogText() throws IOException { + return new ClassPathResource("tool-catalog/rmq-tools.yaml") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static ClusterVO cluster(ClusterType type) { + return cluster("cluster-v5", type); + } + + private static ClusterVO cluster(String id, ClusterType type) { + ClusterVO cluster = ClusterVO.builder() + .name("test") + .type(type) + .status(ClusterStatus.healthy) + .version("5.2.0") + .build(); + cluster.setId(id); + return cluster; + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java index 2fd408c2..c32d4448 100644 --- a/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java @@ -70,6 +70,43 @@ void listRulesShouldReturnEmptyListWhenNoRules() { assertThat(result).isEmpty(); } + @Test + void exportPrometheusRulesYamlShouldReturnDefaultRulesWhenRepositoryIsEmpty() { + when(alertRepository.findAllRules()).thenReturn(Collections.emptyList()); + + String result = alertService.exportPrometheusRulesYaml(); + + assertThat(result) + .contains("groups:") + .contains("# Rule 1: RocketMQBrokerDown") + .contains("up{job=~\".*rocketmq.*\"} == 0") + .contains("rocketmq_consumer_lag_messages > 100000") + .contains("rocketmq_producer_send_to_back_rt > 1000") + .contains("severity: critical"); + } + + @Test + void exportPrometheusRulesYamlShouldConvertConfiguredRules() { + AlertRuleVO rule = AlertRuleVO.builder() + .name("High Lag Alert") + .metric("rocketmq_consumer_lag_messages") + .operator(">") + .threshold(5000) + .duration("3m") + .description("Lag too high") + .build(); + when(alertRepository.findAllRules()).thenReturn(List.of(rule)); + + String result = alertService.exportPrometheusRulesYaml(); + + assertThat(result) + .contains("rocketmq-consumer.rules") + .contains("# Rule 1: HighLagAlert") + .contains("expr: rocketmq_consumer_lag_messages > 5000") + .contains("for: 3m") + .contains("description: \"Lag too high\""); + } + @Test void createRuleShouldAssignId() { AlertRuleVO input = AlertRuleVO.builder().name("New Rule").metric("tps") diff --git a/server/src/test/java/com/rocketmq/studio/ops/alert/LegacyAlertControllerTest.java b/server/src/test/java/com/rocketmq/studio/ops/alert/LegacyAlertControllerTest.java new file mode 100644 index 00000000..6c50ce2a --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/alert/LegacyAlertControllerTest.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.ops.alert; + +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.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(LegacyAlertController.class) +@AutoConfigureMockMvc(addFilters = false) +class LegacyAlertControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AlertService alertService; + + @Test + void getRulesShouldReturnYamlPayloadForLegacyStudioPage() throws Exception { + String yaml = "groups:\n - name: rocketmq-broker.rules\n rules:\n" + + " # Rule 1: RocketMQBrokerDown\n - alert: RocketMQBrokerDown\n"; + when(alertService.exportPrometheusRulesYaml()).thenReturn(yaml); + + mockMvc.perform(get("/api/alert/rules")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.rules").value(yaml)); + + verify(alertService).exportPrometheusRulesYaml(); + } +} 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), diff --git a/server/src/test/java/com/rocketmq/studio/settings/InMemorySettingsRepositoryTest.java b/server/src/test/java/com/rocketmq/studio/settings/InMemorySettingsRepositoryTest.java new file mode 100644 index 00000000..d1d06f60 --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/settings/InMemorySettingsRepositoryTest.java @@ -0,0 +1,54 @@ +/* + * 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.settings; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemorySettingsRepositoryTest { + + private final InMemorySettingsRepository repository = new InMemorySettingsRepository(); + + @Test + void saveDataSourceShouldSupportCreateUpdateAndDeleteByKey() { + DataSourceVO dataSource = DataSourceVO.builder() + .key("source-1") + .name("Prometheus") + .type("Prometheus") + .url("http://localhost:9090") + .build(); + + repository.saveDataSource(dataSource); + + assertThat(repository.findDataSourceByKey("source-1")).containsSame(dataSource); + assertThat(repository.findAllDataSources()).containsExactly(dataSource); + + dataSource.setName("Updated Prometheus"); + repository.saveDataSource(dataSource); + + assertThat(repository.findDataSourceByKey("source-1")) + .get() + .extracting(DataSourceVO::getName) + .isEqualTo("Updated Prometheus"); + + repository.deleteDataSource("source-1"); + + assertThat(repository.findDataSourceByKey("source-1")).isEmpty(); + assertThat(repository.findAllDataSources()).isEmpty(); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java index 42a3c0e6..f82bb1ad 100644 --- a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java +++ b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java @@ -30,8 +30,10 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -77,26 +79,81 @@ void getGeneralSettingsShouldReturnSettings() throws Exception { .andExpect(jsonPath("$.data.sessionTimeout", is(30))) .andExpect(jsonPath("$.data.requireLogin", is(true))) .andExpect(jsonPath("$.data.llmProvider", is("openai"))) + .andExpect(jsonPath("$.data.apiKey").doesNotExist()) + .andExpect(jsonPath("$.data.apiKeyConfigured", is(true))) + .andExpect(jsonPath("$.data.clearApiKey").doesNotExist()) .andExpect(jsonPath("$.data.model", is("gpt-4"))); } @Test void saveGeneralSettingsShouldReturnSuccess() throws Exception { - GeneralSettingsVO settings = GeneralSettingsVO.builder() - .theme("light") - .compact(false) - .sessionTimeout(60) - .build(); doNothing().when(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class)); mockMvc.perform(post("/api/settings/general/save") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(settings))) + .content(""" + { + "theme": "light", + "compact": false, + "desktopNotify": true, + "notifySound": false, + "sessionTimeout": 60, + "requireLogin": true, + "llmProvider": "openai", + "apiKey": "sk-new", + "apiKeyConfigured": false, + "model": "gpt-4", + "baseUrl": "" + } + """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code", is(200))) .andExpect(jsonPath("$.data").doesNotExist()); - verify(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class)); + verify(settingsService).saveGeneralSettings(argThat(settings -> + "sk-new".equals(settings.getApiKey()) && settings.isApiKeyConfigured())); + } + + @Test + void saveGeneralSettingsShouldAcceptExplicitApiKeyClearWithoutBindingResponseState() throws Exception { + doNothing().when(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class)); + + mockMvc.perform(post("/api/settings/general/save") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "theme": "light", + "compact": false, + "desktopNotify": true, + "notifySound": false, + "sessionTimeout": 60, + "requireLogin": true, + "llmProvider": "openai", + "clearApiKey": true, + "apiKeyConfigured": true, + "model": "gpt-4", + "baseUrl": "" + } + """)) + .andExpect(status().isOk()); + + verify(settingsService).saveGeneralSettings(argThat(settings -> + settings.isClearApiKey() && !settings.isApiKeyConfigured())); + } + + @Test + void saveGeneralSettingsShouldRejectIncompleteReplacement() throws Exception { + mockMvc.perform(post("/api/settings/general/save") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "theme": "light", + "apiKey": "sk-new" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(settingsService); } @Test diff --git a/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java b/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java index 331197e3..024a9a75 100644 --- a/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java @@ -68,17 +68,68 @@ void getGeneralSettingsShouldReturnCurrentSettings() { } @Test - void saveGeneralSettingsShouldDelegateToRepository() { - GeneralSettingsVO settings = GeneralSettingsVO.builder() + void saveGeneralSettingsShouldPreserveExistingApiKeyWhenOmitted() { + GeneralSettingsVO existing = GeneralSettingsVO.builder() + .apiKey("sk-existing") + .build(); + GeneralSettingsVO update = GeneralSettingsVO.builder() .theme("light") .compact(false) .sessionTimeout(60) .build(); - doNothing().when(settingsRepository).saveGeneralSettings(settings); + when(settingsRepository.loadGeneralSettings()).thenReturn(existing); - settingsService.saveGeneralSettings(settings); + settingsService.saveGeneralSettings(update); - verify(settingsRepository).saveGeneralSettings(settings); + assertThat(update.getApiKey()).isEqualTo("sk-existing"); + verify(settingsRepository).saveGeneralSettings(update); + } + + @Test + void saveGeneralSettingsShouldReplaceExistingApiKey() { + GeneralSettingsVO existing = GeneralSettingsVO.builder() + .apiKey("sk-existing") + .build(); + GeneralSettingsVO update = GeneralSettingsVO.builder() + .apiKey("sk-new") + .build(); + when(settingsRepository.loadGeneralSettings()).thenReturn(existing); + + settingsService.saveGeneralSettings(update); + + assertThat(update.getApiKey()).isEqualTo("sk-new"); + verify(settingsRepository).saveGeneralSettings(update); + } + + @Test + void saveGeneralSettingsShouldClearApiKeyOnlyWhenExplicitlyRequested() { + GeneralSettingsVO existing = GeneralSettingsVO.builder() + .apiKey("sk-existing") + .build(); + GeneralSettingsVO update = GeneralSettingsVO.builder() + .clearApiKey(true) + .build(); + when(settingsRepository.loadGeneralSettings()).thenReturn(existing); + + settingsService.saveGeneralSettings(update); + + assertThat(update.getApiKey()).isEmpty(); + assertThat(update.isClearApiKey()).isFalse(); + verify(settingsRepository).saveGeneralSettings(update); + } + + @Test + void saveGeneralSettingsShouldLetClearTakePrecedenceOverReplacementApiKey() { + GeneralSettingsVO update = GeneralSettingsVO.builder() + .apiKey("sk-new") + .clearApiKey(true) + .build(); + + settingsService.saveGeneralSettings(update); + + assertThat(update.getApiKey()).isEmpty(); + assertThat(update.isClearApiKey()).isFalse(); + verify(settingsRepository).saveGeneralSettings(update); } @Test @@ -108,18 +159,28 @@ void listDataSourcesShouldReturnEmptyListWhenNoSources() { } @Test - void createDataSourceShouldDelegateToRepository() { + void createDataSourceShouldAssignKeyBeforeSaving() { DataSourceVO input = DataSourceVO.builder().name("New DS").type("rocketmq") .url("new-host:9876").build(); - DataSourceVO saved = DataSourceVO.builder().key("ds-new").name("New DS").type("rocketmq") - .url("new-host:9876").status("connected").build(); - when(settingsRepository.saveDataSource(any(DataSourceVO.class))).thenReturn(saved); + when(settingsRepository.saveDataSource(any(DataSourceVO.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); DataSourceVO result = settingsService.createDataSource(input); - assertThat(result.getKey()).isEqualTo("ds-new"); + assertThat(result.getKey()).isNotBlank(); assertThat(result.getName()).isEqualTo("New DS"); - assertThat(result.getStatus()).isEqualTo("connected"); + verify(settingsRepository).saveDataSource(input); + } + + @Test + void createDataSourceShouldReplaceClientProvidedKey() { + DataSourceVO input = DataSourceVO.builder().key("existing-key").name("New DS").build(); + when(settingsRepository.saveDataSource(any(DataSourceVO.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + DataSourceVO result = settingsService.createDataSource(input); + + assertThat(result.getKey()).isNotBlank().isNotEqualTo("existing-key"); verify(settingsRepository).saveDataSource(input); } diff --git a/web/.env b/web/.env index d3264de4..28ed4877 100644 --- a/web/.env +++ b/web/.env @@ -1,2 +1,6 @@ # Set to "true" to use mock data, "false" to use real API VITE_USE_MOCK=true +# Optional local development proxy target (defaults to http://localhost:8888) +# VITE_API_PROXY_TARGET=http://localhost:8888 +# Optional browser API prefix or absolute API URL (defaults to /api) +# VITE_API_BASE_URL=/api diff --git a/web/Dockerfile b/web/Dockerfile index 0998464d..9687647a 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -8,6 +8,8 @@ RUN npm run build # Stage 2: Serve FROM nginx:alpine +ENV NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 \ + NGINX_ENVSUBST_FILTER=^NGINX_LOCAL_RESOLVERS$ COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/templates/default.conf.template EXPOSE 80 diff --git a/web/nginx.conf b/web/nginx.conf index 165c0f93..65016a0a 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -9,7 +9,7 @@ server { } location /api/ { - resolver 10.89.0.1 valid=10s; + resolver ${NGINX_LOCAL_RESOLVERS} valid=10s; set $backend http://rocketmq-server:8888; proxy_pass $backend; proxy_set_header Host $host; diff --git a/web/package-lock.json b/web/package-lock.json index 647e8692..98a67cb1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -19,26 +19,39 @@ }, "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", "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", "tailwindcss": "^3.4.19", "typescript": "^5.6.0", "typescript-eslint": "^8.62.1", - "vite": "^6.0.0" + "vite": "^6.0.0", + "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", @@ -149,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", @@ -440,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", @@ -1009,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", @@ -1680,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", @@ -1725,6 +2065,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 +2399,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "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": "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": "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": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "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": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "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": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "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": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "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", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "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": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.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,8 +2712,28 @@ "dev": true, "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", + "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", + "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", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" @@ -2296,6 +2787,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", @@ -2319,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", @@ -2436,6 +2951,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2592,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", @@ -2611,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", @@ -2634,6 +3194,13 @@ } } }, + "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" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2650,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", @@ -2664,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", @@ -2692,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", @@ -2723,6 +3321,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "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" + }, "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 +3647,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 +3674,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", @@ -3417,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", @@ -3466,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", @@ -3479,6 +4127,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", @@ -3544,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", @@ -3567,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", @@ -3794,6 +4524,27 @@ "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", + "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", @@ -3803,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", @@ -3874,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", @@ -3974,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", @@ -4040,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", @@ -4067,6 +4862,13 @@ "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4296,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", @@ -5057,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", @@ -5189,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", @@ -5246,6 +6134,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 +6181,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": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "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 +6244,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "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": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", @@ -5377,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", @@ -5447,6 +6376,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 +6410,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "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/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", + "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", @@ -5493,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", @@ -5564,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", @@ -5687,6 +6689,144 @@ } } }, + "node_modules/vitest": { + "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.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "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": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@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": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@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": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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", + "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", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5703,6 +6843,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", @@ -5749,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 3c79ec24..b9dc0ecf 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,8 @@ "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}\"", @@ -33,23 +35,29 @@ }, "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", "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", "tailwindcss": "^3.4.19", "typescript": "^5.6.0", "typescript-eslint": "^8.62.1", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.10" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index eac2795c..593295ac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,10 +33,21 @@ import SystemAlertsPage from './pages/ops/systemAlerts'; import AuditPage from './pages/ops/audit'; import AiPage from './pages/ai'; import SettingsPage from './pages/settings'; +import LlmSettingsPage from './pages/studio/LlmSettings'; +import ProxyPage from './pages/studio/Proxy'; +import LiteTopicPage from './pages/studio/LiteTopic'; +import GroupManagementPage from './pages/studio/GroupManagement'; +import BrokerClusterPage from './pages/studio/BrokerCluster'; +import SslSettingsPage from './pages/studio/SslSettings'; +import AlertManagementPage from './pages/studio/AlertManagement'; +import ProducerPage from './pages/studio/Producer'; +import OpsPage from './pages/studio/Ops'; +import LoginPage from './pages/login'; function App() { return ( + } /> }> } /> } /> @@ -54,6 +65,15 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/web/src/api/acl.test.ts b/web/src/api/acl.test.ts new file mode 100644 index 00000000..499a6c4c --- /dev/null +++ b/web/src/api/acl.test.ts @@ -0,0 +1,127 @@ +/* + * 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 { + createAclRule, + createAclUser, + deleteAclRule, + deleteAclUser, + listAclRules, + updateAclRule, + updateAclUser, +} from './acl'; + +const mock = new MockAdapter(client); + +describe('ACL API contract', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('uses the controller-supported ACL rule filters', async () => { + const params = { clusterId: 'cluster-a', principal: 'orders' }; + mock.onGet('/acl/rules').reply((config) => { + expect(config.params).toEqual(params); + return [200, { code: 200, data: [] }]; + }); + + await expect(listAclRules(params)).resolves.toEqual([]); + }); + + it('returns records created by rule and user APIs', async () => { + const rule = { + id: 'rule-1', + principal: 'orders', + resource: 'orders-*', + resourceType: 'Topic', + resourcePattern: 'PREFIX', + actions: ['PUB'], + decision: 'ALLOW', + scope: 'cluster', + aclVersion: 2, + createdAt: '2026-07-17T00:00:00Z', + }; + const user = { + id: 'user-1', + username: 'orders', + accessKey: 'ak', + secretKey: 'sk', + admin: false, + clusters: ['cluster-a'], + createdAt: '2026-07-17T00:00:00Z', + }; + mock.onPost('/acl/rules/create').reply(200, { code: 200, data: rule }); + mock.onPost('/acl/users/create').reply(200, { code: 200, data: user }); + + await expect(createAclRule({ principal: rule.principal })).resolves.toEqual(rule); + await expect(createAclUser({ username: user.username })).resolves.toEqual(user); + }); + + it('uses backend update and delete endpoints for rules and users', async () => { + const rule = { + id: 'rule-1', + principal: 'orders', + resource: 'orders-*', + resourceType: 'Topic', + resourcePattern: 'PREFIX', + actions: ['SUB'], + decision: 'DENY', + scope: 'cluster', + aclVersion: 2, + createdAt: '2026-07-17T00:00:00Z', + }; + const user = { + id: 'user-1', + username: 'orders', + accessKey: 'ak', + secretKey: 'sk', + admin: true, + clusters: ['cluster-a'], + createdAt: '2026-07-17T00:00:00Z', + }; + mock.onPost('/acl/rules/update').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ id: rule.id, decision: 'DENY' }); + return [200, { code: 200, data: rule }]; + }); + mock.onPost('/acl/users/update').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ id: user.id, admin: true }); + return [200, { code: 200, data: user }]; + }); + mock.onPost('/acl/rules/delete').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ id: rule.id }); + return [200, { code: 200 }]; + }); + mock.onPost('/acl/users/delete').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ id: user.id }); + return [200, { code: 200 }]; + }); + + await expect(updateAclRule({ id: rule.id, decision: 'DENY' })).resolves.toEqual(rule); + await expect(updateAclUser({ id: user.id, admin: true })).resolves.toEqual(user); + await expect(deleteAclRule(rule.id)).resolves.toBeUndefined(); + await expect(deleteAclUser(user.id)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts index 3bd8cd97..14ab320c 100644 --- a/web/src/api/acl.ts +++ b/web/src/api/acl.ts @@ -10,10 +10,15 @@ export interface AclRule { actions: string[]; decision: string; scope: string; - aclVersion: string; + aclVersion: number | string; createdAt: string; } +export interface AclRuleQuery { + clusterId?: string; + principal?: string; +} + export interface AclUser { id: string; username: string; @@ -24,17 +29,19 @@ export interface AclUser { createdAt: string; } -export async function listAclRules(params?: { - keyword?: string; - version?: string; - decision?: string; -}) { +export async function listAclRules(params?: AclRuleQuery) { const res = await client.get<{ data: AclRule[] }>('/acl/rules', { params }); return res.data.data; } export async function createAclRule(data: Partial) { - await client.post('/acl/rules/create', data); + const res = await client.post<{ data: AclRule }>('/acl/rules/create', data); + return res.data.data; +} + +export async function updateAclRule(data: Partial) { + const res = await client.post<{ data: AclRule }>('/acl/rules/update', data); + return res.data.data; } export async function deleteAclRule(id: string) { @@ -47,7 +54,13 @@ export async function listAclUsers(params?: { keyword?: string }) { } export async function createAclUser(data: Partial) { - await client.post('/acl/users/create', data); + const res = await client.post<{ data: AclUser }>('/acl/users/create', data); + return res.data.data; +} + +export async function updateAclUser(data: Partial) { + const res = await client.post<{ data: AclUser }>('/acl/users/update', data); + return res.data.data; } export async function deleteAclUser(id: string) { diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts new file mode 100644 index 00000000..58250ebb --- /dev/null +++ b/web/src/api/ai.test.ts @@ -0,0 +1,174 @@ +/* + * 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 { chatStream, executeAiCommand, listTools, type AiExecuteRequest, type McpTool } from './ai'; + +const mock = new MockAdapter(client); +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 API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { + getItem: vi.fn().mockReturnValue('test-token'), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + 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']); + }); + }); + + 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(); + }); + }); + + 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/ai.ts b/web/src/api/ai.ts index f38bb029..f2d7beb9 100644 --- a/web/src/api/ai.ts +++ b/web/src/api/ai.ts @@ -31,9 +31,59 @@ export interface AiExecuteRequest { tools?: string[]; } +export interface AiChatRequest { + message: string; + mode: string; + model: string; + conversationId?: 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, + data: AiChatRequest, onChunk: (text: string) => void, signal?: AbortSignal, ) { @@ -53,24 +103,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) { 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/api/alertRules.test.ts b/web/src/api/alertRules.test.ts new file mode 100644 index 00000000..682dea8b --- /dev/null +++ b/web/src/api/alertRules.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { + createAlertRule, + deleteAlertRule, + listAlertRules, + toggleAlertRule, + updateAlertRule, +} from './ops'; +import type { AlertRule } from './ops'; + +const mock = new MockAdapter(client); +const rule: AlertRule = { + id: 'rule-1', + name: 'Disk usage', + metric: '磁盘使用率', + operator: '>', + threshold: 80, + thresholdUnit: '%', + duration: '5分钟', + channels: ['email'], + enabled: true, + lastTriggered: null, + description: 'Warn before disk exhaustion', +}; + +describe('alert rules API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads and unwraps alert rules', async () => { + mock.onGet('/alert-rules').reply(200, { code: 200, data: [rule] }); + + await expect(listAlertRules()).resolves.toEqual([rule]); + }); + + it('returns the backend records for create, update, and toggle', async () => { + const updated = { ...rule, threshold: 90, enabled: false }; + mock.onPost('/alert-rules/create').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ name: rule.name }); + return [200, { code: 200, data: rule }]; + }); + mock.onPost('/alert-rules/update').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ id: rule.id, threshold: 90 }); + return [200, { code: 200, data: updated }]; + }); + mock.onPost('/alert-rules/toggle').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ id: rule.id, enabled: false }); + return [200, { code: 200, data: updated }]; + }); + + await expect(createAlertRule({ name: rule.name })).resolves.toEqual(rule); + await expect(updateAlertRule(updated)).resolves.toEqual(updated); + await expect(toggleAlertRule(rule.id, false)).resolves.toEqual(updated); + }); + + it('sends the rule id when deleting', async () => { + mock.onPost('/alert-rules/delete').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ id: rule.id }); + return [200, { code: 200, data: null }]; + }); + + await expect(deleteAlertRule(rule.id)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/audit.test.ts b/web/src/api/audit.test.ts new file mode 100644 index 00000000..2593226d --- /dev/null +++ b/web/src/api/audit.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 { afterEach, describe, expect, it } from 'vitest'; +import MockAdapter from 'axios-mock-adapter'; +import client from './client'; +import { cleanupAuditLogs, listAuditRecords } from './ops'; + +const mock = new MockAdapter(client); + +afterEach(() => { + mock.reset(); +}); + +describe('audit log API', () => { + it('uses the backend PageResult contract for filtered audit queries', async () => { + mock.onGet('/audit-logs').reply((config) => { + expect(config.params).toEqual({ page: 2, pageSize: 10, result: 'SUCCESS' }); + return [200, { code: 200, data: { items: [], total: 12, page: 2, size: 10 } }]; + }); + + await expect(listAuditRecords({ page: 2, pageSize: 10, result: 'SUCCESS' })).resolves.toEqual({ + items: [], + total: 12, + page: 2, + size: 10, + }); + }); + + it('returns the backend cleanup count', async () => { + mock.onPost('/audit-logs/cleanup', { beforeDays: 30 }).reply(200, { + code: 200, + data: { deleted: 3 }, + }); + + await expect(cleanupAuditLogs(30)).resolves.toEqual({ deleted: 3 }); + }); +}); diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts new file mode 100644 index 00000000..c0fcc8fd --- /dev/null +++ b/web/src/api/auth.test.ts @@ -0,0 +1,78 @@ +/* + * 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', + expiresIn: 86400, + user: { + username: 'admin', + admin: true, + }, + }; + 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.expiresIn).toBe(86400); + expect(result.user.username).toBe('admin'); + expect(result.user.admin).toBe(true); + }); + + 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/api/auth.ts b/web/src/api/auth.ts index 5d1530d6..5abeb807 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -25,8 +25,11 @@ export interface LoginRequest { export interface LoginResponse { token: string; - username: string; - role: string; + expiresIn: number; + user: { + username: string; + admin: boolean; + }; } // ─── Auth ─────────────────────────────────────────────────────── 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..6cebadbd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -17,16 +17,39 @@ import axios from 'axios'; import { message } from 'antd'; +import { clearAuthSession, TOKEN_STORAGE_KEY } from '../stores/authStorage'; +import { API_BASE_URL } from '../config'; + +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', + baseURL: API_BASE_URL, timeout: 30000, }); // Request interceptor: attach Authorization header client.interceptors.request.use( (config) => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem(TOKEN_STORAGE_KEY); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -38,16 +61,16 @@ 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; }, (error) => { if (error.response?.status === 401) { - localStorage.removeItem('token'); + clearAuthSession(); window.location.href = '/'; } return Promise.reject(error); diff --git a/web/src/api/cluster.test.ts b/web/src/api/cluster.test.ts new file mode 100644 index 00000000..7019f8b0 --- /dev/null +++ b/web/src/api/cluster.test.ts @@ -0,0 +1,140 @@ +/* + * 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, + createNameServer, + deleteK8sCert, + deleteNameServer, + listK8sCerts, + renewK8sCert, + restartNameServer, + restartProxy, + updateK8sCert, + updateNameServer, + upgradeNameServer, +} 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('renews a certificate using its id', async () => { + const renewed = { ...cert, daysRemaining: 365, status: 'valid' }; + mock.onPost('/k8s-certs/renew').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ id: cert.id }); + return [200, { code: 200, message: 'success', data: renewed }]; + }); + + await expect(renewK8sCert(cert.id)).resolves.toEqual(renewed); + }); + + 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(); + }); + + it('sends NameServer operation payloads to their endpoints', async () => { + const target = { clusterId: 'cluster-1', addr: '127.0.0.1:9876' }; + const requests = [ + ['/nameservers/restart', target], + ['/nameservers/upgrade', { ...target, version: '5.4.0' }], + ['/nameservers/create', target], + ['/nameservers/update', { ...target, newAddr: '127.0.0.2:9876' }], + ['/nameservers/delete', target], + ] as const; + requests.forEach(([url, body]) => { + mock.onPost(url).reply((config) => { + expect(JSON.parse(config.data)).toEqual(body); + return [200, { code: 200, data: null }]; + }); + }); + + await expect(restartNameServer(target)).resolves.toBeUndefined(); + await expect(upgradeNameServer({ ...target, version: '5.4.0' })).resolves.toBeUndefined(); + await expect(createNameServer(target)).resolves.toBeUndefined(); + await expect( + updateNameServer({ ...target, newAddr: '127.0.0.2:9876' }), + ).resolves.toBeUndefined(); + await expect(deleteNameServer(target)).resolves.toBeUndefined(); + }); + + it('sends the proxy restart target', async () => { + const target = { clusterId: 'cluster-1', addr: '127.0.0.1:8081' }; + mock.onPost('/proxies/restart').reply((config) => { + expect(JSON.parse(config.data)).toEqual(target); + return [200, { code: 200, data: null }]; + }); + + await expect(restartProxy(target)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/cluster.ts b/web/src/api/cluster.ts index c7d35ef0..b8de22fa 100644 --- a/web/src/api/cluster.ts +++ b/web/src/api/cluster.ts @@ -21,29 +21,23 @@ import client from './client'; export interface ClusterInfo { id: string; name: string; + nsClusterName: string; type: string; + endpoint: string; status: string; version: string; - brokers: number; - proxies: number; - topics: number; - groups: number; - tpsIn: number; - tpsOut: number; -} - -export interface ClusterDetail extends ClusterInfo { - brokerList: BrokerInfo[]; - proxyList: ProxyInfo[]; - nameServerList: NameServerInfo[]; + brokers: BrokerInfo[]; + proxies: ProxyInfo[]; + nameServers: NameServerInfo[]; config: ClusterConfig; + topicCount: number; + groupCount: number; + tpsHistory: number[]; } export interface BrokerInfo { addr: string; - brokerName: string; - brokerId: number; - clusterName: string; + name: string; status: string; tpsIn: number; tpsOut: number; @@ -53,7 +47,6 @@ export interface BrokerInfo { export interface ProxyInfo { addr: string; - clusterName: string; status: string; connections: number; grpcPort: number; @@ -62,9 +55,7 @@ export interface ProxyInfo { export interface NameServerInfo { addr: string; - clusterName: string; status: string; - version: string; } export interface ClusterConfig { @@ -72,10 +63,12 @@ export interface ClusterConfig { autoCreateTopicEnable: boolean; autoCreateSubscriptionGroup: boolean; maxMessageSize: number; + msgTraceTopicName: string; fileReservedTime: number; writeQueueNums: number; readQueueNums: number; brokerPermission: number; + deleteWhen: string; } export interface K8sCertInfo { @@ -99,7 +92,7 @@ export async function listClusters() { } export async function getCluster(id: string) { - const res = await client.get<{ data: ClusterDetail }>(`/clusters/${id}`); + const res = await client.get<{ data: ClusterInfo }>(`/clusters/${id}`); return res.data.data; } @@ -155,15 +148,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/api/clusterContract.test.ts b/web/src/api/clusterContract.test.ts new file mode 100644 index 00000000..9a5bfeff --- /dev/null +++ b/web/src/api/clusterContract.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { getCluster, listClusters } from './cluster'; +import type { ClusterInfo } from './cluster'; + +const mock = new MockAdapter(client); +const cluster: ClusterInfo = { + id: 'cluster-a', + name: 'cluster-a', + nsClusterName: 'production', + type: 'V5_PROXY_CLUSTER', + endpoint: '127.0.0.1:9876', + status: 'RUNNING', + version: '5.3.0', + brokers: [ + { + name: 'broker-a', + addr: '127.0.0.1:10911', + status: 'RUNNING', + tpsIn: 1, + tpsOut: 2, + diskUsage: 10, + version: '5.3.0', + }, + ], + proxies: [ + { + addr: '127.0.0.1:8080', + status: 'RUNNING', + connections: 3, + grpcPort: 8081, + remotingPort: 8080, + }, + ], + nameServers: [{ addr: '127.0.0.1:9876', status: 'RUNNING' }], + config: { + flushDiskType: 'ASYNC_FLUSH', + autoCreateTopicEnable: true, + autoCreateSubscriptionGroup: true, + maxMessageSize: 4194304, + msgTraceTopicName: 'TRACE_TOPIC', + fileReservedTime: 72, + writeQueueNums: 8, + readQueueNums: 8, + brokerPermission: 6, + deleteWhen: '04', + }, + topicCount: 10, + groupCount: 4, + tpsHistory: [1, 2], +}; + +describe('cluster API contract', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('unwraps the backend ClusterVO shape for list and detail endpoints', async () => { + mock.onGet('/clusters').reply(200, { code: 200, data: [cluster] }); + mock.onGet('/clusters/cluster-a').reply(200, { code: 200, data: cluster }); + + await expect(listClusters()).resolves.toEqual([cluster]); + await expect(getCluster(cluster.id)).resolves.toEqual(cluster); + }); +}); diff --git a/web/src/api/connections.test.ts b/web/src/api/connections.test.ts new file mode 100644 index 00000000..5f5fc5e2 --- /dev/null +++ b/web/src/api/connections.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { listConnections } from './connections'; +import type { ClientConnection } from './connections'; + +const mock = new MockAdapter(client); +const connection: ClientConnection = { + clientId: 'producer-001', + type: 'Producer', + groupOrTopic: 'orders', + protocol: 'gRPC', + address: '127.0.0.1:8081', + language: 'Java', + version: '5.1.0', + connectedAt: '2026-07-17T00:00:00', + clusterName: 'production-cluster', +}; + +describe('client connections API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('uses the query parameters supported by the backend', async () => { + mock.onGet('/clients').reply((config) => { + expect(config.params).toEqual({ clusterId: 'production-cluster', type: 'Producer' }); + return [200, { code: 200, data: [connection] }]; + }); + + await expect( + listConnections({ clusterId: 'production-cluster', type: 'Producer' }), + ).resolves.toEqual([connection]); + }); +}); diff --git a/web/src/api/connections.ts b/web/src/api/connections.ts index 901afad5..88697d70 100644 --- a/web/src/api/connections.ts +++ b/web/src/api/connections.ts @@ -13,11 +13,12 @@ export interface ClientConnection { clusterName: string; } -export async function listConnections(params?: { - keyword?: string; +export interface ClientConnectionQuery { + clusterId?: string; type?: string; - language?: string; -}) { +} + +export async function listConnections(params?: ClientConnectionQuery) { const res = await client.get<{ data: ClientConnection[] }>('/clients', { params }); return res.data.data; } diff --git a/web/src/api/consumerGroups.test.ts b/web/src/api/consumerGroups.test.ts new file mode 100644 index 00000000..7cf943d9 --- /dev/null +++ b/web/src/api/consumerGroups.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { getConsumerGroup, listConsumerGroups, resetConsumerOffset } from './metadata'; + +const mock = new MockAdapter(client); +const group = { + name: 'orders', + namespace: 'default', + clusterId: 'cluster-a', + subscriptionMode: 'Push', + consumeType: 'CLUSTERING', + onlineInstances: 1, + totalLag: 0, + subscribedTopics: ['orders'], + subscriptionDataType: 'NORMAL', + retryMaxTimes: 16, + createdAt: '2026-07-17T00:00:00Z', + updatedAt: '2026-07-17T00:00:00Z', + delaySeconds: 0, + instances: [], +}; + +describe('consumer groups API contract', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('uses the controller-supported list query fields', async () => { + const params = { clusterId: 'cluster-a', search: 'orders' }; + mock.onGet('/groups').reply((config) => { + expect(config.params).toEqual(params); + return [200, { code: 200, data: [group] }]; + }); + + await expect(listConsumerGroups(params)).resolves.toEqual([group]); + }); + + it('unwraps detail records and sends numeric reset timestamps', async () => { + const reset = { name: group.name, topic: 'orders', timestamp: 1784246400000 }; + mock.onGet('/groups/orders').reply(200, { code: 200, data: group }); + mock.onPost('/groups/reset-offset').reply((config) => { + expect(JSON.parse(config.data)).toEqual(reset); + return [200, { code: 200, data: null }]; + }); + + await expect(getConsumerGroup(group.name)).resolves.toEqual(group); + await expect(resetConsumerOffset(reset)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/dataSources.test.ts b/web/src/api/dataSources.test.ts new file mode 100644 index 00000000..60a119ad --- /dev/null +++ b/web/src/api/dataSources.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { + createDataSource, + deleteDataSource, + listDataSources, + testDataSource, + updateDataSource, +} from './settings'; +import type { DataSource } from './settings'; + +const mock = new MockAdapter(client); +const source: DataSource = { + key: 'source-1', + name: 'Prometheus', + type: 'Prometheus', + url: 'http://prometheus:9090', + auth: 'None', + status: 'healthy', +}; + +describe('data sources API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads and returns created or updated data sources', async () => { + mock.onGet('/settings/datasources').reply(200, { code: 200, data: [source] }); + mock.onPost('/settings/datasources/create').reply(200, { code: 200, data: source }); + mock.onPost('/settings/datasources/update').reply(200, { code: 200, data: source }); + + await expect(listDataSources()).resolves.toEqual([source]); + await expect(createDataSource({ name: source.name })).resolves.toEqual(source); + await expect(updateDataSource(source)).resolves.toEqual(source); + }); + + it('uses a key query parameter for deletion and sends test auth details', async () => { + mock.onPost('/settings/datasources/delete').reply((config) => { + expect(config.params).toEqual({ key: source.key }); + return [200, { code: 200, data: null }]; + }); + mock.onPost('/settings/datasources/test').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ + type: source.type, + url: source.url, + auth: source.auth, + }); + return [200, { code: 200, data: { success: true, message: 'Connection successful' } }]; + }); + + await expect(deleteDataSource(source.key)).resolves.toBeUndefined(); + await expect( + testDataSource({ type: source.type, url: source.url, auth: source.auth }), + ).resolves.toEqual({ success: true, message: 'Connection successful' }); + }); +}); diff --git a/web/src/api/dlq.test.ts b/web/src/api/dlq.test.ts new file mode 100644 index 00000000..de4e0b69 --- /dev/null +++ b/web/src/api/dlq.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { listDLQGroups, resendDLQ } from './message'; +import type { DLQGroup } from './message'; + +const mock = new MockAdapter(client); +const group: DLQGroup = { + groupName: 'order-consumer', + dlqTopic: '%DLQ%order-consumer', + messageCount: 3, + lastEnqueueTime: '2026-07-17T00:00:00Z', + retryCount: 16, + status: 'active', +}; + +describe('DLQ API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads and unwraps DLQ groups', async () => { + mock.onGet('/dlq').reply(200, { code: 200, data: [group] }); + + await expect(listDLQGroups()).resolves.toEqual([group]); + }); + + it('sends epoch milliseconds for the resend time range', async () => { + const payload = { + groupName: group.groupName, + startTime: 1784246400000, + endTime: 1784332800000, + targetTopic: 'orders-retry', + }; + mock.onPost('/dlq/resend').reply((config) => { + expect(JSON.parse(config.data)).toEqual(payload); + return [200, { code: 200, data: null }]; + }); + + await expect(resendDLQ(payload)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/generalSettings.test.ts b/web/src/api/generalSettings.test.ts new file mode 100644 index 00000000..b8928fb0 --- /dev/null +++ b/web/src/api/generalSettings.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { getGeneralSettings, saveGeneralSettings } from './settings'; +import type { GeneralSettings, GeneralSettingsUpdate } from './settings'; + +const mock = new MockAdapter(client); +const settings: GeneralSettings = { + theme: 'dark', + compact: true, + desktopNotify: true, + notifySound: false, + sessionTimeout: 60, + requireLogin: true, + llmProvider: 'openai', + apiKeyConfigured: true, + model: 'gpt-5', + baseUrl: 'https://api.example.com/v1', +}; +const editableSettings: GeneralSettingsUpdate = { + theme: settings.theme, + compact: settings.compact, + desktopNotify: settings.desktopNotify, + notifySound: settings.notifySound, + sessionTimeout: settings.sessionTimeout, + requireLogin: settings.requireLogin, + llmProvider: settings.llmProvider, + model: settings.model, + baseUrl: settings.baseUrl, +}; + +describe('general settings API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads the settings shape returned by GeneralSettingsVO', async () => { + mock.onGet('/settings/general').reply(200, { code: 200, data: settings }); + + await expect(getGeneralSettings()).resolves.toEqual(settings); + }); + + it('sends a replacement API key without requiring the stored secret', async () => { + const update: GeneralSettingsUpdate = { ...editableSettings, apiKey: 'sk-new' }; + mock.onPost('/settings/general/save').reply((config) => { + expect(JSON.parse(config.data)).toEqual(update); + return [200, { code: 200, data: null }]; + }); + + await expect(saveGeneralSettings(update)).resolves.toBeUndefined(); + }); + + it('omits blank and response-only API key fields when preserving the stored secret', async () => { + const update = { + ...settings, + apiKey: ' ', + } as GeneralSettingsUpdate & { apiKeyConfigured: boolean }; + mock.onPost('/settings/general/save').reply((config) => { + const body = JSON.parse(config.data); + expect(body).not.toHaveProperty('apiKey'); + expect(body).not.toHaveProperty('apiKeyConfigured'); + return [200, { code: 200, data: null }]; + }); + + await expect(saveGeneralSettings(update)).resolves.toBeUndefined(); + }); + + it('sends the explicit API key clear flag', async () => { + const update: GeneralSettingsUpdate = { ...editableSettings, clearApiKey: true }; + mock.onPost('/settings/general/save').reply((config) => { + expect(JSON.parse(config.data)).toEqual(update); + return [200, { code: 200, data: null }]; + }); + + await expect(saveGeneralSettings(update)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/instance.test.ts b/web/src/api/instance.test.ts new file mode 100644 index 00000000..2bfb0f98 --- /dev/null +++ b/web/src/api/instance.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { createInstance, deleteInstance, listInstances, updateInstance } from './instance'; + +const mock = new MockAdapter(client); +const instance = { + id: 'instance-1', + name: 'orders', + type: 'PROXY' as const, + endpoint: 'proxy:8080', + remark: '', + topicCount: 0, + consumerGroupCount: 0, + createdAt: '2026-07-18T00:00:00Z', + updatedAt: '2026-07-18T00:00:00Z', +}; + +describe('instance API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads and returns persisted instance records', async () => { + mock.onGet('/instances').reply(200, { code: 200, data: [instance] }); + mock.onPost('/instances/create').reply(200, { code: 200, data: instance }); + mock.onPost('/instances/update').reply(200, { code: 200, data: instance }); + + await expect(listInstances()).resolves.toEqual([instance]); + await expect( + createInstance({ name: instance.name, type: instance.type, endpoint: instance.endpoint }), + ).resolves.toEqual(instance); + await expect(updateInstance({ id: instance.id, remark: 'updated' })).resolves.toEqual(instance); + }); + + it('sends the instance id when deleting', async () => { + mock.onPost('/instances/delete').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ id: instance.id }); + return [200, { code: 200, data: null }]; + }); + + await expect(deleteInstance(instance.id)).resolves.toBeUndefined(); + }); +}); diff --git a/web/src/api/liteTopic.test.ts b/web/src/api/liteTopic.test.ts new file mode 100644 index 00000000..352555ca --- /dev/null +++ b/web/src/api/liteTopic.test.ts @@ -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 MockAdapter from 'axios-mock-adapter'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import client from './client'; +import { + queryLiteTopicList, + queryLiteTopicSession, + extendLiteTopicTTL, + queryLiteTopicQuota, + queryLiteTopicCapability, + type LiteTopicItem, + type LiteTopicQuota, + type LiteTopicSession, +} from './liteTopic'; + +const mock = new MockAdapter(client); + +const sampleItem: LiteTopicItem = { + topicPattern: 'order-*', + topicCount: 15, + consumerCount: 3, + totalBacklog: 1200, + averageTTL: 3600, + ttlStatus: 'active', + lastActiveTime: Date.now(), + sessionIds: ['sess-1', 'sess-2'], +}; + +const sampleQuota: LiteTopicQuota = { + currentTopicCount: 50, + maxTopicCount: 200, + currentSessionCount: 10, + maxSessionCount: 100, + currentCreationRate: 5, + maxCreationRate: 50, + defaultTTL: 3600, + maxTTL: 86400, + remainingQuota: 150, + consumerDensity: 0.3, +}; + +const sampleSession: LiteTopicSession = { + sessionId: 'sess-1', + clientId: 'client-001', + clientAddress: '192.168.1.10', + parentTopic: 'order-events', + consumerGroup: 'order-processor', + createTime: Date.now() - 3600000, + lastActiveTime: Date.now(), + ttl: 3600, + ttlRemaining: 1800, + status: 'active', + totalMessages: 5000, + consumedMessages: 4800, + pendingMessages: 200, + popProgress: 96, +}; + +describe('LiteTopic API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('queries lite topic list without filters', async () => { + mock.onGet(/\/liteTopic\/list/).reply(200, { code: 200, data: [sampleItem] }); + + const result = await queryLiteTopicList(); + expect(result).toHaveLength(1); + expect(result[0].topicPattern).toBe('order-*'); + }); + + it('queries lite topic list with pattern and namespace', async () => { + mock.onGet(/\/liteTopic\/list/).reply((config) => { + expect(config.url).toContain('pattern=order'); + expect(config.url).toContain('namespace=ns1'); + return [200, { code: 200, data: [sampleItem] }]; + }); + + const result = await queryLiteTopicList('order', 'ns1'); + expect(result).toHaveLength(1); + }); + + it('queries lite topic session by id', async () => { + mock.onGet('/liteTopic/session/sess-1').reply(200, { code: 200, data: sampleSession }); + + const result = await queryLiteTopicSession('sess-1'); + expect(result.sessionId).toBe('sess-1'); + expect(result.popProgress).toBe(96); + }); + + it('extends lite topic TTL', async () => { + mock.onPost('/liteTopic/extendTTL').reply((config) => { + const body = JSON.parse(config.data); + expect(body.topicPattern).toBe('order-*'); + expect(body.newTTL).toBe(7200); + return [200, { code: 200 }]; + }); + + await extendLiteTopicTTL('order-*', 7200); + }); + + it('queries lite topic quota without namespace', async () => { + mock.onGet(/\/liteTopic\/quota/).reply(200, { code: 200, data: sampleQuota }); + + const result = await queryLiteTopicQuota(); + expect(result.currentTopicCount).toBe(50); + expect(result.maxTopicCount).toBe(200); + expect(result.defaultTTL).toBe(3600); + }); + + it('queries lite topic quota with namespace', async () => { + mock.onGet(/\/liteTopic\/quota/).reply((config) => { + expect(config.url).toContain('namespace=ns1'); + return [200, { code: 200, data: sampleQuota }]; + }); + + const result = await queryLiteTopicQuota('ns1'); + expect(result.currentTopicCount).toBe(50); + }); + + it('queries lite topic capability as supported', async () => { + mock.onGet('/liteTopic/capability').reply(200, { code: 200, data: { supported: true } }); + + const result = await queryLiteTopicCapability(); + expect(result.supported).toBe(true); + }); + + it('queries lite topic capability as unsupported', async () => { + mock.onGet('/liteTopic/capability').reply(200, { code: 200, data: { supported: false } }); + + const result = await queryLiteTopicCapability(); + expect(result.supported).toBe(false); + }); +}); diff --git a/web/src/api/liteTopic.ts b/web/src/api/liteTopic.ts new file mode 100644 index 00000000..f080ef21 --- /dev/null +++ b/web/src/api/liteTopic.ts @@ -0,0 +1,105 @@ +/* + * 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'; + +// ─── Interfaces ────────────────────────────────────────────────── + +export interface LiteTopicQuota { + currentTopicCount: number; + maxTopicCount: number; + currentSessionCount: number; + maxSessionCount: number; + currentCreationRate: number; + maxCreationRate: number; + usageRate?: number; + sessionUsageRate?: number; + defaultTTL?: number; + maxTTL?: number; + remainingQuota?: number; + consumerDensity?: number; +} + +export interface LiteTopicItem { + topicPattern: string; + topicCount?: number; + consumerCount?: number; + totalBacklog?: number; + averageTTL?: number; + ttlStatus?: string; + lastActiveTime?: number; + sessionIds?: string[]; +} + +export interface LiteTopicSession { + sessionId: string; + clientId?: string; + clientAddress?: string; + parentTopic?: string; + consumerGroup?: string; + createTime?: number; + lastActiveTime?: number; + ttl?: number; + ttlRemaining?: number; + status?: string; + totalMessages?: number; + consumedMessages?: number; + pendingMessages?: number; + popProgress?: number; + liteTopicCreationCount?: number; + liteTopics?: { topicName: string; status: string; ttlRemaining?: number }[]; +} + +export interface LiteTopicCapability { + supported: boolean; +} + +// ─── API Functions ─────────────────────────────────────────────── + +export async function queryLiteTopicList( + pattern?: string, + namespace?: string, +): Promise { + const params = new URLSearchParams(); + if (pattern) params.append('pattern', pattern); + if (namespace) params.append('namespace', namespace); + const res = await client.get<{ data: LiteTopicItem[] }>(`/liteTopic/list?${params.toString()}`); + return res.data.data; +} + +export async function queryLiteTopicSession(sessionId: string): Promise { + const res = await client.get<{ data: LiteTopicSession }>( + `/liteTopic/session/${encodeURIComponent(sessionId)}`, + ); + return res.data.data; +} + +export async function extendLiteTopicTTL(topicPattern: string, newTTL: number): Promise { + await client.post('/liteTopic/extendTTL', { topicPattern, newTTL }); +} + +export async function queryLiteTopicQuota(namespace?: string): Promise { + const params = new URLSearchParams(); + if (namespace) params.append('namespace', namespace); + const res = await client.get<{ data: LiteTopicQuota }>(`/liteTopic/quota?${params.toString()}`); + return res.data.data; +} + +export async function queryLiteTopicCapability(): Promise { + const res = await client.get<{ data: LiteTopicCapability }>('/liteTopic/capability'); + return res.data.data; +} diff --git a/web/src/api/llm.test.ts b/web/src/api/llm.test.ts new file mode 100644 index 00000000..63bcaf50 --- /dev/null +++ b/web/src/api/llm.test.ts @@ -0,0 +1,135 @@ +/* + * 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 { + getLlmConfig, + saveLlmConfig, + testLlmConnection, + getLlmModels, + type LlmConfig, +} from './llm'; + +const mock = new MockAdapter(client); + +const sampleConfig: LlmConfig = { + provider: 'openai', + apiKey: 'sk-test-key-1234', + apiBase: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 4096, + temperature: 0.7, + enabled: true, +}; + +describe('LLM API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('fetches LLM config', async () => { + mock.onGet('/llm/config').reply(200, sampleConfig); + + const result = await getLlmConfig(); + expect(result).toEqual(sampleConfig); + expect(result.provider).toBe('openai'); + expect(result.enabled).toBe(true); + }); + + it('saves LLM config and returns success result', async () => { + mock.onPost('/llm/config').reply((config) => { + const body = JSON.parse(config.data); + expect(body.provider).toBe('openai'); + expect(body.model).toBe('gpt-4o'); + return [200, { status: 0, msg: 'saved' }]; + }); + + const result = await saveLlmConfig(sampleConfig); + expect(result.status).toBe(0); + expect(result.msg).toBe('saved'); + }); + + it('tests LLM connection with success', async () => { + mock.onPost('/llm/config/test').reply((config) => { + const body = JSON.parse(config.data); + expect(body.apiKey).toBe('sk-test-key-1234'); + return [200, { status: 0, msg: 'Connection successful' }]; + }); + + const result = await testLlmConnection(sampleConfig); + expect(result.status).toBe(0); + expect(result.msg).toBe('Connection successful'); + }); + + it('tests LLM connection with failure', async () => { + mock.onPost('/llm/config/test').reply(200, { + status: 1, + errMsg: 'Invalid API Key', + }); + + const result = await testLlmConnection(sampleConfig); + expect(result.status).toBe(1); + expect(result.errMsg).toBe('Invalid API Key'); + }); + + it('fetches available models', async () => { + const models = [ + { id: 'gpt-4o', name: 'GPT-4o' }, + { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' }, + ]; + mock.onGet('/llm/models').reply(200, { status: 0, data: models }); + + const result = await getLlmModels(); + expect(result.status).toBe(0); + expect(result.data).toHaveLength(2); + expect(result.data![0].id).toBe('gpt-4o'); + }); + + it('handles empty models list', async () => { + mock.onGet('/llm/models').reply(200, { status: 0, data: [] }); + + const result = await getLlmModels(); + expect(result.status).toBe(0); + expect(result.data).toHaveLength(0); + }); + + it('saves config with Azure extra fields', async () => { + const azureConfig: LlmConfig = { + ...sampleConfig, + provider: 'azure', + deploymentName: 'my-gpt4', + apiVersion: '2024-02-15-preview', + }; + mock.onPost('/llm/config').reply((config) => { + const body = JSON.parse(config.data); + expect(body.provider).toBe('azure'); + expect(body.deploymentName).toBe('my-gpt4'); + expect(body.apiVersion).toBe('2024-02-15-preview'); + return [200, { status: 0 }]; + }); + + await saveLlmConfig(azureConfig); + }); +}); diff --git a/web/src/api/llm.ts b/web/src/api/llm.ts new file mode 100644 index 00000000..871366ac --- /dev/null +++ b/web/src/api/llm.ts @@ -0,0 +1,67 @@ +/* + * 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 LlmConfig { + provider: string; + apiKey: string; + apiBase: string; + model: string; + maxTokens: number; + temperature: number; + enabled: boolean; + deploymentName?: string; + apiVersion?: string; + awsRegion?: string; +} + +export interface LlmTestResult { + status: number; + msg?: string; + errMsg?: string; +} + +export interface LlmModelItem { + id?: string; + name?: string; +} + +export interface LlmModelsResult { + status: number; + data?: LlmModelItem[]; +} + +export async function getLlmConfig(): Promise { + const res = await client.get('/llm/config'); + return res.data; +} + +export async function saveLlmConfig(config: LlmConfig): Promise { + const res = await client.post('/llm/config', config); + return res.data; +} + +export async function testLlmConnection(config: LlmConfig): Promise { + const res = await client.post('/llm/config/test', config); + return res.data; +} + +export async function getLlmModels(): Promise { + const res = await client.get('/llm/models'); + return res.data; +} diff --git a/web/src/api/message.test.ts b/web/src/api/message.test.ts new file mode 100644 index 00000000..5119a1a5 --- /dev/null +++ b/web/src/api/message.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { getMessageTrace, queryMessages } from './message'; + +const mock = new MockAdapter(client); + +describe('message API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('sends the backend-supported query fields with epoch timestamps', async () => { + const params = { + topic: 'orders', + key: 'order-1', + startTime: 1784246400000, + endTime: 1784332800000, + }; + mock.onGet('/messages').reply((config) => { + expect(config.params).toEqual(params); + return [200, { code: 200, data: [] }]; + }); + + await expect(queryMessages(params)).resolves.toEqual([]); + }); + + it('sorts message query results by store time descending', async () => { + mock.onGet('/messages').reply(200, { + code: 200, + data: [ + { + msgId: 'msg-old', + topic: 'orders', + tag: 'created', + key: 'order-1', + body: '{}', + storeTime: '2026-07-23T10:00:00.000Z', + bornHost: '10.0.0.1:1000', + storeHost: '10.0.0.2:10911', + properties: {}, + size: 2, + }, + { + msgId: 'msg-new', + topic: 'orders', + tag: 'created', + key: 'order-2', + body: '{}', + storeTime: 1784804400000, + bornHost: '10.0.0.1:1001', + storeHost: '10.0.0.2:10911', + properties: {}, + size: 2, + }, + ], + }); + + await expect(queryMessages({ topic: 'orders' })).resolves.toMatchObject([ + { msgId: 'msg-new' }, + { msgId: 'msg-old' }, + ]); + }); + + it('unwraps trace records with numeric timestamps', async () => { + const trace = { + nodes: [ + { + title: 'Stored', + timestamp: 1784246400000, + status: 'finish', + costTime: 3, + description: 'ok', + }, + ], + consumerStatus: [ + { + group: 'cg-orders', + deliveryStatus: 'SUCCESS', + consumeTime: 1784246401000, + retryCount: 0, + }, + ], + }; + mock.onGet('/messages/msg-1/trace').reply(200, { code: 200, data: trace }); + + await expect(getMessageTrace('msg-1')).resolves.toEqual(trace); + }); +}); diff --git a/web/src/api/message.ts b/web/src/api/message.ts index 25fdc6a2..716c387c 100644 --- a/web/src/api/message.ts +++ b/web/src/api/message.ts @@ -7,7 +7,7 @@ export interface MessageRecord { tag: string; key: string; body: string; - storeTime: string; + storeTime: number | string; bornHost: string; storeHost: string; properties: Record; @@ -16,19 +16,17 @@ export interface MessageRecord { export interface TraceNode { title: string; - timestamp: string; + timestamp: number | string; costTime: number; - host: string; - status: string; - detail?: string; + status: 'error' | 'wait' | 'process' | 'finish'; + description: string; } export interface ConsumerStatus { group: string; - clientAddr: string; - status: string; - consumeTps: number; - lastConsumedTime: string; + deliveryStatus: string; + consumeTime: number | string; + retryCount: number; } export interface TraceRecord { @@ -36,6 +34,24 @@ export interface TraceRecord { consumerStatus: ConsumerStatus[]; } +export interface MessageQuery { + topic?: string; + key?: string; + msgId?: string; + startTime?: number; + endTime?: number; +} + +const toStoreTimestamp = (storeTime: MessageRecord['storeTime']): number => { + if (typeof storeTime === 'number') return storeTime; + + const parsed = Date.parse(storeTime); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +export const sortMessagesByStoreTimeDesc = (messages: MessageRecord[]): MessageRecord[] => + [...messages].sort((a, b) => toStoreTimestamp(b.storeTime) - toStoreTimestamp(a.storeTime)); + // Matches mock/dlq.ts export interface DLQGroup { groupName: string; @@ -47,16 +63,9 @@ export interface DLQGroup { } // ─── Messages ─────────────────────────────────────────────────── -export async function queryMessages(params: { - mode: string; - topic?: string; - key?: string; - msgId?: string; - startTime?: string; - endTime?: string; -}) { +export async function queryMessages(params: MessageQuery) { const res = await client.get<{ data: MessageRecord[] }>('/messages', { params }); - return res.data.data; + return sortMessagesByStoreTimeDesc(res.data.data); } export async function getMessageTrace(msgId: string) { @@ -72,8 +81,8 @@ export async function listDLQGroups() { export async function resendDLQ(data: { groupName: string; - startTime: string; - endTime: string; + startTime: number; + endTime: number; targetTopic?: string; }) { await client.post('/dlq/resend', data); diff --git a/web/src/api/metadata.test.ts b/web/src/api/metadata.test.ts new file mode 100644 index 00000000..4b18492d --- /dev/null +++ b/web/src/api/metadata.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { createTopic, deleteTopic, listTopics, sendTopicMessage } from './metadata'; + +const mock = new MockAdapter(client); + +describe('topic metadata API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('uses the topic query fields supported by the backend', async () => { + const params = { clusterId: 'cluster-a', type: 'NORMAL', search: 'orders' }; + mock.onGet('/topics').reply((config) => { + expect(config.params).toEqual(params); + return [200, { code: 200, data: [] }]; + }); + + await expect(listTopics(params)).resolves.toEqual([]); + }); + + it('persists topic creation, deletion, and sending through API endpoints', async () => { + const topic = { + name: 'orders', + namespace: 'default', + type: 'NORMAL', + clusterId: 'cluster-a', + writeQueues: 8, + readQueues: 8, + perm: 'RW', + messageCount: 0, + tps: 0, + consumerGroupCount: 0, + remark: '', + createdAt: '2026-07-17T00:00:00Z', + updatedAt: '2026-07-17T00:00:00Z', + }; + mock.onPost('/topics/create').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ + name: topic.name, + writeQueues: 8, + readQueues: 8, + }); + return [200, { code: 200, data: topic }]; + }); + mock.onPost('/topics/delete').reply((config) => { + expect(JSON.parse(config.data)).toEqual({ name: topic.name }); + return [200, { code: 200, data: null }]; + }); + mock.onPost('/topics/send').reply((config) => { + expect(JSON.parse(config.data)).toMatchObject({ topic: topic.name, body: '{"id":1}' }); + return [200, { code: 200, data: { msgId: 'msg-1', sendTime: 1, offsetMsgId: 'offset-1' } }]; + }); + + await expect(createTopic(topic)).resolves.toEqual(topic); + await expect(deleteTopic(topic.name)).resolves.toBeUndefined(); + await expect(sendTopicMessage({ topic: topic.name, body: '{"id":1}' })).resolves.toMatchObject({ + msgId: 'msg-1', + }); + }); +}); diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts index 4c882b18..abb45c91 100644 --- a/web/src/api/metadata.ts +++ b/web/src/api/metadata.ts @@ -22,6 +22,12 @@ export interface Topic { updatedAt: string; } +export interface TopicQuery { + clusterId?: string; + type?: string; + search?: string; +} + export interface BrokerRoute { brokerName: string; brokerAddr: string; @@ -52,13 +58,22 @@ export interface ConsumerGroup { deliveryOrderType?: string; retryMaxTimes: number; createdAt: string; + updatedAt: string; + delaySeconds: number; + instances: ConsumerInstance[]; } export interface ConsumerInstance { clientId: string; - clientAddr: string; - language: string; - version: string; + protocol: string; + address: string; + subscribedTopics: string[]; + lastHeartbeat: string; + topicLag: Record; +} + +export interface ConsumerGroupDetail extends ConsumerGroup { + instances: ConsumerInstance[]; } export interface QueueProgress { @@ -77,8 +92,19 @@ export interface SubscriptionEntry { consistency: string; } +export interface ConsumerGroupQuery { + clusterId?: string; + search?: string; +} + +export interface ResetConsumerOffsetRequest { + name: string; + timestamp: number; + topic?: string; +} + // ─── Topic API ────────────────────────────────────────────────── -export async function listTopics(params?: { keyword?: string; type?: string; namespace?: string }) { +export async function listTopics(params?: TopicQuery) { const res = await client.get<{ data: Topic[] }>('/topics', { params }); return res.data.data; } @@ -89,7 +115,8 @@ export async function createTopic(data: Partial) { } export async function updateTopic(data: Partial) { - await client.post('/topics/update', data); + const res = await client.post<{ data: Topic }>('/topics/update', data); + return res.data.data; } export async function deleteTopic(name: string) { @@ -126,15 +153,13 @@ export async function sendTopicMessage(data: SendTopicMessageRequest) { } // ─── Consumer Group API ───────────────────────────────────────── -export async function listConsumerGroups(params?: { keyword?: string }) { +export async function listConsumerGroups(params?: ConsumerGroupQuery) { const res = await client.get<{ data: ConsumerGroup[] }>('/groups', { params }); return res.data.data; } export async function getConsumerGroup(name: string) { - const res = await client.get<{ data: ConsumerGroup & { instances: ConsumerInstance[] } }>( - `/groups/${name}`, - ); + const res = await client.get<{ data: ConsumerGroupDetail }>(`/groups/${name}`); return res.data.data; } @@ -149,14 +174,21 @@ export async function getConsumerSubscriptions(name: string) { } export async function createConsumerGroup(data: Partial) { - await client.post('/groups/create', data); + const res = await client.post<{ data: ConsumerGroup }>('/groups/create', data); + return res.data.data; } export async function deleteConsumerGroup(name: string) { await client.post('/groups/delete', { name }); } -export async function resetConsumerOffset(data: Record) { +export interface ResetConsumerOffsetRequest { + name: string; + timestamp: number; + topic?: string; +} + +export async function resetConsumerOffset(data: ResetConsumerOffsetRequest) { await client.post('/groups/reset-offset', data); } diff --git a/web/src/api/metrics.test.ts b/web/src/api/metrics.test.ts new file mode 100644 index 00000000..9db303e5 --- /dev/null +++ b/web/src/api/metrics.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { getDashboard, queryMetrics } from './metrics'; + +const mock = new MockAdapter(client); +const dashboard = { + stats: { + totalClusters: 1, + healthyClusters: 1, + totalBrokers: 2, + totalProxies: 1, + totalNameServers: 1, + totalTopics: 4, + totalConsumerGroups: 3, + totalMessagesToday: 100, + messagesPerSecond: 10, + tpsIn: 8, + tpsOut: 7, + }, + clusters: [], +}; + +describe('metrics API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('loads dashboard data from the dashboard endpoint', async () => { + mock.onGet('/dashboard').reply(200, { code: 200, data: dashboard }); + + await expect(getDashboard()).resolves.toEqual(dashboard); + }); + + it('posts a metrics query and returns its result', async () => { + const query = { metric: 'TPS_IN', start: 1, end: 2, step: '1m' }; + mock.onPost('/metrics/query').reply((config) => { + expect(JSON.parse(config.data)).toEqual(query); + return [200, { code: 200, data: [{ timestamp: 1, value: 8 }] }]; + }); + + await expect(queryMetrics(query)).resolves.toEqual([{ timestamp: 1, value: 8 }]); + }); +}); 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; } diff --git a/web/src/api/ops.test.ts b/web/src/api/ops.test.ts new file mode 100644 index 00000000..c10952c1 --- /dev/null +++ b/web/src/api/ops.test.ts @@ -0,0 +1,252 @@ +/* + * 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 } as never); + }); + + 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: { + items: [ + { + id: 'r1', + timestamp: '2026-01-01', + operator: 'admin', + operationType: 'CREATE', + target: 'topic', + detail: 'Created topic', + ipAddress: '127.0.0.1', + result: 'SUCCESS', + }, + ], + total: 1, + page: 1, + size: 20, + }, + }); + const result = await listAuditRecords({ page: 1 }); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.size).toBe(20); + }); + + 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..8df738b7 100644 --- a/web/src/api/ops.ts +++ b/web/src/api/ops.ts @@ -37,6 +37,23 @@ export interface AuditRecord { result: string; } +export interface PageResult { + items: T[]; + total: number; + page: number; + size: number; +} + +export interface AuditQuery { + page?: number; + pageSize?: number; + search?: string; + operationType?: string; + startDate?: string; + endDate?: string; + result?: string; +} + // ─── Alert Rules ──────────────────────────────────────────────── export async function listAlertRules() { const res = await client.get<{ data: AlertRule[] }>('/alert-rules'); @@ -44,15 +61,18 @@ export async function listAlertRules() { } export async function createAlertRule(data: Partial) { - await client.post('/alert-rules/create', data); + const res = await client.post<{ data: AlertRule }>('/alert-rules/create', data); + return res.data.data; } -export async function updateAlertRule(data: Partial) { - await client.post('/alert-rules/update', data); +export async function updateAlertRule(data: AlertRule) { + const res = await client.post<{ data: AlertRule }>('/alert-rules/update', data); + return res.data.data; } export async function toggleAlertRule(id: string, enabled: boolean) { - await client.post('/alert-rules/toggle', { id, enabled }); + const res = await client.post<{ data: AlertRule }>('/alert-rules/toggle', { id, enabled }); + return res.data.data; } export async function deleteAlertRule(id: string) { @@ -70,17 +90,50 @@ export async function acknowledgeAlert(id: string) { } export async function clearAcknowledgedAlerts() { - await client.post('/system-alerts/clear-acknowledged'); + const res = await client.post<{ data: { cleared: number } }>('/system-alerts/clear-acknowledged'); + return res.data.data; } // ─── Audit Logs ───────────────────────────────────────────────── -export async function listAuditRecords(params?: Record) { - const res = await client.get<{ data: { list: AuditRecord[]; total: number } }>('/audit-logs', { +export async function listAuditRecords(params?: AuditQuery) { + const res = await client.get<{ data: PageResult }>('/audit-logs', { params, }); return res.data.data; } export async function cleanupAuditLogs(beforeDays: number) { - await client.post('/audit-logs/cleanup', { beforeDays }); + const res = await client.post<{ data: { deleted: number } }>('/audit-logs/cleanup', { + beforeDays, + }); + return res.data.data; +} + +// ─── 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/api/producer.test.ts b/web/src/api/producer.test.ts new file mode 100644 index 00000000..b5b3f31c --- /dev/null +++ b/web/src/api/producer.test.ts @@ -0,0 +1,94 @@ +/* + * 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 Studio topic records sorted alphabetically', async () => { + mock.onGet('/topics').reply(200, { + code: 200, + data: [{ name: 'order-events' }, { name: 'user-signup' }, { name: 'batch-process' }], + }); + + const result = await fetchTopicList(); + expect(result).toEqual(['batch-process', 'order-events', 'user-signup']); + }); + + it('keeps compatibility with legacy topicList responses', 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..26c31aba --- /dev/null +++ b/web/src/api/producer.ts @@ -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. + */ + +import client from './client'; + +// ─── Types ────────────────────────────────────────────────────── +export interface ProducerConnection { + clientId: string; + clientAddr: string; + language: string; + versionDesc: string; +} + +interface TopicRecord { + name: string; +} + +interface TopicListResponse { + data?: TopicRecord[]; + topicList?: string[]; +} + +// ─── API ──────────────────────────────────────────────────────── + +/** Fetch all topic names */ +export async function fetchTopicList(): Promise { + const res = await client.get('/topics'); + const topics = res.data.data?.map((topic) => topic.name) ?? res.data.topicList ?? []; + return topics.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/api/proxy.test.ts b/web/src/api/proxy.test.ts new file mode 100644 index 00000000..3defd5b1 --- /dev/null +++ b/web/src/api/proxy.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { queryProxyHomePage, addProxyAddr } from './proxy'; + +const mock = new MockAdapter(client); + +describe('Proxy API', () => { + beforeEach(() => { + mock.reset(); + vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) }); + }); + + afterEach(() => { + mock.reset(); + vi.unstubAllGlobals(); + }); + + it('queries proxy home page data', async () => { + const data = { + proxyAddrList: ['192.168.1.1:8081', '192.168.1.2:8081'], + currentProxyAddr: '192.168.1.1:8081', + }; + mock.onGet('/proxy/homePage.query').reply(200, { code: 200, data }); + + const result = await queryProxyHomePage(); + expect(result.proxyAddrList).toHaveLength(2); + expect(result.currentProxyAddr).toBe('192.168.1.1:8081'); + }); + + it('handles empty proxy address list', async () => { + mock + .onGet('/proxy/homePage.query') + .reply(200, { code: 200, data: { proxyAddrList: [], currentProxyAddr: '' } }); + + const result = await queryProxyHomePage(); + expect(result.proxyAddrList).toHaveLength(0); + expect(result.currentProxyAddr).toBe(''); + }); + + it('adds a proxy address with form-urlencoded content type', async () => { + mock.onPost('/proxy/addProxyAddr.do').reply((config) => { + expect(config.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(config.data).toBe('newProxyAddr=192.168.1.3%3A8081'); + return [200, { code: 200 }]; + }); + + await addProxyAddr('192.168.1.3:8081'); + }); + + it('adds a proxy address with localhost', async () => { + mock.onPost('/proxy/addProxyAddr.do').reply((config) => { + expect(config.data).toBe('newProxyAddr=localhost%3A8081'); + return [200, { code: 200 }]; + }); + + await addProxyAddr('localhost:8081'); + }); +}); diff --git a/web/src/api/proxy.ts b/web/src/api/proxy.ts new file mode 100644 index 00000000..9342f61d --- /dev/null +++ b/web/src/api/proxy.ts @@ -0,0 +1,53 @@ +/* + * 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'; + +// ─── Interfaces ────────────────────────────────────────────────── + +export interface ProxyHomePageData { + proxyAddrList: string[]; + currentProxyAddr: string; +} + +export interface ProxyNode { + key: string; + address: string; + status: 'healthy' | 'unhealthy' | 'warning'; + version: string; + connections: number; + tps: number; + memory: number; + cpu: number; + uptime: string; + isSelected: boolean; +} + +// ─── API Functions ─────────────────────────────────────────────── + +export async function queryProxyHomePage(): Promise { + const res = await client.get<{ data: ProxyHomePageData }>('/proxy/homePage.query'); + return res.data.data; +} + +export async function addProxyAddr(address: string): Promise { + const params = new URLSearchParams(); + params.append('newProxyAddr', address); + await client.post('/proxy/addProxyAddr.do', params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); +} diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts index 8f32b235..19829f70 100644 --- a/web/src/api/settings.ts +++ b/web/src/api/settings.ts @@ -19,21 +19,29 @@ import client from './client'; // ─── Types ────────────────────────────────────────────────────── export interface GeneralSettings { - siteName: string; - language: string; theme: string; - autoRefreshInterval: number; - notificationChannels: string[]; + compact: boolean; + desktopNotify: boolean; + notifySound: boolean; + sessionTimeout: number; + requireLogin: boolean; llmProvider: string; - llmModel: string; + apiKeyConfigured: boolean; + model: string; + baseUrl: string; } +export type GeneralSettingsUpdate = Omit & { + apiKey?: string; + clearApiKey?: boolean; +}; + export interface DataSource { - id: string; + key: string; name: string; type: string; url: string; - isDefault: boolean; + auth: string; status: string; } @@ -43,8 +51,11 @@ export async function getGeneralSettings() { return res.data.data; } -export async function saveGeneralSettings(data: Partial) { - await client.post('/settings/general/save', data); +export async function saveGeneralSettings(data: GeneralSettingsUpdate) { + const payload = { ...data } as GeneralSettingsUpdate & { apiKeyConfigured?: boolean }; + delete payload.apiKeyConfigured; + if (!payload.apiKey?.trim()) delete payload.apiKey; + await client.post('/settings/general/save', payload); } // ─── Data Sources ─────────────────────────────────────────────── @@ -54,18 +65,20 @@ export async function listDataSources() { } export async function createDataSource(data: Partial) { - await client.post('/settings/datasources/create', data); + const res = await client.post<{ data: DataSource }>('/settings/datasources/create', data); + return res.data.data; } export async function updateDataSource(data: Partial) { - await client.post('/settings/datasources/update', data); + const res = await client.post<{ data: DataSource }>('/settings/datasources/update', data); + return res.data.data; } -export async function deleteDataSource(id: string) { - await client.post('/settings/datasources/delete', { id }); +export async function deleteDataSource(key: string) { + await client.post('/settings/datasources/delete', undefined, { params: { key } }); } -export async function testDataSource(data: { type: string; url: string }) { +export async function testDataSource(data: { type: string; url: string; auth?: string }) { const res = await client.post<{ data: { success: boolean; message: string } }>( '/settings/datasources/test', data, diff --git a/web/src/components/MiniBar.tsx b/web/src/components/MiniBar.tsx index abddcf39..70db81c7 100644 --- a/web/src/components/MiniBar.tsx +++ b/web/src/components/MiniBar.tsx @@ -20,14 +20,25 @@ interface MiniBarProps { color?: string; height?: number; width?: number; + label?: string; } -const MiniBar = ({ data, color = '#1677ff', height = 32, width = 120 }: MiniBarProps) => { +const MiniBar = ({ data, color = '#1677ff', height = 32, width = 120, label }: MiniBarProps) => { + if (!data.length) { + return ( + + — + + ); + } + const max = Math.max(...data, 1); const barWidth = Math.max(2, (width - (data.length - 1) * 2) / data.length); return (
( +const PageHeader = ({ title, subtitle, extra, headingLevel = 1 }: PageHeaderProps) => ( - + <Title level={headingLevel} style={{ margin: 0, fontSize: 20, fontWeight: 600 }}> {title} {subtitle && ( diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx index 83922f38..1a38781f 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/components/StatusBadge.tsx @@ -17,6 +17,7 @@ import { Badge, Space, Typography } from 'antd'; import { STATUS_MAP } from '../constants/theme'; +import { useLang } from '../i18n/LangContext'; const { Text } = Typography; @@ -27,11 +28,12 @@ interface StatusBadgeProps { } const StatusBadge = ({ status, text, showDot = true }: StatusBadgeProps) => { + const { t } = useLang(); const config = STATUS_MAP[status] || STATUS_MAP.offline; - const label = text || config.label; + const label = text || t(config.labelKey); return ( - - {showDot && } + + {showDot && ); diff --git a/web/src/components/__tests__/StatusBadge.test.tsx b/web/src/components/__tests__/StatusBadge.test.tsx new file mode 100644 index 00000000..07f08c7e --- /dev/null +++ b/web/src/components/__tests__/StatusBadge.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 StatusBadge from '../StatusBadge'; +import { LangProvider } from '../../i18n/LangContext'; + +const renderWithLang = (ui: React.ReactElement) => render({ui}); + +describe('StatusBadge', () => { + it('renders the translated label for a known status in Chinese', () => { + renderWithLang(); + // theme.healthy in zh is '运行中' + expect(screen.getByText('运行中')).toBeInTheDocument(); + }); + + it('renders the translated label for warning status', () => { + renderWithLang(); + // theme.warning in zh is '告警' + expect(screen.getByText('告警')).toBeInTheDocument(); + }); + + it('renders the translated label for error status', () => { + renderWithLang(); + // theme.error in zh is '异常' + expect(screen.getByText('异常')).toBeInTheDocument(); + }); + + it('renders the translated label for offline status', () => { + renderWithLang(); + expect(screen.getByText('离线')).toBeInTheDocument(); + }); + + it('renders the translated label for connecting status', () => { + renderWithLang(); + expect(screen.getByText('连接中')).toBeInTheDocument(); + }); + + it('falls back to offline config for unknown status', () => { + renderWithLang(); + // Should render offline label + expect(screen.getByText('离线')).toBeInTheDocument(); + }); + + it('uses custom text when provided', () => { + renderWithLang(); + expect(screen.getByText('自定义')).toBeInTheDocument(); + }); + + it('hides the dot when showDot is false', () => { + const { container } = renderWithLang(); + // No Badge component should be rendered + const badges = container.querySelectorAll('.ant-badge'); + expect(badges.length).toBe(0); + }); + + it('has role=status for accessibility', () => { + const { container } = renderWithLang(); + const statusEl = container.querySelector('[role="status"]'); + expect(statusEl).toBeInTheDocument(); + }); +}); diff --git a/web/src/config.ts b/web/src/config.ts index ec4e3644..8d58a72c 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -5,3 +5,6 @@ */ export const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'; + +/** API prefix for browser requests. Defaults to the reverse-proxy friendly `/api`. */ +export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); diff --git a/web/src/constants/__tests__/theme.test.ts b/web/src/constants/__tests__/theme.test.ts new file mode 100644 index 00000000..c3945f0d --- /dev/null +++ b/web/src/constants/__tests__/theme.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 { describe, it, expect } from 'vitest'; +import { STATUS_MAP, CLUSTER_TYPE_MAP, TOPIC_TYPE_MAP, PROTOCOL_MAP, THEME_COLORS } from '../theme'; + +describe('theme constants', () => { + describe('STATUS_MAP', () => { + it('has labelKey for all status entries', () => { + for (const [key, val] of Object.entries(STATUS_MAP)) { + expect(val.labelKey, `STATUS_MAP[${key}].labelKey should exist`).toBeDefined(); + expect(val.labelKey, `STATUS_MAP[${key}].labelKey should start with theme.`).toMatch( + /^theme\./, + ); + } + }); + + it('has color and dot for all status entries', () => { + for (const [key, val] of Object.entries(STATUS_MAP)) { + expect(val.color, `STATUS_MAP[${key}].color should exist`).toBeDefined(); + expect(val.dot, `STATUS_MAP[${key}].dot should exist`).toBeDefined(); + } + }); + + it('contains required status keys', () => { + expect(STATUS_MAP.healthy).toBeDefined(); + expect(STATUS_MAP.warning).toBeDefined(); + expect(STATUS_MAP.error).toBeDefined(); + expect(STATUS_MAP.offline).toBeDefined(); + expect(STATUS_MAP.connecting).toBeDefined(); + }); + }); + + describe('CLUSTER_TYPE_MAP', () => { + it('has labelKey for all cluster type entries', () => { + for (const [key, val] of Object.entries(CLUSTER_TYPE_MAP)) { + expect(val.labelKey, `CLUSTER_TYPE_MAP[${key}].labelKey should exist`).toBeDefined(); + expect(val.color, `CLUSTER_TYPE_MAP[${key}].color should exist`).toBeDefined(); + } + }); + + it('contains V4_DIRECT, V5_PROXY_LOCAL, V5_PROXY_CLUSTER', () => { + expect(CLUSTER_TYPE_MAP.V4_DIRECT).toBeDefined(); + expect(CLUSTER_TYPE_MAP.V5_PROXY_LOCAL).toBeDefined(); + expect(CLUSTER_TYPE_MAP.V5_PROXY_CLUSTER).toBeDefined(); + }); + }); + + describe('TOPIC_TYPE_MAP', () => { + it('has labelKey for all topic type entries', () => { + for (const [key, val] of Object.entries(TOPIC_TYPE_MAP)) { + expect(val.labelKey, `TOPIC_TYPE_MAP[${key}].labelKey should exist`).toBeDefined(); + expect(val.color, `TOPIC_TYPE_MAP[${key}].color should exist`).toBeDefined(); + } + }); + + it('contains NORMAL, FIFO, DELAY, TRANSACTION, LITE', () => { + expect(TOPIC_TYPE_MAP.NORMAL).toBeDefined(); + expect(TOPIC_TYPE_MAP.FIFO).toBeDefined(); + expect(TOPIC_TYPE_MAP.DELAY).toBeDefined(); + expect(TOPIC_TYPE_MAP.TRANSACTION).toBeDefined(); + expect(TOPIC_TYPE_MAP.LITE).toBeDefined(); + }); + }); + + describe('PROTOCOL_MAP', () => { + it('has labelKey for all protocol entries', () => { + for (const [key, val] of Object.entries(PROTOCOL_MAP)) { + expect(val.labelKey, `PROTOCOL_MAP[${key}].labelKey should exist`).toBeDefined(); + expect(val.color, `PROTOCOL_MAP[${key}].color should exist`).toBeDefined(); + } + }); + }); + + describe('THEME_COLORS', () => { + it('contains primary colors', () => { + expect(THEME_COLORS.primary).toBeDefined(); + expect(THEME_COLORS.success).toBeDefined(); + expect(THEME_COLORS.warning).toBeDefined(); + expect(THEME_COLORS.error).toBeDefined(); + }); + }); +}); diff --git a/web/src/constants/theme.ts b/web/src/constants/theme.ts index 2cedb9bc..58ff5406 100644 --- a/web/src/constants/theme.ts +++ b/web/src/constants/theme.ts @@ -29,29 +29,41 @@ export const THEME_COLORS = { clusterV5Cluster: '#722ed1', } as const; -export const CLUSTER_TYPE_MAP: Record = { - V4_DIRECT: { label: 'V4 直连', color: 'orange' }, - V5_PROXY_LOCAL: { label: 'V5 Proxy 单节点', color: 'blue' }, - V5_PROXY_CLUSTER: { label: 'V5 Proxy 集群', color: 'purple' }, +/** + * Cluster type map — labels are i18n keys, resolved at render time via t(). + */ +export const CLUSTER_TYPE_MAP: Record = { + V4_DIRECT: { labelKey: 'theme.clusterV4', color: 'orange' }, + V5_PROXY_LOCAL: { labelKey: 'theme.clusterV5Local', color: 'blue' }, + V5_PROXY_CLUSTER: { labelKey: 'theme.clusterV5Cluster', color: 'purple' }, }; -export const STATUS_MAP: Record = { - healthy: { label: '运行中', color: '#52c41a', dot: '#52c41a' }, - warning: { label: '告警', color: '#faad14', dot: '#faad14' }, - error: { label: '异常', color: '#ff4d4f', dot: '#ff4d4f' }, - offline: { label: '离线', color: '#d9d9d9', dot: '#d9d9d9' }, - connecting: { label: '连接中', color: '#1677ff', dot: '#1677ff' }, +/** + * Status map — labels are i18n keys, resolved at render time via t(). + */ +export const STATUS_MAP: Record = { + healthy: { labelKey: 'theme.healthy', color: '#52c41a', dot: '#52c41a' }, + warning: { labelKey: 'theme.warning', color: '#faad14', dot: '#faad14' }, + error: { labelKey: 'theme.error', color: '#ff4d4f', dot: '#ff4d4f' }, + offline: { labelKey: 'theme.offline', color: '#d9d9d9', dot: '#d9d9d9' }, + connecting: { labelKey: 'theme.connecting', color: '#1677ff', dot: '#1677ff' }, }; -export const TOPIC_TYPE_MAP: Record = { - NORMAL: { label: '普通', color: 'default' }, - FIFO: { label: '顺序', color: 'blue' }, - DELAY: { label: '延迟', color: 'orange' }, - TRANSACTION: { label: '事务', color: 'purple' }, - LITE: { label: 'LiteTopic', color: 'magenta' }, +/** + * Topic type map — labels are i18n keys, resolved at render time via t(). + */ +export const TOPIC_TYPE_MAP: Record = { + NORMAL: { labelKey: 'theme.topicNormal', color: 'default' }, + FIFO: { labelKey: 'theme.topicFifo', color: 'blue' }, + DELAY: { labelKey: 'theme.topicDelay', color: 'orange' }, + TRANSACTION: { labelKey: 'theme.topicTransaction', color: 'purple' }, + LITE: { labelKey: 'theme.topicLite', color: 'magenta' }, }; -export const PROTOCOL_MAP: Record = { - REMOTING: { label: 'Remoting', color: 'geekblue' }, - GRPC: { label: 'gRPC', color: 'green' }, +/** + * Protocol map — labels are i18n keys, resolved at render time via t(). + */ +export const PROTOCOL_MAP: Record = { + REMOTING: { labelKey: 'theme.protocolRemoting', color: 'geekblue' }, + GRPC: { labelKey: 'theme.protocolGrpc', color: 'green' }, }; diff --git a/web/src/i18n/LangContext.tsx b/web/src/i18n/LangContext.tsx index 76671569..aedf7176 100644 --- a/web/src/i18n/LangContext.tsx +++ b/web/src/i18n/LangContext.tsx @@ -17,6 +17,7 @@ import { createContext, useContext, useState, type ReactNode } from 'react'; import translations, { type Lang } from './translations'; +import { getInitialLanguage, persistLanguage } from './languagePreference'; interface LangContextType { lang: Lang; @@ -31,7 +32,12 @@ const LangContext = createContext({ }); export const LangProvider = ({ children }: { children: ReactNode }) => { - const [lang, setLang] = useState('zh'); + const [lang, setLangState] = useState(getInitialLanguage); + + const setLang = (nextLang: Lang) => { + setLangState(nextLang); + persistLanguage(nextLang); + }; const t = (key: string, params?: Record): string => { let text = translations[key]?.[lang] ?? key; @@ -47,3 +53,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..5a81cab8 --- /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'); + }); +}); diff --git a/web/src/i18n/languagePreference.test.ts b/web/src/i18n/languagePreference.test.ts new file mode 100644 index 00000000..5ab08456 --- /dev/null +++ b/web/src/i18n/languagePreference.test.ts @@ -0,0 +1,38 @@ +/* + * 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 } from 'vitest'; +import { getInitialLanguage, LANGUAGE_STORAGE_KEY, persistLanguage } from './languagePreference'; + +describe('language preference', () => { + afterEach(() => { + localStorage.clear(); + }); + + it('defaults to Chinese when no valid preference is stored', () => { + expect(getInitialLanguage()).toBe('zh'); + localStorage.setItem(LANGUAGE_STORAGE_KEY, 'fr'); + expect(getInitialLanguage()).toBe('zh'); + }); + + it('persists a supported language for the next application load', () => { + persistLanguage('en'); + + expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe('en'); + expect(getInitialLanguage()).toBe('en'); + }); +}); diff --git a/web/src/i18n/languagePreference.ts b/web/src/i18n/languagePreference.ts new file mode 100644 index 00000000..7354eb66 --- /dev/null +++ b/web/src/i18n/languagePreference.ts @@ -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. + */ + +import type { Lang } from './translations'; + +export const LANGUAGE_STORAGE_KEY = 'rocketmq-studio-language'; + +export function getInitialLanguage(): Lang { + try { + const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY); + return stored === 'en' || stored === 'zh' ? stored : 'zh'; + } catch { + return 'zh'; + } +} + +export function persistLanguage(lang: Lang): void { + try { + localStorage.setItem(LANGUAGE_STORAGE_KEY, lang); + } catch { + // Language selection still works when browser storage is unavailable. + } +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 1916eb09..3b37ff10 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -87,6 +87,14 @@ const translations: Record> = { 'dashboard.group': { zh: 'Group', en: 'Group' }, 'dashboard.trend': { zh: '趋势', en: 'Trend' }, 'dashboard.millionMessages': { zh: '约 {n}M 条消息', en: '~{n}M messages' }, + 'dashboard.tpsTrend': { zh: 'TPS 趋势', en: 'TPS Trend' }, + 'dashboard.tpsInLabel': { zh: 'TPS In', en: 'TPS In' }, + 'dashboard.tpsOutLabel': { zh: 'TPS Out', en: 'TPS Out' }, + 'dashboard.brokers': { zh: '{n} Broker', en: '{n} Brokers' }, + 'dashboard.proxies': { zh: '{n} Proxy', en: '{n} Proxies' }, + 'dashboard.consumerGroups': { zh: '{n} 消费组', en: '{n} Groups' }, + 'dashboard.healthy': { zh: '健康', en: 'Healthy' }, + 'dashboard.last12h': { zh: '近 12 小时', en: 'Last 12 hours' }, // ─── Cluster Page ─── 'cluster.title': { zh: 'RocketMQ 集群', en: 'RocketMQ Cluster' }, @@ -332,12 +340,982 @@ const translations: Record> = { 'acl.required': { zh: '请选择{field}', en: 'Please select {field}' }, 'acl.inputRequired': { zh: '请输入{field}', en: 'Please enter {field}' }, + // ─── Topic Page (unique keys, duplicates merged into Topic section below) ─── + 'topic.name': { zh: 'Topic 名称', en: 'Topic Name' }, + 'topic.action': { zh: '操作', en: 'Actions' }, + 'topic.detail': { zh: '详情', en: 'Detail' }, + 'topic.route': { zh: '路由', en: 'Route' }, + 'topic.send': { zh: '发送', en: 'Send' }, + 'topic.delete': { zh: '删除', en: 'Delete' }, + 'topic.deleteContent': { + zh: '确定要删除 Topic「{name}」吗?此操作不可撤销。', + en: 'Are you sure to delete Topic "{name}"? This cannot be undone.', + }, + 'topic.allTypes': { zh: '全部类型', en: 'All Types' }, + 'topic.normal': { zh: '普通', en: 'Normal' }, + 'topic.fifo': { zh: '顺序', en: 'FIFO' }, + 'topic.delay': { zh: '延迟', en: 'Delay' }, + 'topic.transaction': { zh: '事务', en: 'Transaction' }, + 'topic.lite': { zh: 'LiteTopic', en: 'LiteTopic' }, + 'topic.batchDelete': { zh: '批量删除', en: 'Batch Delete' }, + 'topic.bodyOrder': { zh: '订单事件', en: 'Order Event' }, + 'topic.bodyUserEvent': { zh: '用户行为', en: 'User Event' }, + 'topic.bodyPayment': { zh: '支付回调', en: 'Payment Callback' }, + 'topic.bodyInventory': { zh: '库存变更', en: 'Inventory Change' }, + 'topic.bodyNotification': { zh: '通知消息', en: 'Notification' }, + 'topic.bodyMetrics': { zh: '监控指标', en: 'Metrics' }, + 'topic.randomBody': { zh: '随机消息体', en: 'Random Body' }, + 'topic.sendTestMessage': { zh: '发送测试消息', en: 'Send Test Message' }, + 'topic.tag': { zh: 'Tag', en: 'Tag' }, + 'topic.key': { zh: 'Key', en: 'Key' }, + 'topic.messageBody': { zh: '消息体', en: 'Message Body' }, + 'topic.properties': { zh: '属性', en: 'Properties' }, + 'topic.topicConfig': { zh: 'Topic 配置', en: 'Topic Config' }, + 'topic.queueCount': { zh: '队列数', en: 'Queue Count' }, + + // ─── Consumer Page ─── + 'consumer.name': { zh: 'Group 名称', en: 'Group Name' }, + 'consumer.subType': { zh: '订阅组类型', en: 'Sub Type' }, + 'consumer.subMode': { zh: '订阅模式', en: 'Sub Mode' }, + 'consumer.onlineClients': { zh: '在线客户端', en: 'Online Clients' }, + 'consumer.totalLag': { zh: '总堆积量', en: 'Total Lag' }, + 'consumer.delay': { zh: '消费延迟', en: 'Consume Delay' }, + 'consumer.createdAt': { zh: '创建时间', en: 'Created' }, + 'consumer.updatedAt': { zh: '修改时间', en: 'Updated' }, + 'consumer.action': { zh: '操作', en: 'Actions' }, + 'consumer.detail': { zh: '详情', en: 'Detail' }, + 'consumer.resetOffset': { zh: '重置位点', en: 'Reset Offset' }, + 'consumer.confirmDelete': { + zh: '确认删除消费组 "{name}"?', + en: 'Delete consumer group "{name}"?', + }, + 'consumer.deleteWarning': { + zh: '删除后该消费组的所有配置和消费进度将被清除,此操作不可恢复。', + en: 'All configurations and progress will be removed. This cannot be undone.', + }, + 'consumer.deleted': { zh: '消费组 {name} 已删除', en: 'Consumer group {name} deleted' }, + 'consumer.searchPlaceholder': { zh: '搜索 Group 名称或 Topic', en: 'Search group name or topic' }, + 'consumer.allModes': { zh: '全部模式', en: 'All Modes' }, + 'consumer.push': { zh: 'Push', en: 'Push' }, + 'consumer.pull': { zh: 'Pull', en: 'Pull' }, + 'consumer.sortNameAsc': { zh: '名称升序', en: 'Name A-Z' }, + 'consumer.sortLagDesc': { zh: '堆积量降序', en: 'Lag Descending' }, + 'consumer.createGroup': { zh: '新建消费组', en: 'Create Group' }, + 'consumer.subTopic': { zh: 'Topic 主题', en: 'Topic' }, + 'consumer.consistency': { zh: '订阅一致性', en: 'Consistency' }, + 'consumer.consistent': { zh: '一致', en: 'Consistent' }, + 'consumer.inconsistent': { zh: '不一致', en: 'Inconsistent' }, + 'consumer.filterMode': { zh: '订阅模式', en: 'Filter Mode' }, + 'consumer.filterAll': { zh: '全量', en: 'Full' }, + 'consumer.filterTag': { zh: 'Tag 过滤', en: 'Tag Filter' }, + 'consumer.filterSql92': { zh: 'SQL92 过滤', en: 'SQL92 Filter' }, + 'consumer.expression': { zh: '订阅表达式', en: 'Expression' }, + 'consumer.viewDistribution': { zh: '查看分布', en: 'View Distribution' }, + 'consumer.resetToTime': { zh: '重置到指定时间', en: 'Reset to Time' }, + 'consumer.resetSuccess': { zh: '位点重置成功', en: 'Offset reset successfully' }, + + // ─── Home Page (additional) ─── + 'home.banner': { + zh: 'RocketMQ Studio — 跨集群 · 跨架构 · 跨云的统一管控平台', + en: 'RocketMQ Studio — Cross-cluster · Cross-arch · Cross-cloud unified management', + }, + 'home.placeholder': { + zh: '向 RocketMQ Bot 提问,全程加密、安全、可信', + en: 'Ask RocketMQ Bot, fully encrypted, secure, trusted', + }, + 'home.tools': { zh: '工具', en: 'Tools' }, + 'home.promptEnhance': { zh: 'Prompt 增强', en: 'Prompt Enhance' }, + 'home.docs': { zh: '文档中心', en: 'Documentation' }, + 'home.community': { zh: 'RocketMQ 社区', en: 'RocketMQ Community' }, + 'home.brand': { zh: 'RocketMQ Studio 出品', en: 'Powered by RocketMQ Studio' }, + + // ─── AI Page (additional) ─── + 'ai.recommended': { zh: '推荐', en: 'Rec.' }, + + // ─── Theme Constants ─── + 'theme.clusterV4': { zh: 'V4 直连', en: 'V4 Direct' }, + 'theme.clusterV5Local': { zh: 'V5 Proxy 单节点', en: 'V5 Proxy Local' }, + 'theme.clusterV5Cluster': { zh: 'V5 Proxy 集群', en: 'V5 Proxy Cluster' }, + 'theme.healthy': { zh: '运行中', en: 'Healthy' }, + 'theme.warning': { zh: '告警', en: 'Warning' }, + 'theme.error': { zh: '异常', en: 'Error' }, + 'theme.offline': { zh: '离线', en: 'Offline' }, + 'theme.connecting': { zh: '连接中', en: 'Connecting' }, + 'theme.topicNormal': { zh: '普通', en: 'Normal' }, + 'theme.topicFifo': { zh: '顺序', en: 'FIFO' }, + 'theme.topicDelay': { zh: '延迟', en: 'Delay' }, + 'theme.topicTransaction': { zh: '事务', en: 'Transaction' }, + 'theme.topicLite': { zh: 'LiteTopic', en: 'LiteTopic' }, + 'theme.protocolRemoting': { zh: 'Remoting', en: 'Remoting' }, + 'theme.protocolGrpc': { zh: 'gRPC', en: 'gRPC' }, + // ─── Settings ─── 'settings.title': { zh: '设置', en: 'Settings' }, + 'settings.subtitle': { zh: '管理应用配置与数据源', en: 'Manage app settings and data sources' }, + + // ─── Certs ─── + 'cert.clusterName': { zh: 'K8s 集群名称', en: 'K8s Cluster Name' }, + 'cert.certName': { zh: '证书名称', en: 'Certificate Name' }, + 'cert.issuer': { zh: '签发者', en: 'Issuer' }, + 'cert.expiryTime': { zh: '到期时间', en: 'Expiry Time' }, + 'cert.daysRemaining': { zh: '剩余天数', en: 'Days Remaining' }, + 'cert.statusValid': { zh: '有效', en: 'Valid' }, + 'cert.statusExpiring': { zh: '即将过期', en: 'Expiring' }, + 'cert.statusExpired': { zh: '已过期', en: 'Expired' }, + 'cert.confirmDelete': { zh: '确认删除', en: 'Confirm Delete' }, + 'cert.deleteConfirm': { + zh: '确定要删除证书"{name}"吗?', + en: 'Are you sure to delete certificate "{name}"?', + }, + 'cert.deleted': { zh: '证书已删除: {name}', en: 'Certificate deleted: {name}' }, + 'cert.addCert': { zh: '添加证书', en: 'Add Certificate' }, + 'cert.searchPlaceholder': { zh: '搜索证书名称或集群', en: 'Search cert name or cluster' }, + 'cert.editCert': { zh: '编辑证书 — {name}', en: 'Edit Certificate — {name}' }, + 'cert.certUpdated': { zh: '证书「{name}」已更新', en: 'Certificate "{name}" updated' }, + 'cert.namespace': { zh: '命名空间', en: 'Namespace' }, + 'cert.issuerPlaceholder': { zh: '例:kubernetes-ca', en: 'e.g. kubernetes-ca' }, + 'cert.namespacePlaceholder': { zh: '例:kube-system', en: 'e.g. kube-system' }, + 'cert.featureWip': { zh: '添加证书功能开发中', en: 'Add certificate feature is in development' }, + 'cert.totalCount': { zh: '共 {count} 个证书', en: '{count} certificates total' }, + + // ─── Cluster ─── + 'cluster.brokerCount': { zh: 'Broker 数', en: 'Broker Count' }, + 'cluster.topicCount': { zh: 'Topic 数', en: 'Topic Count' }, + 'cluster.messageCount': { zh: '消息总量', en: 'Message Total' }, + 'cluster.totalTps': { zh: '总 TPS', en: 'Total TPS' }, + 'cluster.avgTps': { zh: '平均 TPS', en: 'Avg TPS' }, + 'cluster.consumerGroupCount': { zh: '消费者组数', en: 'Consumer Group Count' }, + 'cluster.createClusterWip': { + zh: '新建集群功能开发中', + en: 'Create cluster feature is in development', + }, + 'cluster.configTitle': { zh: '配置 - {name}', en: 'Config - {name}' }, + 'cluster.configUpdated': { zh: '配置已更新', en: 'Configuration updated' }, + 'cluster.flushDiskType': { zh: '刷盘方式', en: 'Flush Disk Type' }, + 'cluster.syncFlush': { zh: '同步刷盘', en: 'Sync Flush' }, + 'cluster.asyncFlush': { zh: '异步刷盘', en: 'Async Flush' }, + 'cluster.autoCreateTopic': { zh: '自动创建 Topic', en: 'Auto Create Topic' }, + 'cluster.autoCreateSubGroup': { zh: '自动创建订阅组', en: 'Auto Create Subscription Group' }, + 'cluster.maxMessageSize': { zh: '最大消息大小 (MB)', en: 'Max Message Size (MB)' }, + 'cluster.fileReservedTime': { zh: '文件保留时长 (小时)', en: 'File Retention Time (hours)' }, + 'cluster.writeQueues': { zh: '写队列数', en: 'Write Queues' }, + 'cluster.readQueues': { zh: '读队列数', en: 'Read Queues' }, + 'cluster.brokerPermission': { zh: 'Broker 权限', en: 'Broker Permission' }, + 'cluster.viewDetail': { zh: '查看详情: {addr}', en: 'View detail: {addr}' }, + 'cluster.restartProxyConfirm': { + zh: '确定要重启 Proxy "{addr}" 吗?', + en: 'Are you sure to restart Proxy "{addr}"?', + }, + 'cluster.restartProxySubmitted': { + zh: 'Proxy 重启已提交: {addr}', + en: 'Proxy restart submitted: {addr}', + }, + + // ─── Topic ─── + 'topic.type': { zh: '类型', en: 'Type' }, + 'topic.status': { zh: '状态', en: 'Status' }, + 'topic.serving': { zh: '服务中', en: 'Serving' }, + 'topic.createdAt': { zh: '创建时间', en: 'Created At' }, + 'topic.updatedAt': { zh: '修改时间', en: 'Updated At' }, + 'topic.topicName': { zh: 'Topic 名称', en: 'Topic Name' }, + 'topic.namespace': { zh: '命名空间', en: 'Namespace' }, + 'topic.cluster': { zh: '集群', en: 'Cluster' }, + 'topic.writeQueues': { zh: '写队列数', en: 'Write Queues' }, + 'topic.readQueues': { zh: '读队列数', en: 'Read Queues' }, + 'topic.perm': { zh: '权限', en: 'Permission' }, + 'topic.messageCount': { zh: '今日消息量', en: "Today's Messages" }, + 'topic.tps': { zh: 'TPS', en: 'TPS' }, + 'topic.consumerGroupCount': { zh: '消费者组数', en: 'Consumer Groups' }, + 'topic.quickFill': { zh: '快速填入:', en: 'Quick fill:' }, + 'topic.customProps': { zh: '自定义属性(可选)', en: 'Custom Properties (optional)' }, + 'topic.allNamespaces': { zh: '全部', en: 'All' }, + 'topic.remark': { zh: '备注', en: 'Remark' }, + 'topic.confirmDelete': { zh: '确认删除', en: 'Confirm Delete' }, + 'topic.deleteConfirm': { + zh: '确定要删除 Topic「{name}」吗?此操作不可撤销。', + en: 'Are you sure to delete Topic "{name}"? This action cannot be undone.', + }, + 'topic.deleted': { zh: 'Topic「{name}」已删除', en: 'Topic "{name}" deleted' }, + 'topic.permRW': { zh: '读写', en: 'Read/Write' }, + 'topic.permRO': { zh: '只读', en: 'Read-only' }, + 'topic.permWO': { zh: '只写', en: 'Write-only' }, + 'topic.brokerName': { zh: 'Broker 名称', en: 'Broker Name' }, + 'topic.brokerAddr': { zh: 'Broker 地址', en: 'Broker Address' }, + 'topic.writeQueue': { zh: '写队列', en: 'Write Queue' }, + 'topic.readQueue': { zh: '读队列', en: 'Read Queue' }, + 'topic.consumerGroup': { zh: '消费者组', en: 'Consumer Group' }, + 'topic.consumeMode': { zh: '消费模式', en: 'Consume Mode' }, + 'topic.consumeTps': { zh: '消费 TPS', en: 'Consume TPS' }, + 'topic.backlog': { zh: '堆积量', en: 'Backlog' }, + 'topic.broadcast': { zh: '广播消费', en: 'Broadcast' }, + 'topic.clustering': { zh: '集群消费', en: 'Clustering' }, + 'topic.basicInfo': { zh: '基本信息', en: 'Basic Info' }, + 'topic.routeInfo': { zh: '路由信息', en: 'Route Info' }, + 'topic.consumerInfo': { zh: '消费者', en: 'Consumers' }, + 'topic.createTopic': { zh: '创建 Topic', en: 'Create Topic' }, + 'topic.createSuccess': { + zh: 'Topic「{name}」创建成功', + en: 'Topic "{name}" created successfully', + }, + 'topic.topicNamePlaceholder': { zh: '请输入 Topic 名称', en: 'Enter topic name' }, + 'topic.topicNameRule': { + zh: '仅支持字母、数字、下划线、中划线、斜杠和星号', + en: 'Only letters, numbers, underscore, hyphen, slash and asterisk', + }, + 'topic.topicNameRequired': { zh: '请输入 Topic 名称', en: 'Please enter topic name' }, + 'topic.queueExtra': { zh: '每个 Broker 节点 8 个队列', en: '8 queues per Broker node' }, + 'topic.remarkPlaceholder': { + zh: '可选,描述 Topic 用途', + en: 'Optional, describe the topic purpose', + }, + 'topic.sendMsg': { zh: '发送消息到 {name}', en: 'Send message to {name}' }, + 'topic.sendSuccess': { + zh: '消息发送成功!MsgId: {id}', + en: 'Message sent successfully! MsgId: {id}', + }, + 'topic.tagPlaceholder': { zh: '可选,消息标签', en: 'Optional, message tag' }, + 'topic.keyPlaceholder': { + zh: '可选,消息 Key(用于查询)', + en: 'Optional, message key (for query)', + }, + 'topic.bodyLabel': { zh: '消息体 Body', en: 'Message Body' }, + 'topic.bodyRequired': { zh: '请输入消息体', en: 'Please enter message body' }, + 'topic.bodyPlaceholder': { zh: 'JSON 格式消息体', en: 'JSON format message body' }, + 'topic.propName': { zh: '属性名', en: 'Property Name' }, + 'topic.propValue': { zh: '属性值', en: 'Property Value' }, + 'topic.addProp': { zh: '添加属性', en: 'Add Property' }, + 'topic.confirmBatchDelete': { zh: '确认批量删除', en: 'Confirm Batch Delete' }, + 'topic.batchDeleteConfirm': { + zh: '确定要删除选中的 {count} 个 Topic 吗?此操作不可撤销。', + en: 'Are you sure to delete {count} selected topics? This action cannot be undone.', + }, + 'topic.batchDeleted': { zh: '已删除 {count} 个 Topic', en: '{count} topics deleted' }, + 'topic.importWip': { zh: '导入功能开发中', en: 'Import feature is in development' }, + 'topic.exported': { zh: '已导出 {count} 个 Topic', en: '{count} topics exported' }, + 'topic.totalCount': { zh: '共 {count} 个 Topic', en: '{count} topics total' }, + 'topic.searchPlaceholder': { zh: '搜索 Topic 名称', en: 'Search topic name' }, + 'topic.typeFilter': { zh: '类型筛选', en: 'Type filter' }, + 'topic.listView': { zh: '列表', en: 'List' }, + 'topic.cardView': { zh: '卡片', en: 'Card' }, + 'topic.showTotal': { zh: '共 {total} 条', en: '{total} total' }, + 'topic.orderEvent': { zh: '订单事件', en: 'Order Event' }, + 'topic.userEvent': { zh: '用户行为', en: 'User Event' }, + 'topic.paymentCallback': { zh: '支付回调', en: 'Payment Callback' }, + 'topic.inventoryChange': { zh: '库存变更', en: 'Inventory Change' }, + 'topic.notification': { zh: '通知消息', en: 'Notification' }, + 'topic.metrics': { zh: '监控指标', en: 'Metrics' }, + + // ─── Consumer ─── + 'consumer.subscriptionMode': { zh: '订阅模式', en: 'Subscription Mode' }, + 'consumer.subGroupType': { zh: '订阅组类型', en: 'Subscription Group Type' }, + 'consumer.maxRetry': { zh: '最大重试次数', en: 'Max Retries' }, // ─── 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' }, + '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' }, + + // ─── 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 的创建、配置与删除', + 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.unspecified': { zh: '未指定', en: 'Unspecified' }, + 'topic.readQueueNums': { zh: '读队列数量', en: 'Read Queue Num' }, + 'topic.writeQueueNums': { zh: '写队列数量', en: 'Write Queue Num' }, + '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.resetOffset': { zh: '重置位点', en: 'Reset Offset' }, + 'topic.skipAccumulate': { zh: '跳过堆积', en: 'Skip Accumulate' }, + '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.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' }, + 'proxy.version': { zh: '版本', en: 'Version' }, + 'proxy.status': { zh: '状态', en: 'Status' }, + 'proxy.healthy': { zh: '健康', en: 'Healthy' }, + 'proxy.unhealthy': { zh: '不健康', en: 'Unhealthy' }, + 'proxy.warning': { zh: '警告', en: 'Warning' }, + 'proxy.confirmRemove': { zh: '确认移除此节点?', en: 'Are you sure to remove this node?' }, + + // ─── Broker Cluster ─── + 'brokerCluster.title': { zh: 'Broker 集群', en: 'Broker Cluster' }, + 'brokerCluster.k8sCluster': { zh: 'K8s 集群', en: 'K8s Cluster' }, + 'brokerCluster.brokerName': { zh: 'Broker 名称', en: 'Broker Name' }, + 'brokerCluster.status': { zh: '状态', en: 'Status' }, + 'brokerCluster.statusRunning': { zh: '运行中', en: 'Running' }, + 'brokerCluster.statusReadonly': { zh: '只读', en: 'Read Only' }, + 'brokerCluster.statusMaintenance': { zh: '维护中', en: 'Maintenance' }, + 'brokerCluster.version': { zh: '版本', en: 'Version' }, + 'brokerCluster.diskUsage': { zh: '磁盘使用', en: 'Disk Usage' }, + 'brokerCluster.tpsIn': { zh: '入站 TPS', en: 'TPS In' }, + 'brokerCluster.tpsOut': { zh: '出站 TPS', en: 'TPS Out' }, + 'brokerCluster.config': { zh: '配置', en: 'Config' }, + 'brokerCluster.restart': { zh: '重启', en: 'Restart' }, + 'brokerCluster.manual': { zh: '手动', en: 'Manual' }, + 'brokerCluster.createCluster': { zh: '创建集群', en: 'Create Cluster' }, + 'brokerCluster.nsName': { zh: 'NameServer 名称', en: 'NameServer Name' }, + 'brokerCluster.nsManagement': { zh: 'NameServer 管理', en: 'NameServer Management' }, + 'brokerCluster.brokerManagement': { zh: 'Broker 管理', en: 'Broker Management' }, + 'brokerCluster.proxyName': { zh: 'Proxy 名称', en: 'Proxy Name' }, + 'brokerCluster.proxyManagement': { zh: 'Proxy 管理', en: 'Proxy Management' }, + 'brokerCluster.httpAddr': { zh: 'HTTP 地址', en: 'HTTP Address' }, + 'brokerCluster.grpcAddr': { zh: 'gRPC 地址', en: 'gRPC Address' }, + 'brokerCluster.connections': { zh: '连接数', en: 'Connections' }, + + // ─── Group Management ─── + 'groupMgmt.title': { zh: '消费组管理', en: 'Consumer Group Management' }, + 'groupMgmt.groupName': { zh: '消费组名称', en: 'Group Name' }, + 'groupMgmt.namespace': { zh: '命名空间', en: 'Namespace' }, + 'groupMgmt.cluster': { zh: '集群', en: 'Cluster' }, + 'groupMgmt.onlineInstances': { zh: '在线实例', en: 'Online Instances' }, + 'groupMgmt.consumeMode': { zh: '消费模式', en: 'Consume Mode' }, + 'groupMgmt.clustering': { zh: '集群消费', en: 'Clustering' }, + 'groupMgmt.broadcasting': { zh: '广播消费', en: 'Broadcasting' }, + 'groupMgmt.diff': { zh: '堆积量', en: 'Diff' }, + 'groupMgmt.backlogAlert': { zh: '堆积告警', en: 'Backlog Alert' }, + 'groupMgmt.stopped': { zh: '已停止', en: 'Stopped' }, + 'groupMgmt.createGroup': { zh: '创建消费组', en: 'Create Group' }, + 'groupMgmt.searchPlaceholder': { zh: '搜索消费组', en: 'Search group' }, + 'groupMgmt.manual': { zh: '手动', en: 'Manual' }, + 'groupMgmt.overview': { zh: '概览', en: 'Overview' }, + 'groupMgmt.totalDiff': { zh: '总堆积量', en: 'Total Diff' }, + 'groupMgmt.subscribedTopics': { zh: '订阅主题', en: 'Subscribed Topics' }, + 'groupMgmt.online': { zh: '在线', en: 'Online' }, + 'groupMgmt.consumeType': { zh: '消费类型', en: 'Consume Type' }, + 'groupMgmt.consumeDelay': { zh: '消费延迟', en: 'Consume Delay' }, + 'groupMgmt.maxRetry': { zh: '最大重试', en: 'Max Retry' }, + 'groupMgmt.createdAt': { zh: '创建时间', en: 'Created At' }, + 'groupMgmt.subscription': { zh: '订阅关系', en: 'Subscription' }, + 'groupMgmt.topic': { zh: '主题', en: 'Topic' }, + 'groupMgmt.consistency': { zh: '一致性', en: 'Consistency' }, + 'groupMgmt.consistent': { zh: '一致', en: 'Consistent' }, + 'groupMgmt.inconsistent': { zh: '不一致', en: 'Inconsistent' }, + 'groupMgmt.subMode': { zh: '订阅模式', en: 'Sub Mode' }, + 'groupMgmt.expression': { zh: '过滤表达式', en: 'Expression' }, + 'groupMgmt.viewDistribution': { zh: '查看分布', en: 'View Distribution' }, + 'groupMgmt.instanceId': { zh: '实例 ID', en: 'Instance ID' }, + 'groupMgmt.consumeProgress': { zh: '消费进度', en: 'Consume Progress' }, + + // ─── 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' }, + + // ─── 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' }, + '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' }, + 'liteTopic.defaultTtl': { zh: '默认 TTL', en: 'Default TTL' }, + 'liteTopic.maxTtl': { zh: '最大 TTL', en: 'Max TTL' }, + 'liteTopic.remainingQuota': { zh: '剩余配额', en: 'Remaining Quota' }, + 'liteTopic.consumerDensity': { zh: '消费者密度', en: 'Consumer Density' }, + 'liteTopic.newTtl': { zh: '新 TTL', en: 'New TTL' }, + 'liteTopic.newTtlPlaceholder': { + zh: '请输入新的 TTL 值(毫秒)', + en: 'Enter new TTL value (ms)', + }, + 'liteTopic.extendTtlModalTitle': { zh: '延长 TTL', en: 'Extend TTL' }, + 'liteTopic.popProgress': { zh: 'Pop 进度', en: 'Pop Progress' }, + 'liteTopic.sessionStatus': { zh: '会话状态', en: 'Session Status' }, + 'liteTopic.creationCount': { zh: '创建数量', en: 'Creation Count' }, + 'liteTopic.liteTopics': { zh: 'LiteTopic 列表', en: 'LiteTopics' }, + + // ─── 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' }, + + // ─── LLM Settings ────────────────────────────────────────── + 'llm.title': { zh: 'AI助手配置', en: 'AI Assistant Settings' }, + 'llm.subtitle': { + zh: '配置大语言模型提供商和参数,启用后可通过 ⌘K 呼出AI助手', + en: 'Configure LLM provider and parameters. Enable to invoke AI assistant via ⌘K', + }, + 'llm.enable': { zh: '启用AI助手', en: 'Enable AI Assistant' }, + 'llm.on': { zh: '开', en: 'On' }, + 'llm.off': { zh: '关', en: 'Off' }, + 'llm.selectProvider': { zh: '选择大模型提供商', en: 'Select LLM Provider' }, + 'llm.connectionConfig': { zh: '连接配置', en: 'Connection Configuration' }, + 'llm.modelParams': { zh: '模型参数', en: 'Model Parameters' }, + 'llm.apiKey': { zh: 'API Key', en: 'API Key' }, + 'llm.apiKeyRequired': { zh: '请输入API Key', en: 'Please enter API Key' }, + 'llm.apiKeyEncrypted': { + zh: 'API Key 将加密存储在服务端', + en: 'API Key will be encrypted and stored on the server', + }, + 'llm.apiKeyPlaceholder': { zh: 'sk-xxxxxxxxxxxxxxxx', en: 'sk-xxxxxxxxxxxxxxxx' }, + 'llm.apiKeyNoRequired': { + zh: '本地服务无需API Key', + en: 'No API Key required for local service', + }, + 'llm.apiBase': { zh: 'API Base URL', en: 'API Base URL' }, + 'llm.apiBaseRequired': { zh: '请输入API地址', en: 'Please enter API URL' }, + 'llm.apiBaseCustom': { + zh: '留空使用默认地址,可自定义', + en: 'Leave empty for default URL, or customize', + }, + 'llm.apiBaseRequiredHint': { zh: '必填:自定义服务地址', en: 'Required: Custom service URL' }, + 'llm.deploymentName': { zh: '部署名称 (Deployment Name)', en: 'Deployment Name' }, + 'llm.deploymentNameRequired': { + zh: '请输入Azure部署名称', + en: 'Please enter Azure deployment name', + }, + 'llm.apiVersion': { zh: 'API 版本', en: 'API Version' }, + 'llm.apiVersionRequired': { zh: '请输入API版本', en: 'Please enter API version' }, + 'llm.awsRegion': { zh: 'AWS 区域', en: 'AWS Region' }, + 'llm.awsRegionRequired': { zh: '请选择AWS区域', en: 'Please select AWS region' }, + 'llm.model': { zh: '模型名称', en: 'Model Name' }, + 'llm.modelRequired': { zh: '请选择或输入模型名称', en: 'Please select or enter model name' }, + 'llm.modelExtra': { + zh: '实时从 LLM 提供商获取可用模型列表,也可直接输入自定义模型名', + en: 'Fetch available models from LLM provider in real-time, or enter a custom model name', + }, + 'llm.modelsLoading': { zh: '正在获取模型列表...', en: 'Loading models...' }, + 'llm.modelsNotFound': { + zh: '未获取到模型,请确认已保存配置并测试连接', + en: 'No models found. Please save config and test connection first', + }, + 'llm.maxTokens': { zh: '最大 Token 数', en: 'Max Tokens' }, + 'llm.temperature': { zh: '温度 (Temperature)', en: 'Temperature' }, + 'llm.temperatureExtra': { + zh: '值越低输出越确定,越高越随机', + en: 'Lower values = more deterministic, higher = more random', + }, + 'llm.saveConfig': { zh: '保存配置', en: 'Save Config' }, + 'llm.testConnection': { zh: '连接测试', en: 'Test Connection' }, + 'llm.testing': { zh: '测试中...', en: 'Testing...' }, + 'llm.securityNote': { + zh: 'API Key 将加密存储,连接测试仅验证Key有效性', + en: 'API Key is encrypted. Connection test only verifies key validity', + }, + 'llm.testSuccess': { zh: '连接测试成功', en: 'Connection test successful' }, + 'llm.testSuccessMsg': { + zh: '连接测试成功!API Key 有效。', + en: 'Connection test successful! API Key is valid.', + }, + 'llm.testFailed': { zh: '连接测试失败', en: 'Connection test failed' }, + 'llm.testFailedMsg': { + zh: '连接测试失败,请检查配置。', + en: 'Connection test failed. Please check configuration.', + }, + 'llm.testError': { zh: '连接测试失败: ', en: 'Connection test failed: ' }, + 'llm.saveSuccess': { zh: 'AI助手配置已保存', en: 'AI assistant config saved' }, + 'llm.saveFailed': { zh: '保存配置失败', en: 'Failed to save config' }, + 'llm.formIncomplete': { zh: '请检查表单填写是否完整', en: 'Please check form completeness' }, + 'llm.loadFailed': { zh: '加载AI配置失败', en: 'Failed to load AI config' }, + 'llm.providerOpenai': { zh: 'OpenAI', en: 'OpenAI' }, + 'llm.providerOpenaiDesc': { zh: 'GPT-4o / GPT-4 / GPT-3.5', en: 'GPT-4o / GPT-4 / GPT-3.5' }, + 'llm.providerAzure': { zh: 'Azure OpenAI', en: 'Azure OpenAI' }, + 'llm.providerAzureDesc': { zh: 'Azure 托管的 OpenAI 服务', en: 'Azure-hosted OpenAI service' }, + 'llm.providerDeepseek': { zh: 'DeepSeek', en: 'DeepSeek' }, + 'llm.providerDeepseekDesc': { zh: 'DeepSeek-V3 / DeepSeek-R1', en: 'DeepSeek-V3 / DeepSeek-R1' }, + 'llm.providerTongyi': { zh: '通义千问', en: 'Tongyi Qwen' }, + 'llm.providerTongyiDesc': { + zh: 'Qwen-Max / Qwen-Plus / Qwen-Turbo', + en: 'Qwen-Max / Qwen-Plus / Qwen-Turbo', + }, + 'llm.providerOllama': { zh: 'Ollama', en: 'Ollama' }, + 'llm.providerOllamaDesc': { + zh: '本地部署的开源模型服务', + en: 'Locally deployed open-source model service', + }, + 'llm.providerBedrock': { zh: 'AWS Bedrock', en: 'AWS Bedrock' }, + 'llm.providerBedrockDesc': { + zh: 'Claude / Titan / Llama (AWS)', + en: 'Claude / Titan / Llama (AWS)', + }, + // ─── BrokerCluster ─── }; export default translations; diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index b214bbeb..bfde3c8c 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -15,18 +15,8 @@ * limitations under the License. */ -import { useState } from 'react'; -import { - Layout, - Menu, - Breadcrumb, - Avatar, - Dropdown, - Input, - ConfigProvider, - theme, - Modal, -} from 'antd'; +import { useEffect, useState } from 'react'; +import { Layout, Menu, Breadcrumb, Avatar, Dropdown, Empty, Input, Modal, message } from 'antd'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { House, @@ -49,6 +39,14 @@ import { Notebook, } from '@phosphor-icons/react'; import { useLang } from '../i18n/LangContext'; +import { useTheme } from '../theme/ThemeContext'; +import { logout as requestLogout } from '../api/auth'; +import useAuthStore from '../stores/authStore'; +import { + filterNavigationEntries, + isNavigationSearchShortcut, + type NavigationSearchEntry, +} from './navigationSearch'; const { Sider, Content } = Layout; @@ -57,10 +55,38 @@ 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(); + const clearAuth = useAuthStore((state) => state.logout); + + const handleUserMenuClick = async ({ key }: { key: string }) => { + if (key === 'profile') { + navigate('/settings'); + return; + } + if (key !== 'logout') return; + + try { + await requestLogout(); + } catch { + message.warning('服务端退出失败,已清除本地登录状态'); + } finally { + clearAuth(); + navigate('/'); + } + }; + + useEffect(() => { + const openSearchWithShortcut = (event: KeyboardEvent) => { + if (!isNavigationSearchShortcut(event)) return; + event.preventDefault(); + setSearchOpen(true); + }; + window.addEventListener('keydown', openSearchWithShortcut); + return () => window.removeEventListener('keydown', openSearchWithShortcut); + }, []); const menuItems = [ { key: '/', icon: , label: t('nav.home') }, @@ -139,6 +165,7 @@ const MainLayout = () => { ]; const userMenu = { + onClick: handleUserMenuClick, items: [ { key: 'profile', icon: , label: t('user.profile') }, { type: 'divider' as const }, @@ -150,22 +177,35 @@ const MainLayout = () => { const siderBg = darkMode ? '#2a2a2e' : '#ffffff'; const topBarBg = darkMode ? 'rgba(42,42,46,0.85)' : 'rgba(255,255,255,0.7)'; const logoColor = darkMode ? '#e5e5e5' : '#1b1b1a'; + const navigationEntries: NavigationSearchEntry[] = menuItems + .flatMap((item) => ('children' in item && item.children ? item.children : [item])) + .map((item) => ({ key: String(item.key), label: String(item.label), icon: item.icon })); + const searchResults = filterNavigationEntries(navigationEntries, searchText); return ( - + <> + { + event.currentTarget.style.transform = 'translateY(0)'; + }} + onBlur={(event) => { + event.currentTarget.style.transform = 'translateY(-150%)'; + }} + > + 跳到主要内容 + { {/* Theme toggle */}
setDarkMode(!darkMode)} + onClick={toggleTheme} style={{ cursor: 'pointer', display: 'flex', @@ -314,6 +354,8 @@ const MainLayout = () => {
{ prefix={} value={searchText} onChange={(e) => setSearchText(e.target.value)} + onPressEnter={() => { + const firstResult = searchResults[0]; + if (!firstResult) return; + navigate(firstResult.key); + setSearchOpen(false); + setSearchText(''); + }} autoFocus allowClear style={{ fontSize: 16 }} />
- {menuItems - .flatMap((item) => { - if ('children' in item && item.children) return item.children; - return [item]; - }) - .filter((item) => { - if (!searchText) return true; - const label = String(item.label).toLowerCase(); - return label.includes(searchText.toLowerCase()); - }) - .map((item) => ( + {searchResults.length ? ( + searchResults.map((item) => (
{ @@ -390,10 +430,13 @@ const MainLayout = () => { {item.icon} {item.label}
- ))} + )) + ) : ( + + )}
- + ); }; diff --git a/web/src/layouts/navigationSearch.test.ts b/web/src/layouts/navigationSearch.test.ts new file mode 100644 index 00000000..b6a626c7 --- /dev/null +++ b/web/src/layouts/navigationSearch.test.ts @@ -0,0 +1,46 @@ +/* + * 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, expect, it } from 'vitest'; +import { filterNavigationEntries, isNavigationSearchShortcut } from './navigationSearch'; + +describe('navigation search helpers', () => { + const entries = [ + { key: '/cluster', label: 'RocketMQ 集群' }, + { key: '/settings', label: 'Settings' }, + ]; + + it('filters labels case-insensitively and ignores query whitespace', () => { + expect(filterNavigationEntries(entries, ' SETTINGS ')).toEqual([entries[1]]); + expect(filterNavigationEntries(entries, '集群')).toEqual([entries[0]]); + }); + + it('recognizes Control/Command-K but rejects alternative shortcuts', () => { + expect( + isNavigationSearchShortcut({ key: 'k', ctrlKey: true, metaKey: false, altKey: false }), + ).toBe(true); + expect( + isNavigationSearchShortcut({ key: 'K', ctrlKey: false, metaKey: true, altKey: false }), + ).toBe(true); + expect( + isNavigationSearchShortcut({ key: 'k', ctrlKey: false, metaKey: false, altKey: false }), + ).toBe(false); + expect( + isNavigationSearchShortcut({ key: 'k', ctrlKey: true, metaKey: false, altKey: true }), + ).toBe(false); + }); +}); diff --git a/web/src/layouts/navigationSearch.ts b/web/src/layouts/navigationSearch.ts new file mode 100644 index 00000000..422934d3 --- /dev/null +++ b/web/src/layouts/navigationSearch.ts @@ -0,0 +1,42 @@ +/* + * 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 type { ReactNode } from 'react'; + +export interface NavigationSearchEntry { + key: string; + label: string; + icon?: ReactNode; +} + +export function filterNavigationEntries( + entries: NavigationSearchEntry[], + query: string, +): NavigationSearchEntry[] { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return entries; + return entries.filter((entry) => entry.label.toLocaleLowerCase().includes(normalizedQuery)); +} + +export function isNavigationSearchShortcut(event: { + key: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; +}): boolean { + return event.key.toLocaleLowerCase() === 'k' && (event.metaKey || event.ctrlKey) && !event.altKey; +} 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/pages/ai/chatDraft.test.ts b/web/src/pages/ai/chatDraft.test.ts new file mode 100644 index 00000000..6f1e09e8 --- /dev/null +++ b/web/src/pages/ai/chatDraft.test.ts @@ -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. + */ + +import { describe, expect, it } from 'vitest'; +import { getChatDraft } from './chatDraft'; + +describe('AI chat draft navigation state', () => { + it('normalizes a prompt and preserves a selected model', () => { + expect(getChatDraft({ prompt: ' 检查集群状态 ', model: 'qwen3.7-max' })).toEqual({ + prompt: '检查集群状态', + model: 'qwen3.7-max', + }); + }); + + it('rejects invalid or empty navigation state', () => { + expect(getChatDraft(null)).toBeNull(); + expect(getChatDraft({ prompt: ' ' })).toBeNull(); + expect(getChatDraft({ prompt: 42 })).toBeNull(); + }); +}); diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts new file mode 100644 index 00000000..115692ad --- /dev/null +++ b/web/src/pages/ai/chatDraft.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +export interface ChatDraft { + prompt: string; + model?: string; +} + +export function getChatDraft(state: unknown): ChatDraft | null { + if (typeof state !== 'object' || state === null) return null; + const candidate = state as Record; + if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return null; + + return { + prompt: candidate.prompt.trim(), + ...(typeof candidate.model === 'string' && candidate.model ? { model: candidate.model } : {}), + }; +} diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx index 8c8e38a4..e0e645c0 100644 --- a/web/src/pages/ai/index.tsx +++ b/web/src/pages/ai/index.tsx @@ -16,6 +16,7 @@ */ import { useState, useRef, useEffect, useCallback } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { Card, Button, @@ -29,10 +30,13 @@ import { Flex, Divider, Select, + message, } from 'antd'; import { ArrowUp, Sparkle, SlidersHorizontal, CaretDown } from '@phosphor-icons/react'; import type { ColumnsType } from 'antd/es/table'; import { useLang } from '../../i18n/LangContext'; +import { chatStream } from '../../api/ai'; +import { getChatDraft } from './chatDraft'; const { Text, Paragraph } = Typography; @@ -96,86 +100,7 @@ const modelOptions = [ /* ─── Mock Data ─── */ -const topicColumns: ColumnsType = [ - { title: 'Topic 名称', dataIndex: 'name', key: 'name' }, - { - title: '类型', - dataIndex: 'type', - key: 'type', - render: (type: string) => { - const map: Record = { - NORMAL: { label: '普通', color: 'default' }, - FIFO: { label: '顺序', color: 'blue' }, - DELAY: { label: '延迟', color: 'orange' }, - TRANSACTION: { label: '事务', color: 'purple' }, - }; - const cfg = map[type] || { label: type, color: 'default' }; - return {cfg.label}; - }, - }, - { title: '队列数', dataIndex: 'queues', key: 'queues', width: 80, align: 'center' }, -]; - -const mockTopicData: TopicRow[] = [ - { key: '1', name: 'order-create', type: 'TRANSACTION', queues: 16 }, - { key: '2', name: 'payment-notify', type: 'NORMAL', queues: 12 }, - { key: '3', name: 'user-login-event', type: 'NORMAL', queues: 8 }, - { key: '4', name: 'inventory-sync', type: 'FIFO', queues: 8 }, - { key: '5', name: 'promo-push', type: 'DELAY', queues: 4 }, -]; - -const initialMessages: Message[] = [ - { - id: 'm1', - role: 'user', - text: '查看生产集群-杭州的 Topic 列表', - }, - { - id: 'm2', - role: 'ai', - toolCall: { name: 'list_topics', label: '🔧 list_topics' }, - tableData: mockTopicData, - tableColumns: topicColumns, - summary: '生产集群-杭州 共有 128 个 Topic,以上为按吞吐量排序的前 5 个。', - }, - { - id: 'm3', - role: 'user', - text: 'order-create 这个 Topic 最近 1 小时的堆积情况', - }, - { - id: 'm4', - role: 'ai', - toolCall: { name: 'get_topic_lag', label: '🔧 get_topic_lag' }, - stats: [ - { title: '当前堆积', value: '2,340', color: '#1677ff' }, - { title: '消费速率', value: '856', suffix: '/s', color: '#52c41a' }, - { title: '预计追平', value: '2.7', suffix: 's', color: '#722ed1' }, - ], - summary: 'order-create 消费状态良好,堆积量较低,消费速率稳定。', - }, - { - id: 'm5', - role: 'user', - text: '帮我创建一个事务类型的 Topic,名称 order-refund', - }, - { - id: 'm6', - role: 'ai', - toolCall: { name: 'create_topic', label: '🔧 create_topic (dry-run)' }, - descriptions: [ - { label: '名称', value: 'order-refund' }, - { label: '类型', value: 'TRANSACTION' }, - { label: '队列数', value: '16' }, - { label: '集群', value: '生产集群-杭州' }, - ], - summary: '已生成创建预览,确认后将执行创建操作。', - actions: [ - { label: '确认创建', type: 'primary' }, - { label: '取消', type: 'default' }, - ], - }, -]; +const initialMessages: Message[] = []; /* ─── Quick Actions ─── */ @@ -346,12 +271,17 @@ const AiMessage = ({ msg }: { msg: Message }) => ( const AiPage = () => { const { t } = useLang(); + const location = useLocation(); + const navigate = useNavigate(); const [messages, setMessages] = useState(initialMessages); const [inputValue, setInputValue] = useState(''); const [loading, setLoading] = useState(false); const [selectedModel, setSelectedModel] = useState('qwen3.7-max'); const chatEndRef = useRef(null); const textareaRef = useRef(null); + const abortControllerRef = useRef(null); + const conversationIdRef = useRef(null); + const consumedDraftRef = useRef(false); const scrollToBottom = useCallback(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -361,6 +291,19 @@ const AiPage = () => { scrollToBottom(); }, [messages, scrollToBottom]); + useEffect(() => { + const draft = getChatDraft(location.state); + if (!draft || consumedDraftRef.current) return; + consumedDraftRef.current = true; + + void Promise.resolve().then(() => { + setInputValue(draft.prompt); + if (draft.model) setSelectedModel(draft.model); + navigate('/ai', { replace: true, state: null }); + textareaRef.current?.focus(); + }); + }, [location.state, navigate]); + /* ─── Auto-resize textarea ─── */ useEffect(() => { const ta = textareaRef.current; @@ -373,35 +316,75 @@ const AiPage = () => { return () => ta.removeEventListener('input', handler); }, []); - const handleSend = useCallback(() => { + useEffect(() => { + return () => abortControllerRef.current?.abort(); + }, []); + + const handleSend = useCallback(async () => { const text = inputValue.trim(); if (!text || loading) return; + if (!conversationIdRef.current) { + conversationIdRef.current = `conversation-${Date.now()}`; + } + const userMsg: Message = { id: `user-${Date.now()}`, role: 'user', text, }; - setMessages((prev) => [...prev, userMsg]); + const responseId = `ai-${Date.now()}`; + setMessages((prev) => [...prev, userMsg, { id: responseId, role: 'ai', summary: '' }]); setInputValue(''); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } setLoading(true); - - // Mock AI response after brief delay - setTimeout(() => { - const aiMsg: Message = { - id: `ai-${Date.now()}`, - role: 'ai', - toolCall: { name: 'process_query', label: '🔧 process_query' }, - summary: `已收到你的问题:「${text}」。AI 助手正在分析并调用相关 MCP 工具,结果将在此处展示。(当前为演示模式)`, - }; - setMessages((prev) => [...prev, aiMsg]); + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + await chatStream( + { + message: text, + mode: 'chat', + model: selectedModel, + conversationId: conversationIdRef.current, + }, + (chunk) => { + setMessages((prev) => + prev.map((item) => + item.id === responseId ? { ...item, summary: `${item.summary ?? ''}${chunk}` } : item, + ), + ); + }, + controller.signal, + ); + } catch (error) { + if (controller.signal.aborted) { + setMessages((prev) => + prev.map((item) => + item.id === responseId && !item.summary ? { ...item, summary: '回答已停止。' } : item, + ), + ); + } else { + setMessages((prev) => + prev.map((item) => + item.id === responseId ? { ...item, summary: 'AI 服务暂时不可用,请稍后重试。' } : item, + ), + ); + message.error(error instanceof Error ? error.message : 'AI 请求失败'); + } + } finally { + if (abortControllerRef.current === controller) abortControllerRef.current = null; setLoading(false); - }, 1200); - }, [inputValue, loading]); + } + }, [inputValue, loading, selectedModel]); + + const handleStop = useCallback(() => { + abortControllerRef.current?.abort(); + }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -609,6 +592,11 @@ const AiPage = () => { > + {loading && ( + + )} diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx index 84493579..dfa0da2c 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, @@ -31,9 +31,16 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { EditOutlined, DeleteOutlined, PlusOutlined, SyncOutlined } 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, + renewK8sCert, + updateK8sCert, +} from '../../services/clusterService'; const { Text } = Typography; @@ -43,14 +50,85 @@ 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 [renewingId, setRenewingId] = useState(null); 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 || @@ -60,6 +138,20 @@ const K8sCertsPage = () => { return matchSearch && matchType; }); + const renewCert = async (cert: K8sCertInfo) => { + setRenewingId(cert.id); + try { + const renewed = await renewK8sCert(cert.id); + setCerts((prev) => prev.map((item) => (item.id === renewed.id ? renewed : item))); + message.success(`证书「${renewed.name}」已续期`); + } catch (error) { + message.error(getErrorMessage(error)); + throw error; + } finally { + setRenewingId(null); + } + }; + const certColumns: ColumnsType = [ { title: 'K8s 集群名称', @@ -149,7 +241,7 @@ const K8sCertsPage = () => { { title: '操作', key: 'action', - width: 200, + width: 270, render: (_: unknown, record: K8sCertInfo) => ( + } @@ -236,6 +348,7 @@ const K8sCertsPage = () => { columns={certColumns} dataSource={filteredCerts} rowKey="id" + loading={loading} pagination={{ pageSize: 20 }} size="small" /> @@ -243,39 +356,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/pages/cluster/clients.tsx b/web/src/pages/cluster/clients.tsx index 04bba374..8cca2c23 100644 --- a/web/src/pages/cluster/clients.tsx +++ b/web/src/pages/cluster/clients.tsx @@ -15,16 +15,15 @@ * limitations under the License. */ -import { useState, useMemo } from 'react'; -import { Table, Card, Tag, Space, Input, Select, Flex, Typography } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { Table, Card, Tag, Space, Input, Select, Flex, Typography, message } from 'antd'; import { MagnifyingGlass } from '@phosphor-icons/react'; import type { ColumnsType } from 'antd/es/table'; import PageHeader from '../../components/PageHeader'; -import { mockClients } from '../../mock/clients'; -import type { ClientConnection } from '../../mock/clients'; -import clusters from '../../mock/clusters'; import { useLang } from '../../i18n/LangContext'; +import type { ClientConnection } from '../../api/connections'; +import { listConnections } from '../../services/connectionsService'; const { Text } = Typography; @@ -52,20 +51,44 @@ const languageConfig: Record = { ═══════════════════════════════════════════ */ const ClientsPage = () => { const { t } = useLang(); + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [clusterFilter, setClusterFilter] = useState('ALL'); + 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 = [ + ...new Set(connections.map((connection) => connection.clusterName)), + ].sort(); return [ { value: 'ALL', label: t('clients.allClusters') }, - ...clusters.map((c) => ({ value: c.nsClusterName, label: c.nsClusterName })), + ...clusterNames.map((name) => ({ value: name, label: name })), ]; - }, []); + }, [connections, t]); /* ─── Filtered data (search + cluster only, table handles column filters) ─── */ const filtered = useMemo(() => { - let data = mockClients.filter( + let data = connections.filter( (c) => c.clientId.toLowerCase().includes(search.toLowerCase()) || c.address.includes(search), ); @@ -74,7 +97,7 @@ const ClientsPage = () => { } return data; - }, [search, clusterFilter]); + }, [connections, search, clusterFilter]); /* ═══════════════════════════════════════════ Table Columns (with built-in filters) @@ -85,7 +108,9 @@ const ClientsPage = () => { dataIndex: 'clusterName', key: 'clusterName', width: 130, - filters: clusters.map((c) => ({ text: c.nsClusterName, value: c.nsClusterName })), + filters: clusterOptions + .filter((option) => option.value !== 'ALL') + .map((option) => ({ text: option.label, value: option.value })), onFilter: (value, record) => record.clusterName === value, render: (name: string) => ( @@ -236,6 +261,7 @@ const ClientsPage = () => { columns={columns} dataSource={filtered} rowKey="clientId" + loading={loading} scroll={{ x: 1320 }} pagination={{ pageSize: 20, diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx index f22ca74a..5fdcf63e 100644 --- a/web/src/pages/cluster/index.tsx +++ b/web/src/pages/cluster/index.tsx @@ -363,7 +363,7 @@ const ClusterPage = () => { @@ -380,51 +380,51 @@ const ClusterPage = () => { {selectedCluster && ( setConfigModalOpen(false)} onOk={() => { configForm.validateFields().then(() => { - message.success('配置已更新'); + message.success(t('cluster.configUpdated')); setConfigModalOpen(false); }); }} width={560} >
- + - 同步刷盘 - 异步刷盘 + {t('cluster.syncFlush')} + {t('cluster.asyncFlush')} - + - + - + - + - + @@ -468,9 +468,9 @@ const ClusterPage = () => { render: (status: string) => { const map: Record = { healthy: { color: 'green', label: t('cluster.running') }, - warning: { color: 'gold', label: '告警' }, - error: { color: 'red', label: '异常' }, - offline: { color: 'default', label: '离线' }, + warning: { color: 'gold', label: t('cluster.warning') }, + error: { color: 'red', label: t('cluster.error') }, + offline: { color: 'default', label: t('cluster.offline') }, }; const cfg = map[status] ?? { color: 'default', label: status }; return {cfg.label}; @@ -530,9 +530,9 @@ const ClusterPage = () => { render: (status: string) => { const map: Record = { healthy: { color: 'green', label: t('cluster.running') }, - warning: { color: 'gold', label: '告警' }, - error: { color: 'red', label: '异常' }, - offline: { color: 'default', label: '离线' }, + warning: { color: 'gold', label: t('cluster.warning') }, + error: { color: 'red', label: t('cluster.error') }, + offline: { color: 'default', label: t('cluster.offline') }, }; const cfg = map[status] ?? { color: 'default', label: status }; return {cfg.label}; @@ -661,9 +661,9 @@ const ClusterPage = () => { render: (status: string) => { const map: Record = { healthy: { color: 'green', label: t('cluster.running') }, - warning: { color: 'gold', label: '告警' }, - error: { color: 'red', label: '异常' }, - offline: { color: 'default', label: '离线' }, + warning: { color: 'gold', label: t('cluster.warning') }, + error: { color: 'red', label: t('cluster.error') }, + offline: { color: 'default', label: t('cluster.offline') }, }; const cfg = map[status] ?? { color: 'default', label: status }; return {cfg.label}; @@ -704,7 +704,7 @@ const ClusterPage = () => { size="small" icon={} style={{ borderColor: '#1677ff', color: '#1677ff' }} - onClick={() => message.info(`查看详情: ${record.addr}`)} + onClick={() => message.info(t('cluster.viewDetail', { addr: record.addr }))} > {t('common.detail')} @@ -715,10 +715,11 @@ const ClusterPage = () => { onClick={() => { Modal.confirm({ title: t('cluster.confirmRestart'), - content: `确定要重启 Proxy "${record.addr}" 吗?`, - okText: '确认', - cancelText: '取消', - onOk: () => message.success(`Proxy 重启已提交: ${record.addr}`), + content: t('cluster.restartProxyConfirm', { addr: record.addr }), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + onOk: () => + message.success(t('cluster.restartProxySubmitted', { addr: record.addr })), }); }} > @@ -744,7 +745,7 @@ const ClusterPage = () => { diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx index b1aa4848..1242fca6 100644 --- a/web/src/pages/home/dashboard.tsx +++ b/web/src/pages/home/dashboard.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Row, Col, Card, Statistic, Table, Tag, Typography } from 'antd'; +import { Alert, Button, Card, Col, Row, Skeleton, Statistic, Table, Tag, Typography } from 'antd'; import { ClusterOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { ListDashes, ArrowDown } from '@phosphor-icons/react'; import PageHeader from '../../components/PageHeader'; @@ -17,12 +17,52 @@ const DashboardPage = () => { const navigate = useNavigate(); const { t } = useLang(); const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); - useEffect(() => { - getDashboard().then(setDashboard).catch(console.error); + const loadDashboard = useCallback(async () => { + setLoading(true); + setLoadError(false); + try { + setDashboard(await getDashboard()); + } catch { + setLoadError(true); + } finally { + setLoading(false); + } }, []); - if (!dashboard) return null; + useEffect(() => { + void Promise.resolve().then(loadDashboard); + }, [loadDashboard]); + + if (loading && !dashboard) { + return ( +
+ + +
+ ); + } + + if (loadError || !dashboard) { + return ( +
+ + void loadDashboard()} loading={loading}> + 重试 + + } + /> +
+ ); + } const { stats, clusters } = dashboard; @@ -83,7 +123,7 @@ const DashboardPage = () => { key: 'type', render: (type: string) => { const info = CLUSTER_TYPE_MAP[type]; - return info ? {info.label} : type; + return info ? {t(info.labelKey)} : type; }, }, { diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx index dcd9d2a7..a83e3095 100644 --- a/web/src/pages/home/index.tsx +++ b/web/src/pages/home/index.tsx @@ -66,6 +66,7 @@ const modelOptions = [ const HomePage = () => { const [activeMode, setActiveMode] = useState('query'); const [selectedModel, setSelectedModel] = useState('qwen3.7-max'); + const [inputValue, setInputValue] = useState(''); const [indicatorStyle, setIndicatorStyle] = useState({ width: 83, left: 6 }); const textareaRef = useRef(null); const modeBarRef = useRef(null); @@ -117,10 +118,15 @@ const HomePage = () => { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - navigate('/ai'); + handlePromptSubmit(); } }; + const handlePromptSubmit = () => { + const prompt = inputValue.trim(); + navigate('/ai', { state: prompt ? { prompt, model: selectedModel } : null }); + }; + return (
{ ref={textareaRef} className="chat-input" placeholder="向 RocketMQ Bot 提问,全程加密、安全、可信" + value={inputValue} + onChange={(event) => setInputValue(event.target.value)} onKeyDown={handleKeyDown} /> {
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx b/web/src/pages/instance/__tests__/AclPage.test.tsx new file mode 100644 index 00000000..e25de3d0 --- /dev/null +++ b/web/src/pages/instance/__tests__/AclPage.test.tsx @@ -0,0 +1,108 @@ +/* + * 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 } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LangProvider } from '../../../i18n/LangContext'; +import * as aclService from '../../../services/aclService'; +import AclPage from '../acl'; + +vi.mock('../../../services/aclService', () => ({ + createAclRule: vi.fn(), + createAclUser: vi.fn(), + deleteAclRule: vi.fn(), + deleteAclUser: vi.fn(), + listAclRules: vi.fn(), + listAclUsers: vi.fn(), + updateAclRule: vi.fn(), + updateAclUser: vi.fn(), +})); + +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('ACL page', () => { + beforeEach(() => { + vi.mocked(aclService.listAclRules).mockResolvedValue([ + { + id: 'rule-remote', + principal: 'remote-user', + resource: 'remote-topic', + resourceType: 'Topic', + resourcePattern: 'LITERAL', + actions: ['PUB'], + decision: 'ALLOW', + scope: 'cluster', + aclVersion: 2, + createdAt: '2026-07-23T00:00:00Z', + }, + ]); + vi.mocked(aclService.listAclUsers).mockResolvedValue([ + { + id: 'user-remote', + username: 'remote-admin', + accessKey: 'ak-remote', + secretKey: 'sk-remote', + admin: true, + clusters: ['cluster-a'], + createdAt: '2026-07-23T00:00:00Z', + }, + ]); + }); + + it('loads ACL rules and users through the service layer', async () => { + renderWithProviders(); + + expect(await screen.findByText('remote-user')).toBeInTheDocument(); + expect(screen.getByText('remote-topic')).toBeInTheDocument(); + expect(aclService.listAclRules).toHaveBeenCalledTimes(1); + expect(aclService.listAclUsers).toHaveBeenCalledTimes(1); + }); + + it('renders backend users on the user tab', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByText('用户管理')); + + expect(await screen.findByText('remote-admin')).toBeInTheDocument(); + expect(screen.getByText('cluster-a')).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx index a663ecd7..97295085 100644 --- a/web/src/pages/instance/acl.tsx +++ b/web/src/pages/instance/acl.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Table, Card, @@ -40,8 +40,49 @@ import { EditOutlined, DeleteOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import PageHeader from '../../components/PageHeader'; import { useLang } from '../../i18n/LangContext'; -import { aclRules, aclUsers } from '../../mock/acl'; -import type { AclRule, AclUser } from '../../mock/acl'; +import { + createAclRule, + createAclUser, + deleteAclRule, + deleteAclUser, + listAclRules, + listAclUsers, + updateAclRule, + updateAclUser, +} from '../../services/aclService'; +import type { AclRule, AclUser } from '../../api/acl'; + +type AclRuleFormValues = Pick< + AclRule, + 'principal' | 'resource' | 'resourceType' | 'resourcePattern' | 'actions' | 'decision' | 'scope' +>; +type AclUserFormValues = Pick; + +const normalizeRule = (rule: AclRule): AclRule => ({ + ...rule, + principal: rule.principal ?? '', + resource: rule.resource ?? '', + resourceType: rule.resourceType ?? '', + resourcePattern: rule.resourcePattern ?? '', + actions: rule.actions ?? [], + decision: rule.decision ?? '', + scope: rule.scope ?? '', + aclVersion: rule.aclVersion ?? '2.0', + createdAt: rule.createdAt ?? new Date().toISOString(), +}); + +const normalizeUser = (user: AclUser): AclUser => ({ + ...user, + username: user.username ?? '', + accessKey: user.accessKey ?? '', + secretKey: user.secretKey ?? '', + admin: user.admin ?? false, + clusters: user.clusters ?? [], + createdAt: user.createdAt ?? new Date().toISOString(), +}); + +const isFormValidationError = (error: unknown) => + typeof error === 'object' && error !== null && 'errorFields' in error; /* ═══════════════════════════════════════════ ACL Management Page @@ -50,8 +91,12 @@ const AclPage = () => { const { t } = useLang(); /* ─── State ─── */ - const [rules, setRules] = useState(aclRules); - const [users, setUsers] = useState(aclUsers); + const [rules, setRules] = useState([]); + const [users, setUsers] = useState([]); + const [rulesLoading, setRulesLoading] = useState(true); + const [usersLoading, setUsersLoading] = useState(true); + const [ruleSubmitting, setRuleSubmitting] = useState(false); + const [userSubmitting, setUserSubmitting] = useState(false); const [activeTab, setActiveTab] = useState('rules'); // Rule filters @@ -72,13 +117,37 @@ const AclPage = () => { // Secret key reveal const [revealedKeys, setRevealedKeys] = useState>(new Set()); + useEffect(() => { + let mounted = true; + + Promise.all([listAclRules(), listAclUsers()]) + .then(([nextRules, nextUsers]) => { + if (!mounted) return; + setRules(nextRules.map(normalizeRule)); + setUsers(nextUsers.map(normalizeUser)); + }) + .catch(() => { + if (mounted) message.error(t('common.fetchDataFailed')); + }) + .finally(() => { + if (!mounted) return; + setRulesLoading(false); + setUsersLoading(false); + }); + + return () => { + mounted = false; + }; + }, [t]); + /* ─── Filtered rules ─── */ const filteredRules = rules.filter((r) => { + const aclVersion = String(r.aclVersion); const matchSearch = !ruleSearch || r.principal.toLowerCase().includes(ruleSearch.toLowerCase()) || r.resource.toLowerCase().includes(ruleSearch.toLowerCase()); - const matchVersion = ruleVersionFilter === 'all' || r.aclVersion === ruleVersionFilter; + const matchVersion = ruleVersionFilter === 'all' || aclVersion === ruleVersionFilter; const matchDecision = ruleDecisionFilter === 'all' || r.decision === ruleDecisionFilter; return matchSearch && matchVersion && matchDecision; }); @@ -125,28 +194,40 @@ const AclPage = () => { setRuleModalOpen(true); }; - const handleRuleSubmit = () => { - ruleForm.validateFields().then((values) => { + const handleRuleSubmit = async () => { + try { + const values = (await ruleForm.validateFields()) as AclRuleFormValues; + setRuleSubmitting(true); if (editingRule) { - setRules((prev) => prev.map((r) => (r.id === editingRule.id ? { ...r, ...values } : r))); + const updated = await updateAclRule({ ...editingRule, ...values }); + const normalized = normalizeRule(updated); + setRules((prev) => prev.map((r) => (r.id === editingRule.id ? normalized : r))); message.success(t('acl.ruleUpdated')); } else { - const newRule: AclRule = { - id: `acl-${Date.now()}`, + const created = await createAclRule({ ...values, - aclVersion: '2.0', - createdAt: new Date().toISOString(), - }; - setRules((prev) => [newRule, ...prev]); + aclVersion: 2, + }); + setRules((prev) => [normalizeRule(created), ...prev]); message.success(t('acl.ruleAdded')); } setRuleModalOpen(false); - }); + } catch (error) { + if (isFormValidationError(error)) return; + message.error(t('common.operationFailed')); + } finally { + setRuleSubmitting(false); + } }; - const handleDeleteRule = (id: string) => { - setRules((prev) => prev.filter((r) => r.id !== id)); - message.success(t('acl.ruleDeleted')); + const handleDeleteRule = async (id: string) => { + try { + await deleteAclRule(id); + setRules((prev) => prev.filter((r) => r.id !== id)); + message.success(t('acl.ruleDeleted')); + } catch { + message.error(t('common.operationFailed')); + } }; /* ─── User helpers ─── */ @@ -180,42 +261,59 @@ const AclPage = () => { setUserModalOpen(true); }; - const handleUserSubmit = () => { - userForm.validateFields().then((values) => { + const handleUserSubmit = async () => { + try { + const values = (await userForm.validateFields()) as AclUserFormValues; + setUserSubmitting(true); if (editingUser) { - setUsers((prev) => prev.map((u) => (u.id === editingUser.id ? { ...u, ...values } : u))); + const updated = await updateAclUser({ ...editingUser, ...values }); + const normalized = normalizeUser(updated); + setUsers((prev) => prev.map((u) => (u.id === editingUser.id ? normalized : u))); message.success(t('acl.userUpdated')); } else { - const newUser: AclUser = { - id: `u-${Date.now()}`, + const created = await createAclUser({ username: values.username, - accessKey: values.accessKey || `LTAI****${values.username.slice(-4)}`, - secretKey: - values.secretKey || - `${Math.random().toString(36).slice(2, 6)}****${Math.random().toString(36).slice(2, 6)}`, + accessKey: values.accessKey, + secretKey: values.secretKey, admin: values.admin ?? false, clusters: ['rmq-cn-v5-prod-01'], - createdAt: new Date().toISOString(), - }; - setUsers((prev) => [newUser, ...prev]); + }); + setUsers((prev) => [normalizeUser(created), ...prev]); message.success(t('acl.userAdded')); } setUserModalOpen(false); - }); + } catch (error) { + if (isFormValidationError(error)) return; + message.error(t('common.operationFailed')); + } finally { + setUserSubmitting(false); + } }; - const handleDeleteUser = (id: string) => { - setUsers((prev) => prev.filter((u) => u.id !== id)); - message.success(t('acl.userDeleted')); + const handleDeleteUser = async (id: string) => { + try { + await deleteAclUser(id); + setUsers((prev) => prev.filter((u) => u.id !== id)); + message.success(t('acl.userDeleted')); + } catch { + message.error(t('common.operationFailed')); + } }; - const handleToggleAdmin = (userId: string, checked: boolean) => { - setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, admin: checked } : u))); - message.success(checked ? t('acl.adminSet') : t('acl.adminRemoved')); + const handleToggleAdmin = async (user: AclUser, checked: boolean) => { + try { + const updated = await updateAclUser({ ...user, admin: checked }); + const normalized = normalizeUser(updated); + setUsers((prev) => prev.map((u) => (u.id === user.id ? normalized : u))); + message.success(checked ? t('acl.adminSet') : t('acl.adminRemoved')); + } catch { + message.error(t('common.operationFailed')); + } }; const formatDate = (iso: string) => { const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '-'; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; @@ -291,9 +389,9 @@ const AclPage = () => { dataIndex: 'aclVersion', key: 'aclVersion', width: 100, - sorter: (a, b) => a.aclVersion.localeCompare(b.aclVersion), - render: (version: string) => ( - {version} + sorter: (a, b) => String(a.aclVersion).localeCompare(String(b.aclVersion)), + render: (version: AclRule['aclVersion']) => ( + {version} ), }, { @@ -409,7 +507,7 @@ const AclPage = () => { handleToggleAdmin(record.id, checked)} + onChange={(checked) => handleToggleAdmin(record, checked)} /> ), }, @@ -554,6 +652,7 @@ const AclPage = () => { columns={ruleColumns} dataSource={filteredRules} rowKey="id" + loading={rulesLoading} pagination={{ pageSize: 20, showSizeChanger: true, @@ -590,6 +689,7 @@ const AclPage = () => { columns={userColumns} dataSource={users} rowKey="id" + loading={usersLoading} pagination={{ pageSize: 20, showSizeChanger: true, @@ -612,6 +712,7 @@ const AclPage = () => { onOk={handleRuleSubmit} okText={editingRule ? t('acl.save') : t('acl.add')} cancelText={t('common.cancel')} + confirmLoading={ruleSubmitting} width={560} destroyOnClose > @@ -713,6 +814,7 @@ const AclPage = () => { onOk={handleUserSubmit} okText={editingUser ? t('acl.save') : t('acl.add')} cancelText={t('common.cancel')} + confirmLoading={userSubmitting} width={520} destroyOnClose > diff --git a/web/src/pages/instance/consumer.tsx b/web/src/pages/instance/consumer.tsx index 9e0ec7c3..d7cec904 100644 --- a/web/src/pages/instance/consumer.tsx +++ b/web/src/pages/instance/consumer.tsx @@ -177,8 +177,8 @@ const ConsumerPage = () => { width: 110, sorter: (a, b) => a.subscriptionDataType.localeCompare(b.subscriptionDataType), render: (type: string) => { - const config = TOPIC_TYPE_MAP[type] || { label: type, color: 'default' }; - return {config.label}; + const config = TOPIC_TYPE_MAP[type] || { labelKey: type, color: 'default' }; + return {t(config.labelKey)}; }, }, { @@ -374,8 +374,8 @@ const ConsumerPage = () => { key: 'protocol', width: 100, render: (protocol: string) => { - const config = PROTOCOL_MAP[protocol] || { label: protocol, color: 'default' }; - return {config.label}; + const config = PROTOCOL_MAP[protocol] || { labelKey: protocol, color: 'default' }; + return {t(config.labelKey)}; }, }, { @@ -710,8 +710,9 @@ const ConsumerPage = () => { TOPIC_TYPE_MAP[selectedGroup.subscriptionDataType]?.color || 'default' } > - {TOPIC_TYPE_MAP[selectedGroup.subscriptionDataType]?.label || - selectedGroup.subscriptionDataType} + {TOPIC_TYPE_MAP[selectedGroup.subscriptionDataType] + ? t(TOPIC_TYPE_MAP[selectedGroup.subscriptionDataType].labelKey) + : selectedGroup.subscriptionDataType} diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx index a914d6d1..88a385d6 100644 --- a/web/src/pages/instance/dlq.tsx +++ b/web/src/pages/instance/dlq.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Card, Table, @@ -34,8 +34,8 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import PageHeader from '../../components/PageHeader'; import { useLang } from '../../i18n/LangContext'; -import { mockDLQGroups } from '../../mock/dlq'; -import type { DLQGroup } from '../../mock/dlq'; +import type { DLQGroup } from '../../api/message'; +import { listDLQGroups, resendDLQ } from '../../services/messageService'; const { Text } = Typography; const { RangePicker } = DatePicker; @@ -53,6 +53,9 @@ const formatDateTime = (iso: string): string => { ═══════════════════════════════════════════ */ const DLQPage = () => { const { t } = useLang(); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); const [search, setSearch] = useState(''); const [retryModalOpen, setRetryModalOpen] = useState(false); const [retryGroup, setRetryGroup] = useState(null); @@ -61,15 +64,35 @@ const DLQPage = () => { dayjs(), ]); const [retryTargetTopic, setRetryTargetTopic] = useState(''); + const [retrySubmitting, setRetrySubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + + void listDLQGroups() + .then((nextGroups) => { + if (!cancelled) setGroups(nextGroups); + }) + .catch(() => { + if (!cancelled) message.error('死信队列加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [refreshKey]); /* ─── Filtering ─── */ const filtered = useMemo(() => { - if (!search) return mockDLQGroups; - return mockDLQGroups.filter( + if (!search) return groups; + return groups.filter( (g) => g.groupName.includes(search) || g.dlqTopic.toLowerCase().includes(search.toLowerCase()), ); - }, [search]); + }, [groups, search]); /* ─── Handlers ─── */ const openRetryModal = (group: DLQGroup) => { @@ -79,14 +102,30 @@ const DLQPage = () => { setRetryModalOpen(true); }; - const handleRetry = () => { + const handleRetry = async () => { if (!retryTargetTopic) { message.warning('请输入目标 Topic'); return; } - message.success(`已提交重投任务:${retryGroup?.groupName} → ${retryTargetTopic}(模拟)`); - setRetryModalOpen(false); - setRetryGroup(null); + if (!retryGroup) return; + + setRetrySubmitting(true); + try { + await resendDLQ({ + groupName: retryGroup.groupName, + startTime: retryRange[0].valueOf(), + endTime: retryRange[1].valueOf(), + targetTopic: retryTargetTopic, + }); + setRefreshKey((key) => key + 1); + message.success(`已提交重投任务:${retryGroup.groupName} → ${retryTargetTopic}`); + setRetryModalOpen(false); + setRetryGroup(null); + } catch { + message.error('提交重投任务失败,请稍后重试'); + } finally { + setRetrySubmitting(false); + } }; const handleExport = (group: DLQGroup) => { @@ -210,6 +249,7 @@ const DLQPage = () => { columns={columns} dataSource={filtered} rowKey="groupName" + loading={loading} pagination={{ pageSize: 20, showSizeChanger: true, @@ -235,6 +275,7 @@ const DLQPage = () => { setRetryGroup(null); }} onOk={handleRetry} + confirmLoading={retrySubmitting} okText="确认重投" cancelText="取消" width={520} diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx index 1e3905eb..3eb3d0ed 100644 --- a/web/src/pages/instance/index.tsx +++ b/web/src/pages/instance/index.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Table, Card, @@ -34,81 +34,16 @@ import { useLang } from '../../i18n/LangContext'; import { Plus, MagnifyingGlass } from '@phosphor-icons/react'; import { EditOutlined, DeleteOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; +import type { Instance } from '../../api/instance'; +import { + createInstance, + deleteInstance, + listInstances, + updateInstance, +} from '../../services/instanceService'; const { Text } = Typography; -/* ─── Types ─── */ -interface Instance { - id: string; - name: string; - remark: string; - type: 'PROXY' | 'DIRECT'; - endpoint: string; - topicCount: number; - consumerGroupCount: number; - createdAt: string; - updatedAt: string; -} - -/* ─── Mock Data ─── */ -const mockInstances: Instance[] = [ - { - id: '1', - name: 'rocketmq-trade', - remark: '核心交易链路,承载订单、支付等主要业务', - type: 'PROXY', - endpoint: 'proxy-hz.rocketmq.internal:8080', - topicCount: 128, - consumerGroupCount: 56, - createdAt: '2024-03-15 08:30:00', - updatedAt: '2026-06-20 14:15:00', - }, - { - id: '2', - name: 'rocketmq-dr', - remark: '灾备集群,与 trade 集群互为双活', - type: 'PROXY', - endpoint: 'proxy-sh.rocketmq.internal:8080', - topicCount: 96, - consumerGroupCount: 42, - createdAt: '2024-05-10 10:00:00', - updatedAt: '2026-06-18 09:30:00', - }, - { - id: '3', - name: 'rocketmq-debug', - remark: '开发测试环境,仅供内部调试使用', - type: 'PROXY', - endpoint: 'localhost:8081', - topicCount: 15, - consumerGroupCount: 8, - createdAt: '2025-01-20 14:00:00', - updatedAt: '2026-07-01 11:45:00', - }, - { - id: '4', - name: 'rocketmq-legacy', - remark: '旧版集群,计划 Q3 完成迁移后下线', - type: 'DIRECT', - endpoint: 'namesrv-legacy:9876', - topicCount: 64, - consumerGroupCount: 30, - createdAt: '2022-08-01 09:00:00', - updatedAt: '2025-12-10 16:20:00', - }, - { - id: '5', - name: 'rocketmq-staging', - remark: '预发布验证环境,与生产配置一致', - type: 'PROXY', - endpoint: 'proxy-staging:8080', - topicCount: 32, - consumerGroupCount: 18, - createdAt: '2024-11-05 11:30:00', - updatedAt: '2026-06-25 08:00:00', - }, -]; - /* ─── Helpers ─── */ const typeLabel: Record = { PROXY: { text: 'Proxy 模式', color: 'blue' }, @@ -120,7 +55,8 @@ const typeLabel: Record = { ═══════════════════════════════════════════ */ const InstancePage = () => { const { t } = useLang(); - const [instances, setInstances] = useState(mockInstances); + const [instances, setInstances] = useState([]); + const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState('ALL'); const [addModalOpen, setAddModalOpen] = useState(false); @@ -128,6 +64,70 @@ const InstancePage = () => { const [editModalOpen, setEditModalOpen] = useState(false); const [editingInstance, setEditingInstance] = useState(null); const [editForm] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + void listInstances() + .then((nextInstances) => { + if (!cancelled) setInstances(nextInstances); + }) + .catch(() => { + if (!cancelled) message.error('实例列表加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const handleCreate = async () => { + try { + const values = await addForm.validateFields(); + setSubmitting(true); + const created = await createInstance(values); + setInstances((previous) => [...previous, created]); + message.success(`实例「${created.name}」添加成功`); + setAddModalOpen(false); + addForm.resetFields(); + } catch { + message.error('添加实例失败,请稍后重试'); + } finally { + setSubmitting(false); + } + }; + + const handleUpdate = async () => { + if (!editingInstance) return; + try { + const values = await editForm.validateFields(); + setSubmitting(true); + const updated = await updateInstance({ id: editingInstance.id, remark: values.remark || '' }); + setInstances((previous) => + previous.map((instance) => (instance.id === updated.id ? updated : instance)), + ); + message.success(`实例「${updated.name}」备注已更新`); + setEditModalOpen(false); + editForm.resetFields(); + } catch { + message.error('更新实例失败,请稍后重试'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (instance: Instance) => { + try { + await deleteInstance(instance.id); + setInstances((previous) => previous.filter((item) => item.id !== instance.id)); + message.success('已删除'); + } catch { + message.error('删除实例失败,请稍后重试'); + } + }; const filtered = instances .filter((i) => { @@ -241,7 +241,7 @@ const InstancePage = () => { content: '此操作不可恢复。', okText: '删除', okButtonProps: { danger: true }, - onOk: () => message.success('已删除'), + onOk: () => handleDelete(record), }) } > @@ -304,6 +304,7 @@ const InstancePage = () => { { setAddModalOpen(false); addForm.resetFields(); }} - onOk={() => { - addForm.validateFields().then((values) => { - const newInstance: Instance = { - id: String(Date.now()), - name: values.name, - remark: values.remark || '', - type: values.type, - endpoint: values.endpoint, - topicCount: 0, - consumerGroupCount: 0, - createdAt: new Date().toISOString().replace('T', ' ').slice(0, 19), - updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19), - }; - setInstances((prev) => [...prev, newInstance]); - message.success(`实例「${values.name}」添加成功`); - setAddModalOpen(false); - addForm.resetFields(); - }); - }} + onOk={() => void handleCreate()} + confirmLoading={submitting} okText="连接" cancelText="取消" width={520} @@ -387,25 +371,8 @@ const InstancePage = () => { setEditModalOpen(false); editForm.resetFields(); }} - onOk={() => { - editForm.validateFields().then((values) => { - if (!editingInstance) return; - setInstances((prev) => - prev.map((inst) => - inst.id === editingInstance.id - ? { - ...inst, - remark: values.remark || '', - updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19), - } - : inst, - ), - ); - message.success(`实例「${editingInstance.name}」备注已更新`); - setEditModalOpen(false); - editForm.resetFields(); - }); - }} + onOk={() => void handleUpdate()} + confirmLoading={submitting} okText="保存" cancelText="取消" width={520} diff --git a/web/src/pages/instance/message.tsx b/web/src/pages/instance/message.tsx index 10e0403c..73d9bb66 100644 --- a/web/src/pages/instance/message.tsx +++ b/web/src/pages/instance/message.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { Card, Table, @@ -48,8 +48,8 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import PageHeader from '../../components/PageHeader'; import { useLang } from '../../i18n/LangContext'; -import { mockMessages, mockMessageTraces } from '../../mock/messages'; -import type { MessageRecord } from '../../mock/messages'; +import type { MessageRecord, TraceRecord } from '../../api/message'; +import { getMessageTrace, queryMessages } from '../../services/messageService'; const { Paragraph, Text } = Typography; const { RangePicker } = DatePicker; @@ -97,8 +97,8 @@ const formatSize = (bytes: number): string => { return `${bytes} B`; }; -const formatTimeMs = (iso: string): string => { - const d = new Date(iso); +const formatTimeMs = (value: number | string): string => { + const d = new Date(value); const pad = (n: number, len = 2) => String(n).padStart(len, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; }; @@ -121,25 +121,13 @@ const MessagePage = () => { const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>(getDefaultRange); const [keyInput, setKeyInput] = useState(''); const [msgIdInput, setMsgIdInput] = useState(''); + const [messages, setMessages] = useState([]); + const [queryLoading, setQueryLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [modalTab, setModalTab] = useState('content'); const [selectedMsg, setSelectedMsg] = useState(null); - - /* ─── Filtering ─── */ - const filteredMessages = useMemo(() => { - switch (queryMode) { - case 'topic': - return selectedTopic ? mockMessages.filter((m) => m.topic === selectedTopic) : mockMessages; - case 'key': - return keyInput ? mockMessages.filter((m) => m.key.includes(keyInput)) : mockMessages; - case 'msgid': - return msgIdInput - ? mockMessages.filter((m) => m.msgId.toLowerCase().includes(msgIdInput.toLowerCase())) - : mockMessages; - default: - return mockMessages; - } - }, [queryMode, selectedTopic, keyInput, msgIdInput]); + const [traceData, setTraceData] = useState(null); + const [traceLoading, setTraceLoading] = useState(false); /* ─── Handlers ─── */ const handleReset = () => { @@ -147,16 +135,50 @@ const MessagePage = () => { setKeyInput(''); setMsgIdInput(''); setDateRange(getDefaultRange()); + setMessages([]); + }; + + const handleQuery = async () => { + const params = + queryMode === 'topic' + ? { + topic: selectedTopic, + startTime: dateRange[0].valueOf(), + endTime: dateRange[1].valueOf(), + } + : queryMode === 'key' + ? { topic: selectedTopic, key: keyInput || undefined } + : { msgId: msgIdInput || undefined }; + + setQueryLoading(true); + try { + const result = await queryMessages(params); + setMessages(result); + message.success(`查询完成,共 ${result.length} 条`); + } catch { + message.error('消息查询失败,请稍后重试'); + } finally { + setQueryLoading(false); + } }; const handleResend = () => { message.success('消息重新发送成功(模拟)'); }; - const openDetail = (record: MessageRecord, tab = 'content') => { + const openDetail = async (record: MessageRecord, tab = 'content') => { setSelectedMsg(record); setModalTab(tab); setModalOpen(true); + setTraceData(null); + setTraceLoading(true); + try { + setTraceData(await getMessageTrace(record.msgId)); + } catch { + message.error('消息轨迹加载失败,请稍后重试'); + } finally { + setTraceLoading(false); + } }; const handleDownload = (record: MessageRecord) => { @@ -216,7 +238,7 @@ const MessagePage = () => { dataIndex: 'storeTime', key: 'storeTime', width: 185, - sorter: (a, b) => a.storeTime.localeCompare(b.storeTime), + sorter: (a, b) => new Date(a.storeTime).valueOf() - new Date(b.storeTime).valueOf(), render: (time: string) => ( {formatTimeMs(time)} @@ -242,7 +264,7 @@ const MessagePage = () => { size="small" icon={} style={{ borderColor: '#1677ff', color: '#1677ff' }} - onClick={() => openDetail(record, 'content')} + onClick={() => void openDetail(record, 'content')} > 详情 @@ -250,7 +272,7 @@ const MessagePage = () => { size="small" icon={} style={{ borderColor: '#722ed1', color: '#722ed1' }} - onClick={() => openDetail(record, 'trace')} + onClick={() => void openDetail(record, 'trace')} > 轨迹 @@ -275,13 +297,10 @@ const MessagePage = () => { }, ]; - /* ─── Modal Data ─── */ - const traceData = selectedMsg ? mockMessageTraces[selectedMsg.msgId] : undefined; - const consumerStatusColumns: ColumnsType<{ group: string; deliveryStatus: string; - consumeTime: string; + consumeTime: number | string; retryCount: number; }> = [ { @@ -295,7 +314,7 @@ const MessagePage = () => { dataIndex: 'deliveryStatus', key: 'deliveryStatus', render: (status: string) => { - const s = DELIVERY_STATUS_MAP[status] || { + const s = DELIVERY_STATUS_MAP[status.toLowerCase()] || { label: status, color: 'default', }; @@ -384,7 +403,9 @@ const MessagePage = () => { { key: 'trace', label: '消息轨迹', - children: traceData?.nodes?.length ? ( + children: traceLoading ? ( + 正在加载轨迹数据… + ) : traceData?.nodes?.length ? ( { type="primary" icon={} onClick={() => { - message.info(`查询完成,共 ${filteredMessages.length} 条`); + void handleQuery(); }} > 查询 @@ -517,7 +538,8 @@ const MessagePage = () => {
{ const { t } = useLang(); // ─── State ───────────────────────────────────────────────────── - const [topics, setTopics] = useState(mockTopics); + const [topics, setTopics] = useState([]); + const [loading, setLoading] = useState(true); + const [routesByTopic, setRoutesByTopic] = useState>({}); + const [consumersByTopic, setConsumersByTopic] = useState>({}); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [searchText, setSearchText] = useState(''); const [typeFilter, setTypeFilter] = useState(''); @@ -229,6 +234,24 @@ const TopicPage = () => { const [sendForm] = Form.useForm(); const { modal } = App.useApp(); + useEffect(() => { + let cancelled = false; + void listTopics() + .then((nextTopics) => { + if (!cancelled) setTopics(nextTopics); + }) + .catch(() => { + if (!cancelled) message.error('Topic 列表加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + // ─── Filtered data ───────────────────────────────────────────── const filteredTopics = useMemo( () => @@ -244,21 +267,30 @@ const TopicPage = () => { ); // ─── Open detail modal ──────────────────────────────────────── - const openDetail = (topic: Topic) => { + const openDetail = async (topic: Topic) => { setSelectedTopic(topic); setDetailModalOpen(true); + try { + const [routes, consumers] = await Promise.all([ + getTopicRoutes(topic.name), + getTopicConsumers(topic.name), + ]); + setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes })); + setConsumersByTopic((previous) => ({ ...previous, [topic.name]: consumers })); + } catch { + message.error('Topic 详情加载失败,请稍后重试'); + } }; // ─── Route / consumer helpers ───────────────────────────────── - const getRoutes = (name: string): BrokerRoute[] => topicRoutes[name] || defaultRoute; - const getConsumers = (name: string): ConsumerGroupInfo[] => - topicConsumers[name] || defaultConsumers; + const getRoutes = (name: string): BrokerRoute[] => routesByTopic[name] ?? []; + const getConsumers = (name: string): ConsumerGroupInfo[] => consumersByTopic[name] ?? []; const handleAction = (key: string, topic: Topic) => { if (key === 'detail') { - openDetail(topic); + void openDetail(topic); } else if (key === 'route') { - openDetail(topic); + void openDetail(topic); } else if (key === 'send') { setSendTopic(topic); sendForm.setFieldsValue({ topic: topic.name, tag: '', key: '', body: '', properties: [] }); @@ -270,7 +302,15 @@ const TopicPage = () => { okText: '删除', okType: 'danger', cancelText: '取消', - onOk: () => message.success(`Topic「${topic.name}」已删除`), + onOk: async () => { + try { + await deleteTopic(topic.name); + setTopics((previous) => previous.filter((item) => item.name !== topic.name)); + message.success(`Topic「${topic.name}」已删除`); + } catch { + message.error('删除 Topic 失败,请稍后重试'); + } + }, }); } }; @@ -309,7 +349,7 @@ const TopicPage = () => { sorter: (a, b) => a.type.localeCompare(b.type), render: (type: string) => { const cfg = TOPIC_TYPE_MAP[type]; - return cfg ? {cfg.label} : {type}; + return cfg ? {t(cfg.labelKey)} : {type}; }, }, { @@ -418,7 +458,9 @@ const TopicPage = () => { {topic.name} - {typeInfo?.label} + + {typeInfo?.labelKey ? t(typeInfo.labelKey) : topic.type} + {topic.namespace} @@ -426,7 +468,7 @@ const TopicPage = () => { {topic.clusterId} - {clusterType && {clusterType.label}} + {clusterType && {t(clusterType.labelKey)}} {topic.writeQueues} @@ -457,7 +499,7 @@ const TopicPage = () => { openDetail(topic)} + onClick={() => void openDetail(topic)} styles={{ body: { padding: '16px 20px' } }} style={{ borderRadius: 8, border: '1px solid #f0f0f0' }} > @@ -466,7 +508,9 @@ const TopicPage = () => { {topic.name} - {typeInfo?.label} + + {typeInfo?.labelKey ? t(typeInfo.labelKey) : topic.type} + {/* Namespace + cluster tags */} @@ -474,7 +518,7 @@ const TopicPage = () => { {topic.namespace} {clusterType && ( - {clusterType.label} + {t(clusterType.labelKey)} )} @@ -514,12 +558,17 @@ const TopicPage = () => { ); // ─── Create modal submit ────────────────────────────────────── - const handleCreate = () => { - form.validateFields().then((values) => { - message.success(`Topic「${values.name}」创建成功`); + const handleCreate = async () => { + try { + const values = await form.validateFields(); + const created = await createTopic(values); + setTopics((previous) => [created, ...previous]); + message.success(`Topic「${created.name}」创建成功`); setModalOpen(false); form.resetFields(); - }); + } catch { + message.error('创建 Topic 失败,请稍后重试'); + } }; // ─── Send message modal submit ──────────────────────────────── @@ -534,10 +583,14 @@ const TopicPage = () => { if (p.key) props[p.key] = p.value || ''; }); } - // Simulate send (mock always succeeds) - await new Promise((r) => setTimeout(r, 500)); - const mockMsgId = `7F${Math.random().toString(16).slice(2, 18).toUpperCase()}`; - message.success(`消息发送成功!MsgId: ${mockMsgId}`); + const result = await sendTopicMessage({ + topic: values.topic, + tag: values.tag || undefined, + key: values.key || undefined, + body: values.body, + properties: props, + }); + message.success(`消息发送成功!MsgId: ${result.msgId}`); setSendModalOpen(false); sendForm.resetFields(); } catch { @@ -608,10 +661,18 @@ const TopicPage = () => { okText: '删除', okType: 'danger', cancelText: '取消', - onOk: () => { - setTopics((prev) => prev.filter((t) => !selectedRowKeys.includes(t.name))); - message.success(`已删除 ${selectedRowKeys.length} 个 Topic`); - setSelectedRowKeys([]); + onOk: async () => { + try { + const names = selectedRowKeys.map(String); + await batchDeleteTopics(names); + setTopics((previous) => + previous.filter((topic) => !names.includes(topic.name)), + ); + message.success(`已删除 ${names.length} 个 Topic`); + setSelectedRowKeys([]); + } catch { + message.error('批量删除 Topic 失败,请稍后重试'); + } }, }); }} @@ -640,6 +701,7 @@ const TopicPage = () => { columns={columns} dataSource={filteredTopics} + loading={loading} rowKey="name" rowSelection={{ selectedRowKeys, @@ -652,7 +714,7 @@ const TopicPage = () => { }} size="small" onRow={(record) => ({ - onClick: () => openDetail(record), + onClick: () => void openDetail(record), style: { cursor: 'pointer' }, })} /> @@ -727,8 +789,8 @@ const TopicPage = () => { form={form} layout="vertical" initialValues={{ - writeQueueNums: 8, - readQueueNums: 8, + writeQueues: 8, + readQueues: 8, perm: 'RW', type: 'NORMAL', namespace: 'default', @@ -766,7 +828,7 @@ const TopicPage = () => { @@ -776,7 +838,7 @@ const TopicPage = () => { diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx new file mode 100644 index 00000000..37ceca17 --- /dev/null +++ b/web/src/pages/login/index.tsx @@ -0,0 +1,101 @@ +/* + * 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.user.username); + 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/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx index af6e99f0..3f7265ae 100644 --- a/web/src/pages/ops/alerts.tsx +++ b/web/src/pages/ops/alerts.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Plus, Pencil, Trash } from '@phosphor-icons/react'; import { Button, @@ -34,8 +34,15 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import PageHeader from '../../components/PageHeader'; -import { mockAlertRules, type AlertRule } from '../../mock/alerts'; import { useLang } from '../../i18n/LangContext'; +import type { AlertRule } from '../../api/ops'; +import { + createAlertRule, + deleteAlertRule, + listAlertRules, + toggleAlertRule, + updateAlertRule, +} from '../../services/opsService'; const { TextArea } = Input; @@ -49,10 +56,22 @@ const metricOptions = ['磁盘使用率', '消费堆积量', 'TPS 异常', 'Brok const durationOptions = ['1分钟', '5分钟', '15分钟', '30分钟']; +const thresholdUnits: Record = { + 磁盘使用率: '%', + 消费堆积量: '条', + 'TPS 异常': 'TPS', + 'Broker 离线': '个', + 'Proxy 连接数': '个', +}; + const AlertsPage = () => { const { t } = useLang(); - const [rules, setRules] = useState([...mockAlertRules]); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [actionId, setActionId] = useState(null); const [form] = Form.useForm(); const channelLabels: Record = { @@ -61,6 +80,25 @@ const AlertsPage = () => { sms: 'SMS', }; + useEffect(() => { + let cancelled = false; + + void listAlertRules() + .then((nextRules) => { + if (!cancelled) setRules(nextRules); + }) + .catch(() => { + if (!cancelled) message.error('告警规则加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + const enabledCount = rules.filter((r) => r.enabled).length; // eslint-disable-next-line react-hooks/purity @@ -69,6 +107,43 @@ const AlertsPage = () => { (r) => r.lastTriggered && new Date(r.lastTriggered).getTime() > dayAgo, ).length; + const openCreateModal = () => { + setEditingRule(null); + form.resetFields(); + setModalVisible(true); + }; + + const openEditModal = (rule: AlertRule) => { + setEditingRule(rule); + form.setFieldsValue(rule); + setModalVisible(true); + }; + + const handleToggle = async (rule: AlertRule, enabled: boolean) => { + setActionId(`toggle-${rule.id}`); + try { + const updated = await toggleAlertRule(rule.id, enabled); + setRules((previous) => previous.map((item) => (item.id === rule.id ? updated : item))); + } catch { + message.error('更新告警规则状态失败,请稍后重试'); + } finally { + setActionId(null); + } + }; + + const handleDelete = async (rule: AlertRule) => { + setActionId(`delete-${rule.id}`); + try { + await deleteAlertRule(rule.id); + setRules((previous) => previous.filter((item) => item.id !== rule.id)); + message.success('告警规则已删除'); + } catch { + message.error('删除告警规则失败,请稍后重试'); + } finally { + setActionId(null); + } + }; + const columns: ColumnsType = [ { title: t('alerts.ruleName'), @@ -103,11 +178,8 @@ const AlertsPage = () => { render: (_, record) => ( { - setRules((prev) => - prev.map((r) => (r.id === record.id ? { ...r, enabled: !r.enabled } : r)), - ); - }} + loading={actionId === `toggle-${record.id}`} + onChange={(enabled) => void handleToggle(record, enabled)} /> ), }, @@ -128,6 +200,7 @@ const AlertsPage = () => { size="small" icon={} style={{ borderColor: '#1890ff', color: '#1890ff' }} + onClick={() => openEditModal(record)} > {t('common.edit')} @@ -135,8 +208,9 @@ const AlertsPage = () => { size="small" icon={} danger + loading={actionId === `delete-${record.id}`} style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }} - onClick={() => setRules((prev) => prev.filter((r) => r.id !== record.id))} + onClick={() => void handleDelete(record)} > {t('common.delete')} @@ -146,10 +220,30 @@ const AlertsPage = () => { ]; const handleSubmit = async () => { - await form.validateFields(); - message.success(t('alerts.ruleCreated')); - setModalVisible(false); - form.resetFields(); + try { + const values = await form.validateFields(); + setSubmitting(true); + if (editingRule) { + const updated = await updateAlertRule({ ...editingRule, ...values }); + setRules((previous) => + previous.map((rule) => (rule.id === editingRule.id ? updated : rule)), + ); + message.success('告警规则已更新'); + } else { + const created = await createAlertRule({ + ...values, + thresholdUnit: thresholdUnits[values.metric] ?? '', + }); + setRules((previous) => [...previous, created]); + message.success(t('alerts.ruleCreated')); + } + setModalVisible(false); + form.resetFields(); + } catch { + message.error('保存告警规则失败,请稍后重试'); + } finally { + setSubmitting(false); + } }; return ( @@ -178,7 +272,7 @@ const AlertsPage = () => { {triggered24h} - @@ -192,19 +286,22 @@ const AlertsPage = () => { dataSource={rules} rowKey="id" size="small" + loading={loading} pagination={false} /> { setModalVisible(false); + setEditingRule(null); form.resetFields(); }} - okText={t('common.create')} + okText={editingRule ? t('common.edit') : t('common.create')} cancelText={t('common.cancel')} >
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx index 0fa678fc..92491699 100644 --- a/web/src/pages/ops/audit.tsx +++ b/web/src/pages/ops/audit.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useState, useMemo } from 'react'; +import { useEffect, useState } from 'react'; import { Card, Table, @@ -32,11 +32,11 @@ import { } from 'antd'; import { Trash } from '@phosphor-icons/react'; import type { ColumnsType } from 'antd/es/table'; -import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import PageHeader from '../../components/PageHeader'; -import { mockAuditRecords, type AuditRecord } from '../../mock/audit'; import { useLang } from '../../i18n/LangContext'; +import type { AuditRecord } from '../../api/ops'; +import { cleanupAuditLogs, listAuditRecords } from '../../services/opsService'; const operationTypeColors: Record = { 创建Topic: 'blue', @@ -60,7 +60,12 @@ const operationTypeOptions = [ const AuditPage: React.FC = () => { const { t } = useLang(); - const [records, setRecords] = useState([...mockAuditRecords] as AuditRecord[]); + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); const [searchText, setSearchText] = useState(''); const [selectedType, setSelectedType] = useState(undefined); const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null); @@ -68,42 +73,49 @@ const AuditPage: React.FC = () => { const [cleanupModalOpen, setCleanupModalOpen] = useState(false); const [cleanupDays, setCleanupDays] = useState(30); - const filteredRecords = useMemo(() => { - return records.filter((record) => { - if (searchText) { - const lower = searchText.toLowerCase(); - if ( - !record.operator.toLowerCase().includes(lower) && - !record.target.toLowerCase().includes(lower) - ) { - return false; - } - } - if (selectedType && record.operationType !== selectedType) { - return false; - } - if (dateRange && dateRange[0] && dateRange[1]) { - const recordTime = dayjs(record.timestamp); - const start = dateRange[0].startOf('day'); - const end = dateRange[1].endOf('day'); - if (recordTime.isBefore(start) || recordTime.isAfter(end)) { - return false; - } - } - if (resultFilter !== 'all' && record.result !== resultFilter) { - return false; - } - return true; - }); - }, [records, searchText, selectedType, dateRange, resultFilter]); + useEffect(() => { + let cancelled = false; + const startDate = dateRange?.[0]?.format('YYYY-MM-DD'); + const endDate = dateRange?.[1]?.format('YYYY-MM-DD'); + + void listAuditRecords({ + page, + pageSize, + search: searchText || undefined, + operationType: selectedType, + startDate, + endDate, + result: resultFilter === 'all' ? undefined : resultFilter, + }) + .then((result) => { + if (cancelled) return; + setRecords(result.items); + setTotal(result.total); + }) + .catch(() => { + if (!cancelled) message.error('审计日志加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [page, pageSize, searchText, selectedType, dateRange, resultFilter, refreshKey]); const { Text } = Typography; - const handleCleanup = () => { - const cutoff = dayjs().subtract(cleanupDays, 'day'); - setRecords((prev) => prev.filter((r) => dayjs(r.timestamp).isAfter(cutoff))); - message.success(t('audit.cleanupSuccess', { n: cleanupDays })); - setCleanupModalOpen(false); + const handleCleanup = async () => { + try { + await cleanupAuditLogs(cleanupDays); + setPage(1); + setRefreshKey((key) => key + 1); + message.success(t('audit.cleanupSuccess', { n: cleanupDays })); + setCleanupModalOpen(false); + } catch { + message.error('清理审计日志失败,请稍后重试'); + } }; const columns: ColumnsType = [ @@ -144,7 +156,7 @@ const AuditPage: React.FC = () => { dataIndex: 'result', width: 80, render: (result: string) => - result === 'success' ? ( + result.toUpperCase() === 'SUCCESS' ? ( {t('common.success')} ) : ( {t('common.failure')} @@ -163,7 +175,10 @@ const AuditPage: React.FC = () => { setSearchText(e.target.value)} + onChange={(e) => { + setPage(1); + setSearchText(e.target.value); + }} style={{ width: 240 }} allowClear /> @@ -172,21 +187,30 @@ const AuditPage: React.FC = () => { allowClear style={{ width: 180 }} value={selectedType} - onChange={setSelectedType} + onChange={(value) => { + setPage(1); + setSelectedType(value); + }} options={operationTypeOptions.map((opt) => ({ label: opt, value: opt }))} /> setDateRange(vals as [Dayjs | null, Dayjs | null] | null)} + onChange={(vals) => { + setPage(1); + setDateRange(vals as [Dayjs | null, Dayjs | null] | null); + }} />
{ + setPage(nextPage); + setPageSize(nextPageSize); + }, + }} /> diff --git a/web/src/pages/ops/systemAlerts.tsx b/web/src/pages/ops/systemAlerts.tsx index e4ceaec7..09e59041 100644 --- a/web/src/pages/ops/systemAlerts.tsx +++ b/web/src/pages/ops/systemAlerts.tsx @@ -15,12 +15,17 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card, Tag, Flex, Typography, Badge, Button, message } from 'antd'; import { CheckCircle, Trash } from '@phosphor-icons/react'; import PageHeader from '../../components/PageHeader'; -import { systemAlerts } from '../../mock/dashboard'; import { useLang } from '../../i18n/LangContext'; +import { + acknowledgeAlert, + clearAcknowledgedAlerts, + listSystemAlerts, +} from '../../services/opsService'; +import type { SystemAlert } from '../../api/ops'; const { Text } = Typography; @@ -33,21 +38,59 @@ const SystemAlertsPage = () => { info: { color: '#1677ff', bg: '#e6f4ff', label: t('sysAlerts.info') }, }; - const [alerts, setAlerts] = useState([...systemAlerts]); + const [alerts, setAlerts] = useState([]); const [levelFilter, setLevelFilter] = useState('all'); + const [loading, setLoading] = useState(true); + const [acknowledgingId, setAcknowledgingId] = useState(null); + const [clearing, setClearing] = useState(false); + + useEffect(() => { + let cancelled = false; + + void listSystemAlerts() + .then((data) => { + if (!cancelled) setAlerts(data); + }) + .catch(() => { + if (!cancelled) message.error('系统告警加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); const filtered = levelFilter === 'all' ? alerts : alerts.filter((a) => a.level === levelFilter); const unackCount = alerts.filter((a) => !a.acknowledged).length; - const handleAck = (id: string) => { - setAlerts((prev) => prev.map((a) => (a.id === id ? { ...a, acknowledged: true } : a))); - message.success(t('sysAlerts.acknowledged')); + const handleAck = async (id: string) => { + setAcknowledgingId(id); + try { + await acknowledgeAlert(id); + setAlerts((prev) => prev.map((a) => (a.id === id ? { ...a, acknowledged: true } : a))); + message.success(t('sysAlerts.acknowledged')); + } catch { + message.error('确认告警失败,请稍后重试'); + } finally { + setAcknowledgingId(null); + } }; - const handleClearAcked = () => { - setAlerts((prev) => prev.filter((a) => !a.acknowledged)); - message.success(t('sysAlerts.cleared')); + const handleClearAcked = async () => { + setClearing(true); + try { + await clearAcknowledgedAlerts(); + setAlerts((prev) => prev.filter((a) => !a.acknowledged)); + message.success(t('sysAlerts.cleared')); + } catch { + message.error('清理已确认告警失败,请稍后重试'); + } finally { + setClearing(false); + } }; return ( @@ -60,6 +103,7 @@ const SystemAlertsPage = () => { icon={} onClick={handleClearAcked} disabled={!alerts.some((a) => a.acknowledged)} + loading={clearing} > {t('sysAlerts.clearAcked')} @@ -91,63 +135,66 @@ const SystemAlertsPage = () => { - {filtered.map((alert) => { - const cfg = alertLevelConfig[alert.level]; - return ( -
-
- - - {alert.title} + {loading && } + {!loading && + filtered.map((alert) => { + const cfg = alertLevelConfig[alert.level]; + return ( +
+
+ + + {alert.title} + + + {cfg.label} + + + + {alert.description} + +
+ + + {alert.time} - - {cfg.label} - + {!alert.acknowledged && ( + + )} - - {alert.description} -
- - - {alert.time} - - {!alert.acknowledged && ( - - )} - -
- ); - })} - {filtered.length === 0 && ( + ); + })} + {!loading && filtered.length === 0 && ( {t('sysAlerts.noAlerts')} diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 612d497e..9344eea0 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -15,9 +15,10 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button, + Checkbox, Descriptions, Divider, Flex, @@ -49,49 +50,20 @@ import type { ColumnsType } from 'antd/es/table'; import PageHeader from '../../components/PageHeader'; import { useLang } from '../../i18n/LangContext'; import StatusBadge from '../../components/StatusBadge'; +import { getGeneralSettings, saveGeneralSettings } from '../../api/settings'; +import type { GeneralSettingsUpdate } from '../../api/settings'; +import { + createDataSource, + deleteDataSource, + listDataSources, + testDataSource, + updateDataSource, +} from '../../api/settings'; +import type { DataSource } from '../../api/settings'; +import { STATUS_MAP } from '../../constants/theme'; const { Title, Text, Link: TypoLink } = Typography; -// ─── Types ────────────────────────────────────────────────────────────────── - -interface DataSource { - key: string; - name: string; - type: 'Prometheus' | 'VictoriaMetrics' | 'Thanos'; - url: string; - auth: string; - status: 'healthy' | 'error'; -} - -// ─── Mock Data ────────────────────────────────────────────────────────────── - -const mockDataSources: DataSource[] = [ - { - key: '1', - name: 'Prometheus 生产监控', - type: 'Prometheus', - url: 'http://prometheus.prod:9090', - auth: 'Bearer Token', - status: 'healthy', - }, - { - key: '2', - name: 'VictoriaMetrics 历史数据', - type: 'VictoriaMetrics', - url: 'http://vm.prod:8428', - auth: 'Basic Auth', - status: 'healthy', - }, - { - key: '3', - name: '测试环境 Prometheus', - type: 'Prometheus', - url: 'http://prometheus.test:9090', - auth: 'None', - status: 'error', - }, -]; - const typeTagColor: Record = { Prometheus: 'orange', VictoriaMetrics: 'blue', @@ -101,7 +73,48 @@ const typeTagColor: Record = { // ─── General Settings Tab ─────────────────────────────────────────────────── const GeneralSettingsTab = () => { - const [form] = Form.useForm(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [apiKeyConfigured, setApiKeyConfigured] = useState(false); + const clearApiKey = Form.useWatch('clearApiKey', form); + + useEffect(() => { + let cancelled = false; + void getGeneralSettings() + .then((settings) => { + if (!cancelled) { + setApiKeyConfigured(settings.apiKeyConfigured); + form.setFieldsValue({ ...settings, apiKey: undefined, clearApiKey: false }); + } + }) + .catch(() => { + if (!cancelled) message.error('通用设置加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [form]); + + const handleFinish = async (values: GeneralSettingsUpdate) => { + setSaving(true); + try { + await saveGeneralSettings(values); + setApiKeyConfigured( + values.clearApiKey ? false : apiKeyConfigured || Boolean(values.apiKey?.trim()), + ); + form.setFieldsValue({ apiKey: undefined, clearApiKey: false }); + message.success('设置已保存'); + } catch { + message.error('设置保存失败,请稍后重试'); + } finally { + setSaving(false); + } + }; return ( { layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 14 }} - initialValues={{ - theme: 'light', - compact: false, - desktopNotify: true, - notifySound: false, - sessionTimeout: 30, - requireLogin: true, - llmProvider: 'qwen', - model: 'qwen-max', - }} - onFinish={() => message.success('设置已保存')} + onFinish={handleFinish} style={{ maxWidth: 800 }} > {/* ── 外观 ── */} @@ -194,10 +197,26 @@ const GeneralSettingsTab = () => { /> - - + + + {apiKeyConfigured && ( + + { + if (event.target.checked) form.setFieldValue('apiKey', undefined); + }} + > + 清除已保存的 API Key + + + )} + @@ -208,7 +227,7 @@ const GeneralSettingsTab = () => { {/* ── Submit ── */} - @@ -219,16 +238,88 @@ const GeneralSettingsTab = () => { // ─── Data Source Tab ──────────────────────────────────────────────────────── const DataSourceTab = () => { + const [dataSources, setDataSources] = useState([]); + const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); + const [editingDataSource, setEditingDataSource] = useState(null); const [dsForm] = Form.useForm(); const [testing, setTesting] = useState(false); - - const handleTestConnection = () => { + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + void listDataSources() + .then((sources) => { + if (!cancelled) setDataSources(sources); + }) + .catch(() => { + if (!cancelled) message.error('数据源加载失败,请稍后重试'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const handleTestConnection = async (data: Pick) => { setTesting(true); - setTimeout(() => { + try { + const result = await testDataSource(data); + if (result.success) message.success(result.message); + else message.error(result.message); + } catch { + message.error('连接测试失败,请稍后重试'); + } finally { setTesting(false); - message.success('连接成功'); - }, 1200); + } + }; + + const openCreateModal = () => { + setEditingDataSource(null); + dsForm.resetFields(); + dsForm.setFieldValue('auth', 'None'); + setModalOpen(true); + }; + + const openEditModal = (dataSource: DataSource) => { + setEditingDataSource(dataSource); + dsForm.setFieldsValue(dataSource); + setModalOpen(true); + }; + + const handleSubmit = async () => { + try { + const values = await dsForm.validateFields(); + setSubmitting(true); + const saved = editingDataSource + ? await updateDataSource({ ...editingDataSource, ...values }) + : await createDataSource(values); + setDataSources((previous) => + editingDataSource + ? previous.map((dataSource) => (dataSource.key === saved.key ? saved : dataSource)) + : [...previous, saved], + ); + message.success(editingDataSource ? '数据源已更新' : '数据源已添加'); + setModalOpen(false); + dsForm.resetFields(); + } catch { + message.error('保存数据源失败,请稍后重试'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (dataSource: DataSource) => { + try { + await deleteDataSource(dataSource.key); + setDataSources((previous) => previous.filter((item) => item.key !== dataSource.key)); + message.success('数据源已删除'); + } catch { + message.error('删除数据源失败,请稍后重试'); + } }; const columns: ColumnsType = [ @@ -245,25 +336,37 @@ const DataSourceTab = () => { title: '状态', dataIndex: 'status', key: 'status', - render: (s: DataSource['status']) => , + render: (s: DataSource['status']) => , }, { title: '操作', key: 'action', - render: () => ( + render: (_: unknown, record: DataSource) => ( - - @@ -274,29 +377,30 @@ const DataSourceTab = () => { return ( <> - columns={columns} - dataSource={mockDataSources} + dataSource={dataSources} + rowKey="key" + loading={loading} pagination={false} size="middle" /> setModalOpen(false)} - onOk={() => { - dsForm.validateFields().then(() => { - message.success('数据源已添加'); - setModalOpen(false); - dsForm.resetFields(); - }); + onCancel={() => { + setModalOpen(false); + setEditingDataSource(null); + dsForm.resetFields(); }} + onOk={() => void handleSubmit()} + confirmLoading={submitting} destroyOnClose > @@ -344,7 +448,12 @@ const DataSourceTab = () => {
+ } + /> + + + + + + + + + + + + + + {/* 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 + > + + + + + + + + + +