You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/09/22 06:31:42 UTC
(rocketmq-dashboard) branch rocketmq-studio updated: fix(studio): repair MCP tool gating, smooth AI streaming, and polish console surfaces (#4855)
This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 1ef5d8607 fix(studio): repair MCP tool gating, smooth AI streaming, and polish console surfaces (#4855)
1ef5d8607 is described below
commit 1ef5d860799ac3fabfcdea942cc4dcc77ded7be6
Author: lizhimins <70...@qq.com>
AuthorDate: Tue Sep 22 14:31:35 2026 +0800
fix(studio): repair MCP tool gating, smooth AI streaming, and polish console surfaces (#4855)
MCP tool layer (server):
- rmq.proxy.list / rmq.proxy.config no longer require PROXY_DISCOVERY on
the bound instance: both aggregate every manageable instance through
the heartbeat-syncer group, so gating them on the bound instance type
made them permanently UNSUPPORTED in direct-attached deployments even
with proxies running; they join audit.list/alert.rule.list as ungated
- rmq.instance.metrics accepts the sole shared data source bound to the
instance, still preferring a dedicated one
- CORS allowedMethods gains PATCH: browsers attach an Origin header to
every non-GET request, so the missing verb answered 403 "Invalid CORS
request" on conversation rename/archive in every real browser
AI conversation surface (web):
- memoise transcript bubbles and blocks: a streaming tick re-rendered the
page at display rate and re-ran ReactMarkdown over every persisted
answer plus every finished tool card each frame
- follow the stream with instant scrolls instead of stacking a fresh
smooth scroll on every animation frame
- show the operator's question immediately via an optimistic user
bubble; the stream carries no user frame and the transcript refetches
only when the run finishes, so the question was invisible for minutes
- history modal gains a per-row archive/unarchive action
- context meter pairs a mini bar with the percent number, hidden below
1%; composer header wraps on narrow viewports; send button slims to
32px
Cluster/clients console (web):
- broker config modal compacted into a grouped two-column grid; the
permission bitfield becomes a named Select; the hard-coded queue hint
becomes a translated tooltip
- diagnostics tables clip long values with ellipsis + title instead of
spilling over neighbouring columns
rmqctl:
- native Windows build script; plain HTTP allowed for private-network
Studio endpoints
Tests:
- stabilise three flake families under parallel load: antd spin-blur
lagging the loading prop one commit (pointerEventsCheck: 0 on
table-driving suites), CSV import waits racing the instance load
(wait for the instance-scoped fetch), static message/notification
holders leaking across tests (global afterEach destroy)
- pin the diagnostics race test to fake intervals; new coverage for the
metrics source selection, optimistic bubble, scroll behaviour, CORS
PATCH and the archive flow
---
rmqctl/internal/studio/auth.go | 16 +++
rmqctl/internal/studio/client.go | 4 +-
rmqctl/internal/studio/client_test.go | 78 +++++++++++++
rmqctl/scripts/build-windows.ps1 | 94 +++++++++++++++
.../studio/cluster/metrics/MetricsService.java | 26 +++--
.../rocketmq/studio/common/config/CorsConfig.java | 6 +-
.../ops/ai/tool/filter/ToolCapabilityFilter.java | 9 +-
.../main/resources/tool-catalog/tools/proxy.yaml | 6 +-
.../studio/cluster/metrics/MetricsServiceTest.java | 77 +++++++++++++
.../studio/common/config/CorsConfigTest.java | 17 +++
web/src/i18n/translations.ts | 16 +++
web/src/index.css | 6 +-
web/src/pages/ai/components/AssistantBubble.tsx | 9 +-
web/src/pages/ai/components/BubbleList.tsx | 21 +++-
web/src/pages/ai/components/ChatThread.tsx | 20 +++-
web/src/pages/ai/components/Composer.tsx | 35 ++++--
.../pages/ai/components/ConversationListModal.tsx | 81 ++++++++++---
web/src/pages/ai/components/SendStopButton.tsx | 8 +-
web/src/pages/ai/components/UserBubble.tsx | 3 +-
.../ai/components/__tests__/ChatThread.test.tsx | 73 +++++++++++-
.../__tests__/ConversationListModal.test.tsx | 37 ++++++
web/src/pages/ai/components/blocks/ErrorBlock.tsx | 3 +-
web/src/pages/ai/components/blocks/NoticeBlock.tsx | 3 +-
web/src/pages/ai/components/blocks/TextBlock.tsx | 9 +-
.../pages/ai/components/blocks/ThinkingBlock.tsx | 4 +-
web/src/pages/ai/components/blocks/ToolBlock.tsx | 8 +-
web/src/pages/ai/hooks/useAgentRun.ts | 30 ++++-
web/src/pages/ai/index.tsx | 25 ++--
.../pages/cluster/__tests__/ClientsPage.test.tsx | 32 +++---
.../pages/cluster/__tests__/ClusterPage.test.tsx | 24 ++--
web/src/pages/cluster/clients.tsx | 54 +++++++--
web/src/pages/cluster/index.tsx | 127 ++++++++++++++-------
.../pages/home/__tests__/DashboardPage.test.tsx | 10 +-
web/src/pages/instance/__tests__/AclPage.test.tsx | 40 +++----
.../pages/instance/__tests__/ConsumerPage.test.tsx | 75 ++++++------
.../__tests__/ConsumerPageDiagnosticsRace.test.tsx | 8 +-
web/src/pages/instance/__tests__/DLQPage.test.tsx | 34 +++---
.../pages/instance/__tests__/InstancePage.test.tsx | 46 ++++----
.../pages/instance/__tests__/MessagePage.test.tsx | 24 ++--
.../__tests__/MessagePageAsyncState.test.tsx | 36 +++---
.../pages/instance/__tests__/TopicPage.test.tsx | 70 +++++++-----
.../__tests__/NotificationDeliveriesPage.test.tsx | 6 +-
web/src/test/setup.ts | 10 ++
43 files changed, 1001 insertions(+), 319 deletions(-)
diff --git a/rmqctl/internal/studio/auth.go b/rmqctl/internal/studio/auth.go
index 85bd50ea2..fa234324f 100644
--- a/rmqctl/internal/studio/auth.go
+++ b/rmqctl/internal/studio/auth.go
@@ -118,3 +118,19 @@ func isLoopbackHost(host string) bool {
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
+
+// isPrivateHost reports whether host is a loopback, RFC1918 private-network,
+// or link-local address. Plain HTTP is permitted for these non-routable,
+// trusted networks (local development and same-VPC/intranet deployments);
+// public endpoints must still use HTTPS so that HMAC credentials are never
+// transmitted in the clear over the Internet.
+func isPrivateHost(host string) bool {
+ if isLoopbackHost(host) {
+ return true
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return false
+ }
+ return ip.IsPrivate() || ip.IsLinkLocalUnicast()
+}
diff --git a/rmqctl/internal/studio/client.go b/rmqctl/internal/studio/client.go
index 04ec2c56d..b459fc78d 100644
--- a/rmqctl/internal/studio/client.go
+++ b/rmqctl/internal/studio/client.go
@@ -58,8 +58,8 @@ func (t Target) validate() error {
if err != nil || server.Hostname() == "" {
return fmt.Errorf("invalid Studio Server URL %q", t.Server)
}
- if server.Scheme != "https" && !(server.Scheme == "http" && isLoopbackHost(server.Hostname())) {
- return fmt.Errorf("studio server must use HTTPS (HTTP is allowed only for loopback development)")
+ if server.Scheme != "https" && !(server.Scheme == "http" && isPrivateHost(server.Hostname())) {
+ return fmt.Errorf("studio server must use HTTPS (HTTP is allowed only for loopback or private-network addresses)")
}
if server.User != nil || server.RawQuery != "" || server.Fragment != "" {
return fmt.Errorf("studio server URL must not contain user info, a query, or a fragment")
diff --git a/rmqctl/internal/studio/client_test.go b/rmqctl/internal/studio/client_test.go
new file mode 100644
index 000000000..7bc6fd464
--- /dev/null
+++ b/rmqctl/internal/studio/client_test.go
@@ -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.
+ */
+package studio
+
+import "testing"
+
+// TestValidateServerSchemeAdaptiveHTTP verifies the adaptive scheme policy:
+// HTTPS is always accepted, plain HTTP is accepted for loopback / RFC1918
+// private / link-local hosts, and rejected for public hosts.
+func TestValidateServerSchemeAdaptiveHTTP(t *testing.T) {
+ tests := []struct {
+ name string
+ server string
+ wantErr bool
+ }{
+ {"https public host", "https://studio.example.com", false},
+ {"http ipv4 loopback", "http://127.0.0.1:6789", false},
+ {"http localhost", "http://localhost:6789", false},
+ {"http ipv6 loopback", "http://[::1]:6789", false},
+ {"http private 10/8", "http://10.0.3.104:6789", false},
+ {"http private 172.16/12", "http://172.16.5.10:6789", false},
+ {"http private 192.168/16", "http://192.168.1.20:6789", false},
+ {"http link-local 169.254/16", "http://169.254.1.5:6789", false},
+ {"http public ipv4 rejected", "http://47.98.43.243:6789", true},
+ {"http public domain rejected", "http://studio.example.com", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ target := Target{
+ Server: tt.server,
+ InstanceID: "test-instance",
+ Credential: Credential{AccessKey: "ak", SecretKey: "sk"},
+ }
+ err := target.validate()
+ if tt.wantErr && err == nil {
+ t.Fatalf("validate(%q) = nil, want HTTPS-required error", tt.server)
+ }
+ if !tt.wantErr && err != nil {
+ t.Fatalf("validate(%q) = %v, want nil", tt.server, err)
+ }
+ })
+ }
+}
+
+func TestIsPrivateHost(t *testing.T) {
+ private := []string{
+ "localhost", "127.0.0.1", "::1",
+ "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.0.1", "169.254.1.1",
+ }
+ for _, host := range private {
+ if !isPrivateHost(host) {
+ t.Errorf("isPrivateHost(%q) = false, want true", host)
+ }
+ }
+ public := []string{
+ "studio.example.com", "47.98.43.243", "8.8.8.8",
+ "172.15.0.1", "172.32.0.1", "11.0.0.1",
+ }
+ for _, host := range public {
+ if isPrivateHost(host) {
+ t.Errorf("isPrivateHost(%q) = true, want false", host)
+ }
+ }
+}
diff --git a/rmqctl/scripts/build-windows.ps1 b/rmqctl/scripts/build-windows.ps1
new file mode 100644
index 000000000..275cd0f95
--- /dev/null
+++ b/rmqctl/scripts/build-windows.ps1
@@ -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.
+#
+
+<#
+.SYNOPSIS
+ Native Windows build for rmqctl (equivalent to `make build` on Unix).
+
+.DESCRIPTION
+ Compiles rmqctl.exe with the local Go toolchain on Windows, injecting the
+ same version metadata as the Makefile LDFLAGS. This is the native-build
+ counterpart to the cross-compilation done by `make build-all`.
+
+ Go must be on PATH (or installed at C:\go). If the local Go is older than
+ the version required by go.mod, set GOTOOLCHAIN=auto so Go fetches the
+ right toolchain automatically.
+
+.PARAMETER Version
+ Value injected into cmd.CLIVersion (default: 1.0.0).
+
+.PARAMETER Output
+ Output binary path (default: bin\rmqctl.exe).
+
+.PARAMETER RunTests
+ Run `go test -count=1 ./...` after a successful build.
+
+.EXAMPLE
+ .\scripts\build-windows.ps1
+ .\scripts\build-windows.ps1 -Version 1.2.3 -RunTests
+#>
+param(
+ [string]$Version = "1.0.0",
+ [string]$Output = "bin\rmqctl.exe",
+ [switch]$RunTests
+)
+
+$ErrorActionPreference = "Stop"
+
+# Resolve module root: this script lives in scripts/, module root is one level up.
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$moduleRoot = Split-Path -Parent $scriptDir
+Set-Location $moduleRoot
+
+# Locate the Go toolchain.
+if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
+ if (Test-Path "C:\go\bin\go.exe") {
+ $env:Path = "C:\go\bin;" + $env:Path
+ } else {
+ throw "Go not found on PATH. Install Go (>= the version in go.mod) and retry."
+ }
+}
+
+Write-Host ">> $(go version)"
+Write-Host ">> module root: $moduleRoot"
+
+# Version metadata, mirroring the Makefile LDFLAGS.
+$gitCommit = "unknown"
+try {
+ $resolved = (git rev-parse --short HEAD 2>$null)
+ if ($resolved) { $gitCommit = $resolved }
+} catch { }
+$buildDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
+$pkg = "github.com/apache/rocketmq-dashboard/rmqctl/cmd"
+$ldflags = "-s -w -X $pkg.CLIVersion=$Version -X $pkg.GitCommit=$gitCommit -X $pkg.BuildDate=$buildDate"
+
+$env:CGO_ENABLED = "0"
+
+Write-Host ">> building $Output (VERSION=$Version COMMIT=$gitCommit DATE=$buildDate)"
+go build -ldflags "$ldflags" -o "$Output" main.go
+if ($LASTEXITCODE -ne 0) { throw "go build failed (exit $LASTEXITCODE)" }
+
+$item = Get-Item "$Output"
+Write-Host ">> built $($item.FullName) ($($item.Length) bytes)"
+
+if ($RunTests) {
+ Write-Host ">> running go test -count=1 ./..."
+ go test -count=1 ./...
+ if ($LASTEXITCODE -ne 0) { throw "go test failed (exit $LASTEXITCODE)" }
+}
+
+Write-Host ">> done. verify with: .\$Output version"
diff --git a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
index a50e4f771..c1c9bf26c 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsService.java
@@ -101,15 +101,27 @@ public class MetricsService {
return source.query(resolvedQuery);
}
- /** Tool queries require a dedicated source explicitly bound to this Instance. */
+ /**
+ * Tool queries use the single data source bound to this Instance: a source bound only to
+ * this Instance wins; otherwise the sole shared source whose bindings include the Instance
+ * is accepted. Ambiguous or missing bindings stay a loud 409.
+ */
public MetricDataVO queryInstance(String instanceId, MetricQueryDTO query) {
if (!StringUtils.hasText(instanceId)) throw badRequest("Instance is required for metrics query");
- List<DataSourceVO> sources = settingsService.listDataSources().stream()
- .filter(source -> source.getInstanceIds() != null && source.getInstanceIds().size() == 1
- && instanceId.equals(source.getInstanceIds().get(0))).toList();
- if (sources.size() != 1) throw new org.apache.rocketmq.studio.common.exception.BusinessException(409,
- "Configure exactly one dedicated metrics data source bound only to Instance " + instanceId);
- DataSourceVO source = sources.get(0);
+ List<DataSourceVO> boundSources = settingsService.listDataSources().stream()
+ .filter(source -> source.getInstanceIds() != null
+ && source.getInstanceIds().contains(instanceId)).toList();
+ List<DataSourceVO> dedicatedSources = boundSources.stream()
+ .filter(source -> source.getInstanceIds().size() == 1).toList();
+ DataSourceVO source;
+ if (dedicatedSources.size() == 1) {
+ source = dedicatedSources.get(0);
+ } else if (dedicatedSources.isEmpty() && boundSources.size() == 1) {
+ source = boundSources.get(0);
+ } else {
+ throw new org.apache.rocketmq.studio.common.exception.BusinessException(409,
+ "Configure exactly one metrics data source bound to Instance " + instanceId);
+ }
if (!"none".equals(normalizeAuth(source.getAuth())))
throw new org.apache.rocketmq.studio.common.exception.BusinessException(501,
"Tool queries do not support data sources requiring per-request credentials");
diff --git a/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java b/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java
index cdcbd27b5..c1a8d8141 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/common/config/CorsConfig.java
@@ -41,7 +41,11 @@ public class CorsConfig implements WebMvcConfigurer {
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
- .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+ // PATCH must stay in this list: browsers attach an Origin header to every
+ // non-GET request, so Spring's CORS processor vets even the same-origin PATCHes
+ // (conversation rename/archive), and a method missing here answers 403
+ // "Invalid CORS request" before the handler ever runs.
+ .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
// Without exposedHeaders, browsers hide the DLQ export scan-completeness
// headers from cross-origin JavaScript and the export truncation warning
diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
index 427278fa6..9cdf6c2fd 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/filter/ToolCapabilityFilter.java
@@ -39,9 +39,12 @@ import java.util.Set;
*
* <p>Exempt handlers never read {@code context.instanceId()} -- only this gate and
* {@link ToolAuditFilter} do -- so binding one cannot widen the data a platform-level tool returns.
- * Tools that declare no {@code requiredCapabilities} at all (today {@code rmq.audit.list} and
- * {@code rmq.alert.rule.list}) stay ungated: their data is deployment-wide, so there is no capability
- * to check and no Instance to bind.
+ * Tools that declare no {@code requiredCapabilities} at all (today {@code rmq.audit.list},
+ * {@code rmq.alert.rule.list}, {@code rmq.proxy.list} and {@code rmq.proxy.config}) stay ungated:
+ * their data is deployment-wide, so there is no capability to check and no Instance to bind.
+ * The Proxy pair aggregates every manageable Instance through the heartbeat-syncer group, so
+ * gating them on the bound Instance's type would refuse legitimate discovery whenever that
+ * Instance is not Proxy-attached even though Proxies exist elsewhere in the deployment.
*/
@Component
@RequiredArgsConstructor
diff --git a/server/src/main/resources/tool-catalog/tools/proxy.yaml b/server/src/main/resources/tool-catalog/tools/proxy.yaml
index e865076db..f2019459f 100644
--- a/server/src/main/resources/tool-catalog/tools/proxy.yaml
+++ b/server/src/main/resources/tool-catalog/tools/proxy.yaml
@@ -7,8 +7,7 @@ tools:
description: "Discover Proxy data endpoints, optionally filtered by physical cluster name. Discovery does not establish Broker cluster membership or management capability."
riskLevel: L1
permission: proxy:read
- requiredCapabilities:
- - PROXY_DISCOVERY
+ requiredCapabilities: []
inputSchema:
type: object
required: []
@@ -57,8 +56,7 @@ tools:
description: "Read the Proxy configuration and reachability snapshot for one physical cluster, with an optional addr filter (read-only; configuration reload is not exposed through MCP)."
riskLevel: L1
permission: proxy:read
- requiredCapabilities:
- - PROXY_DISCOVERY
+ requiredCapabilities: []
inputSchema:
type: object
required:
diff --git a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
index 86f2fb6f1..1f19b8835 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsServiceTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.cluster.metrics;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.model.MetricsDataSourceConfig;
import org.apache.rocketmq.studio.model.request.MetricsDataSourceQueryRequest;
import org.apache.rocketmq.studio.settings.DataSourceVO;
@@ -562,4 +563,80 @@ class MetricsServiceTest {
.satisfies(exception -> assertThat(exception.getStatusCode()).isEqualTo(400));
verifyNoInteractions(settingsService, metricsSourceFactory, metricsSource);
}
+
+ @Test
+ void queryInstanceShouldPreferDedicatedSourceOverSharedSourceTest() {
+ DataSourceVO dedicated = dataSource("ds-dedicated", List.of("instance-a"));
+ DataSourceVO shared = dataSource("ds-shared", List.of("instance-a", "instance-b"));
+ when(settingsService.listDataSources()).thenReturn(List.of(shared, dedicated));
+ when(settingsService.getDataSource("ds-dedicated")).thenReturn(dedicated);
+ when(metricsSourceFactory.create(any(MetricsDataSourceConfig.class))).thenReturn(metricsSource);
+ when(metricsSource.query(any(MetricQueryDTO.class))).thenReturn(emptyMetricData());
+
+ metricsService.queryInstance("instance-a", rawQuery());
+
+ verify(settingsService).getDataSource("ds-dedicated");
+ verify(metricsSource).query(any(MetricQueryDTO.class));
+ }
+
+ @Test
+ void queryInstanceShouldAcceptSoleSharedSourceTest() {
+ DataSourceVO shared = dataSource("ds-shared", List.of("instance-a", "instance-b"));
+ when(settingsService.listDataSources()).thenReturn(List.of(shared));
+ when(settingsService.getDataSource("ds-shared")).thenReturn(shared);
+ when(metricsSourceFactory.create(any(MetricsDataSourceConfig.class))).thenReturn(metricsSource);
+ when(metricsSource.query(any(MetricQueryDTO.class))).thenReturn(emptyMetricData());
+
+ metricsService.queryInstance("instance-b", rawQuery());
+
+ verify(settingsService).getDataSource("ds-shared");
+ verify(metricsSource).query(any(MetricQueryDTO.class));
+ }
+
+ @Test
+ void queryInstanceShouldRejectInstanceWithoutAnyBoundSourceTest() {
+ DataSourceVO other = dataSource("ds-other", List.of("instance-b"));
+ when(settingsService.listDataSources()).thenReturn(List.of(other));
+
+ assertThatExceptionOfType(BusinessException.class)
+ .isThrownBy(() -> metricsService.queryInstance("instance-a", rawQuery()))
+ .satisfies(exception -> {
+ assertThat(exception.getCode()).isEqualTo(409);
+ assertThat(exception.getMessage())
+ .isEqualTo("Configure exactly one metrics data source bound to Instance instance-a");
+ });
+ verifyNoInteractions(metricsSourceFactory, metricsSource);
+ }
+
+ @Test
+ void queryInstanceShouldRejectAmbiguousSharedSourcesTest() {
+ DataSourceVO first = dataSource("ds-1", List.of("instance-a", "instance-b"));
+ DataSourceVO second = dataSource("ds-2", List.of("instance-a", "instance-c"));
+ when(settingsService.listDataSources()).thenReturn(List.of(first, second));
+
+ assertThatExceptionOfType(BusinessException.class)
+ .isThrownBy(() -> metricsService.queryInstance("instance-a", rawQuery()))
+ .satisfies(exception -> assertThat(exception.getCode()).isEqualTo(409));
+ verifyNoInteractions(metricsSourceFactory, metricsSource);
+ }
+
+ private DataSourceVO dataSource(String key, List<String> instanceIds) {
+ return DataSourceVO.builder()
+ .key(key)
+ .name(key)
+ .type("prometheus")
+ .url("http://prometheus:9090")
+ .auth("none")
+ .instanceIds(instanceIds)
+ .build();
+ }
+
+ private MetricQueryDTO rawQuery() {
+ return MetricQueryDTO.builder()
+ .metric("cpu")
+ .start(1700000000L)
+ .end(1700003600L)
+ .step("1m")
+ .build();
+ }
}
diff --git a/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java b/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
index c21c45453..779a42486 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/common/config/CorsConfigTest.java
@@ -83,6 +83,23 @@ class CorsConfigTest extends WebMvcAuthTestSupport {
() -> "CORS mapping must expose the DLQ export scan headers, but declared: " + exposed);
}
+ @Test
+ void corsMappingShouldAllowPatchTest() {
+ // Browsers attach an Origin header to every non-GET request, so the CORS processor vets
+ // even same-origin PATCHes; without PATCH in allowedMethods the conversation rename and
+ // archive endpoints answer 403 "Invalid CORS request" in every real browser.
+ ExposingCorsRegistry registry = new ExposingCorsRegistry();
+ new CorsConfig(FRONTEND_ORIGIN).addCorsMappings(registry);
+
+ CorsConfiguration configuration = registry.getCorsConfigurations().get("/**");
+ assertNotNull(configuration);
+ List<String> methods = configuration.getAllowedMethods();
+ assertNotNull(methods, "CORS mapping must declare allowed methods");
+ assertTrue(methods.containsAll(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")),
+ () -> "CORS mapping must allow every method the REST surface uses, but declared: "
+ + methods);
+ }
+
@Test
void excelExportShouldExposeScanHeadersToCrossOriginClients() throws Exception {
when(dlqService.exportExcel(eq("instance-1"), eq("test-group"), isNull(), isNull(), isNull()))
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index bb1bceae1..b57616769 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1659,6 +1659,14 @@ const translations: Record<string, Record<Lang, string>> = {
'ai.list.openAria': { zh: '打开会话 {title}', en: 'Open conversation {title}' },
'ai.list.columnAction': { zh: '操作', en: 'Action' },
'ai.list.deleteAria': { zh: '删除会话 {title}', en: 'Delete conversation {title}' },
+ 'ai.list.archiveAria': { zh: '归档会话 {title}', en: 'Archive conversation {title}' },
+ 'ai.list.unarchiveAria': { zh: '取消归档 {title}', en: 'Unarchive conversation {title}' },
+ 'ai.list.archivedToast': { zh: '会话已归档', en: 'Conversation archived' },
+ 'ai.list.unarchivedToast': {
+ zh: '会话已恢复到进行中',
+ en: 'Conversation moved back to active',
+ },
+ 'ai.list.archiveFailed': { zh: '归档操作失败,请稍后重试', en: 'Archive action failed, please retry later' },
'ai.list.deleteConfirm': { zh: '删除这条会话?', en: 'Delete this conversation?' },
'ai.list.deleteSelected': { zh: '删除 ({count})', en: 'Delete ({count})' },
'ai.list.deleteSelectedConfirm': {
@@ -2587,6 +2595,14 @@ const translations: Record<string, Record<Lang, string>> = {
'cluster.writeQueues': { zh: '写队列数', en: 'Write Queues' },
'cluster.readQueues': { zh: '读队列数', en: 'Read Queues' },
'cluster.brokerPermission': { zh: 'Broker 权限', en: 'Broker Permission' },
+ 'cluster.permRW': { zh: '读写(6)', en: 'Read/Write (6)' },
+ 'cluster.permR': { zh: '只读(4)', en: 'Read-only (4)' },
+ 'cluster.permW': { zh: '只写(2)', en: 'Write-only (2)' },
+ 'cluster.permNone': { zh: '无权限(0)', en: 'Disabled (0)' },
+ 'cluster.queueMatchHint': {
+ zh: 'Broker 默认 Topic 的读写队列数需保持一致,否则会出现不可读/不可写队列',
+ en: 'RocketMQ Broker uses one default Topic queue count; read and write values must match',
+ },
'cluster.viewDetail': { zh: '查看详情: {addr}', en: 'View detail: {addr}' },
'cluster.proxyDetailTitle': { zh: 'Proxy 详情 - {addr}', en: 'Proxy Detail - {addr}' },
'cluster.restartProxyConfirm': {
diff --git a/web/src/index.css b/web/src/index.css
index 2d964ad1c..7b7434f37 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -263,9 +263,9 @@ body {
The purple gradient is the base on purpose: the composer renders both on the AI page and on
the home page, and the background must not depend on an `.ai-page` ancestor. */
.ai-send-button {
- width: 36px;
- height: 36px;
- flex: 0 0 36px;
+ width: 32px;
+ height: 32px;
+ flex: 0 0 32px;
border: none;
border-radius: 50%;
cursor: pointer;
diff --git a/web/src/pages/ai/components/AssistantBubble.tsx b/web/src/pages/ai/components/AssistantBubble.tsx
index 3362f98b9..adb43685d 100644
--- a/web/src/pages/ai/components/AssistantBubble.tsx
+++ b/web/src/pages/ai/components/AssistantBubble.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState } from 'react';
+import { memo, useState } from 'react';
import { Card, Flex, Tag, Tooltip, Typography, theme } from 'antd';
import { Check, Copy } from '@phosphor-icons/react';
import { useLang } from '../../../i18n/LangContext';
@@ -41,6 +41,11 @@ import { modelBrandLogo } from './ModelBadge';
* `streaming` marks the bubble the run in flight is filling. It drives exactly two things: the
* pending dots before the first block lands, and the auto-expand of the LAST thinking block (see
* `ThinkingBlock`). `toolCatalog` is passed through to `ToolBlock` for its risk tag.
+ *
+ * Memoised because a streaming run re-renders the page at display rate, and a persisted bubble's
+ * props (the folded `Bubble` object, the catalog array, the model string) keep their identity
+ * across those renders: without the bailout every tick re-ran ReactMarkdown over EVERY persisted
+ * answer, i.e. one full transcript re-parse per frame once a conversation collected a few runs.
*/
export interface AssistantBubbleProps {
@@ -302,4 +307,4 @@ const AssistantBubble = ({
);
};
-export default AssistantBubble;
+export default memo(AssistantBubble);
diff --git a/web/src/pages/ai/components/BubbleList.tsx b/web/src/pages/ai/components/BubbleList.tsx
index e1030ca7d..3bf3b4e1e 100644
--- a/web/src/pages/ai/components/BubbleList.tsx
+++ b/web/src/pages/ai/components/BubbleList.tsx
@@ -37,10 +37,17 @@ import AssistantBubble from './AssistantBubble';
export interface BubbleListProps {
bubbles: Bubble[];
- /** Blocks of the run in flight, rendered as a trailing streaming assistant bubble. */
+ /** Blocks of the run in flight; rendered as a trailing streaming assistant bubble. */
liveBlocks?: RenderBlock[];
/** True while that run is streaming; drives the pending dots and the thinking auto-expand. */
streaming?: boolean;
+ /**
+ * Optimistic text of the question the run in flight was admitted with, drawn as a user bubble
+ * ahead of the live assistant bubble: the live stream carries no user frame and the persisted
+ * transcript only gains the row at the end-of-run refetch, so without this copy the operator's
+ * own question stays invisible for the whole run.
+ */
+ pendingUserText?: string | null;
/** Catalog from `listTools()`, so tool blocks can show a risk level. */
toolCatalog?: readonly McpTool[];
/** Estimated speed of the run in flight, shown live on the streaming bubble. */
@@ -65,6 +72,7 @@ const BubbleList = ({
bubbles,
liveBlocks,
streaming = false,
+ pendingUserText = null,
toolCatalog,
liveTokensPerSecond = null,
lastRunTokensPerSecond = null,
@@ -72,8 +80,9 @@ const BubbleList = ({
empty,
}: BubbleListProps) => {
const hasLive = liveBlocks !== undefined && (streaming || liveBlocks.length > 0);
+ const hasPendingUser = pendingUserText !== null && pendingUserText.trim() !== '';
- if (bubbles.length === 0 && !hasLive) return <>{empty ?? null}</>;
+ if (bubbles.length === 0 && !hasLive && !hasPendingUser) return <>{empty ?? null}</>;
// The speed of the last finished run belongs to the newest persisted assistant bubble — but
// only while no newer run is streaming (the hook resets it when one starts).
@@ -109,6 +118,14 @@ const BubbleList = ({
/>
),
)}
+ {hasPendingUser && (
+ <UserBubble
+ key="user-pending"
+ // No createdAt: the persisted row's timestamp replaces this bubble at the end-of-run
+ // refetch anyway, and a client-side clock would only pretend to be it.
+ text={pendingUserText ?? ''}
+ />
+ )}
{hasLive && (
<AssistantBubble
key="assistant-live"
diff --git a/web/src/pages/ai/components/ChatThread.tsx b/web/src/pages/ai/components/ChatThread.tsx
index e954163e5..40cecda1e 100644
--- a/web/src/pages/ai/components/ChatThread.tsx
+++ b/web/src/pages/ai/components/ChatThread.tsx
@@ -53,6 +53,12 @@ export interface ChatThreadProps {
liveBlocks?: RenderBlock[];
/** True while that run is streaming. */
streaming?: boolean;
+ /**
+ * Optimistic text of the question the run in flight was admitted with. The persisted transcript
+ * only gains its user row at the end-of-run refetch, so until then the question is drawn from
+ * this copy — otherwise the operator's own message stays invisible for the whole run.
+ */
+ pendingUserText?: string | null;
/** Catalog from `listTools()`, so tool blocks can show a risk level. */
toolCatalog?: readonly McpTool[];
/** Estimated speed of the run in flight, shown live on the streaming bubble. */
@@ -79,6 +85,7 @@ const ChatThread = ({
bubbles,
liveBlocks,
streaming = false,
+ pendingUserText = null,
toolCatalog,
liveTokensPerSecond = null,
lastRunTokensPerSecond = null,
@@ -137,8 +144,12 @@ const ChatThread = ({
}, [followToBottom, resetKey]);
// Follow the stream only while the reader is already at the bottom. The first arrival after a
- // reset positions instantly (the transcript just loaded, there is nothing to animate over);
- // later arrivals follow smoothly.
+ // reset positions instantly (the transcript just loaded, there is nothing to animate over).
+ // While streaming this effect re-runs once per animation frame, so the follow must be instant:
+ // a fresh `smooth` scroll started on EVERY frame stacks dozens of concurrent scroll animations
+ // that fight each other and the compositor — that is the visible jank of a long answer. A
+ // per-frame instant jump is indistinguishable from smooth at display rate; the smooth behaviour
+ // is kept for discrete arrivals (a refetched transcript growing while idle).
useEffect(() => {
if (!atBottomRef.current) return;
if (!positionedRef.current) {
@@ -146,8 +157,8 @@ const ChatThread = ({
followToBottom('auto');
return;
}
- followToBottom('smooth');
- }, [bubbles, followToBottom, liveBlocks]);
+ followToBottom(streaming ? 'auto' : 'smooth');
+ }, [bubbles, followToBottom, liveBlocks, streaming]);
// Count what arrives while the reader is away: one per message, not one per streamed token.
// Only a genuine increase in bubble count is a new message — a streaming failure that removes
@@ -184,6 +195,7 @@ const ChatThread = ({
bubbles={bubbles}
liveBlocks={liveBlocks}
streaming={streaming}
+ pendingUserText={pendingUserText}
toolCatalog={toolCatalog}
liveTokensPerSecond={liveTokensPerSecond}
lastRunTokensPerSecond={lastRunTokensPerSecond}
diff --git a/web/src/pages/ai/components/Composer.tsx b/web/src/pages/ai/components/Composer.tsx
index 61c6f1931..6587878fb 100644
--- a/web/src/pages/ai/components/Composer.tsx
+++ b/web/src/pages/ai/components/Composer.tsx
@@ -24,7 +24,7 @@ import {
type ReactNode,
type RefObject,
} from 'react';
-import { Button, Progress, Select, Tag, Tooltip, theme } from 'antd';
+import { Button, Flex, Progress, Select, Tag, Tooltip, theme } from 'antd';
import {
BookOpen,
CaretDown,
@@ -333,7 +333,9 @@ const Composer = ({
}
>
<div className="flex items-center justify-between gap-3 px-3 pt-2.5">
- <div className="flex flex-1 min-w-0 items-center gap-2">
+ {/* Wraps on narrow viewports: the selectors carry min-widths and the provider status
+ never shrinks, so without wrap the row overflows the panel instead of reflowing. */}
+ <div className="flex flex-1 min-w-0 items-center gap-2 flex-wrap">
<span className="shrink-0 text-gray-500" style={{ fontSize: '0.893rem' }}>
{t('ai.composer.modelLabel')}
</span>
@@ -519,7 +521,11 @@ const Composer = ({
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
- {showContextBar && (
+ {/* Context-usage meter, the pattern the AI products converged on (ChatGPT's
+ "Context left" pill, Cursor's composer meter): a mini bar PLUS the percent
+ number — a bare bar is unreadable at a glance — hidden while the conversation
+ is still under 1% of the window so a fresh chat carries no noise. */}
+ {showContextBar && contextPercent >= 1 && (
<Tooltip
title={t('ai.composer.contextUsage', {
used: contextTokens.toLocaleString(),
@@ -527,15 +533,30 @@ const Composer = ({
percent: contextPercent,
})}
>
- <div data-testid="ai-context-bar" style={{ width: 96 }}>
+ <Flex
+ align="center"
+ gap={6}
+ data-testid="ai-context-bar"
+ style={{ cursor: 'default' }}
+ >
<Progress
percent={contextPercent}
showInfo={false}
- strokeWidth={10}
+ strokeWidth={8}
strokeColor={contextColor}
- style={{ margin: 0 }}
+ trailColor={token.colorFillSecondary}
+ style={{ margin: 0, width: 56 }}
/>
- </div>
+ <span
+ style={{
+ fontSize: 13,
+ color: token.colorTextSecondary,
+ fontVariantNumeric: 'tabular-nums',
+ }}
+ >
+ {contextPercent}%
+ </span>
+ </Flex>
</Tooltip>
)}
<SendStopButton
diff --git a/web/src/pages/ai/components/ConversationListModal.tsx b/web/src/pages/ai/components/ConversationListModal.tsx
index 1624f5f3c..c91eccabe 100644
--- a/web/src/pages/ai/components/ConversationListModal.tsx
+++ b/web/src/pages/ai/components/ConversationListModal.tsx
@@ -29,11 +29,11 @@ import {
Typography,
message,
} from 'antd';
-import { Trash } from '@phosphor-icons/react';
+import { Archive, TrayArrowUp, Trash } from '@phosphor-icons/react';
import type { ColumnsType } from 'antd/es/table';
import { useLang } from '../../../i18n/LangContext';
import type { AiConversationListItemVO, RunStatus } from '../../../api/aiEvents';
-import { deleteConversation } from '../../../api/aiConversations';
+import { deleteConversation, updateConversation } from '../../../api/aiConversations';
import { useConversationList } from '../hooks/useConversationList';
import { formatUtcDateTime } from '../../../utils/format';
import { tableScrollX } from '../../../utils/table';
@@ -119,7 +119,7 @@ const COLUMN_WIDTHS = {
instance: 120,
status: 106,
updatedAt: 176,
- action: 56,
+ action: 88,
} as const;
const TABLE_SCROLL_X = tableScrollX(
@@ -169,12 +169,35 @@ const ConversationListPanel = ({
const list = useConversationList();
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const [deletingIds, setDeletingIds] = useState<readonly number[]>([]);
+ const [archivingIds, setArchivingIds] = useState<readonly number[]>([]);
const deleting = deletingIds.length > 0;
// Pulled out of `list` for the callback below: the hook returns a fresh object every render, so
// depending on it would rebuild `columns` — and with it every cell — on each one.
- const { items, page, setPage, reload } = list;
+ const { items, page, setPage, reload, archived } = list;
const rowCount = items.length;
+ /**
+ * Flips the row's archived flag (PATCH) and reloads the scope: an archived conversation leaves
+ * the active list and the other way round, so the row disappearing IS the feedback — the toast
+ * only names what happened. Archiving the conversation currently on screen is allowed: the
+ * transcript stays open, the flag only files it out of the active list.
+ */
+ const toggleArchive = useCallback(
+ async (id: number) => {
+ setArchivingIds((ids) => [...ids, id]);
+ try {
+ await updateConversation(id, { archived: !archived });
+ message.success(archived ? t('ai.list.unarchivedToast') : t('ai.list.archivedToast'));
+ reload();
+ } catch {
+ message.error(t('ai.list.archiveFailed'));
+ } finally {
+ setArchivingIds((ids) => ids.filter((entry) => entry !== id));
+ }
+ },
+ [archived, reload, t],
+ );
+
/**
* Hard-deletes conversations one request at a time.
*
@@ -331,28 +354,48 @@ const ConversationListPanel = ({
key: 'action',
width: COLUMN_WIDTHS.action,
render: (_: unknown, row) => (
- <Popconfirm
- title={t('ai.list.deleteConfirm')}
- description={t('ai.list.deleteHint')}
- okText={t('common.delete')}
- cancelText={t('common.cancel')}
- okButtonProps={{ danger: true }}
- onConfirm={() => void remove([row.id])}
- >
+ <Flex align="center" gap={0}>
<Button
type="text"
size="small"
- danger
- loading={deletingIds.includes(row.id)}
- aria-label={t('ai.list.deleteAria', { title: row.title })}
- data-testid={`ai-conversation-row-delete-${row.id}`}
- icon={<Trash size={16} />}
+ loading={archivingIds.includes(row.id)}
+ aria-label={
+ archived
+ ? t('ai.list.unarchiveAria', { title: row.title })
+ : t('ai.list.archiveAria', { title: row.title })
+ }
+ title={
+ archived
+ ? t('ai.list.unarchiveAria', { title: row.title })
+ : t('ai.list.archiveAria', { title: row.title })
+ }
+ data-testid={`ai-conversation-row-archive-${row.id}`}
+ icon={archived ? <TrayArrowUp size={16} /> : <Archive size={16} />}
+ onClick={() => void toggleArchive(row.id)}
/>
- </Popconfirm>
+ <Popconfirm
+ title={t('ai.list.deleteConfirm')}
+ description={t('ai.list.deleteHint')}
+ okText={t('common.delete')}
+ cancelText={t('common.cancel')}
+ okButtonProps={{ danger: true }}
+ onConfirm={() => void remove([row.id])}
+ >
+ <Button
+ type="text"
+ size="small"
+ danger
+ loading={deletingIds.includes(row.id)}
+ aria-label={t('ai.list.deleteAria', { title: row.title })}
+ data-testid={`ai-conversation-row-delete-${row.id}`}
+ icon={<Trash size={16} />}
+ />
+ </Popconfirm>
+ </Flex>
),
},
],
- [activeConversationId, deletingIds, onSelect, remove, t],
+ [activeConversationId, archivingIds, archived, deletingIds, onSelect, remove, t, toggleArchive],
);
return (
diff --git a/web/src/pages/ai/components/SendStopButton.tsx b/web/src/pages/ai/components/SendStopButton.tsx
index 42f15dd53..42ba20d76 100644
--- a/web/src/pages/ai/components/SendStopButton.tsx
+++ b/web/src/pages/ai/components/SendStopButton.tsx
@@ -21,7 +21,7 @@ import { useLang } from '../../../i18n/LangContext';
/**
* The single send/stop slot of the composer.
*
- * ONE `<button>` that morphs between three states, always mounted, always the same 36x36 circular
+ * ONE `<button>` that morphs between three states, always mounted, always the same 32x32 circular
* box. The page it replaces mounted a SECOND antd `<Button danger>停止生成</Button>` next to the send
* button while a run streamed, which pushed the rest of the toolbar sideways on every turn — the
* regression `SendStopButton.test.tsx` guards with "exactly one button in all three states".
@@ -84,9 +84,9 @@ const SendStopButton = ({ state, canSend, onSend, onStop }: SendStopButtonProps)
// `stopping` is disabled, so the click cannot reach this branch.
}}
>
- {state === 'send' && <ArrowUp size={19} weight="bold" />}
- {state === 'stop' && <Stop size={16} weight="fill" />}
- {state === 'stopping' && <CircleNotch size={18} weight="bold" className="ai-icon-spin" />}
+ {state === 'send' && <ArrowUp size={17} weight="bold" />}
+ {state === 'stop' && <Stop size={14} weight="fill" />}
+ {state === 'stopping' && <CircleNotch size={16} weight="bold" className="ai-icon-spin" />}
</button>
);
};
diff --git a/web/src/pages/ai/components/UserBubble.tsx b/web/src/pages/ai/components/UserBubble.tsx
index 29f26972a..5bd9baf89 100644
--- a/web/src/pages/ai/components/UserBubble.tsx
+++ b/web/src/pages/ai/components/UserBubble.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
+import { memo } from 'react';
import { Flex, theme } from 'antd';
import useAuthStore from '../../../stores/authStore';
import { formatUtcDateTime } from '../../../utils/format';
@@ -98,4 +99,4 @@ const UserBubble = ({ text, createdAt }: UserBubbleProps) => {
);
};
-export default UserBubble;
+export default memo(UserBubble);
diff --git a/web/src/pages/ai/components/__tests__/ChatThread.test.tsx b/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
index a1d0f7773..3095f8cec 100644
--- a/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
+++ b/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
@@ -19,7 +19,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LangProvider } from '../../../../i18n/LangContext';
-import type { Bubble } from '../../render/blocks';
+import type { Bubble, RenderBlock } from '../../render/blocks';
import { appendText } from '../../render/blocks';
import ChatThread from '../ChatThread';
@@ -75,13 +75,21 @@ function placeReader(distance: number) {
return container;
}
-function renderThread(props: { bubbles?: Bubble[]; streaming?: boolean; resetKey?: number }) {
+function renderThread(props: {
+ bubbles?: Bubble[];
+ streaming?: boolean;
+ resetKey?: number;
+ pendingUserText?: string | null;
+ liveBlocks?: RenderBlock[];
+}) {
return render(
<LangProvider>
<ChatThread
bubbles={props.bubbles ?? []}
streaming={props.streaming}
resetKey={props.resetKey}
+ pendingUserText={props.pendingUserText}
+ liveBlocks={props.liveBlocks}
/>
</LangProvider>,
);
@@ -108,6 +116,67 @@ describe('ChatThread', () => {
expect(screen.queryByTestId('ai-thread-jump-to-latest')).not.toBeInTheDocument();
});
+ it('followsInstantlyWhileStreamingSoFramesDoNotStackSmoothScrollsTest', () => {
+ const { rerender } = renderThread({ bubbles: [userBubble('检查集群状态')] });
+ placeReader(0);
+ rerender(
+ <LangProvider>
+ <ChatThread bubbles={[userBubble('检查集群状态')]} liveBlocks={appendText([], '部分')} streaming />
+ </LangProvider>,
+ );
+ scrollIntoView.mockClear();
+
+ // The next frame of the SAME run: the follow must be instant. A fresh smooth scroll on every
+ // animation frame stacks concurrent scroll animations — the jank of a long streamed answer.
+ rerender(
+ <LangProvider>
+ <ChatThread
+ bubbles={[userBubble('检查集群状态')]}
+ liveBlocks={appendText([], '部分回答,还在继续')}
+ streaming
+ />
+ </LangProvider>,
+ );
+
+ expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto' });
+ });
+
+ it('keepsSmoothFollowForDiscreteArrivalsWhileIdleTest', () => {
+ const { rerender } = renderThread({ bubbles: [userBubble('检查集群状态')] });
+ placeReader(0);
+ scrollIntoView.mockClear();
+
+ // A whole persisted answer arrives while no run is streaming: one discrete jump, animated.
+ rerender(
+ <LangProvider>
+ <ChatThread bubbles={[userBubble('检查集群状态'), assistantBubble('集群正常')]} />
+ </LangProvider>,
+ );
+
+ expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' });
+ });
+
+ it('showsThePendingQuestionImmediatelyWhileTheRunStreamsTest', () => {
+ // The live stream carries no user frame and the persisted transcript only gains the row at
+ // the end-of-run refetch; the optimistic copy is what keeps the operator's own question
+ // visible from the moment they sent it.
+ renderThread({
+ bubbles: [userBubble('第一个问题'), assistantBubble('第一个回答')],
+ liveBlocks: appendText([], '正在回答'),
+ streaming: true,
+ pendingUserText: '第二个问题',
+ });
+
+ expect(screen.getByText('第二个问题')).toBeInTheDocument();
+ expect(screen.getAllByTestId('ai-bubble-user-avatar')).toHaveLength(2);
+ // The pending question sits ahead of the streaming answer.
+ const pending = screen.getByText('第二个问题');
+ const answer = screen.getByText('正在回答');
+ expect(
+ pending.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
it('stopsFollowingAndOffersAJumpOnceTheReaderScrollsUpTest', () => {
const { rerender } = renderThread({ bubbles: [userBubble('检查集群状态')] });
scrollIntoView.mockClear();
diff --git a/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx b/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
index 47d47cd04..a3e4be132 100644
--- a/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
+++ b/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
@@ -24,6 +24,7 @@ import type { AiConversationListItemVO } from '../../../../api/aiEvents';
import {
deleteConversation,
listConversations,
+ updateConversation,
type PageResult,
} from '../../../../api/aiConversations';
import ConversationListModal from '../ConversationListModal';
@@ -31,6 +32,7 @@ import ConversationListModal from '../ConversationListModal';
vi.mock('../../../../api/aiConversations', () => ({
listConversations: vi.fn(),
deleteConversation: vi.fn(),
+ updateConversation: vi.fn(),
}));
/**
@@ -55,6 +57,7 @@ vi.mock('../../../../api/aiConversations', () => ({
const listMock = vi.mocked(listConversations);
const deleteMock = vi.mocked(deleteConversation);
+const updateMock = vi.mocked(updateConversation);
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
@@ -154,10 +157,44 @@ describe('ConversationListModal', () => {
// of its own stub.
listMock.mockReset();
deleteMock.mockReset();
+ updateMock.mockReset();
vi.clearAllMocks();
localStorage.clear();
});
+ it('archivesAConversationFromTheActiveScopeTest', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ listMock.mockResolvedValue(page([conversation(7, '检查集群状态')], 1, 1));
+ updateMock.mockResolvedValue({ id: 7 } as never);
+ renderModal();
+ await screen.findByText('检查集群状态');
+ const listCallsBefore = listMock.mock.calls.length;
+
+ await user.click(screen.getByTestId('ai-conversation-row-archive-7'));
+
+ await waitFor(() => expect(updateConversation).toHaveBeenCalledWith(7, { archived: true }));
+ // The scope reloads so the archived row leaves the active list.
+ await waitFor(() => expect(listMock.mock.calls.length).toBeGreaterThan(listCallsBefore));
+ });
+
+ it('unarchivesAConversationFromTheArchivedScopeTest', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ listMock.mockResolvedValue(page([conversation(7, '检查集群状态')], 1, 1));
+ updateMock.mockResolvedValue({ id: 7 } as never);
+ renderModal();
+ await screen.findByText('检查集群状态');
+
+ await user.click(screen.getByText('已归档'));
+ await waitFor(() =>
+ expect(listConversations).toHaveBeenLastCalledWith(
+ expect.objectContaining({ archived: true }),
+ ),
+ );
+
+ await user.click(screen.getByTestId('ai-conversation-row-archive-7'));
+ await waitFor(() => expect(updateConversation).toHaveBeenCalledWith(7, { archived: false }));
+ });
+
it('doesNotQueryWhileClosedTest', async () => {
renderModal({ open: false });
diff --git a/web/src/pages/ai/components/blocks/ErrorBlock.tsx b/web/src/pages/ai/components/blocks/ErrorBlock.tsx
index c8f3ea3ad..10f8547a7 100644
--- a/web/src/pages/ai/components/blocks/ErrorBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ErrorBlock.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
+import { memo } from 'react';
import { Alert, Flex, Tag, theme } from 'antd';
import type { ErrorBlock as ErrorBlockData } from '../../render/blocks';
@@ -62,4 +63,4 @@ const ErrorBlock = ({ block }: ErrorBlockProps) => {
);
};
-export default ErrorBlock;
+export default memo(ErrorBlock);
diff --git a/web/src/pages/ai/components/blocks/NoticeBlock.tsx b/web/src/pages/ai/components/blocks/NoticeBlock.tsx
index 3cc2c6426..67b77890d 100644
--- a/web/src/pages/ai/components/blocks/NoticeBlock.tsx
+++ b/web/src/pages/ai/components/blocks/NoticeBlock.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
+import { memo } from 'react';
import InfoBanner from '../../../../components/InfoBanner';
import { useLang } from '../../../../i18n/LangContext';
import type { NoticeBlock as NoticeBlockData } from '../../render/blocks';
@@ -47,4 +48,4 @@ const NoticeBlock = ({ block }: NoticeBlockProps) => {
);
};
-export default NoticeBlock;
+export default memo(NoticeBlock);
diff --git a/web/src/pages/ai/components/blocks/TextBlock.tsx b/web/src/pages/ai/components/blocks/TextBlock.tsx
index 3bc06ec41..e0dfc5274 100644
--- a/web/src/pages/ai/components/blocks/TextBlock.tsx
+++ b/web/src/pages/ai/components/blocks/TextBlock.tsx
@@ -15,6 +15,7 @@
* limitations under the License.
*/
+import { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { TextBlock as TextBlockData } from '../../render/blocks';
@@ -30,16 +31,20 @@ import { normalizeAiMarkdown } from '../../render/markdown';
* The same component renders a live `text_delta` accumulation and a replayed persisted `text` event:
* both reducers coalesce adjacent text into ONE block, so a streamed answer and the same answer
* reloaded from the database produce identical DOM.
+ *
+ * Memoised on the block object: the reducers only replace the block that actually changed, so an
+ * unchanged text keeps its identity across streaming ticks and skips the full ReactMarkdown
+ * re-parse — that parse is the single most expensive thing a transcript frame does.
*/
export interface TextBlockProps {
block: TextBlockData;
}
-const TextBlock = ({ block }: TextBlockProps) => (
+const TextBlock = memo(({ block }: TextBlockProps) => (
<div className="ai-markdown" data-testid="ai-text-block">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{normalizeAiMarkdown(block.text)}</ReactMarkdown>
</div>
-);
+));
export default TextBlock;
diff --git a/web/src/pages/ai/components/blocks/ThinkingBlock.tsx b/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
index e6d11947f..4ccbc3df7 100644
--- a/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState } from 'react';
+import { memo, useState } from 'react';
import { theme } from 'antd';
import { Brain, Sparkle } from '@phosphor-icons/react';
import { useLang } from '../../../../i18n/LangContext';
@@ -113,4 +113,4 @@ const ThinkingBlock = ({ block, streaming = false, latest = false }: ThinkingBlo
);
};
-export default ThinkingBlock;
+export default memo(ThinkingBlock);
diff --git a/web/src/pages/ai/components/blocks/ToolBlock.tsx b/web/src/pages/ai/components/blocks/ToolBlock.tsx
index 3797d2bee..d1dd58119 100644
--- a/web/src/pages/ai/components/blocks/ToolBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ToolBlock.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import type { CSSProperties } from 'react';
+import { memo, type CSSProperties } from 'react';
import { Progress, Tag, theme } from 'antd';
import { CaretRight, CheckCircle, CircleNotch, XCircle } from '@phosphor-icons/react';
import { useLang } from '../../../../i18n/LangContext';
@@ -49,6 +49,10 @@ import { formatBytes } from '../../../../utils/format';
* that merely usually holds serialised JSON. The backend already stripped inline base64 and capped
* the text at 32 KiB; `outputBytes` keeps the real size and `truncated` says whether that cap bit,
* which is what the 已截断(N KB) badge reports.
+ *
+ * Memoised on the block object: a run with dozens of tool calls re-renders the page at display
+ * rate while the NEXT call streams, and `withToolResult` only replaces the block it fills — every
+ * finished card keeps its identity and must not reconcile its (up to 32 KiB) `<pre>` again.
*/
/** Risk colour of the tool catalog: L1 reads, L2 previewable changes, L3 destructive changes. */
@@ -258,4 +262,4 @@ const ToolBlock = ({ block, toolCatalog }: ToolBlockProps) => {
);
};
-export default ToolBlock;
+export default memo(ToolBlock);
diff --git a/web/src/pages/ai/hooks/useAgentRun.ts b/web/src/pages/ai/hooks/useAgentRun.ts
index 63287c61b..03f0c1a57 100644
--- a/web/src/pages/ai/hooks/useAgentRun.ts
+++ b/web/src/pages/ai/hooks/useAgentRun.ts
@@ -108,6 +108,12 @@ export interface UseAgentRunResult {
liveTokensPerSecond: number | null;
/** Final speed of the most recent finished run; null when it produced no measurable text. */
lastRunTokensPerSecond: number | null;
+ /**
+ * The prompt the run in flight was admitted with, or null. The stream carries no user frame and
+ * the persisted transcript only gains the row at the end-of-run refetch, so the caller renders
+ * this as an optimistic user bubble while the run is alive.
+ */
+ pendingUserMessage: string | null;
/**
* Send a message and stream the run it starts. Resolves once the terminal frames were processed
* and the timeline refetch settled. A no-op while another run is in flight.
@@ -150,6 +156,14 @@ export function useAgentRun(
const [error, setError] = useState('');
const [lastRunTokensPerSecond, setLastRunTokensPerSecond] = useState<number | null>(null);
const [liveTokensPerSecond, setLiveTokensPerSecond] = useState<number | null>(null);
+ /**
+ * The prompt the admitted run was started with, or null. The live stream carries no `user`
+ * frame and the persisted transcript is only refetched when the run FINISHES, so without this
+ * optimistic copy the operator's own question stays invisible for the whole run — minutes on a
+ * long tool-heavy answer. Rendered as a user bubble ahead of the live assistant bubble; the
+ * final refetch replaces it with the persisted row in one commit.
+ */
+ const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(null);
const generationRef = useRef(0);
const streamRequestIdRef = useRef(0);
@@ -262,6 +276,7 @@ export function useAgentRun(
// Keep the live blocks: with the refetch failed they are the only copy of this answer. When
// the stream failed too, that message is the informative one — a dead connection explains
// the truncated answer, a failed reload only explains why it did not move into history.
+ // The optimistic user bubble stays for the same reason: the transcript never got the row.
if (streamFailure === null) setError(describeThrownMessage(refetchError));
scheduleTick();
return;
@@ -271,6 +286,9 @@ export function useAgentRun(
// The speed of this run is final the moment the stream closes; persist it before the
// refetch swaps the live bubble for its persisted twin, which is what displays it.
setLastRunTokensPerSecond(speedTrackerRef.current.tokensPerSecond());
+ // The refetched transcript now renders the persisted user row; drop the optimistic twin in
+ // the same commit so it never paints twice.
+ setPendingUserMessage(null);
blocksRef.current = [];
scheduleTick();
},
@@ -282,6 +300,7 @@ export function useAgentRun(
targetConversationId: number,
open: (handlers: RunStreamHandlers, signal: AbortSignal) => Promise<void>,
knownRunId: number | null = null,
+ userMessage?: string,
): Promise<void> => {
// Double-submit guard: Enter twice in one tick must not admit two runs (the server would
// reject the second with 409 anyway, but the UI should not even try).
@@ -295,6 +314,8 @@ export function useAgentRun(
return;
}
chatInFlightRef.current = true;
+ // Only `send` carries a prompt; a re-attach finds the user row already in the timeline.
+ if (userMessage !== undefined) setPendingUserMessage(userMessage);
const requestId = ++streamRequestIdRef.current;
const generation = ++generationRef.current;
@@ -344,8 +365,11 @@ export function useAgentRun(
const send = useCallback(
(targetConversationId: number, request: AiMessageRequest): Promise<void> =>
- startStream(targetConversationId, (handlers, signal) =>
- openRunStream(targetConversationId, request, handlers, signal),
+ startStream(
+ targetConversationId,
+ (handlers, signal) => openRunStream(targetConversationId, request, handlers, signal),
+ null,
+ request.message,
),
[startStream],
);
@@ -394,6 +418,7 @@ export function useAgentRun(
setIsStreaming(false);
setStopRequested(false);
setError('');
+ setPendingUserMessage(null);
bump();
}, [cancelFrame]);
@@ -436,6 +461,7 @@ export function useAgentRun(
error,
liveTokensPerSecond,
lastRunTokensPerSecond,
+ pendingUserMessage,
send,
attach,
stop,
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 3e8f57eba..93252f5e4 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useCallback, useRef, useState } from 'react';
+import { useCallback, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Flex, message, theme } from 'antd';
import { useLang } from '../../i18n/LangContext';
@@ -127,15 +127,21 @@ const AiPage = () => {
);
// Estimated context footprint of this conversation — persisted text plus whatever the run in
- // flight has produced so far — drawn as the composer's context-usage bar. Recomputed on every
- // render on purpose: the run's renderTick bumps re-render this page as blocks stream in.
- let contextTokens = 0;
- for (const item of timeline.items) {
- const event = item.event;
- if (event.type === 'text' || event.type === 'user') {
- contextTokens += estimateTokens(event.text);
+ // flight has produced so far — drawn as the composer's context-usage bar. The persisted half is
+ // memoised on the timeline rows: a streaming tick re-renders this page at display rate, and
+ // re-scanning EVERY persisted answer with the token estimator each frame is pure waste; only
+ // the live blocks of the run in flight are recomputed per render.
+ const persistedTokens = useMemo(() => {
+ let tokens = 0;
+ for (const item of timeline.items) {
+ const event = item.event;
+ if (event.type === 'text' || event.type === 'user') {
+ tokens += estimateTokens(event.text);
+ }
}
- }
+ return tokens;
+ }, [timeline.items]);
+ let contextTokens = persistedTokens;
for (const block of run.blocksRef.current) {
if (block.kind === 'text') contextTokens += estimateTokens(block.text);
}
@@ -146,6 +152,7 @@ const AiPage = () => {
bubbles={timeline.bubbles}
liveBlocks={run.blocksRef.current}
streaming={run.isStreaming}
+ pendingUserText={run.pendingUserMessage}
toolCatalog={tools}
liveTokensPerSecond={run.liveTokensPerSecond}
lastRunTokensPerSecond={run.lastRunTokensPerSecond}
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index e53199dbd..004de88c7 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -168,7 +168,7 @@ describe('Clients page', () => {
address: `10.0.1.${index + 1}:49152`,
}));
vi.mocked(connectionsService.listConnections).mockResolvedValue(pagedConnections);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const { container } = renderWithProviders(<ClientsPage />);
await screen.findByText('client-00');
@@ -195,7 +195,7 @@ describe('Clients page', () => {
{ ...connection, clusterName: 'ns-prod', type: 'Consumer' },
];
vi.mocked(connectionsService.listConnections).mockResolvedValue(mixedConnections);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClientsPage />);
await screen.findAllByText('order-svc-0@10.0.1.12:49152');
@@ -285,7 +285,7 @@ describe('Clients page', () => {
});
it('updates statistics when the selected cluster filter changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(connectionsService.listConnections).mockImplementation((query) =>
Promise.resolve(
query?.clusterId === 'ns-prod'
@@ -330,7 +330,7 @@ describe('Clients page', () => {
});
it('keeps cluster statistics stable when text search narrows the table', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
renderWithProviders(<ClientsPage />);
@@ -351,7 +351,7 @@ describe('Clients page', () => {
});
it('keeps incomplete client metadata searchable by address', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(connectionsService.listConnections).mockResolvedValue([
{
...connection,
@@ -369,7 +369,7 @@ describe('Clients page', () => {
});
it('opens details for connections without client id or address', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(connectionsService.listConnections).mockResolvedValue([
{
...connection,
@@ -405,7 +405,7 @@ describe('Clients page', () => {
value: revokeObjectURL,
});
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(connectionsService.listConnections).mockResolvedValue([
{
...connection,
@@ -449,7 +449,7 @@ describe('Clients page', () => {
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClientsPage />);
await screen.findByText('order-svc-0@10.0.1.12:49152');
@@ -484,7 +484,7 @@ describe('Clients page', () => {
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const billingConnections: ClientConnection[] = [
{
clientId: 'billing-svc-0@10.0.3.10:49155',
@@ -529,7 +529,7 @@ describe('Clients page', () => {
it('clears the text search and type filter when the nameserver changes', async () => {
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClientsPage />);
expect(await screen.findByText('order-svc-0@10.0.1.12:49152')).toBeInTheDocument();
@@ -591,7 +591,7 @@ describe('Clients page', () => {
vi.mocked(connectionsService.listConnections)
.mockRejectedValueOnce(new Error('Client connection provider is not configured'))
.mockResolvedValueOnce([connection]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClientsPage />);
expect(
@@ -614,7 +614,7 @@ describe('Clients page', () => {
? Promise.resolve([connection])
: Promise.reject(new Error('Cluster ns-audit is unavailable')),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClientsPage />);
await screen.findByText('order-svc-0@10.0.1.12:49152');
@@ -631,7 +631,7 @@ describe('Clients page', () => {
});
it('ignores a stale connection response after switching nameservers', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const stale = deferred<ClientConnection[]>();
const latest = deferred<ClientConnection[]>();
vi.mocked(connectionsService.listConnections)
@@ -665,7 +665,7 @@ describe('Clients page', () => {
});
it('ignores a stale registry response after a retry', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const stale = deferred<ClusterInfo[]>();
vi.mocked(clusterService.listRegistryClusters)
.mockRejectedValueOnce(new Error('Unable to load registry clusters'))
@@ -689,7 +689,7 @@ describe('Clients page', () => {
vi.mocked(clusterService.listRegistryClusters)
.mockRejectedValueOnce(new Error('Unable to load registry clusters'))
.mockResolvedValueOnce(registryClusters);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClientsPage />);
expect(await screen.findByText('Unable to load registry clusters')).toBeInTheDocument();
@@ -703,7 +703,7 @@ describe('Clients page', () => {
});
it('opens a client detail dialog from the connection table', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClientsPage />);
const row = await screen.findByRole('row', { name: /order-svc-0@10\.0\.1\.12:49152/ });
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 64e452eb5..fdef6239b 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -368,7 +368,7 @@ describe('Cluster page', () => {
}, 10000);
it('opens proxy detail dialog from the proxy table', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClusterPage />);
await user.click(screen.getByRole('tab', { name: /Proxy 管理/ }));
@@ -385,7 +385,7 @@ describe('Cluster page', () => {
});
it('previews broker config changes before submitting the update', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClusterPage />);
const brokerRow = await screen.findByRole('row', { name: /10\.101\.2\.11:10911/ });
@@ -414,7 +414,7 @@ describe('Cluster page', () => {
});
it('keeps the latest broker config preview after a superseded response finishes last', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const stalePreview = deferred<ClusterConfigPreviewResult>();
const latestPreview = deferred<ClusterConfigPreviewResult>();
clusterServiceMocks.previewClusterConfig
@@ -486,7 +486,7 @@ describe('Cluster page', () => {
});
it('keeps cluster tabs usable when address fields are missing', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const submitSearch = async (placeholder: string, value: string) => {
const input = screen.getByPlaceholderText(placeholder);
await user.type(input, value);
@@ -521,7 +521,7 @@ describe('Cluster page', () => {
});
it('creates and deletes nameserver registry entries', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClusterPage />);
await user.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
expect(await screen.findByText('rocketmq1-nameserver:9876')).toBeInTheDocument();
@@ -575,7 +575,7 @@ describe('Cluster page', () => {
it('localizes the NameServer address guidance', async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ClusterPage />);
await user.click(screen.getByRole('tab', { name: /NameServer/ }));
await user.click(screen.getByRole('button', { name: /New NameServer/ }));
@@ -590,7 +590,7 @@ describe('Cluster page', () => {
});
it('opens NameServer config drift details from a registry row', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 10,
@@ -676,7 +676,7 @@ describe('Cluster page', () => {
});
it('omits instanceId for NameServer config drift when no instance owns the registry cluster', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
clusterServiceMocks.listRegistryClusters.mockResolvedValue([
{
...buildCluster(),
@@ -701,7 +701,7 @@ describe('Cluster page', () => {
});
it('opens Broker config drift details from a broker row', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 10,
@@ -788,7 +788,7 @@ describe('Cluster page', () => {
});
it('reports Broker config drift load failures without closing the dialog', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
clusterServiceMocks.getBrokerConfigDiff.mockRejectedValueOnce(new Error('failure'));
renderWithProviders(<ClusterPage />);
@@ -805,7 +805,7 @@ describe('Cluster page', () => {
});
it('renders a failed NameServer config diff as an error with retry instead of the loading banner', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
clusterServiceMocks.listRegistryClusters.mockResolvedValue([
{
@@ -848,7 +848,7 @@ describe('Cluster page', () => {
});
it('renders a failed Broker config diff as an error with retry instead of the loading banner', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
clusterServiceMocks.getBrokerConfigDiff
.mockRejectedValueOnce(new Error('broker unreachable'))
diff --git a/web/src/pages/cluster/clients.tsx b/web/src/pages/cluster/clients.tsx
index 256cb8cb5..01c86ca37 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
Alert,
Button,
@@ -63,6 +63,20 @@ const typeConfig: Record<string, { color: string; label: string }> = {
Consumer: { color: 'green', label: 'Consumer' },
};
+/**
+ * One clipped line inside a diagnostics-table cell. The project-wide
+ * `.ant-table-cell { white-space: nowrap }` keeps long values (trace-producer resource names,
+ * client ids, risk descriptions) from wrapping, so without an explicit ellipsis they spill over
+ * the neighbouring column; each line clips itself and carries its full text in a `title`.
+ */
+const diagnosticCellLine: CSSProperties = {
+ display: 'block',
+ maxWidth: '100%',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+};
+
const protocolConfig: Record<string, { color: string; label: string }> = {
gRPC: { color: 'green', label: 'gRPC' },
Remoting: { color: 'blue', label: 'Remoting' },
@@ -531,11 +545,20 @@ const ClientsPage = () => {
title: t('clients.groupOrTopic'),
key: 'resource',
width: 220,
+ // Unbounded resource names (_INNER_TRACE_PRODUCER-…-CONSUME-1) must clip inside the
+ // cell: the project-wide `.ant-table-cell { white-space: nowrap }` otherwise lets them
+ // spill over the neighbouring column. Each line ellipsises on its own with a native
+ // title tooltip, the ConversationListModal title-column idiom.
+ ellipsis: { showTitle: false },
render: (_: unknown, record) => (
- <Space direction="vertical" size={2}>
- <Text strong>{record.resource}</Text>
- <Text type="secondary">{record.type}</Text>
- </Space>
+ <div style={{ minWidth: 0 }}>
+ <Text strong style={diagnosticCellLine} title={record.resource}>
+ {record.resource}
+ </Text>
+ <Text type="secondary" style={diagnosticCellLine}>
+ {record.type}
+ </Text>
+ </div>
),
},
{
@@ -610,11 +633,18 @@ const ClientsPage = () => {
title: t('clients.diagnosticIssue'),
key: 'issue',
width: 260,
+ // The description is a full sentence; clipped to one line with the full text on hover,
+ // never wrapped (project table rule) and never spilling into the Client ID column.
+ ellipsis: { showTitle: false },
render: (_: unknown, record) => (
- <Space direction="vertical" size={2}>
- <Text strong>{record.title}</Text>
- <Text type="secondary">{record.description}</Text>
- </Space>
+ <div style={{ minWidth: 0 }}>
+ <Text strong style={diagnosticCellLine} title={record.title}>
+ {record.title}
+ </Text>
+ <Text type="secondary" style={diagnosticCellLine} title={record.description}>
+ {record.description}
+ </Text>
+ </div>
),
},
{
@@ -622,9 +652,12 @@ const ClientsPage = () => {
dataIndex: 'clientId',
key: 'clientId',
width: 180,
+ ellipsis: { showTitle: false },
render: (clientId?: string) =>
clientId ? (
- <Text style={{ fontFamily: 'monospace' }}>{clientId}</Text>
+ <Text style={{ ...diagnosticCellLine, fontFamily: 'monospace' }} title={clientId}>
+ {clientId}
+ </Text>
) : (
<Text type="secondary">-</Text>
),
@@ -634,6 +667,7 @@ const ClientsPage = () => {
dataIndex: 'resource',
key: 'resource',
width: 160,
+ ellipsis: true,
render: (resource?: string) => resource || '-',
},
{
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 375826711..c768612f5 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -38,6 +38,8 @@ import {
Alert,
Spin,
message,
+ Row,
+ Col,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -1389,15 +1391,20 @@ const ClusterPage = () => {
confirmLoading={configSubmitting}
width={720}
>
- <Space style={{ marginBottom: 16 }}>
+ <Flex justify="flex-end" style={{ marginBottom: 12 }}>
<Button
+ size="small"
icon={<EyeOutlined />}
loading={configPreviewLoading}
onClick={() => void handleConfigPreview()}
>
{t('cluster.configPreview')}
</Button>
- </Space>
+ </Flex>
+ {/* Two-column grid: the eight fields pair up into four rows, halving the modal
+ height. Related controls sit side by side (flush + retention, size + permission,
+ the queue pair, the two auto-create switches) so the form reads as groups
+ instead of a corridor of labels. */}
<Form
form={configForm}
layout="vertical"
@@ -1407,45 +1414,83 @@ const ClusterPage = () => {
setConfigPreviewLoading(false);
}}
>
- <Form.Item label={t('cluster.flushDiskType')} name="flushDiskType">
- <Radio.Group>
- <Radio value="SYNC_FLUSH">{t('cluster.syncFlush')}</Radio>
- <Radio value="ASYNC_FLUSH">{t('cluster.asyncFlush')}</Radio>
- </Radio.Group>
- </Form.Item>
- <Form.Item
- label={t('cluster.autoCreateTopic')}
- name="autoCreateTopicEnable"
- valuePropName="checked"
- >
- <Switch />
- </Form.Item>
- <Form.Item
- label={t('cluster.autoCreateSubGroup')}
- name="autoCreateSubscriptionGroup"
- valuePropName="checked"
- >
- <Switch />
- </Form.Item>
- <Form.Item label={t('cluster.maxMessageSize')} name="maxMessageSizeMB">
- <InputNumber min={1} max={128} style={{ width: '100%' }} />
- </Form.Item>
- <Form.Item label={t('cluster.fileReservedTime')} name="fileReservedTime">
- <InputNumber min={1} max={720} style={{ width: '100%' }} />
- </Form.Item>
- <Form.Item label={t('cluster.writeQueues')} name="writeQueueNums">
- <InputNumber min={1} max={256} style={{ width: '100%' }} />
- </Form.Item>
- <Form.Item label={t('cluster.readQueues')} name="readQueueNums">
- <InputNumber min={1} max={256} style={{ width: '100%' }} />
- </Form.Item>
- <Text type="secondary" style={{ display: 'block', marginTop: -16, marginBottom: 16 }}>
- RocketMQ Broker uses one default Topic queue count; read and write values must
- match.
- </Text>
- <Form.Item label={t('cluster.brokerPermission')} name="brokerPermission">
- <InputNumber min={0} max={7} style={{ width: '100%' }} />
- </Form.Item>
+ <Row gutter={16}>
+ <Col span={12}>
+ <Form.Item label={t('cluster.flushDiskType')} name="flushDiskType">
+ <Radio.Group>
+ <Radio value="SYNC_FLUSH">{t('cluster.syncFlush')}</Radio>
+ <Radio value="ASYNC_FLUSH">{t('cluster.asyncFlush')}</Radio>
+ </Radio.Group>
+ </Form.Item>
+ </Col>
+ <Col span={12}>
+ <Form.Item label={t('cluster.fileReservedTime')} name="fileReservedTime">
+ <InputNumber min={1} max={720} style={{ width: '100%' }} />
+ </Form.Item>
+ </Col>
+ </Row>
+ <Row gutter={16}>
+ <Col span={12}>
+ <Form.Item label={t('cluster.maxMessageSize')} name="maxMessageSizeMB">
+ <InputNumber min={1} max={128} style={{ width: '100%' }} />
+ </Form.Item>
+ </Col>
+ <Col span={12}>
+ {/* Named options instead of a raw 0-7 number: the permission is a bitfield
+ and only 6/4/2/0 carry meaning for an operator. An unusual persisted
+ value (e.g. 7) still renders — the Select falls back to showing it raw. */}
+ <Form.Item label={t('cluster.brokerPermission')} name="brokerPermission">
+ <Select
+ options={[
+ { value: 6, label: t('cluster.permRW') },
+ { value: 4, label: t('cluster.permR') },
+ { value: 2, label: t('cluster.permW') },
+ { value: 0, label: t('cluster.permNone') },
+ ]}
+ />
+ </Form.Item>
+ </Col>
+ </Row>
+ <Row gutter={16}>
+ <Col span={12}>
+ <Form.Item
+ label={t('cluster.writeQueues')}
+ name="writeQueueNums"
+ tooltip={t('cluster.queueMatchHint')}
+ >
+ <InputNumber min={1} max={256} style={{ width: '100%' }} />
+ </Form.Item>
+ </Col>
+ <Col span={12}>
+ <Form.Item
+ label={t('cluster.readQueues')}
+ name="readQueueNums"
+ tooltip={t('cluster.queueMatchHint')}
+ >
+ <InputNumber min={1} max={256} style={{ width: '100%' }} />
+ </Form.Item>
+ </Col>
+ </Row>
+ <Row gutter={16}>
+ <Col span={12}>
+ <Form.Item
+ label={t('cluster.autoCreateTopic')}
+ name="autoCreateTopicEnable"
+ valuePropName="checked"
+ >
+ <Switch />
+ </Form.Item>
+ </Col>
+ <Col span={12}>
+ <Form.Item
+ label={t('cluster.autoCreateSubGroup')}
+ name="autoCreateSubscriptionGroup"
+ valuePropName="checked"
+ >
+ <Switch />
+ </Form.Item>
+ </Col>
+ </Row>
</Form>
{renderConfigPreview()}
</Modal>
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx b/web/src/pages/home/__tests__/DashboardPage.test.tsx
index 2df36867a..1533a4f5b 100644
--- a/web/src/pages/home/__tests__/DashboardPage.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -179,7 +179,7 @@ describe('DashboardPage', () => {
it('exposes traffic insight metrics in the expanded cluster health row', async () => {
vi.mocked(dashboardService.getDashboard).mockResolvedValue(trafficDashboard());
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DashboardPage />);
await screen.findAllByText('traffic-a');
@@ -212,7 +212,7 @@ describe('DashboardPage', () => {
vi.mocked(dashboardService.getDashboard)
.mockResolvedValueOnce(dashboard('initial-cluster'))
.mockReturnValueOnce(instanceA.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DashboardPage />);
await screen.findAllByText('initial-cluster');
@@ -235,7 +235,7 @@ describe('DashboardPage', () => {
.mockResolvedValueOnce(dashboard('initial-cluster'))
.mockReturnValueOnce(instanceA.promise)
.mockReturnValueOnce(instanceB.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DashboardPage />);
await screen.findAllByText('initial-cluster');
@@ -261,7 +261,7 @@ describe('DashboardPage', () => {
it('preserves the selected instance when navigating to the cluster page', async () => {
vi.mocked(dashboardService.getDashboard).mockResolvedValue(dashboard('instance-a-cluster'));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(
<>
<DashboardPage />
@@ -308,7 +308,7 @@ describe('DashboardPage', () => {
},
]);
vi.mocked(dashboardService.getDashboard).mockResolvedValue(dashboard('apache-cluster'));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DashboardPage />);
await screen.findAllByText('apache-cluster');
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx b/web/src/pages/instance/__tests__/AclPage.test.tsx
index 0e07ec39a..b8219c964 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -132,7 +132,7 @@ describe('ACL page', () => {
});
it('reloads the server rule page after deleting one ACL rule', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
void config.onOk?.();
return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -176,7 +176,7 @@ describe('ACL page', () => {
});
it('shows a non-copyable placeholder when an ACL user has no access key', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.pageAclUsers).mockResolvedValue({
items: [
{
@@ -203,7 +203,7 @@ describe('ACL page', () => {
});
it('clamps rules back to a valid page when the current page becomes empty', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const ruleItems = (count: number) =>
Array.from({ length: count }, (_, index) => ({
id: index + 1,
@@ -246,7 +246,7 @@ describe('ACL page', () => {
});
it('closes an ACL rule dialog when switching to another instance', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 1,
@@ -295,7 +295,7 @@ describe('ACL page', () => {
});
it('keeps users available when loading rules fails', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.listAclRules).mockRejectedValue(new Error('rules unavailable'));
renderWithProviders(<AclPage />);
@@ -313,7 +313,7 @@ describe('ACL page', () => {
});
it('uses Tencent role names for role-backed ACL rules and users', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 21,
@@ -425,7 +425,7 @@ describe('ACL page', () => {
});
it('renders backend users on the user tab', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<AclPage />);
await user.click(await screen.findByText('用户管理'));
@@ -435,7 +435,7 @@ describe('ACL page', () => {
});
it('shows missing backend timestamps as unavailable', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.listAclRules).mockResolvedValue({
items: [
{
@@ -483,7 +483,7 @@ describe('ACL page', () => {
});
it('does not submit masked credentials when editing a user', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.updateAclUser).mockResolvedValue({
id: 11,
username: 'remote-admin',
@@ -519,7 +519,7 @@ describe('ACL page', () => {
it('ignores duplicate admin toggles while an update is pending', async () => {
vi.mocked(aclService.updateAclUser).mockImplementation(() => new Promise(() => {}));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<AclPage />);
await user.click(await screen.findByText('用户管理'));
@@ -533,7 +533,7 @@ describe('ACL page', () => {
});
it('does not submit masked credentials when toggling admin', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.updateAclUser).mockResolvedValue({
id: 11,
username: 'remote-admin',
@@ -563,7 +563,7 @@ describe('ACL page', () => {
});
it('creates a user with the selected cluster scope', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.createAclUser).mockResolvedValue({
id: 13,
username: 'orders-service',
@@ -598,7 +598,7 @@ describe('ACL page', () => {
});
it('reloads the server user page after creating an ACL user', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.createAclUser).mockResolvedValue({
id: 13,
username: 'orders-service',
@@ -628,7 +628,7 @@ describe('ACL page', () => {
});
it('reloads the server user page after deleting one ACL user', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
void config.onOk?.();
return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -647,7 +647,7 @@ describe('ACL page', () => {
});
it('replaces the cluster scope of an existing user', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.updateAclUser).mockResolvedValue({
id: 11,
username: 'remote-admin',
@@ -680,7 +680,7 @@ describe('ACL page', () => {
});
it('examines the broker cluster ACL config', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.examineBrokerClusterAclConfig).mockResolvedValue({
clusterId: 'DefaultCluster',
aclEnabled: true,
@@ -727,7 +727,7 @@ describe('ACL page', () => {
deferred<Awaited<ReturnType<typeof aclService.examineBrokerClusterAclConfig>>>();
const successSpy = vi.spyOn(message, 'success').mockImplementation(vi.fn());
const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.examineBrokerClusterAclConfig)
.mockReturnValueOnce(firstExamine.promise)
.mockReturnValueOnce(secondExamine.promise)
@@ -823,7 +823,7 @@ describe('ACL page', () => {
const secondReveal = deferred<CredentialResponse>();
const thirdReveal = deferred<CredentialResponse>();
const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.getAclUserCredentials)
.mockReturnValueOnce(firstReveal.promise)
.mockReturnValueOnce(secondReveal.promise)
@@ -897,7 +897,7 @@ describe('ACL page', () => {
});
it('renders ACL risk diagnostics for the examined cluster config', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.examineBrokerClusterAclConfig).mockResolvedValue({
clusterId: 'DefaultCluster',
aclEnabled: true,
@@ -934,7 +934,7 @@ describe('ACL page', () => {
});
it('creates a plain access account', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(aclService.createAndUpdatePlainAccessConfig).mockResolvedValue({
accessKey: 'new-svc',
admin: false,
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 1873a3993..6cf189487 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -313,7 +313,7 @@ describe('Consumer page', () => {
});
it('clears selected consumer groups when the search scope changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -332,7 +332,7 @@ describe('Consumer page', () => {
});
it('clamps back to a valid page when the current page becomes empty after a delete', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
let call = 0;
vi.mocked(consumerService.listConsumerGroupPage).mockImplementation(async (params) => {
call += 1;
@@ -361,7 +361,7 @@ describe('Consumer page', () => {
});
it('reloads the server page after deleting one consumer group', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
void config.onOk?.();
return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -384,7 +384,7 @@ describe('Consumer page', () => {
});
it('moves back from an emptied last consumer group page after batch deletion', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
void config.onOk?.();
return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -440,7 +440,7 @@ describe('Consumer page', () => {
});
it('keeps failed consumer groups selected after a partially successful batch deletion', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
void config.onOk?.();
return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -479,7 +479,7 @@ describe('Consumer page', () => {
});
it('submits the canonical global delivery order type', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
void config.onOk?.();
return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
@@ -528,7 +528,7 @@ describe('Consumer page', () => {
void config.onOk?.();
return { destroy: vi.fn(), update: vi.fn() } as unknown as ReturnType<typeof Modal.confirm>;
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await screen.findByText('remote-cg');
@@ -561,7 +561,7 @@ describe('Consumer page', () => {
});
it('downloads all consumer groups matching the current filters when exporting', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(vi.fn());
let exportedBlob: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((blob) => {
@@ -644,7 +644,7 @@ describe('Consumer page', () => {
});
it('loads subscriptions and progress when opening a group', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -675,7 +675,7 @@ describe('Consumer page', () => {
vi.mocked(consumerService.getConsumerProgress).mockRejectedValue(
new Error('broker unreachable'),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -754,7 +754,7 @@ describe('Consumer page', () => {
diffTotal: 1_100,
},
]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -785,7 +785,7 @@ describe('Consumer page', () => {
consistency: '一致',
},
]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -819,7 +819,7 @@ describe('Consumer page', () => {
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
groupPage([{ ...group, instanceId: 'instance-a' }]),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -836,7 +836,7 @@ describe('Consumer page', () => {
});
it('previews queue impact before resetting consumer offsets', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -892,7 +892,7 @@ describe('Consumer page', () => {
});
it('invalidates the reset preview when reset parameters change', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -918,7 +918,7 @@ describe('Consumer page', () => {
});
it('supports skipping accumulation by resetting to the latest offsets', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -981,7 +981,7 @@ describe('Consumer page', () => {
},
],
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /重置位点/ }));
@@ -1033,7 +1033,7 @@ describe('Consumer page', () => {
size: params?.pageSize ?? 20,
}),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />, '/instance/instance-a/consumer');
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1136,7 +1136,7 @@ describe('Consumer page', () => {
},
]),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1171,7 +1171,7 @@ describe('Consumer page', () => {
},
]),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1226,7 +1226,7 @@ describe('Consumer page', () => {
},
]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1278,7 +1278,7 @@ describe('Consumer page', () => {
},
]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1303,7 +1303,7 @@ describe('Consumer page', () => {
},
]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1317,7 +1317,7 @@ describe('Consumer page', () => {
new Error('request failed'),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
await user.click(await screen.findByRole('button', { name: /详情/ }));
@@ -1352,7 +1352,7 @@ describe('Consumer page', () => {
failures: [{ index: 1, name: 'cg-fail', message: 'broker rejected group' }],
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 4,
@@ -1368,7 +1368,16 @@ describe('Consumer page', () => {
]);
renderWithProviders(<ConsumerPage />, '/instance/instance-proxy-1/consumer');
- await screen.findByText(/共 0 个 Group/);
+ // NOT `findByText(/共 0 个 Group/)`: the header subtitle carries that text from the very
+ // first render (totalGroups starts at 0), so it resolves before the instance list lands.
+ // Uploading that early races `handleImportFile`'s `selectedInstanceId` guard, which then
+ // bails with 请先选择实例 and the import modal never opens. The instance-scoped fetch only
+ // fires once the selection resolved, so observing the call is the race-free readiness mark.
+ await waitFor(() =>
+ expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+ ),
+ );
const csv = [
'"Name","Subscription Mode","Consume Type","Retry Max Times","Subscription Data Type","Delivery Order Type"',
'"cg-ok","Push","CLUSTERING","16","NORMAL",""',
@@ -1439,7 +1448,7 @@ describe('Consumer page', () => {
});
it('auto-refreshes the selected group while the detail modal is open', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -1472,7 +1481,7 @@ describe('Consumer page', () => {
retryQueueNums: 2,
retryMaxTimes: 8,
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -1525,7 +1534,7 @@ describe('Consumer page', () => {
consumeMessageOrderly: true,
consumeBroadcastEnable: false,
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
const row = await screen.findByRole('row', { name: /remote-cg/ });
@@ -1557,7 +1566,7 @@ describe('Consumer page', () => {
});
it('renders an unknown (-1) lag as unavailable in the table and the lag detail', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
groupPage([
{ ...group, name: 'unknown-lag-cg', totalLag: -1 },
@@ -1577,7 +1586,7 @@ describe('Consumer page', () => {
});
it('sorts groups with an unknown lag after known backlogs in lag order', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
groupPage([
{ ...group, name: 'unknown-lag-cg', totalLag: -1 },
@@ -1606,7 +1615,7 @@ describe('Consumer page', () => {
});
it('sorts groups with unavailable connections after known client counts', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
groupPage([
{ ...group, name: 'unknown-conn-cg', onlineInstances: -1 },
@@ -1656,7 +1665,7 @@ describe('Consumer page', () => {
vi.mocked(consumerService.getConsumerGroupSettings)
.mockImplementationOnce(() => firstSettings.promise)
.mockImplementationOnce(() => secondSettings.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<ConsumerPage />);
const firstRow = await screen.findByRole('row', { name: /remote-cg/ });
diff --git a/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx b/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx
index a2c82e56c..29887341c 100644
--- a/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPageDiagnosticsRace.test.tsx
@@ -82,6 +82,11 @@ const renderPage = () =>
describe('Consumer page diagnostic request ownership', () => {
beforeEach(() => {
+ // The details modal re-diagnoses every 2s and the list refreshes on another interval.
+ // Faking only setInterval keeps those ticks off wall-clock time, so the requests in
+ // flight are exactly the three this test controls; with real timers the call-count
+ // assertion below fails whenever the machine is loaded enough for a tick to slip in.
+ vi.useFakeTimers({ toFake: ['setInterval'] });
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
@@ -121,6 +126,7 @@ describe('Consumer page diagnostic request ownership', () => {
afterEach(() => {
cleanup();
+ vi.useRealTimers();
vi.resetAllMocks();
});
@@ -136,7 +142,7 @@ describe('Consumer page diagnostic request ownership', () => {
.mockReturnValueOnce(second.promise)
.mockReturnValueOnce(third.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
const row = await screen.findByRole('row', { name: /remote-cg/ });
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 5025b85ba..791a6c799 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -189,7 +189,7 @@ describe('DLQ page', () => {
page: 1,
size: 20,
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
@@ -232,7 +232,7 @@ describe('DLQ page', () => {
vi.mocked(messageService.listDLQGroups).mockResolvedValue(
pageOf([dlqGroup, { ...secondDlqGroup, lastEnqueueTime: null }]),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
@@ -246,7 +246,7 @@ describe('DLQ page', () => {
});
it('opens a message detail drawer with the selected group metadata', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
@@ -299,7 +299,7 @@ describe('DLQ page', () => {
resolveResend = resolve;
}),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const firstGroupRow = (await screen.findByText('cg-order')).closest('tr');
@@ -375,7 +375,7 @@ describe('DLQ page', () => {
page: 1,
size: 20,
} satisfies DLQMessagePage);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
@@ -412,7 +412,7 @@ describe('DLQ page', () => {
page: 1,
size: 20,
} satisfies DLQMessagePage);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
@@ -431,7 +431,7 @@ describe('DLQ page', () => {
}),
meta: { truncated: false, failedQueueCount: 0, limit: 5000 },
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
@@ -457,7 +457,7 @@ describe('DLQ page', () => {
}),
meta: { truncated: true, failedQueueCount: 2, limit: 100 },
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
@@ -472,7 +472,7 @@ describe('DLQ page', () => {
it('exports summaries for the selected groups in one CSV file', async () => {
vi.mocked(messageService.listDLQGroups).mockResolvedValue(pageOf([dlqGroup, secondDlqGroup]));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const batchExport = screen.getByRole('button', { name: /批量导出/ });
@@ -505,7 +505,7 @@ describe('DLQ page', () => {
},
]),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const row = (await screen.findByText('%DLQ%formula')).closest('tr');
@@ -521,7 +521,7 @@ describe('DLQ page', () => {
vi.mocked(messageService.listDLQGroups)
.mockResolvedValueOnce(pageOf([dlqGroup]))
.mockResolvedValueOnce(pageOf([{ ...dlqGroup, messageCount: 0 }]));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -548,7 +548,7 @@ describe('DLQ page', () => {
vi.mocked(messageService.resendDLQ).mockRejectedValue(
new Error('DLQ provider is not configured'),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -569,7 +569,7 @@ describe('DLQ page', () => {
resolveResend = resolve;
}),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const row = (await screen.findByText('cg-order')).closest('tr');
@@ -595,7 +595,7 @@ describe('DLQ page', () => {
scanIncomplete: true,
failedQueueCount: 1,
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -620,7 +620,7 @@ describe('DLQ page', () => {
resolveSecondInstance = resolve;
}),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const orderRow = (await screen.findByText('cg-order')).closest('tr');
@@ -656,7 +656,7 @@ describe('DLQ page', () => {
vi.mocked(messageService.listDLQGroups)
.mockResolvedValueOnce(pageOf([dlqGroup]))
.mockResolvedValueOnce(pageOf([secondDlqGroup]));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
const firstRow = (await screen.findByText('cg-order')).closest('tr');
@@ -694,7 +694,7 @@ describe('DLQ page', () => {
}),
)
.mockResolvedValueOnce(pageOf([secondDlqGroup]));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<DLQPage />);
await user.click(screen.getAllByRole('combobox')[0]);
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 065b3ac90..5be2b341f 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -130,7 +130,7 @@ describe('InstancePage', () => {
});
it('loads server-filtered results when the search or type changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
expect(await screen.findByText('production-proxy')).toBeInTheDocument();
@@ -248,7 +248,7 @@ describe('InstancePage', () => {
});
it('ignores duplicate create submissions while the first request is pending', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.createInstance).mockImplementation(() => new Promise(() => {}));
renderPage();
@@ -272,7 +272,7 @@ describe('InstancePage', () => {
});
it('reloads the current filters after creating an instance', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.createInstance).mockResolvedValue(instance(9, 'new-proxy'));
renderPage();
@@ -309,7 +309,7 @@ describe('InstancePage', () => {
});
it('creates an Apache instance with an explicit Proxy Local deployment type', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.createInstance).mockResolvedValue(
instance(3, 'local-proxy', 'PROXY_LOCAL'),
);
@@ -339,7 +339,7 @@ describe('InstancePage', () => {
});
it('updates instance type and endpoint through the edit dialog', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.updateInstance).mockResolvedValue(
instance(1, 'production-proxy', 'DIRECT'),
);
@@ -373,7 +373,7 @@ describe('InstancePage', () => {
});
it('keeps the endpoint editable for Apache instances but read-only for cloud vendors', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances).mockResolvedValue([
instance(1, 'production-proxy'),
{
@@ -409,7 +409,7 @@ describe('InstancePage', () => {
});
it('submits a cloud instance whose stored endpoint is blank', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const blankEndpointCloud = {
...instance(2, 'aliyun-prod'),
vendor: 'ALIYUN' as InstanceVendor,
@@ -441,7 +441,7 @@ describe('InstancePage', () => {
});
it('surfaces the server error reason when creating an instance fails', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.createInstance).mockRejectedValue({
response: { data: { message: 'Instance name already exists: new-proxy' } },
});
@@ -465,7 +465,7 @@ describe('InstancePage', () => {
});
it('surfaces the server error reason when updating an instance fails', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.updateInstance).mockRejectedValue({
response: { data: { message: 'Instance endpoint is not reachable' } },
});
@@ -486,7 +486,7 @@ describe('InstancePage', () => {
});
it('reloads the latest filters after a pending instance deletion completes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const pendingDelete = deferred<void>();
vi.mocked(instanceService.deleteInstance).mockReturnValue(pendingDelete.promise);
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
@@ -518,7 +518,7 @@ describe('InstancePage', () => {
});
it('shows vendor tabs in the add instance modal and switches description', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
expect(await screen.findByText('production-proxy')).toBeInTheDocument();
@@ -535,7 +535,7 @@ describe('InstancePage', () => {
});
it('imports every Aliyun instance of the credential via one-click import', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
cloudCredentialPage([
{
@@ -587,7 +587,7 @@ describe('InstancePage', () => {
});
it('imports every Tencent instance of the credential via one-click import', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
cloudCredentialPage([
{
@@ -643,7 +643,7 @@ describe('InstancePage', () => {
});
it('shows the total cloud import failure count when details are truncated', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
cloudCredentialPage([
{
@@ -688,7 +688,7 @@ describe('InstancePage', () => {
});
it('ignores a stale region response after the cloud credential changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const oldRegions = deferred<Array<{ regionId: string; regionName: string }>>();
const latestRegions = deferred<Array<{ regionId: string; regionName: string }>>();
vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
@@ -751,7 +751,7 @@ describe('InstancePage', () => {
});
it('ignores a stale instance response after the cloud region changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const oldInstances =
deferred<
Array<{ instanceId: string; instanceName: string; status: string; regionId: string }>
@@ -840,7 +840,7 @@ describe('InstancePage', () => {
});
it('clears a pending region load when the cloud vendor changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const pendingRegions = deferred<Array<{ regionId: string; regionName: string }>>();
vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
cloudCredentialPage([
@@ -898,7 +898,7 @@ describe('InstancePage', () => {
});
it('clears a pending instance load when the cloud vendor changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const pendingInstances =
deferred<
Array<{ instanceId: string; instanceName: string; status: string; regionId: string }>
@@ -974,7 +974,7 @@ describe('InstancePage', () => {
});
it('sorts and renders instances without remarks', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances).mockResolvedValue([
instance(10, 'instance-without-remark', 'PROXY_CLUSTER', null),
instance(11, 'instance-with-remark', 'PROXY_CLUSTER', 'production'),
@@ -1009,7 +1009,7 @@ describe('InstancePage', () => {
});
it('deletes selected instances through the toolbar batch delete button', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances).mockResolvedValue([
instance(20, 'batch-a'),
instance(21, 'batch-b'),
@@ -1044,7 +1044,7 @@ describe('InstancePage', () => {
});
it('clears selections hidden by a search result', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances)
.mockResolvedValueOnce([instance(22, 'search-selected'), instance(23, 'search-visible')])
.mockResolvedValueOnce([instance(23, 'search-visible')]);
@@ -1066,7 +1066,7 @@ describe('InstancePage', () => {
});
it('clears selections hidden by a type-filter result', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances)
.mockResolvedValueOnce([
instance(24, 'proxy-selected'),
@@ -1094,7 +1094,7 @@ describe('InstancePage', () => {
});
it('reconciles selections when a mutation refresh removes an instance', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(instanceService.listInstances)
.mockResolvedValueOnce([instance(26, 'refresh-selected'), instance(27, 'refresh-trigger')])
.mockResolvedValueOnce([instance(27, 'refresh-trigger')]);
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index dcbe3e155..b737ab01b 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -128,7 +128,7 @@ describe('Message page query history', () => {
});
it('requires the active query mode fields and trims submitted identifiers', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<MessagePage />);
const queryButton = screen.getByRole('button', { name: /^search查询$/ });
@@ -183,7 +183,7 @@ describe('Message page query history', () => {
});
it('surfaces Topic loading failures and retries without changing instance', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
topicServiceMocks.listTopics
.mockReset()
.mockRejectedValueOnce(new Error('NameServer unavailable'))
@@ -200,7 +200,7 @@ describe('Message page query history', () => {
});
it('requires a topic even when a key or message ID is present', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<MessagePage />);
const queryButton = screen.getByRole('button', { name: /^search查询$/ });
@@ -217,7 +217,7 @@ describe('Message page query history', () => {
});
it('does not report consume verification success without a backend API', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-CONSUME-VERIFY-001')]);
renderWithProviders(<MessagePage />);
@@ -237,7 +237,7 @@ describe('Message page query history', () => {
});
it('sorts and renders messages without tags or keys', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
messageServiceMocks.queryMessages.mockResolvedValue([
{ ...createMessage('MID-NULL-FIELDS'), tag: null, key: null },
{ ...createMessage('MID-FULL-FIELDS'), tag: 'vip', key: 'order-001' },
@@ -266,7 +266,7 @@ describe('Message page query history', () => {
selectInstance: vi.fn(),
instanceOptions: [],
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<MessagePage />);
await user.click(screen.getByText('按 Message ID'));
@@ -277,7 +277,7 @@ describe('Message page query history', () => {
});
it('shows the redelivery count on the message detail panel', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
messageServiceMocks.queryMessages.mockResolvedValue([
{ ...createMessage('MID-RETRY'), reconsumeTimes: 2 },
]);
@@ -334,7 +334,7 @@ describe('Message page query history', () => {
instanceOptions: [{ value: 1, label: 'Instance A' }],
});
topicServiceMocks.listTopics.mockRejectedValue(new Error('topic lookup failed'));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<MessagePage />);
await waitFor(() => expect(topicServiceMocks.listTopics).toHaveBeenCalledTimes(1));
@@ -347,7 +347,7 @@ describe('Message page query history', () => {
messageServiceMocks.queryMessages.mockResolvedValue(
Array.from({ length: 60 }, (_, index) => createMessage(`m-${index}`)),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<MessagePage />);
await user.click(lastElement(screen.getAllByRole('combobox')));
@@ -382,7 +382,7 @@ describe('Message page query history', () => {
});
it('shows the storage location on the message detail panel', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-LOCATION')]);
renderWithProviders(<MessagePage />);
@@ -441,7 +441,7 @@ describe('Message page query history', () => {
},
],
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders(<MessagePage />);
await user.click(screen.getByText('Query by Message ID'));
@@ -463,7 +463,7 @@ describe('Message page query history', () => {
});
it('renders placeholders on the detail panel when the storage location is unknown', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
messageServiceMocks.queryMessages.mockResolvedValue([
{ ...createMessage('MID-NO-LOCATION'), brokerName: null, queueId: null, queueOffset: null },
]);
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 4b3c8ea15..25edbcb94 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -175,7 +175,7 @@ describe('MessagePage async request ownership', () => {
it('does not restore query results after the user resets an in-flight query', async () => {
const query = createDeferred<MessageRecord[]>();
serviceMocks.queryMessages.mockReturnValue(query.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -198,7 +198,7 @@ describe('MessagePage async request ownership', () => {
size: 50,
resultMayBeTruncated: true,
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -226,7 +226,7 @@ describe('MessagePage async request ownership', () => {
resultMayBeTruncated: true,
})
.mockReturnValueOnce(lateQuery.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -264,7 +264,7 @@ describe('MessagePage async request ownership', () => {
{ value: 2, label: 'Instance B' },
],
}));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const view = renderPage();
await selectTopic(user);
@@ -288,7 +288,7 @@ describe('MessagePage async request ownership', () => {
serviceMocks.queryMessages.mockRejectedValue(
new Error('Message query provider is not configured'),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -302,7 +302,7 @@ describe('MessagePage async request ownership', () => {
serviceMocks.getMessageTrace.mockRejectedValue(
new Error('Message query provider is not configured'),
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -319,7 +319,7 @@ describe('MessagePage async request ownership', () => {
it('loads a message trace lazily and reuses it for the same message', async () => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
serviceMocks.getMessageTrace.mockResolvedValue(createTrace('cached-trace'));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -346,7 +346,7 @@ describe('MessagePage async request ownership', () => {
it('requiresGroupAndClientBeforeDirectConsumeTest', async () => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -373,7 +373,7 @@ describe('MessagePage async request ownership', () => {
it('queries trace by key with a custom trace topic from the trace tab', async () => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
serviceMocks.getMessageTraceByKey.mockResolvedValue(createTrace('key-trace'));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -405,7 +405,7 @@ describe('MessagePage async request ownership', () => {
const pendingTrace = createDeferred<TraceRecord>();
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
serviceMocks.getMessageTrace.mockReturnValue(pendingTrace.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -472,7 +472,7 @@ describe('MessagePage async request ownership', () => {
},
],
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -492,7 +492,7 @@ describe('MessagePage async request ownership', () => {
it('remembers a custom trace topic per instance across page remounts', async () => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('remembered-message')]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const firstRender = renderPage();
await selectTopic(user);
@@ -534,7 +534,7 @@ describe('MessagePage async request ownership', () => {
{ value: 2, label: 'Instance B' },
],
}));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const view = renderPage();
await selectTopic(user);
await user.click(screen.getByRole('button', { name: /^search查询$/ }));
@@ -576,7 +576,7 @@ describe('MessagePage async request ownership', () => {
size: 20,
});
serviceMocks.queryMessages.mockResolvedValue([createMessage('history-message')]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await user.click(screen.getByRole('button', { name: /服务端历史/ }));
@@ -602,7 +602,7 @@ describe('MessagePage async request ownership', () => {
serviceMocks.queryMessages
.mockReturnValueOnce(firstQuery.promise)
.mockReturnValueOnce(secondQuery.promise);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -637,7 +637,7 @@ describe('MessagePage async request ownership', () => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
serviceMocks.getMessageTrace.mockReturnValue(trace.promise);
const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -675,7 +675,7 @@ describe('MessagePage async request ownership', () => {
serviceMocks.getMessageTrace.mockImplementation((msgId: string) =>
msgId === 'message-a' ? firstTrace.promise : secondTrace.promise,
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
@@ -715,7 +715,7 @@ describe('MessagePage async request ownership', () => {
serviceMocks.getMessageTrace.mockImplementation((msgId: string) =>
msgId === 'message-a' ? firstTrace.promise : secondTrace.promise,
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
await selectTopic(user);
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index f316dcfd9..d426ac9a4 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -253,7 +253,7 @@ describe('TopicPage', () => {
size: 20,
});
topicServiceMocks.createTopic.mockResolvedValue(createdTopic);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders();
expect(await screen.findByText('共 1 个 Topic')).toBeInTheDocument();
@@ -285,7 +285,7 @@ describe('TopicPage', () => {
perm: 'RO',
remark: 'updated remark',
});
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders();
expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -319,7 +319,7 @@ describe('TopicPage', () => {
});
it('invalidates an open Topic edit when the selected instance changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const topicA = { ...buildTopics(1)[0], name: 'topic-a', instanceId: 'instance-a' };
const topicB = { ...buildTopics(1)[0], name: 'topic-b', instanceId: 'instance-b' };
instanceServiceMocks.listInstances.mockResolvedValue([
@@ -355,7 +355,7 @@ describe('TopicPage', () => {
{ ...selectedInstance, vendor: 'ALIYUN' },
]);
topicServiceMocks.updateTopic.mockResolvedValue(buildTopics(1)[0]);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders();
expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -383,7 +383,7 @@ describe('TopicPage', () => {
});
it('sends a normal test message from an Aliyun cloud topic', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([
{ ...selectedInstance, type: 'CLOUD', vendor: 'ALIYUN' },
]);
@@ -409,7 +409,7 @@ describe('TopicPage', () => {
});
it('opens a clean create dialog after a cancelled edit', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders();
expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -430,7 +430,7 @@ describe('TopicPage', () => {
it('ignores duplicate Topic creates while the first request is pending', async () => {
topicServiceMocks.createTopic.mockImplementation(() => new Promise(() => {}));
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders();
expect(await screen.findByText('topic-01')).toBeInTheDocument();
@@ -447,7 +447,7 @@ describe('TopicPage', () => {
});
it('downloads all topics matching the current filters when exporting', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(vi.fn());
let exportedBlob: Blob | undefined;
vi.mocked(URL.createObjectURL).mockImplementation((blob) => {
@@ -502,7 +502,7 @@ describe('TopicPage', () => {
});
it('keeps matching rows when the search term has leading or trailing spaces', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
// The server query uses the trimmed term, so it returns the matching topic; the
// client-side row filter must not re-filter with the padded raw input.
mockTopicsList([{ ...buildTopics(1)[0], name: 'orders-topic' }]);
@@ -521,7 +521,7 @@ describe('TopicPage', () => {
});
it('keeps the current table page after opening and closing topic details', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 6,
@@ -567,7 +567,7 @@ describe('TopicPage', () => {
});
it('clamps back to a valid page when the current page becomes empty after a delete', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 6,
@@ -624,7 +624,7 @@ describe('TopicPage', () => {
});
it('reloads the server page after deleting one topic', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const topic = buildTopics(1)[0];
let call = 0;
topicServiceMocks.listTopicsPage.mockImplementation(async () => {
@@ -661,7 +661,7 @@ describe('TopicPage', () => {
});
it('keeps the selected instance when rebuilding a topic without a broker route', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const topic = { ...buildTopics(1)[0], instanceId: 'instance-a' };
instanceServiceMocks.listInstances.mockResolvedValue([
{
@@ -691,7 +691,7 @@ describe('TopicPage', () => {
});
it('renders topic route health diagnostics in the detail modal', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const routes: BrokerRoute[] = [
{
brokerName: 'broker-a',
@@ -746,7 +746,7 @@ describe('TopicPage', () => {
});
it('keeps failed topics selected after a partially successful batch deletion', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const remainingTopics = [buildTopics(3)[1]];
topicServiceMocks.listTopicsPage
.mockResolvedValueOnce({
@@ -789,7 +789,7 @@ describe('TopicPage', () => {
});
it('clears selected topics when the search scope changes', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithProviders();
const row = await screen.findByRole('row', { name: /topic-01/ });
@@ -803,7 +803,7 @@ describe('TopicPage', () => {
});
it('moves back from an emptied last topic page after batch deletion', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const firstPage = buildTopics(20);
const secondPage = [buildTopics(21)[20]];
let deletedLastPage = false;
@@ -898,12 +898,21 @@ describe('TopicPage', () => {
});
it('imports valid topic CSV rows through the backend batch service with the selected instance', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
mockTopicsList([]);
instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
renderWithProviders('/instance/instance-proxy-1/topic');
- await screen.findByText(/共 0 个 Topic/);
+ // NOT `findByText(/共 0 个 Topic/)`: the header subtitle carries that text from the very
+ // first render (totalTopics starts at 0), so it resolves before the instance list lands and
+ // the upload races `handleImportFile`'s `selectedInstanceId` guard, which bails with
+ // 请先选择实例 and the import modal never opens. The instance-scoped page fetch only fires
+ // once the selection resolved, so observing the call is the race-free readiness mark.
+ await waitFor(() =>
+ expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+ ),
+ );
const csv = [
'"Name","Namespace","Type","Cluster ID","Write Queues","Read Queues","Permission","Remark"',
'"imported-topic","ignored","NORMAL","ignored-cluster","4","6","RW","orders"',
@@ -931,7 +940,7 @@ describe('TopicPage', () => {
});
it('reloads the paginated inventory after importing topics', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
const imported: Topic = {
...buildTopics(1)[0],
name: 'imported-topic',
@@ -944,7 +953,12 @@ describe('TopicPage', () => {
instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
renderWithProviders('/instance/instance-proxy-1/topic');
- await screen.findByText(/共 0 个 Topic/);
+ // Race-free readiness mark — see the comment in the batch-import test above.
+ await waitFor(() =>
+ expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+ expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+ ),
+ );
const csv = [
'"Name","Namespace","Type","Cluster ID","Write Queues","Read Queues","Permission","Remark"',
'"imported-topic","ignored","NORMAL","ignored-cluster","4","6","RW","orders"',
@@ -960,7 +974,7 @@ describe('TopicPage', () => {
});
it('does not call importTopics when imported topic CSV is invalid or duplicated', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
mockTopicsList([{ ...buildTopics(1)[0], instanceId: 'instance-proxy-1' }]);
renderWithProviders('/instance/instance-proxy-1/topic');
@@ -982,7 +996,7 @@ describe('TopicPage', () => {
});
it('imports valid topic rows while skipping duplicate rows', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
mockTopicsList([]);
instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
topicServiceMocks.importTopics.mockResolvedValue({
@@ -1044,7 +1058,7 @@ describe('TopicPage', () => {
});
it('previews the send payload and submits the normalized properties', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
mockTopicsList([buildTopics(1)[0]]);
renderWithProviders();
@@ -1083,7 +1097,7 @@ describe('TopicPage', () => {
});
it('blocks duplicate form properties in the send payload preflight', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
mockTopicsList([buildTopics(1)[0]]);
renderWithProviders();
@@ -1114,7 +1128,7 @@ describe('TopicPage', () => {
});
it('renders unavailable Topic consumer metrics distinctly from zero', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
mockTopicsList([buildTopics(1)[0]]);
topicServiceMocks.getTopicConsumerPage.mockResolvedValue({
items: [
@@ -1145,7 +1159,7 @@ describe('TopicPage', () => {
});
it('renders subscription group names as links in the topic detail modal', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
mockTopicsList([buildTopics(1)[0]]);
topicServiceMocks.getTopicConsumerPage.mockResolvedValue({
items: [
@@ -1170,7 +1184,7 @@ describe('TopicPage', () => {
});
it('keeps a reopened sync modal owned by its newest route check', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
let resolveFirst!: (routes: BrokerRoute[]) => void;
let resolveSecond!: (routes: BrokerRoute[]) => void;
const firstCheck = new Promise<BrokerRoute[]>((resolve) => {
diff --git a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
index 99ab398df..2dab3893c 100644
--- a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
+++ b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
@@ -67,7 +67,7 @@ describe('NotificationDeliveriesPage', () => {
});
it('retries a failed delivery from the list and refreshes its status', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<App>
<LangProvider>
@@ -145,7 +145,7 @@ describe('NotificationDeliveriesPage', () => {
size: 20,
},
);
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<App>
<LangProvider>
@@ -171,7 +171,7 @@ describe('NotificationDeliveriesPage', () => {
});
it('surfaces a failed instance-list load with a retry instead of an empty filter', async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
vi.mocked(listInstances)
.mockRejectedValueOnce(new Error('the instance service is down'))
.mockResolvedValueOnce([]);
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
index a64e7c036..02fa8a0ef 100644
--- a/web/src/test/setup.ts
+++ b/web/src/test/setup.ts
@@ -1,6 +1,7 @@
/// <reference types="vitest/globals" />
import '@testing-library/jest-dom/vitest';
import { configure } from '@testing-library/react';
+import { message, notification } from 'antd';
// findBy*/waitFor default to 1s, which antd's async rendering exceeds once the whole
// suite runs in parallel.
@@ -10,3 +11,12 @@ configure({ asyncUtilTimeout: 5000 });
beforeEach(() => {
localStorage.clear();
});
+
+// The static `message`/`notification` APIs render into body-level holders that RTL's cleanup
+// does not own: their notices (3s duration plus a leave animation) survive into later tests,
+// and their rc-notification timers fire mid-test on an orphaned React root — the source of
+// cross-test DOM pollution and "window is not defined" teardown errors under parallel load.
+afterEach(() => {
+ message.destroy();
+ notification.destroy();
+});