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 da2513c9f fix(studio): scope ACL pushes, gate settings writes, fix
unmeasured values (#6851)
da2513c9f is described below
commit da2513c9f43e0b26b2540ba2bc56324e879576d0
Author: bai mengxuan <[email protected]>
AuthorDate: Tue Oct 27 20:06:09 2026 +0800
fix(studio): scope ACL pushes, gate settings writes, fix unmeasured values
(#6851)
* fix(acl): scope the broker push to the instance's cluster (#6851)
The ACL push resolved every master behind the instance's NameServer, so on a
NameServer shared by several clusters one instance's accounts and policies
were
written to brokers it is not bound to. masterAddresses() now takes the
instance
and filters through AclBrokerScope - the same scope the ACL read path
already
used - and an instance that names no cluster keeps every master, as before.
* fix(settings): prefer instance names in data source labels (#6267)
formatInstanceIds did a single find and OR-ed the results, so a data source
referencing both a name and a numeric key rendered the key. Look names up
first
and fall back to the id, which also fixes the exported CSV.
* fix(web): keep a reader out of the AI settings writes (#6865)
The AI, data source and cloud credential tabs had no role gate, so a reader
account reached the write handlers. Derive canWriteSettings once in a shared
useCanWriteSettings hook - the same expression GeneralSettingsTab already
used -
disable the write buttons and modal confirmations, and return early in the
handlers. The gate is checked before the submitting lock so a rejected
attempt
cannot leave the modal permanently locked.
* fix(clients): ignore source port changes in ID collision diagnostics
(#6203)
The client-id collision check compared full addresses, so a reconnect that
only
changed the TCP source port was reported as a critical collision. Compare
hosts
via the new connectionHost() helper.
* fix(cluster): count every listed connection in the client summary (#6321)
The summary tiles de-duplicated by type:clientId while the table, subtitle
and
footer counted rows, so the numbers disagreed. All five figures now come
from
the listed connection rows.
* fix(alert): invalidate test results when criteria change (#6297)
A dry-run result stayed on screen after the rule criteria changed, so an
operator
could act on a stale verdict. Track the 13 criteria fields, invalidate on
change,
and add a generation guard to handleTest so an in-flight response cannot
land on
a newer edit. Every clear path now goes through invalidateTestResult(),
which
also bumps the generation.
* fix(web): tolerate Git line endings in the license gate (#6189)
With core.autocrlf the vendored-icon comparison and the distributed LICENSE
were
byte-compared against LF-normalized manifests, so a Windows checkout failed
the
gate. Normalize both sides through eolInsensitive() and cover it with a CRLF
fixture that exercises the vendored SVG branch.
* fix(consumer): clear selection after confirmed empty refresh (#6352)
A refresh that confirmed the list is empty left the previous selection in
place,
so a following bulk action targeted rows that no longer exist.
* fix(instance): stamp the ownership reservation's bookkeeping columns
(#6668)
The two reservation INSERTs relied on database defaults for gmt_create and
gmt_modified, which left them null where no default applied and let the two
columns disagree. Write both explicitly from one LocalDateTime.now().
* fix(tencent): derive a topic consumer's subscription model from
MessageModel (#7033)
consumeType was read from the wrong field, inverting the clustered/broadcast
verdict for Tencent topic consumers. The parameter is renamed to
messageModel so
the signature does not contradict what it receives.
* fix(rmqctl): declare and enforce the paged tools' page size ceiling
(#6845)
Three paged tools documented pageSize with only a minimum, so the CLI
accepted
values the services reject with 400 (all three cap at 100). Declare
maximum: 100
in the catalog and carry it end to end; catalog_gen.go is regenerated and
`make catalog-verify` reports no drift. The constraint existed in
rmq-tools.yaml
before #4225 split the catalog and was dropped there, because the
generator's
allow-list did not include `maximum`.
Batch integration of eleven related fixes by the same contributor; #6189,
#6203,
#6267, #6297, #6321, #6352, #6668, #6845, #6865 and #7033 are closed as
merged
into this one. Verified on the combined branch: 4143 backend tests, 2009
frontend
tests across 166 files, license gate 8/8, production build, eslint with no
new
warnings, and catalog-verify / go test / go vet / make fmt all clean.
---
rmqctl/cmd/catalog.go | 3 +
rmqctl/cmd/catalog_bounds_test.go | 107 ++++++++++
rmqctl/cmd/catalog_validate.go | 8 +-
rmqctl/cmd/explain.go | 7 +
rmqctl/cmd/explain_test.go | 44 ++++
rmqctl/internal/catalog/catalog.go | 2 +
rmqctl/internal/catalog/catalog_gen.go | 8 +-
rmqctl/internal/catalog/generate/main.go | 9 +-
.../studio/instance/ResourceOwnershipGuard.java | 19 +-
.../studio/instance/acl/AclBrokerScope.java | 57 ++++++
.../rocketmq/studio/instance/acl/AclService.java | 15 +-
.../studio/instance/acl/ApacheAclReadService.java | 27 +--
.../provider/tencent/TencentInstanceProvider.java | 11 +-
.../src/main/resources/tool-catalog/tools/acl.yaml | 1 +
.../main/resources/tool-catalog/tools/audit.yaml | 1 +
.../main/resources/tool-catalog/tools/message.yaml | 1 +
.../instance/ResourceOwnershipGuardTest.java | 57 ++++++
.../studio/instance/acl/AclServiceTest.java | 62 ++++++
.../ops/ai/tool/catalog/ToolCatalogTest.java | 28 +++
.../tencent/TencentInstanceProviderTest.java | 20 ++
web/scripts/licenses.mjs | 13 +-
web/scripts/licenses.test.mjs | 83 +++++++-
web/src/hooks/useCanWriteSettings.test.tsx | 49 +++++
web/src/hooks/useCanWriteSettings.ts | 35 ++++
.../pages/cluster/__tests__/ClientsPage.test.tsx | 55 +++++
web/src/pages/cluster/clients.tsx | 22 +-
.../pages/instance/__tests__/ConsumerPage.test.tsx | 38 ++++
web/src/pages/instance/consumer.tsx | 2 +
.../ops/__tests__/AlertsTestResultResidue.test.tsx | 149 +++++++++++++-
web/src/pages/ops/alerts.tsx | 55 ++++-
web/src/pages/settings/AiAssistantTab.tsx | 18 +-
web/src/pages/settings/CloudCredentialTab.tsx | 16 +-
web/src/pages/settings/DataSourceTab.tsx | 25 ++-
web/src/pages/settings/GeneralSettingsTab.tsx | 12 +-
.../settings/__tests__/DataSourceTab.test.tsx | 121 +++++++++++
.../__tests__/SettingsWriteTabsReader.test.tsx | 227 +++++++++++++++++++++
web/src/utils/clientConnectionDiagnostics.test.ts | 45 ++++
web/src/utils/clientConnectionDiagnostics.ts | 12 +-
38 files changed, 1369 insertions(+), 95 deletions(-)
diff --git a/rmqctl/cmd/catalog.go b/rmqctl/cmd/catalog.go
index 735d2dce8..6f408801c 100644
--- a/rmqctl/cmd/catalog.go
+++ b/rmqctl/cmd/catalog.go
@@ -315,6 +315,9 @@ func schemaFlagUsage(field toolcatalog.Field) string {
if field.HasMinimum {
usage += fmt.Sprintf(" (minimum: %g)", field.Minimum)
}
+ if field.HasMaximum {
+ usage += fmt.Sprintf(" (maximum: %g)", field.Maximum)
+ }
if field.Required {
usage += " (required)"
}
diff --git a/rmqctl/cmd/catalog_bounds_test.go
b/rmqctl/cmd/catalog_bounds_test.go
new file mode 100644
index 000000000..0545964ed
--- /dev/null
+++ b/rmqctl/cmd/catalog_bounds_test.go
@@ -0,0 +1,107 @@
+/*
+ * 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 cmd
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ toolcatalog
"github.com/apache/rocketmq-dashboard/rmqctl/internal/catalog"
+ "github.com/apache/rocketmq-dashboard/rmqctl/internal/types"
+)
+
+// The paged tools hand pageSize to a service that rejects anything above 100
with a 400
+// ("pageSize must be between 1 and 100"), so the bounds belong to the tool
contract and not only to
+// the server: a caller that follows the schema must not be able to build a
call that fails at
+// execution, and `--help` has to name the ceiling the same way it names the
minimum.
+func TestPagedToolPageSizeCeilingTest(t *testing.T) {
+ for _, toolName := range []string{"rmq.acl.list", "rmq.audit.list",
"rmq.message.query_dlq"} {
+ t.Run(toolName, func(t *testing.T) {
+ tool, ok := toolcatalog.LookupTool(toolName)
+ if !ok {
+ t.Fatalf("tool %q is missing", toolName)
+ }
+ pageSize, ok := nestedField(tool, "page", "pageSize")
+ if !ok {
+ t.Fatalf("%s has no page.pageSize field",
toolName)
+ }
+ if !pageSize.HasMaximum || pageSize.Maximum != 100 {
+ t.Fatalf("%s page.pageSize = %+v; want maximum
100", toolName, pageSize)
+ }
+
+ if err := validateValue(tool, pageSize, int64(100));
err != nil {
+ t.Fatalf("%s rejects the boundary value 100:
%v", toolName, err)
+ }
+ for _, value := range []int64{101, 1000} {
+ err := validateValue(tool, pageSize, value)
+ if err == nil {
+ t.Fatalf("%s accepts pageSize %d, which
the service rejects at execution", toolName, value)
+ }
+ assertInvalidArgument(t, err, "must be at most
100")
+ }
+ assertInvalidArgument(t, validateValue(tool, pageSize,
int64(0)), "must be at least 1")
+
+ usage := schemaFlagUsage(pageSize)
+ if !strings.Contains(usage, "(maximum: 100)") {
+ t.Fatalf("%s --help usage = %q; want the
ceiling advertised next to the minimum", toolName, usage)
+ }
+ if !strings.Contains(usage, "(minimum: 1)") {
+ t.Fatalf("%s --help usage = %q; want the
minimum still advertised", toolName, usage)
+ }
+ })
+ }
+}
+
+func nestedField(tool toolcatalog.Tool, path ...string) (toolcatalog.Field,
bool) {
+ fields := tool.InputSchema.Fields
+ for index, name := range path {
+ var found *toolcatalog.Field
+ for position := range fields {
+ if fields[position].Name == name {
+ found = &fields[position]
+ break
+ }
+ }
+ if found == nil {
+ return toolcatalog.Field{}, false
+ }
+ if index == len(path)-1 {
+ return *found, true
+ }
+ if found.Object == nil {
+ return toolcatalog.Field{}, false
+ }
+ fields = found.Object.Fields
+ }
+ return toolcatalog.Field{}, false
+}
+
+func assertInvalidArgument(t *testing.T, err error, want string) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("err = nil, want INVALID_ARGUMENT containing %q", want)
+ }
+ var cliError *types.CLIError
+ if !errors.As(err, &cliError) || cliError.Code !=
types.CodeInvalidArgument {
+ t.Fatalf("err = %v, want INVALID_ARGUMENT", err)
+ }
+ if !strings.Contains(cliError.Message, want) {
+ t.Fatalf("message = %q, want it to contain %q",
cliError.Message, want)
+ }
+}
diff --git a/rmqctl/cmd/catalog_validate.go b/rmqctl/cmd/catalog_validate.go
index def11f17d..7f18fd5fd 100644
--- a/rmqctl/cmd/catalog_validate.go
+++ b/rmqctl/cmd/catalog_validate.go
@@ -110,7 +110,7 @@ func validateValue(tool toolcatalog.Tool, field
toolcatalog.Field, value any) er
tool.CommandPath(), field.Flag,
strings.Join(field.Enum, ", ")))
}
}
- if !field.HasMinimum {
+ if !field.HasMinimum && !field.HasMaximum {
return nil
}
var number float64
@@ -122,10 +122,14 @@ func validateValue(tool toolcatalog.Tool, field
toolcatalog.Field, value any) er
default:
return nil
}
- if number < field.Minimum {
+ if field.HasMinimum && number < field.Minimum {
return invalidArgument(fmt.Sprintf(
"%s --%s must be at least %g", tool.CommandPath(),
field.Flag, field.Minimum))
}
+ if field.HasMaximum && number > field.Maximum {
+ return invalidArgument(fmt.Sprintf(
+ "%s --%s must be at most %g", tool.CommandPath(),
field.Flag, field.Maximum))
+ }
return nil
}
diff --git a/rmqctl/cmd/explain.go b/rmqctl/cmd/explain.go
index 8a2c447ae..4bb3d98d1 100644
--- a/rmqctl/cmd/explain.go
+++ b/rmqctl/cmd/explain.go
@@ -98,6 +98,7 @@ type fieldExplanation struct {
Global bool `json:"global,omitempty" yaml:"global,omitempty"`
Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"
yaml:"minimum,omitempty"`
+ Maximum *float64 `json:"maximum,omitempty"
yaml:"maximum,omitempty"`
MinLength int `json:"minLength,omitempty"
yaml:"minLength,omitempty"`
}
@@ -162,6 +163,9 @@ func explainSchemaFields(schema toolcatalog.InputSchema,
prefix string) []fieldE
if field.HasMinimum {
explained.Minimum = new(field.Minimum)
}
+ if field.HasMaximum {
+ explained.Maximum = new(field.Maximum)
+ }
fields = append(fields, explained)
}
return fields
@@ -205,6 +209,9 @@ func renderResourceExplanation(out interface{ Write([]byte)
(int, error) }, expl
if field.Minimum != nil {
constraints = append(constraints,
fmt.Sprintf("minimum=%g", *field.Minimum))
}
+ if field.Maximum != nil {
+ constraints = append(constraints,
fmt.Sprintf("maximum=%g", *field.Maximum))
+ }
if field.MinLength > 0 {
constraints = append(constraints,
fmt.Sprintf("minLength=%d", field.MinLength))
}
diff --git a/rmqctl/cmd/explain_test.go b/rmqctl/cmd/explain_test.go
index 4325053b0..25cc3e8ff 100644
--- a/rmqctl/cmd/explain_test.go
+++ b/rmqctl/cmd/explain_test.go
@@ -60,6 +60,50 @@ func TestExplainResourceJSONComesFromCatalog(t *testing.T) {
}
}
+// TestExplainReportsTheDeclaredPageSizeCeiling verifies that explain reports
every bound the
+// catalog declares for a field. It is the machine-readable view agents build
calls from, so it
+// cannot be the one surface that omits the ceiling --help prints and the CLI
enforces.
+func TestExplainReportsTheDeclaredPageSizeCeiling(t *testing.T) {
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
+ exitCode := NewApp(stdout, stderr).Execute([]string{"--output", "json",
"explain", "acl"})
+ if exitCode != 0 {
+ t.Fatalf("explain failed: %s", stderr)
+ }
+ var explanation resourceExplanation
+ if err := json.Unmarshal(stdout.Bytes(), &explanation); err != nil {
+ t.Fatalf("decode output: %v", err)
+ }
+ var found *fieldExplanation
+ for _, action := range explanation.Actions {
+ for index := range action.Fields {
+ if action.Fields[index].Name == "page.pageSize" {
+ found = &action.Fields[index]
+ break
+ }
+ }
+ if found != nil {
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("explain acl does not report page.pageSize: %#v",
explanation.Actions)
+ }
+ if found.Minimum == nil || *found.Minimum != 1 {
+ t.Fatalf("page.pageSize minimum = %v, want 1", found.Minimum)
+ }
+ if found.Maximum == nil || *found.Maximum != 100 {
+ t.Fatalf("page.pageSize maximum = %v, want 100", found.Maximum)
+ }
+
+ tableOut, tableErr := &bytes.Buffer{}, &bytes.Buffer{}
+ if code := NewApp(tableOut, tableErr).Execute([]string{"explain",
"acl"}); code != 0 {
+ t.Fatalf("explain acl failed: %s", tableErr)
+ }
+ if !strings.Contains(tableOut.String(), "minimum=1, maximum=100") {
+ t.Fatalf("the constraints column does not carry the
ceiling:\n%s", tableOut.String())
+ }
+}
+
// TestExplainMarksInstanceFieldGlobal verifies that the instance identifier
// field stays visible in explain output even though it is no longer a per-tool
// flag: it must be presented as the global --instance-id flag.
diff --git a/rmqctl/internal/catalog/catalog.go
b/rmqctl/internal/catalog/catalog.go
index cc0a86efb..edd58557a 100644
--- a/rmqctl/internal/catalog/catalog.go
+++ b/rmqctl/internal/catalog/catalog.go
@@ -113,6 +113,8 @@ type Field struct {
Enum []string
Minimum float64
HasMinimum bool
+ Maximum float64
+ HasMaximum bool
MinLength int
// ClientDefault records the x-client-default catalog extension. The
only
// supported value today is ClientDefaultNow ("NOW"): rmqctl fills the
diff --git a/rmqctl/internal/catalog/catalog_gen.go
b/rmqctl/internal/catalog/catalog_gen.go
index 39871e7fb..ffdc4f97a 100644
--- a/rmqctl/internal/catalog/catalog_gen.go
+++ b/rmqctl/internal/catalog/catalog_gen.go
@@ -21,7 +21,7 @@ package catalog
var defaultDocument = Document{
Version: "2.0.0",
MinimumClientVersion: "2.0.0",
- Digest:
"de70ac57695e5fb9db7b1a16d995c745eaa89c1e33f339954a9fb7831e7e9fd7",
+ Digest:
"e2ebdf66500503301025ed12f41ada2176e63249dce4732ee0e98c57300be682",
Tools: []Tool{
{
Name: "rmq.acl.list",
@@ -40,7 +40,7 @@ var defaultDocument = Document{
{Name: "page", Kind: ObjectField,
Object: &InputSchema{
Fields: []Field{
{Name: "page", Flag:
"page", Kind: IntegerField, Minimum: 1, HasMinimum: true},
- {Name: "pageSize",
Flag: "page-size", Kind: IntegerField, Minimum: 1, HasMinimum: true},
+ {Name: "pageSize",
Flag: "page-size", Kind: IntegerField, Minimum: 1, HasMinimum: true, Maximum:
100, HasMaximum: true},
},
}},
},
@@ -163,7 +163,7 @@ var defaultDocument = Document{
{Name: "page", Kind: ObjectField,
Object: &InputSchema{
Fields: []Field{
{Name: "page", Flag:
"page", Kind: IntegerField, Minimum: 1, HasMinimum: true},
- {Name: "pageSize",
Flag: "page-size", Kind: IntegerField, Minimum: 1, HasMinimum: true},
+ {Name: "pageSize",
Flag: "page-size", Kind: IntegerField, Minimum: 1, HasMinimum: true, Maximum:
100, HasMaximum: true},
},
}},
},
@@ -515,7 +515,7 @@ var defaultDocument = Document{
{Name: "page", Kind: ObjectField,
Object: &InputSchema{
Fields: []Field{
{Name: "page", Flag:
"page", Kind: IntegerField, Minimum: 1, HasMinimum: true},
- {Name: "pageSize",
Flag: "page-size", Kind: IntegerField, Minimum: 1, HasMinimum: true},
+ {Name: "pageSize",
Flag: "page-size", Kind: IntegerField, Minimum: 1, HasMinimum: true, Maximum:
100, HasMaximum: true},
},
}},
},
diff --git a/rmqctl/internal/catalog/generate/main.go
b/rmqctl/internal/catalog/generate/main.go
index dc4fc78a8..c3a2c4f6f 100644
--- a/rmqctl/internal/catalog/generate/main.go
+++ b/rmqctl/internal/catalog/generate/main.go
@@ -75,6 +75,7 @@ type property struct {
Description string `yaml:"description"
json:"description,omitempty"`
Enum []string `yaml:"enum"
json:"enum,omitempty"`
Minimum *float64 `yaml:"minimum"
json:"minimum,omitempty"`
+ Maximum *float64 `yaml:"maximum"
json:"maximum,omitempty"`
MinLength int `yaml:"minLength"
json:"minLength,omitempty"`
Items *property `yaml:"items"
json:"items,omitempty"`
Required []string `yaml:"required,omitempty"
json:"required,omitempty"`
@@ -130,6 +131,7 @@ type goField struct {
Required bool
Enum []string
Minimum *float64
+ Maximum *float64
MinLength int
ClientDefault string
Object *goInputSchema
@@ -198,7 +200,7 @@ var defaultDocument = Document{
{{- end }}
},
}
-{{ define "field" }}{Name: {{ quote .Name }}{{ if .Flag }}, Flag: {{ quote
.Flag }}{{ end }}{{ if .Description }}, Description: {{ quote .Description }}{{
end }}, Kind: {{ .Kind }}{{ if .Required }}, Required: true{{ end }}{{ if .Enum
}}, Enum: {{ stringSlice .Enum }}{{ end }}{{ if .Minimum }}, Minimum: {{ number
.Minimum }}, HasMinimum: true{{ end }}{{ if .MinLength }}, MinLength: {{
.MinLength }}{{ end }}{{ if .ClientDefault }}, ClientDefault: {{ quote
.ClientDefault }}{{ end }}{{ with [...]
+{{ define "field" }}{Name: {{ quote .Name }}{{ if .Flag }}, Flag: {{ quote
.Flag }}{{ end }}{{ if .Description }}, Description: {{ quote .Description }}{{
end }}, Kind: {{ .Kind }}{{ if .Required }}, Required: true{{ end }}{{ if .Enum
}}, Enum: {{ stringSlice .Enum }}{{ end }}{{ if .Minimum }}, Minimum: {{ number
.Minimum }}, HasMinimum: true{{ end }}{{ if .Maximum }}, Maximum: {{ number
.Maximum }}, HasMaximum: true{{ end }}{{ if .MinLength }}, MinLength: {{
.MinLength }}{{ end }}{{ if [...]
Fields: []Field{
{{ range .Fields }}{{ template "field" . }},
{{ end }}},
@@ -236,7 +238,7 @@ func (schema *property) UnmarshalYAML(node *yaml.Node)
error {
// Reject unsupported input keywords instead of silently dropping
constraints.
for index := 0; index+1 < len(node.Content); index += 2 {
switch key := node.Content[index].Value; key {
- case "type", "description", "enum", "minimum", "minLength",
"items",
+ case "type", "description", "enum", "minimum", "maximum",
"minLength", "items",
"required", "properties", "additionalProperties",
"anyOf", "x-cli-flag",
"x-client-default":
default:
@@ -757,6 +759,9 @@ func compileSchema(schema inputSchema) (goInputSchema,
error) {
if field.Minimum != nil {
compiledField.Minimum = new(*field.Minimum)
}
+ if field.Maximum != nil {
+ compiledField.Maximum = new(*field.Maximum)
+ }
if kind == "ObjectField" {
object, err := compileSchema(field)
if err != nil {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
index b311253d7..0fec7e692 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuard.java
@@ -31,6 +31,7 @@ import
org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
+import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
@@ -195,14 +196,20 @@ public class ResourceOwnershipGuard {
Ownership existing = checkOwner(instance, resource,
read(domain, resource, target,
true).stream().findFirst().orElse(null), !create);
if (existing == null) {
+ // The reservation must stamp its own bookkeeping columns:
leaving them to the
+ // `DEFAULT CURRENT_TIMESTAMP` of the schema would store a
creation time taken from the
+ // database clock, while every later write (the create
path included) stamps
+ // gmt_modified from the JVM clock, so a row whose
database and studio run in different
+ // zones would report a creation time offset from its own
update time.
+ LocalDateTime stampedAt = LocalDateTime.now();
if (resource.kind() == Kind.TOPIC) {
- jdbc.update("INSERT INTO rmq_instance_topic (name,
instance_id, cluster_id, domain, status, topic_type)"
- + " VALUES (?, ?, ?, ?, 'PENDING',
?)", resource.name(), instance.getName(), target,
- domain, topicType == null ? "NORMAL" :
topicType);
+ jdbc.update("INSERT INTO rmq_instance_topic (name,
instance_id, cluster_id, domain, status, topic_type, gmt_create, gmt_modified)"
+ + " VALUES (?, ?, ?, ?, 'PENDING', ?,
?, ?)", resource.name(), instance.getName(), target,
+ domain, topicType == null ? "NORMAL" :
topicType, stampedAt, stampedAt);
} else {
- jdbc.update("INSERT INTO rmq_instance_group (name,
instance_id, cluster_id, domain, status)"
- + " VALUES (?, ?, ?, ?, 'PENDING')",
resource.name(), instance.getName(), target,
- domain);
+ jdbc.update("INSERT INTO rmq_instance_group (name,
instance_id, cluster_id, domain, status, gmt_create, gmt_modified)"
+ + " VALUES (?, ?, ?, ?, 'PENDING', ?,
?)", resource.name(), instance.getName(), target,
+ domain, stampedAt, stampedAt);
}
} else {
requireTopicType(existing, topicType);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclBrokerScope.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclBrokerScope.java
new file mode 100644
index 000000000..aa62bdc1d
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclBrokerScope.java
@@ -0,0 +1,57 @@
+/*
+ * 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 org.apache.rocketmq.studio.instance.acl;
+
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * The brokers of an instance's endpoint that one ACL operation is allowed to
touch.
+ *
+ * <p>An instance bound to a single cluster on a NameServer shared by several
must neither surface nor
+ * mutate another cluster's brokers, so the topology is narrowed the same way
broker discovery narrows
+ * it ({@code RocketMQClusterProvider.discoverBrokers}). An instance that
names no cluster keeps every
+ * broker of the endpoint, and a configured cluster whose membership cannot be
resolved fails closed
+ * instead of falling back to the whole cluster table.
+ *
+ * <p>The ACL read and the ACL push both resolve their broker set through this
method: if they
+ * disagreed, the page would show one cluster's rules while a push wrote the
dashboard's accounts and
+ * policies into another cluster's brokers.
+ */
+final class AclBrokerScope {
+
+ private AclBrokerScope() {
+ }
+
+ static Set<String> scopedBrokerNames(ClusterInfo clusterInfo, String
configuredCluster) {
+ Set<String> names = new
TreeSet<>(clusterInfo.getBrokerAddrTable().keySet());
+ if (configuredCluster == null) {
+ return names;
+ }
+ Map<String, Set<String>> clusters = clusterInfo.getClusterAddrTable();
+ if (clusters == null || clusters.containsKey(configuredCluster)
+ && clusters.get(configuredCluster) == null) {
+ throw new BusinessException(503, "Broker cluster membership is
unavailable");
+ }
+ names.retainAll(clusters.getOrDefault(configuredCluster, Set.of()));
+ return names;
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index 224d2482d..666350a90 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -369,7 +369,7 @@ public class AclService {
log.info("Pushing {} ACL account(s) of instance {} to the brokers",
plans.size(), instanceId);
return runtimeAdminClientResolver.execute(instanceId, admin -> {
DefaultMQAdminExt adminExt = (DefaultMQAdminExt) admin;
- List<String> masters = masterAddresses(adminExt);
+ List<String> masters = masterAddresses(adminExt, instanceId);
if (!masters.isEmpty() && !acl2Enabled(adminExt, masters.get(0))) {
throw new BusinessException(409, "Broker ACL 2.0 is not
enabled on "
+ masters.get(0) + "; set authenticationEnabled=true
and "
@@ -501,12 +501,21 @@ public class AclService {
}
}
- private List<String> masterAddresses(DefaultMQAdminExt adminExt) throws
Exception {
+ /**
+ * The master brokers this push may write to. The instance's configured
cluster decides the set,
+ * through the same scope the ACL read uses ({@link AclBrokerScope}): a
NameServer shared by
+ * several clusters must not receive this instance's accounts and policies
on the clusters it is
+ * not bound to. An instance that names no cluster keeps every master of
the endpoint.
+ */
+ private List<String> masterAddresses(DefaultMQAdminExt adminExt, String
instanceId) throws Exception {
ClusterInfo clusterInfo = adminExt.examineBrokerClusterInfo();
if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
return List.of();
}
- return clusterInfo.getBrokerAddrTable().values().stream()
+ String configuredCluster = StringUtils.hasText(instanceId)
+ ? runtimeAdminClientResolver.configuredClusterName(instanceId)
: null;
+ return AclBrokerScope.scopedBrokerNames(clusterInfo,
configuredCluster).stream()
+ .map(clusterInfo.getBrokerAddrTable()::get)
.filter(broker -> broker != null && broker.getBrokerAddrs() !=
null)
.map(broker -> broker.getBrokerAddrs().get(MixAll.MASTER_ID))
.filter(Objects::nonNull)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
index a4b729333..fe813cabd 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
@@ -22,7 +22,6 @@ import org.apache.rocketmq.remoting.protocol.body.AclInfo;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.remoting.protocol.route.BrokerData;
import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
-import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -30,8 +29,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.Set;
-import java.util.TreeSet;
@Service
@RequiredArgsConstructor
@@ -48,7 +45,7 @@ public class ApacheAclReadService {
if (clusterInfo == null || clusterInfo.getBrokerAddrTable() ==
null) {
return result(policies, failures);
}
- for (String brokerName : scopedBrokerNames(clusterInfo,
configuredCluster)) {
+ for (String brokerName :
AclBrokerScope.scopedBrokerNames(clusterInfo, configuredCluster)) {
String address =
masterAddress(clusterInfo.getBrokerAddrTable().get(brokerName));
if (address == null || policies.containsKey(address) ||
failures.containsKey(address)) {
continue;
@@ -63,28 +60,6 @@ public class ApacheAclReadService {
});
}
- /**
- * The broker set this read is allowed to touch. An instance bound to one
cluster on a NameServer
- * shared by several must not surface another cluster's ACL, so the
topology is narrowed the same
- * way {@code RocketMQClusterProvider.discoverBrokers} narrows it; an
instance that names no
- * cluster keeps every broker of the endpoint. A configured cluster whose
membership is missing
- * from the topology cannot be resolved, so the read fails closed instead
of reading the whole
- * cluster table (mirrors the 503 that broker discovery raises for the
same condition).
- */
- private Set<String> scopedBrokerNames(ClusterInfo clusterInfo, String
configuredCluster) {
- Set<String> names = new
TreeSet<>(clusterInfo.getBrokerAddrTable().keySet());
- if (configuredCluster == null) {
- return names;
- }
- Map<String, Set<String>> clusters = clusterInfo.getClusterAddrTable();
- if (clusters == null || clusters.containsKey(configuredCluster)
- && clusters.get(configuredCluster) == null) {
- throw new BusinessException(503, "Broker cluster membership is
unavailable");
- }
- names.retainAll(clusters.getOrDefault(configuredCluster, Set.of()));
- return names;
- }
-
private List<RemoteAclPolicyVO> mapPolicies(List<AclInfo> brokerPolicies) {
if (brokerPolicies == null) {
return List.of();
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
index c3d0d7632..5b6de1d07 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProvider.java
@@ -1024,7 +1024,9 @@ public class TencentInstanceProvider implements
InstanceProvider {
}
return TopicConsumerVO.builder()
.group(subscription.getConsumerGroup())
- .consumeType(toConsumeType(subscription.getConsumeType(),
messageModel))
+ // ConsumeType is the vendor's consumption mechanism
(PUSH/PULL/POP); the
+ // subscription model the contract carries is MessageModel.
+ .consumeType(toConsumeType(messageModel,
subscription.getConsumeType()))
.messageModel(messageModel)
.diffTotal(subscription.getConsumerLag() == null ? 0L :
subscription.getConsumerLag())
.build();
@@ -1155,8 +1157,11 @@ public class TencentInstanceProvider implements
InstanceProvider {
}
}
- private static ConsumeType toConsumeType(String consumeType, String
messageModel) {
- String value = StringUtils.hasText(consumeType) ? consumeType :
messageModel;
+ private static ConsumeType toConsumeType(String messageModel, String
consumeType) {
+ // Tencent's ConsumeType is the consumption mechanism (PUSH/PULL/POP)
and never carries the
+ // subscription model, so MessageModel is the one to read; the
mechanism stays as the fallback
+ // for rows that only report it.
+ String value = StringUtils.hasText(messageModel) ? messageModel :
consumeType;
if (!StringUtils.hasText(value)) {
return null;
}
diff --git a/server/src/main/resources/tool-catalog/tools/acl.yaml
b/server/src/main/resources/tool-catalog/tools/acl.yaml
index 9f649af3e..bf8e8694e 100644
--- a/server/src/main/resources/tool-catalog/tools/acl.yaml
+++ b/server/src/main/resources/tool-catalog/tools/acl.yaml
@@ -37,6 +37,7 @@ tools:
pageSize:
type: integer
minimum: 1
+ maximum: 100
outputSchema:
type: object
required:
diff --git a/server/src/main/resources/tool-catalog/tools/audit.yaml
b/server/src/main/resources/tool-catalog/tools/audit.yaml
index 8609e7ef8..e64279dec 100644
--- a/server/src/main/resources/tool-catalog/tools/audit.yaml
+++ b/server/src/main/resources/tool-catalog/tools/audit.yaml
@@ -35,6 +35,7 @@ tools:
pageSize:
type: integer
minimum: 1
+ maximum: 100
outputSchema:
type: object
required:
diff --git a/server/src/main/resources/tool-catalog/tools/message.yaml
b/server/src/main/resources/tool-catalog/tools/message.yaml
index 52fa0f448..c43e04b15 100644
--- a/server/src/main/resources/tool-catalog/tools/message.yaml
+++ b/server/src/main/resources/tool-catalog/tools/message.yaml
@@ -300,6 +300,7 @@ tools:
pageSize:
type: integer
minimum: 1
+ maximum: 100
outputSchema:
type: object
required:
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
index 5a8d5c57b..57b5ab340 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/ResourceOwnershipGuardTest.java
@@ -23,6 +23,7 @@ import
org.apache.rocketmq.studio.provider.apache.RocketMQLiteTopicProvider;
import
org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
import org.apache.rocketmq.studio.persistence.entity.RmqGroup;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.JdbcRowValues;
import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Kind;
import org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.Resource;
import
org.apache.rocketmq.studio.provider.apache.RocketMQDefaultClusterResolver;
@@ -60,6 +61,7 @@ import
org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.core.io.ClassPathResource;
import org.springframework.transaction.support.TransactionTemplate;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
@@ -688,6 +690,61 @@ class ResourceOwnershipGuardTest {
return info;
}
+ /**
+ * A reservation has to carry its own bookkeeping stamps. The shipped
columns are declared
+ * {@code DEFAULT CURRENT_TIMESTAMP}, which the database evaluates on its
own clock, while the create
+ * path stamps {@code gmt_modified} from the JVM clock — so a reservation
that leans on the column
+ * default stores a creation time taken from a different clock than the
modification time, and the
+ * two can disagree by the offset between the database and the studio
(eight hours when the database
+ * runs in UTC and the studio in Asia/Shanghai). The two columns are
recreated without their defaults
+ * here, so that "the reservation supplied the value" is what this test
observes.
+ */
+ @Test
+ void topicReservationSuppliesItsOwnBookkeepingStampsTest() {
+ dropTimestampDefaults("topic");
+ LocalDateTime before = LocalDateTime.now();
+ guard.write(first, topic, "cluster", true, "NORMAL", () -> null);
+ LocalDateTime after = LocalDateTime.now();
+ Map<String, Object> row = jdbc.queryForMap(
+ "SELECT gmt_create, gmt_modified FROM rmq_instance_topic WHERE
name = ?", topic.name());
+ assertStampedByTheStudioClock(row, before, after);
+ }
+
+ @Test
+ void groupReservationSuppliesItsOwnBookkeepingStampsTest() {
+ dropTimestampDefaults("group");
+ Resource group = new Resource(Kind.GROUP, "orders-group");
+ LocalDateTime before = LocalDateTime.now();
+ guard.write(first, group, "cluster", true, null, () -> null);
+ LocalDateTime after = LocalDateTime.now();
+ Map<String, Object> row = jdbc.queryForMap(
+ "SELECT gmt_create, gmt_modified FROM rmq_instance_group WHERE
name = ?", group.name());
+ assertStampedByTheStudioClock(row, before, after);
+ }
+
+ /**
+ * Both columns have to carry the value the reservation supplied, not
merely a non-null one. Equality
+ * locks the single {@code stampedAt} the two columns share, and the
window proves the studio clock
+ * produced them — a database default would land outside it whenever the
two clocks disagree, which is
+ * the hours-wide zone offset this fix removes. The one-second slack
absorbs the second precision the
+ * DATETIME columns are stored with, which floors the stamped value.
+ */
+ private static void assertStampedByTheStudioClock(Map<String, Object> row,
LocalDateTime before, LocalDateTime after) {
+ LocalDateTime created = JdbcRowValues.dateTimeValue(row, "gmt_create");
+ LocalDateTime modified = JdbcRowValues.dateTimeValue(row,
"gmt_modified");
+ assertThat(created).as("gmt_create").isNotNull();
+ assertThat(modified).as("gmt_modified").isNotNull();
+ assertThat(created).as("gmt_create and gmt_modified come from one
stampedAt").isEqualTo(modified);
+ assertThat(created).isAfterOrEqualTo(before.minusSeconds(1));
+ assertThat(modified).isBeforeOrEqualTo(after.plusSeconds(1));
+ }
+
+ /** Same table, but the two bookkeeping columns no longer fall back to the
database clock. */
+ private void dropTimestampDefaults(String kind) {
+ jdbc.execute("ALTER TABLE rmq_instance_" + kind + " ALTER COLUMN
gmt_create DROP DEFAULT");
+ jdbc.execute("ALTER TABLE rmq_instance_" + kind + " ALTER COLUMN
gmt_modified DROP DEFAULT");
+ }
+
private void legacyIndexes() {
for (String kind : List.of("topic", "group")) {
jdbc.execute("ALTER TABLE rmq_instance_" + kind + " DROP INDEX
uk_" + kind + "_domain");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index c91836d45..0547fa804 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -55,6 +55,8 @@ import java.util.Locale;
import java.util.Optional;
import java.util.Properties;
import java.util.TimeZone;
+import java.util.Set;
+import java.util.TreeSet;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -1504,6 +1506,66 @@ class AclServiceTest {
verify(admin, never()).updateAcl(any(), any(AclInfo.class));
}
+ @Test
+ void
pushAclToBrokerTouchesOnlyTheConfiguredClusterOfASharedNameServerTest() throws
Exception {
+ AclUserVO stored =
AclUserVO.builder().id(11L).username("orders").secretKey("secret-4").build();
+ when(aclRepository.findUsers()).thenReturn(List.of(stored));
+ when(aclRepository.findUserById(11L)).thenReturn(Optional.of(stored));
+ when(aclRepository.findRulePage("user:orders", null, null, null, null,
1, 200))
+ .thenReturn(PageResult.of(List.of(), 0, 1, 200));
+ when(clusterProvider.discoverBrokers("instance-a",
null)).thenReturn(List.of());
+
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+
when(admin.examineBrokerClusterInfo()).thenReturn(sharedNameServerTopology());
+
when(runtimeAdminClientResolver.configuredClusterName("instance-a")).thenReturn("cluster-a");
+ when(runtimeAdminClientResolver.execute(eq("instance-a"),
any(MqAdminExtFactory.AdminAction.class)))
+ .thenAnswer(call -> call.getArgument(1,
MqAdminExtFactory.AdminAction.class).apply(admin));
+
+ PushAclToBrokerResultVO result =
aclService.pushAclToBroker("instance-a");
+
+ // The instance is bound to cluster-a; the other cluster behind the
same NameServer must not
+ // receive this instance's accounts, policies or pre-flight config
reads.
+ assertThat(result.getBrokers()).containsExactly("10.0.0.1:10911");
+ assertThat(result.getAccounts().get(0).getBrokers())
+ .allSatisfy(broker ->
assertThat(broker.getBroker()).isEqualTo("10.0.0.1:10911"));
+ verify(admin).createUser(eq("10.0.0.1:10911"), any());
+ verify(admin, never()).createUser(eq("10.0.0.2:10911"), any());
+ verify(admin, never()).updateUser(eq("10.0.0.2:10911"), any());
+ verify(admin, never()).updateAcl(eq("10.0.0.2:10911"),
any(AclInfo.class));
+ verify(admin, never()).getBrokerConfig("10.0.0.2:10911");
+ }
+
+ @Test
+ void pushAclToBrokerKeepsEveryMasterWhenTheInstanceNamesNoClusterTest()
throws Exception {
+ AclUserVO stored =
AclUserVO.builder().id(12L).username("reader").secretKey("secret-5").build();
+ when(aclRepository.findUsers()).thenReturn(List.of(stored));
+ when(aclRepository.findUserById(12L)).thenReturn(Optional.of(stored));
+ when(aclRepository.findRulePage("user:reader", null, null, null, null,
1, 200))
+ .thenReturn(PageResult.of(List.of(), 0, 1, 200));
+ when(clusterProvider.discoverBrokers("instance-a",
null)).thenReturn(List.of());
+
+ DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+
when(admin.examineBrokerClusterInfo()).thenReturn(sharedNameServerTopology());
+
when(runtimeAdminClientResolver.configuredClusterName("instance-a")).thenReturn(null);
+ when(runtimeAdminClientResolver.execute(eq("instance-a"),
any(MqAdminExtFactory.AdminAction.class)))
+ .thenAnswer(call -> call.getArgument(1,
MqAdminExtFactory.AdminAction.class).apply(admin));
+
+ PushAclToBrokerResultVO result =
aclService.pushAclToBroker("instance-a");
+
+
assertThat(result.getBrokers()).containsExactlyInAnyOrder("10.0.0.1:10911",
"10.0.0.2:10911");
+ }
+
+ private ClusterInfo sharedNameServerTopology() {
+ BrokerData first = new BrokerData("cluster-a", "broker-a", new
HashMap<>(Map.of(0L, "10.0.0.1:10911")));
+ BrokerData second = new BrokerData("cluster-b", "broker-b", new
HashMap<>(Map.of(0L, "10.0.0.2:10911")));
+ ClusterInfo clusterInfo = new ClusterInfo();
+ clusterInfo.setBrokerAddrTable(new HashMap<>(Map.of("broker-a", first,
"broker-b", second)));
+ clusterInfo.setClusterAddrTable(new HashMap<>(Map.of(
+ "cluster-a", new TreeSet<>(Set.of("broker-a")),
+ "cluster-b", new TreeSet<>(Set.of("broker-b")))));
+ return clusterInfo;
+ }
+
private ClusterInfo clusterInfoWithMaster(String address) {
ClusterInfo clusterInfo = new ClusterInfo();
BrokerData broker = new BrokerData("cluster-a", "broker-a", new
HashMap<>(Map.of(0L, address)));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
index f6178e71b..bad84f9a5 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/catalog/ToolCatalogTest.java
@@ -27,6 +27,7 @@ import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import tools.jackson.databind.json.JsonMapper;
@@ -107,6 +108,33 @@ class ToolCatalogTest {
}
}
+ @Test
+ void rejectsPageSizesAboveTheCeilingThePagedServicesEnforceTest() {
+ ToolCatalog catalog = new ToolCatalog(new DefaultResourceLoader());
+ ToolSchemaValidator validator = new ToolSchemaValidator(catalog,
+ new LegacyJackson2Config().jackson2ObjectMapper(),
JsonMapper.builder().build());
+ // The audit feed is platform level, the other two are scoped to one
instance.
+ Map<String, Map<String, Object>> scopes = Map.of(
+ "rmq.acl.list", Map.of("instanceId", "instance-a"),
+ "rmq.audit.list", Map.of(),
+ "rmq.message.query_dlq", Map.of("instanceId", "instance-a"));
+ for (Map.Entry<String, Map<String, Object>> scoped :
scopes.entrySet()) {
+ ToolDefinition definition = catalog.getDefinition(scoped.getKey());
+ Map<String, Object> overLimit = new HashMap<>(scoped.getValue());
+ overLimit.put("page", Map.of("page", 1, "pageSize", 101));
+ Map<String, Object> atLimit = new HashMap<>(scoped.getValue());
+ atLimit.put("page", Map.of("page", 1, "pageSize", 100));
+
+ // The services answer 400 ("pageSize must be between 1 and 100")
for anything above the
+ // ceiling, so a schema-valid call must not be able to ask for it
in the first place. The
+ // boundary value stays valid: rejecting 100 would invent a
ceiling the service does not have.
+ assertThatThrownBy(() -> validator.validateInput(definition,
overLimit))
+ .isInstanceOfSatisfying(ToolExecutionException.class,
+ error ->
assertThat(error.getCode()).isEqualTo(400));
+ validator.validateInput(definition, atLimit);
+ }
+ }
+
@Test
void rejectsCatalogThatDoesNotMatchItsJsonSchema() {
Resource invalid = utf8Resource("""
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
index 5ef54fb12..656da42a7 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentInstanceProviderTest.java
@@ -575,6 +575,26 @@ class TencentInstanceProviderTest {
assertThat(consumers.get(0).getDiffTotal()).isEqualTo(42L);
}
+ @Test
+ void
getTopicConsumersShouldDeriveTheSubscriptionModelFromTheMessageModelTest()
throws Exception {
+ SubscriptionData subscription = new SubscriptionData();
+ subscription.setConsumerGroup("GID_orders");
+ // The vendor reports the consumption mechanism in ConsumeType
(PUSH/PULL/POP); the
+ // subscription model the contract carries lives in MessageModel.
+ subscription.setConsumeType("PUSH");
+ subscription.setMessageModel("CLUSTERING");
+ subscription.setConsumerLag(42L);
+ DescribeTopicResponse response = new DescribeTopicResponse();
+ response.setSubscriptionData(new SubscriptionData[]{subscription});
+ when(client.DescribeTopic(any())).thenReturn(response);
+
+ List<TopicConsumerVO> consumers =
provider.getTopicConsumers(STUDIO_INSTANCE_ID, "orders");
+
+ assertThat(consumers).hasSize(1);
+
assertThat(consumers.get(0).getConsumeType()).isEqualTo(ConsumeType.CLUSTERING);
+ assertThat(consumers.get(0).getMessageModel()).isEqualTo("CLUSTERING");
+ }
+
@Test
void getTopicConsumersShouldReadEverySubscriptionPageTest() throws
Exception {
DescribeTopicResponse firstPage = new DescribeTopicResponse();
diff --git a/web/scripts/licenses.mjs b/web/scripts/licenses.mjs
index 42278db04..85f94bfa7 100644
--- a/web/scripts/licenses.mjs
+++ b/web/scripts/licenses.mjs
@@ -28,6 +28,8 @@ const read = (name) => {
if (!data.toString().trim()) throw new Error(`empty license material:
${name}`);
return data;
};
+// Git may hand back CRLF for tracked text; compare and distribute the
canonical upstream LF bytes.
+const eolInsensitive = (data) => Buffer.from(data.toString().replace(/\r\n/g,
'\n'));
const json = (name) => JSON.parse(read(name));
const slash = (name) => name.split(path.sep).join('/');
@@ -62,7 +64,7 @@ export function collectLicenses(moduleIds, base = root) {
const packages = new Map();
const files = new Map();
const components = [];
- const baseText = (name) => read(path.join(base,
name)).toString().split('\nThird-party source materials\n')[0];
+ const baseText = (name) => read(path.join(base,
name)).toString().replace(/\r\n/g, '\n').split('\nThird-party source
materials\n')[0];
let license = baseText('LICENSE');
let notice = baseText('NOTICE');
const ids = [...new Set(moduleIds)].sort();
@@ -120,8 +122,10 @@ export function collectLicenses(moduleIds, base = root) {
if (identity === '[email protected]') {
// The 1.0.6 npm package omits LICENSE; the index.js from the immutable
upstream commit below is identical to the package, and the MIT text is supplied.
const fallback = path.join(base,
'licenses/toggle-selection-1.0.6/LICENSE');
+ // Git may convert this tracked text to CRLF; verify and distribute the
canonical upstream LF bytes.
+ sourceContent[fallback] =
Buffer.from(read(fallback).toString().replace(/\r\n/g, '\n'));
if (sha(read(path.join(dir, 'index.js'))) !==
'd1a1caf366f8ae5ed3cf4a87c42c46e73bb5536acbcbc5ca479c180d4d3e7756'
- || sha(read(fallback)) !==
'5149051aed807f78acfbf9a43ac66368374a8fa1f9dfc092b73de5a67d42673a') {
+ || sha(sourceContent[fallback]) !==
'5149051aed807f78acfbf9a43ac66368374a8fa1f9dfc092b73de5a67d42673a') {
throw new Error('toggle-selection code or upstream license text does
not match the verified source');
}
sources.push(fallback, path.join(dir, 'README.md'));
@@ -134,12 +138,15 @@ export function collectLicenses(moduleIds, base = root) {
const icons = readdirSync(vendoredDir).filter((name) =>
name.endsWith('.svg')).sort();
if (icons.length !== 17) throw new Error('the vendored SVG list changed;
the source must be re-approved');
for (const name of icons) {
- if (!read(path.join(vendoredDir, name)).equals(read(path.join(dir,
'icons', name)))) {
+ // Both sides are text: the tracked copy may be CRLF, so compare the
normalized bytes.
+ if (!eolInsensitive(read(path.join(vendoredDir,
name))).equals(eolInsensitive(read(path.join(dir, 'icons', name))))) {
throw new Error(`a vendored icon does not match the locked upstream
text: ${name}`);
}
}
const fallback = path.join(vendoredDir, 'LICENSE');
sources = [...new Set([...sources, fallback])];
+ // Keep the distributed text and its manifest checksum on the canonical
LF bytes.
+ sourceContent[fallback] = eolInsensitive(read(fallback));
sourceURLs[fallback] =
'https://github.com/lobehub/lobe-icons/blob/v1.95.0/LICENSE';
notice += '\nModel brand icons from LobeHub
(https://github.com/lobehub/lobe-icons):\nCopyright (c) 2023 LobeHub.\n';
}
diff --git a/web/scripts/licenses.test.mjs b/web/scripts/licenses.test.mjs
index 35c100e87..5ee749060 100644
--- a/web/scripts/licenses.test.mjs
+++ b/web/scripts/licenses.test.mjs
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import assert from 'node:assert/strict';
-import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from
'node:fs';
+import { mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync,
rmSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
@@ -51,6 +51,81 @@ test('upstreamFallbacksKeepCompleteTextTest', () => {
assert.match(result.files.get('legal/licenses/@ant-design/[email protected]/LICENSE.txt').toString(),
/2018-present Ant UED/);
});
+function lineEndingFixture(t, ending = '\n') {
+ const directory = temporary(t);
+ const license = readFileSync(path.join(root,
'licenses/toggle-selection-1.0.6/LICENSE'), 'utf8').replace(/\r\n/g, '\n');
+ for (const name of ['LICENSE', 'NOTICE']) {
+ const text = readFileSync(path.join(root, name), 'utf8').replace(/\r\n/g,
'\n');
+ write(directory, name, text.replace(/\n/g, ending));
+ }
+ for (const name of ['package.json', 'index.js', 'README.md']) {
+ write(directory, `node_modules/toggle-selection/${name}`,
readFileSync(path.join(root, 'node_modules/toggle-selection', name)));
+ }
+ write(directory, 'licenses/toggle-selection-1.0.6/LICENSE',
license.replace(/\n/g, ending));
+ // The vendored LobeHub icons are tracked too, so Git can hand them back as
CRLF. The installed
+ // package has no root LICENSE: the vendored one is the only license text
this component carries.
+ const logoLicense = readFileSync(path.join(root,
'src/assets/model-logos/LICENSE'), 'utf8').replace(/\r\n/g, '\n');
+ write(directory, 'src/assets/model-logos/LICENSE',
logoLicense.replace(/\n/g, ending));
+ write(directory, 'node_modules/@lobehub/icons-static-svg/package.json',
+ readFileSync(path.join(root,
'node_modules/@lobehub/icons-static-svg/package.json')));
+ const icons = readdirSync(path.join(root,
'src/assets/model-logos')).filter((name) => name.endsWith('.svg')).sort();
+ for (const name of icons) {
+ const svg = readFileSync(path.join(root, 'src/assets/model-logos', name),
'utf8').replace(/\r\n/g, '\n');
+ write(directory, `src/assets/model-logos/${name}`, svg.replace(/\n/g,
ending));
+ write(directory, `node_modules/@lobehub/icons-static-svg/icons/${name}`,
+ readFileSync(path.join(root,
'node_modules/@lobehub/icons-static-svg/icons', name)));
+ }
+ return { directory, license, logoLicense };
+}
+
+test('gitLineEndingsPreserveVerifiedLicenseAndBinaryAttributionTest', (t) => {
+ const lf = lineEndingFixture(t);
+ const crlf = lineEndingFixture(t, '\r\n');
+ const modules = ['node_modules/toggle-selection/index.js'];
+ const expected = collectLicenses(modules, lf.directory);
+ const actual = collectLicenses(modules, crlf.directory);
+ assert.deepEqual(actual.components, expected.components);
+ assert.deepEqual(actual.files, expected.files);
+
assert.equal(actual.files.get('legal/licenses/[email protected]/LICENSE.txt').toString(),
lf.license);
+ for (const name of ['LICENSE', 'NOTICE']) {
+ assert.doesNotMatch(actual.files.get(name).toString(), /Third-party source
materials|src\/assets\/model-logos/);
+ }
+});
+
+test('gitLineEndingsPreserveTheVendoredIconAttributionTest', (t) => {
+ const lf = lineEndingFixture(t);
+ const crlf = lineEndingFixture(t, '\r\n');
+ const modules = ['src/assets/model-logos/openai.svg',
'node_modules/toggle-selection/index.js'];
+ const expected = collectLicenses(modules, lf.directory);
+ const actual = collectLicenses(modules, crlf.directory);
+ assert.deepEqual(actual.components, expected.components);
+ assert.deepEqual(actual.files, expected.files);
+
assert.equal(actual.files.get('legal/licenses/@lobehub/[email protected]/LICENSE.txt').toString(),
lf.logoLicense);
+ assert.match(actual.files.get('NOTICE').toString(), /Copyright \(c\) 2023
LobeHub/);
+ assert.doesNotMatch(actual.files.get('LICENSE').toString(), /Third-party
source materials|src\/assets\/model-logos/);
+ // Every tracked icon is a single line today, so Git leaves them alone and
the byte comparison above
+ // proves nothing on its own. Re-wrap one pair to keep the icon gate honest
about line endings.
+ const icon = 'openai.svg';
+ const upstream = readFileSync(path.join(root,
'node_modules/@lobehub/icons-static-svg/icons', icon), 'utf8')
+ .replace(/\r\n/g, '\n').replace('</svg>', '\n</svg>');
+ write(crlf.directory,
`node_modules/@lobehub/icons-static-svg/icons/${icon}`, upstream);
+ write(crlf.directory, `src/assets/model-logos/${icon}`,
upstream.replace(/\n/g, '\r\n'));
+ const rewrapped = collectLicenses(modules, crlf.directory);
+ assert.deepEqual(rewrapped.components, expected.components);
+ assert.deepEqual(rewrapped.files, expected.files);
+});
+
+test('lineEndingToleranceStillRejectsChangedLicenseAndCodeTest', (t) => {
+ const { directory, license } = lineEndingFixture(t, '\r\n');
+ const fallback = 'licenses/toggle-selection-1.0.6/LICENSE';
+ write(directory, fallback, license.replace('Copyright', 'Altered
copyright').replace(/\n/g, '\r\n'));
+ assert.throws(() =>
collectLicenses(['node_modules/toggle-selection/index.js'], directory), /does
not match the verified source/);
+ write(directory, fallback, license);
+ const code = readFileSync(path.join(directory,
'node_modules/toggle-selection/index.js'));
+ write(directory, 'node_modules/toggle-selection/index.js',
Buffer.concat([code, Buffer.from('\n// changed code')]));
+ assert.throws(() =>
collectLicenses(['node_modules/toggle-selection/index.js'], directory), /does
not match the verified source/);
+});
+
test('missingOrUnknownLicenseFailsTest', (t) => {
const directory = temporary(t);
for (const name of ['LICENSE', 'NOTICE']) write(directory, name,
readFileSync(path.join(root, name)));
@@ -65,6 +140,7 @@ test('missingOrUnknownLicenseFailsTest', (t) => {
test('vitePackagingAndTamperGateTest', async (t) => {
const directory = temporary(t);
const licensePlugin = distributionLicenses();
+ const fixturePath = (name) => JSON.stringify(path.join(root,
name).split(path.sep).join('/'));
// Build only fixtures for React, CSS, SVG and small dependencies; do not
build the app or run app tests.
const result = await build({
root,
@@ -75,7 +151,10 @@ test('vitePackagingAndTamperGateTest', async (t) => {
name: 'license-fixture',
resolveId(id) { if (id === 'license-fixture') return
'\0license-fixture'; },
load(id) {
- if (id === '\0license-fixture') return `import React from
'${root}node_modules/react/index.js'; import logo from
'${root}src/assets/model-logos/openai.svg'; import '${root}src/index.css';
import toggle from '${root}node_modules/toggle-selection/index.js';
console.log(React, logo, toggle);`;
+ if (id === '\0license-fixture') return `import React from
${fixturePath('node_modules/react/index.js')};
+ import logo from
${fixturePath('src/assets/model-logos/openai.svg')};
+ import ${fixturePath('src/index.css')};
+ import toggle from
${fixturePath('node_modules/toggle-selection/index.js')}; console.log(React,
logo, toggle);`;
},
},
licensePlugin,
diff --git a/web/src/hooks/useCanWriteSettings.test.tsx
b/web/src/hooks/useCanWriteSettings.test.tsx
new file mode 100644
index 000000000..c33c87d05
--- /dev/null
+++ b/web/src/hooks/useCanWriteSettings.test.tsx
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderHook } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { useCanWriteSettings } from './useCanWriteSettings';
+
+type MockAuthState = { admin: boolean | null; userId: number | null };
+
+// Read inside the selector, not when the factory runs, so the cases below can
swap the session
+// without re-registering the module mock.
+let authState: MockAuthState = { admin: false, userId: 42 };
+
+vi.mock('../stores/authStore', () => ({
+ default: (selector: (state: MockAuthState) => unknown) =>
selector(authState),
+}));
+
+const rendered = () => renderHook(() => useCanWriteSettings()).result.current;
+
+const sessions: Array<[string, MockAuthState, boolean]> = [
+ ['a signed-in reader', { userId: 42, admin: false }, false],
+ ['a signed-in admin', { userId: 42, admin: true }, true],
+ // Auth disabled: nobody is signed in, the caller counts as system and the
server lets the write
+ // through. This row is why the guard reads `!userId ||` first — dropping it
would lock every
+ // deployment that runs without studio authentication.
+ ['a deployment with studio auth disabled', { userId: null, admin: null },
true],
+ ['a signed-in operator whose admin flag never arrived', { userId: 42, admin:
null }, false],
+];
+
+describe('useCanWriteSettings', () => {
+ it.each(sessions)('decides the settings writes for %sTest', (_label, state,
expected) => {
+ authState = state;
+ expect(rendered()).toBe(expected);
+ });
+});
diff --git a/web/src/hooks/useCanWriteSettings.ts
b/web/src/hooks/useCanWriteSettings.ts
new file mode 100644
index 000000000..f2427cdb7
--- /dev/null
+++ b/web/src/hooks/useCanWriteSettings.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import useAuthStore from '../stores/authStore';
+
+/**
+ * 设置类写操作的角色闸门:当前登录者能否写设置。
+ *
+ * Mirrors the server policy: the settings writes (POST
/api/settings/general/save, the LLM
+ * configuration POST, and the data source / cloud credential mutations) are
admin-only, and the
+ * server also hands redacted secret values to readers. Without a signed-in
studio user (auth
+ * disabled) the caller counts as system, so writes stay allowed — that
fallback is deliberate:
+ * dropping it would lock out every deployment that runs without studio
authentication.
+ *
+ * 四个设置 tab 共用这一份派生,避免权限口径在复制中分叉。
+ */
+export function useCanWriteSettings(): boolean {
+ const authUserId = useAuthStore((state) => state.userId);
+ const authAdmin = useAuthStore((state) => state.admin);
+ return !authUserId || authAdmin === true;
+}
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index acee44de1..7291f4db5 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -168,6 +168,29 @@ async function expectInteractive(element: Element) {
}
describe('Clients page', () => {
+ it('shows one producer connected to two brokers without a false ID collision
Test', async () => {
+ vi.mocked(connectionsService.listConnections).mockResolvedValue([
+ {
+ ...connection,
+ clientId: '192.0.2.10@4242',
+ protocol: 'Remoting',
+ address: '192.0.2.10:51001',
+ },
+ {
+ ...connection,
+ clientId: '192.0.2.10@4242',
+ protocol: 'Remoting',
+ address: '192.0.2.10:51002',
+ },
+ ]);
+ renderWithProviders(<ClientsPage />);
+ await screen.findAllByText('192.0.2.10:51001');
+ await screen.findAllByText('192.0.2.10:51002');
+ const diagnostics = screen.getByTestId('client-connection-diagnostics');
+ expect(within(diagnostics).queryByText('Client ID
连接到多个地址')).not.toBeInTheDocument();
+ expect(within(diagnostics).getByText('客户端连接健康')).toBeInTheDocument();
+ });
+
it('returns to the first page when the connection search changes', async ()
=> {
const pagedConnections = Array.from({ length: 21 }, (_, index) => ({
...connection,
@@ -274,6 +297,38 @@ describe('Clients page', () => {
expect(within(languageVersions).getByText('C++ 4.9.8:
1')).toBeInTheDocument();
});
+ it('countsEveryListedConnectionWhenAClientServesSeveralGroupsTest', async ()
=> {
+ // A RocketMQ client registers one entry per group it serves - its own
group plus the shared
+ // CLIENT_INNER_PRODUCER - so the same clientId appears on several rows.
The summary tiles
+ // describe the connections the table lists; the de-duplicated clients
stay the diagnostics
+ // panel's job ("唯一 Client / 唯一地址" below).
+ vi.mocked(connectionsService.listConnections).mockResolvedValue([
+ { ...connection, clientId: 'shared-client', groupOrTopic: 'order-create'
},
+ { ...connection, clientId: 'shared-client', groupOrTopic:
'CLIENT_INNER_PRODUCER' },
+ {
+ ...connection,
+ clientId: 'order-consumer',
+ type: 'Consumer',
+ groupOrTopic: 'cg-order',
+ address: '10.0.1.40:49152',
+ },
+ ]);
+ renderWithProviders(<ClientsPage />);
+
+ await waitFor(() => {
+
expect(within(screen.getByTestId('connection-total')).getByText('3')).toBeInTheDocument();
+ });
+
expect(within(screen.getByTestId('producer-total')).getByText('2')).toBeInTheDocument();
+
expect(within(screen.getByTestId('consumer-total')).getByText('1')).toBeInTheDocument();
+ expect(
+ within(screen.getByTestId('protocol-distribution')).getByText('gRPC: 3'),
+ ).toBeInTheDocument();
+
+ const diagnostics = screen.getByTestId('client-connection-diagnostics');
+ expect(diagnostics.textContent).toContain('唯一 Client: 2');
+ expect(diagnostics.textContent).toContain('唯一地址: 2');
+ });
+
it('renders client connection diagnostics for risky inventories', async ()
=> {
vi.mocked(connectionsService.listConnections).mockResolvedValue([
{
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 73aa5139f..9740332e0 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -292,21 +292,17 @@ const ClientsPage = () => {
const clusterConnections = useMemo(() => connections, [connections]);
const connectionStats = useMemo(() => {
- const instances = Array.from(
- new Map(
- clusterConnections.map((connection) => [
- `${connection.type}:${connection.clientId ?? connection.address ??
connection.groupOrTopic}`,
- connection,
- ]),
- ).values(),
- );
+ // Every tile describes the connections the table below lists: the page
header and the table
+ // footer both count these rows, and the diagnostics panel already reports
the de-duplicated
+ // view (unique clients / addresses). Collapsing a client that serves
several groups into one
+ // entry here made the summary disagree with both, e.g. 5 in the header of
a 7-row page.
return {
- total: instances.length,
- producers: instances.filter((connection) => connection.type ===
'Producer').length,
- consumers: instances.filter((connection) => connection.type ===
'Consumer').length,
- protocols: countBy(instances.map((connection) => connection.protocol)),
+ total: clusterConnections.length,
+ producers: clusterConnections.filter((connection) => connection.type ===
'Producer').length,
+ consumers: clusterConnections.filter((connection) => connection.type ===
'Consumer').length,
+ protocols: countBy(clusterConnections.map((connection) =>
connection.protocol)),
languageVersions: countBy(
- instances.map(
+ clusterConnections.map(
(connection) =>
`${connection.language ?? UNKNOWN_LANGUAGE} ${connection.version
?? UNKNOWN_LANGUAGE}`,
),
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 312796132..89f671747 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -411,6 +411,44 @@ describe('Consumer page', () => {
errorSpy.mockRestore();
});
+ it.each(['empty', 'unchanged', 'failure'])(
+ 'keepsBatchSelectionBoundToConfirmedConsumerInventoryTest: %s',
+ async (outcome) => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ let finishRefresh!: (value: ReturnType<typeof groupPage>) => void;
+ let failRefresh!: (error: Error) => void;
+ const refreshedPage = new Promise<ReturnType<typeof
groupPage>>((resolve, reject) => {
+ finishRefresh = resolve;
+ failRefresh = reject;
+ });
+ vi.mocked(consumerService.listConsumerGroupPage)
+ .mockResolvedValueOnce(groupPage([group]))
+ .mockReturnValue(refreshedPage);
+ renderWithProviders(<ConsumerPage />);
+ const row = await screen.findByRole('row', { name: /remote-cg/ });
+ await user.click(within(row).getByRole('checkbox'));
+ expect(screen.getByRole('button', { name: /删除 \(1\)$/
})).toBeInTheDocument();
+
+ // Enabling the ordinary list refresh immediately reads the same
instance/query.
+ await user.click(screen.getByRole('button', { name: /自动刷新/ }));
+ await waitFor(() =>
expect(consumerService.listConsumerGroupPage).toHaveBeenCalledTimes(2));
+ await act(async () => {
+ if (outcome === 'failure') failRefresh(new Error('list read failed'));
+ else finishRefresh(groupPage(outcome === 'empty' ? [] : [group]));
+ });
+ expect(consumerService.deleteConsumerGroup).not.toHaveBeenCalled();
+ expect(consumerService.batchDeleteConsumerGroups).not.toHaveBeenCalled();
+ if (outcome === 'empty') {
+ expect(screen.queryByText('remote-cg')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /删除 \(1\)$/
})).not.toBeInTheDocument();
+ } else {
+ const retained = screen.getByText('remote-cg').closest('tr') as
HTMLElement;
+ expect(within(retained).getByRole('checkbox')).toBeChecked();
+ expect(screen.getByRole('button', { name: /删除 \(1\)$/
})).toBeInTheDocument();
+ }
+ },
+ );
+
afterEach(async () => {
await act(async () => {
cleanup();
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index bede02a48..1a02b099a 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -368,6 +368,8 @@ const ConsumerPageContent = ({
if (requestId === groupRequestIdRef.current) {
setGroups(result.items);
setTotalGroups(result.total);
+ // A current successful empty result confirms there are no targets
in this query.
+ if (result.total === 0) setSelectedRowKeys([]);
if (result.items.length === 0 && result.total > 0 && pageToLoad > 1)
{
setPage(Math.max(1, Math.ceil(result.total / pageSizeToLoad)));
}
diff --git a/web/src/pages/ops/__tests__/AlertsTestResultResidue.test.tsx
b/web/src/pages/ops/__tests__/AlertsTestResultResidue.test.tsx
index 67bb06ae0..e93e8c2da 100644
--- a/web/src/pages/ops/__tests__/AlertsTestResultResidue.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsTestResultResidue.test.tsx
@@ -16,10 +16,10 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
-import type { AlertRule, PageResult } from '../../../api/ops';
+import type { AlertRule, AlertRuleDomain, AlertRuleTestResult, PageResult }
from '../../../api/ops';
import { LangProvider } from '../../../i18n/LangContext';
import { listInstances } from '../../../services/instanceService';
import {
@@ -88,11 +88,11 @@ const pageResult = (rules: AlertRule[]):
PageResult<AlertRule> => ({
size: 20,
});
-const renderPage = () =>
+const renderPage = (domain?: AlertRuleDomain) =>
render(
<App>
<LangProvider>
- <AlertsPage />
+ <AlertsPage domain={domain} />
</LangProvider>
</App>,
);
@@ -103,6 +103,49 @@ function getRuleRow(ruleName: string) {
return row;
}
+function mockBusinessLagRule() {
+ const lagRule: AlertRule = {
+ ...buildRule(3, 'Lag guard'),
+ metric: 'consumer.lag.total',
+ threshold: 1000,
+ thresholdUnit: 'messages',
+ channels: ['email'],
+ consumerGroup: 'orders',
+ aggregation: 'LAST',
+ windowSeconds: 0,
+ consecutiveSamples: 1,
+ };
+ vi.mocked(listAlertRulesPage).mockResolvedValue(pageResult([lagRule]));
+ vi.mocked(listNativeAlertMetrics).mockResolvedValue([
+ {
+ key: 'consumer.lag.total',
+ label: 'Consumer lag total',
+ thresholdUnit: 'messages',
+ supportsConsumerGroup: true,
+ },
+ ]);
+}
+
+function lagTestResult(conditionMet = true): AlertRuleTestResult {
+ return {
+ samples: [
+ {
+ labels: { consumerGroup: 'orders' },
+ availability: 'AVAILABLE',
+ currentValue: 1200,
+ conditionMet,
+ },
+ ],
+ };
+}
+
+async function openLagEditor(user: ReturnType<typeof userEvent.setup>) {
+ renderPage('BUSINESS');
+ await screen.findByText('Lag guard');
+ await user.click(within(getRuleRow('Lag guard')).getByRole('button', { name:
'编辑' }));
+ return screen.findByRole('dialog');
+}
+
describe('AlertsPage rule test results', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -172,4 +215,102 @@ describe('AlertsPage rule test results', () => {
expect(within(reopened).queryByText('规则试运行结果')).toBeNull();
expect(within(reopened).queryByText('broker=stale-broker-sample')).toBeNull();
}, 30_000);
+
+ it.each(['completed', 'pending', 'unchanged', 'metadata'])(
+ 'keepsTestResultsBoundToTheCurrentThresholdTest: %s',
+ async (completion) => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ mockBusinessLagRule();
+ const samples = lagTestResult();
+ let finishTest!: (value: AlertRuleTestResult) => void;
+ const pendingTest = new Promise<AlertRuleTestResult>((resolve) => {
+ finishTest = resolve;
+ });
+ vi.mocked(testAlertRule).mockReset();
+ vi.mocked(testAlertRule).mockReturnValue(pendingTest);
+ const editor = await openLagEditor(user);
+ await user.click(within(editor).getByRole('button', { name: '试运行' }));
+ await waitFor(() =>
+ expect(testAlertRule).toHaveBeenCalledWith(
+ expect.objectContaining({ metric: 'consumer.lag.total', threshold:
1000 }),
+ 'BUSINESS',
+ ),
+ );
+ if (completion !== 'pending') {
+ await act(async () => finishTest(samples));
+ expect(await within(editor).findByText('规则试运行结果')).toBeInTheDocument();
+ expect(within(editor).getByText('命中')).toBeInTheDocument();
+ }
+ if (completion === 'completed' || completion === 'pending') {
+ const threshold = within(editor).getByPlaceholderText('阈值');
+ await user.clear(threshold);
+ await user.type(threshold, '2000');
+ expect(threshold).toHaveValue('2000');
+ }
+ if (completion === 'metadata') {
+ await user.type(within(editor).getByRole('textbox', { name: '规则名称' }),
' updated');
+ }
+ if (completion === 'pending') {
+ await act(async () => finishTest(samples));
+ }
+ expect(testAlertRule).toHaveBeenCalledTimes(1);
+ if (completion === 'unchanged' || completion === 'metadata') {
+ expect(within(editor).getByText('规则试运行结果')).toBeInTheDocument();
+ expect(within(editor).getByText('命中')).toBeInTheDocument();
+ } else {
+ expect(within(editor).queryByText('规则试运行结果')).not.toBeInTheDocument();
+ expect(within(editor).queryByText('命中')).not.toBeInTheDocument();
+ }
+ },
+ );
+
+ it.each(['success', 'failure'])(
+ 'keepsTheNewCriteriaTestActiveWhenAnOldRequestSettlesTest: %s',
+ async (outcome) => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ mockBusinessLagRule();
+ let finishEarlier!: (value: AlertRuleTestResult) => void;
+ let failEarlier!: (error: Error) => void;
+ const earlier = new Promise<AlertRuleTestResult>((resolve, reject) => {
+ finishEarlier = resolve;
+ failEarlier = reject;
+ });
+ let finishCurrent!: (value: AlertRuleTestResult) => void;
+ const current = new Promise<AlertRuleTestResult>((resolve) => {
+ finishCurrent = resolve;
+ });
+ vi.mocked(testAlertRule).mockReset();
+
vi.mocked(testAlertRule).mockReturnValueOnce(earlier).mockReturnValueOnce(current);
+ const editor = await openLagEditor(user);
+ const testButton = within(editor).getByRole('button', { name: '试运行' });
+ await user.click(testButton);
+ await waitFor(() => expect(testAlertRule).toHaveBeenCalledTimes(1));
+ const threshold = within(editor).getByPlaceholderText('阈值');
+ await user.clear(threshold);
+ await user.type(threshold, '2000');
+ await user.click(testButton);
+ await waitFor(() =>
+ expect(testAlertRule).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ threshold: 2000 }),
+ 'BUSINESS',
+ ),
+ );
+ await act(async () => {
+ if (outcome === 'success') finishEarlier(lagTestResult());
+ else failEarlier(new Error('old criteria test failed'));
+ });
+ expect(testButton).toHaveClass('ant-btn-loading');
+ expect(within(editor).queryByText('规则试运行结果')).not.toBeInTheDocument();
+ expect(screen.queryByText(/规则试运行失败/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/1 个命中:1200/)).not.toBeInTheDocument();
+ await act(async () => finishCurrent(lagTestResult(false)));
+ expect(await within(editor).findByText('规则试运行结果')).toBeInTheDocument();
+ expect(within(editor).getByText('未命中')).toBeInTheDocument();
+ expect(testButton).not.toHaveClass('ant-btn-loading');
+ },
+ // Opening the editor, running two tests and retyping the threshold is a
long chain of
+ // userEvent interactions; the case above needs the same budget for the
same reason.
+ 30_000,
+ );
});
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 266474041..6b56ebdb7 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -134,6 +134,21 @@ const aggregationLabelKeys: Record<string, string> = {
SUM: 'alerts.aggregation.SUM',
};
const notificationTemplateVariables = ALERT_NOTIFICATION_TEMPLATE_VARIABLES;
+const testCriteriaFields = new Set([
+ 'instanceId',
+ 'metric',
+ 'operator',
+ 'threshold',
+ 'thresholdUnit',
+ 'consumerGroup',
+ 'topic',
+ 'clusterName',
+ 'brokerName',
+ 'duration',
+ 'aggregation',
+ 'windowSeconds',
+ 'consecutiveSamples',
+]);
const availabilityMetrics = new Set([
'nameserver.availability',
'broker.availability',
@@ -245,6 +260,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
const [instances, setInstances] = useState<Instance[]>([]);
const [transferringRules, setTransferringRules] = useState(false);
const metricRequestVersion = useRef(0);
+ const testRequestVersion = useRef(0);
const importInputRef = useRef<HTMLInputElement>(null);
const notificationTemplateRef = useRef<TextAreaRef>(null);
const supportsUnavailableCondition =
supportsUnavailableOperator(selectedMetric);
@@ -468,7 +484,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
setSelectedInstanceId(undefined);
setMetricOptions([]);
setMetricLoading(false);
- setTestResult(null);
+ invalidateTestResult();
form.resetFields();
setModalVisible(true);
};
@@ -510,6 +526,12 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
}
};
+ const invalidateTestResult = () => {
+ testRequestVersion.current += 1;
+ setTestResult(null);
+ setTesting(false);
+ };
+
const loadMetricCapabilities = async (instanceId?: string, resetMetric =
true) => {
if (!instanceId?.trim()) return;
const requestVersion = ++metricRequestVersion.current;
@@ -519,7 +541,12 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
const metrics = await listNativeAlertMetrics(instanceId.trim(), domain);
if (requestVersion !== metricRequestVersion.current) return;
setMetricOptions(metrics);
- if (resetMetric) form.setFieldValue('metric', undefined);
+ if (resetMetric) {
+ // Programmatic setFieldValue does not fire onValuesChange, so
clearing the metric here
+ // has to invalidate the stale run itself.
+ if (form.getFieldValue('metric') !== undefined) invalidateTestResult();
+ form.setFieldValue('metric', undefined);
+ }
if (metrics.length === 0) message.warning(t('alerts.metricUnavailable'));
} catch {
if (requestVersion === metricRequestVersion.current) {
@@ -532,7 +559,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
const openEditModal = (rule: AlertRule) => {
setEditingRule(rule);
- setTestResult(null);
+ invalidateTestResult();
form.setFieldsValue({
...rule,
metric: normalizeMetric(rule.metric),
@@ -551,7 +578,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
const openDuplicateModal = (rule: AlertRule) => {
setEditingRule(null);
- setTestResult(null);
+ invalidateTestResult();
setSelectedInstanceId(rule.instanceId);
const { id: _id, lastTriggered: _lastTriggered, ...copy } = rule;
form.setFieldsValue({
@@ -889,8 +916,10 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
};
const handleTest = async () => {
+ const requestVersion = ++testRequestVersion.current;
try {
const values = await form.validateFields();
+ if (requestVersion !== testRequestVersion.current) return;
const payload = {
...attachThresholdUnit(values),
...(nativeRatioMetrics.has(normalizeMetric(values.metric)) ? {
thresholdUnit: '%' } : {}),
@@ -899,6 +928,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
const result = await (domain === 'CLUSTER'
? testAlertRule(payload)
: testAlertRule(payload, domain));
+ if (requestVersion !== testRequestVersion.current) return;
setTestResult(result);
if (result.samples.length === 0) {
message.warning(t('alerts.testNoSamples'));
@@ -936,10 +966,11 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
}),
);
} catch (error) {
+ if (requestVersion !== testRequestVersion.current) return;
if (error && typeof error === 'object' && 'errorFields' in error) return;
message.error(t('alerts.testRunFailed'));
} finally {
- setTesting(false);
+ if (requestVersion === testRequestVersion.current) setTesting(false);
}
};
@@ -1125,7 +1156,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
metricRequestVersion.current += 1;
setModalVisible(false);
setEditingRule(null);
- setTestResult(null);
+ invalidateTestResult();
setSelectedInstanceId(undefined);
setMetricOptions([]);
setMetricLoading(false);
@@ -1143,7 +1174,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
metricRequestVersion.current += 1;
setModalVisible(false);
setEditingRule(null);
- setTestResult(null);
+ invalidateTestResult();
setSelectedInstanceId(undefined);
setMetricOptions([]);
setMetricLoading(false);
@@ -1158,7 +1189,15 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
</Flex>
}
>
- <Form form={form} layout="vertical">
+ <Form
+ form={form}
+ layout="vertical"
+ onValuesChange={(changedValues) => {
+ if (Object.keys(changedValues).some((field) =>
testCriteriaFields.has(field))) {
+ invalidateTestResult();
+ }
+ }}
+ >
<div
style={{
display: 'grid',
diff --git a/web/src/pages/settings/AiAssistantTab.tsx
b/web/src/pages/settings/AiAssistantTab.tsx
index d4bcd589a..abb524862 100644
--- a/web/src/pages/settings/AiAssistantTab.tsx
+++ b/web/src/pages/settings/AiAssistantTab.tsx
@@ -45,6 +45,7 @@ import {
} from '../../api/llm';
import { fallbackModelOptions } from '../studio/llmModelOptions';
import { describeThrownMessage } from '../../utils/apiError';
+import { useCanWriteSettings } from '../../hooks/useCanWriteSettings';
const DEFAULT_BASE_URL: Record<string, string> = {
tongyi: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
@@ -108,6 +109,11 @@ interface TestState {
export const AiAssistantTab = () => {
const { t } = useLang();
const { message } = App.useApp();
+ // The LLM configuration surface is admin-only on both ends (the GET is an
admin-only read, the
+ // POST falls through to the admin requirement), so a reader's save, test or
key clear could only
+ // ever come back 403. The controls stay disabled instead, the same way the
other settings tabs
+ // treat the writes they own.
+ const canWriteSettings = useCanWriteSettings();
const [form] = Form.useForm();
const selectedProvider = Form.useWatch('provider', form);
@@ -239,6 +245,7 @@ export const AiAssistantTab = () => {
};
const handleClearApiKey = async () => {
+ if (!canWriteSettings) return;
const payload = await buildPayload();
if (!payload) return;
const confirmed = window.confirm(t('settings.clearApiKeyConfirm'));
@@ -277,6 +284,7 @@ export const AiAssistantTab = () => {
};
const handleTest = async () => {
+ if (!canWriteSettings) return;
const payload = await buildPayload();
if (!payload) return;
const requestId = testRequestIdRef.current + 1;
@@ -304,6 +312,7 @@ export const AiAssistantTab = () => {
};
const handleSave = async () => {
+ if (!canWriteSettings) return;
const payload = await buildPayload();
if (!payload) return;
const configGeneration = configGenerationRef.current;
@@ -444,6 +453,7 @@ export const AiAssistantTab = () => {
size="small"
style={{ marginLeft: 8 }}
loading={saving}
+ disabled={!canWriteSettings}
onClick={() => void handleClearApiKey()}
>
{t('settings.clearApiKey')}
@@ -554,12 +564,16 @@ export const AiAssistantTab = () => {
<Button
type="primary"
loading={saving}
- disabled={loading}
+ disabled={loading || !canWriteSettings}
onClick={() => void handleSave()}
>
{t('common.save')}
</Button>
- <Button loading={testing} disabled={loading} onClick={() => void
handleTest()}>
+ <Button
+ loading={testing}
+ disabled={loading || !canWriteSettings}
+ onClick={() => void handleTest()}
+ >
{t('settings.testConnection')}
</Button>
</Space>
diff --git a/web/src/pages/settings/CloudCredentialTab.tsx
b/web/src/pages/settings/CloudCredentialTab.tsx
index a404bbe67..69c379187 100644
--- a/web/src/pages/settings/CloudCredentialTab.tsx
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -35,6 +35,7 @@ import { DeleteOutlined, DownloadOutlined, EditOutlined,
PlusOutlined } from '@a
import type { ColumnsType } from 'antd/es/table';
import { MagnifyingGlass } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
+import { useCanWriteSettings } from '../../hooks/useCanWriteSettings';
import {
createCloudCredential,
@@ -65,6 +66,10 @@ interface CredentialFormValues {
export const CloudCredentialTab = () => {
const { t } = useLang();
+ // Credential writes are admin-only (POST/PUT/DELETE fall through to the
admin requirement in
+ // AuthInterceptor), so a reader's save or delete could only ever come back
403. The controls stay
+ // disabled instead, the same way the other settings tabs treat the writes
they own.
+ const canWriteSettings = useCanWriteSettings();
const [credentials, setCredentials] = useState<CloudCredential[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@@ -194,6 +199,7 @@ export const CloudCredentialTab = () => {
};
const handleSubmit = async () => {
+ if (!canWriteSettings) return;
if (submitInFlightRef.current) return;
submitInFlightRef.current = true;
setSubmitting(true);
@@ -233,6 +239,7 @@ export const CloudCredentialTab = () => {
};
const handleDelete = async (credential: CloudCredential) => {
+ if (!canWriteSettings) return;
if (loading || loadFailed || search.trim() !== debouncedSearch ||
loadedQueryKey !== queryKey)
return;
try {
@@ -277,7 +284,7 @@ export const CloudCredentialTab = () => {
type="link"
size="small"
icon={<EditOutlined />}
- disabled={staleActionsDisabled}
+ disabled={staleActionsDisabled || !canWriteSettings}
onClick={() => openEditModal(record)}
>
{t('common.edit')}
@@ -293,7 +300,7 @@ export const CloudCredentialTab = () => {
size="small"
danger
icon={<DeleteOutlined />}
- disabled={staleActionsDisabled}
+ disabled={staleActionsDisabled || !canWriteSettings}
>
{t('common.delete')}
</Button>
@@ -339,7 +346,9 @@ export const CloudCredentialTab = () => {
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
- disabled={loading || filtersPending || (queryMismatch &&
!loadFailed)}
+ disabled={
+ loading || filtersPending || (queryMismatch && !loadFailed) ||
!canWriteSettings
+ }
>
{t('settings.addCredential')}
</Button>
@@ -385,6 +394,7 @@ export const CloudCredentialTab = () => {
open={modalOpen}
onCancel={closeModal}
onOk={() => void handleSubmit()}
+ okButtonProps={{ disabled: !canWriteSettings }}
confirmLoading={submitting}
closable={!submitting}
maskClosable={!submitting}
diff --git a/web/src/pages/settings/DataSourceTab.tsx
b/web/src/pages/settings/DataSourceTab.tsx
index 1c2755a26..5d1e45466 100644
--- a/web/src/pages/settings/DataSourceTab.tsx
+++ b/web/src/pages/settings/DataSourceTab.tsx
@@ -35,6 +35,7 @@ import { ApiOutlined, DeleteOutlined, EditOutlined,
PlusOutlined } from '@ant-de
import type { ColumnsType } from 'antd/es/table';
import { useLang } from '../../i18n/LangContext';
+import { useCanWriteSettings } from '../../hooks/useCanWriteSettings';
import StatusBadge from '../../components/StatusBadge';
import {
createDataSource,
@@ -117,6 +118,10 @@ const withoutSecrets = (values: DataSourceFormValues):
Partial<DataSource> => {
export const DataSourceTab = () => {
const { t } = useLang();
+ // Data source writes are admin-only (POST/PUT/DELETE fall through to the
admin requirement in
+ // AuthInterceptor), so a reader's test, save or delete could only ever come
back 403. The controls
+ // stay disabled instead, the same way the other settings tabs treat the
writes they own.
+ const canWriteSettings = useCanWriteSettings();
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@@ -225,6 +230,7 @@ export const DataSourceTab = () => {
const isCurrent = () => key !== 'modal' || modalSession ===
modalTestSessionRef.current;
if (!isCurrent()) return;
if (key !== 'modal' && listActionsDisabled) return;
+ if (!canWriteSettings) return;
if (key !== 'modal' && authNeedsSecret(data.auth)) {
message.warning(t('settings.dataSourceAuthTestHint'));
return;
@@ -282,6 +288,10 @@ export const DataSourceTab = () => {
};
const handleSubmit = async () => {
+ // The role guard runs before the re-entrancy lock: a reader who reaches
this handler (keyboard
+ // or assistive paths bypass `disabled`) must not take the lock and never
release it, wedging
+ // the dialog for admins too. CloudCredentialTab orders its guards the
same way.
+ if (!canWriteSettings) return;
if (submittingRef.current) return;
submittingRef.current = true;
setSubmitting(true);
@@ -318,6 +328,7 @@ export const DataSourceTab = () => {
const handleDelete = async (dataSource: DataSource) => {
if (listActionsDisabled) return;
+ if (!canWriteSettings) return;
try {
await deleteDataSource(dataSource.key);
// Refresh through the effect so a completed deletion uses the current
filters/page,
@@ -334,8 +345,9 @@ export const DataSourceTab = () => {
return instanceIds
.map(
(instanceId) =>
- instances.find(
- (instance) => instance.name === instanceId || String(instance.id)
=== instanceId,
+ (
+ instances.find((instance) => instance.name === instanceId) ??
+ instances.find((instance) => String(instance.id) === instanceId)
)?.name ?? instanceId,
)
.join('、');
@@ -406,7 +418,7 @@ export const DataSourceTab = () => {
size="small"
icon={<ApiOutlined />}
loading={testingKeys.has(record.key)}
- disabled={listActionsDisabled || authNeedsSecret(record.auth)}
+ disabled={listActionsDisabled || authNeedsSecret(record.auth) ||
!canWriteSettings}
title={authNeedsSecret(record.auth) ?
t('settings.dataSourceAuthTestHint') : undefined}
onClick={() => void handleTestConnection(record, record.key)}
>
@@ -416,7 +428,7 @@ export const DataSourceTab = () => {
type="link"
size="small"
icon={<EditOutlined />}
- disabled={listActionsDisabled}
+ disabled={listActionsDisabled || !canWriteSettings}
onClick={() => openEditModal(record)}
>
{t('common.edit')}
@@ -432,7 +444,7 @@ export const DataSourceTab = () => {
size="small"
danger
icon={<DeleteOutlined />}
- disabled={listActionsDisabled}
+ disabled={listActionsDisabled || !canWriteSettings}
>
{t('common.delete')}
</Button>
@@ -482,7 +494,7 @@ export const DataSourceTab = () => {
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
- disabled={loading}
+ disabled={loading || !canWriteSettings}
>
{t('settings.addDataSource')}
</Button>
@@ -518,6 +530,7 @@ export const DataSourceTab = () => {
open={modalOpen}
onCancel={closeModal}
onOk={() => void handleSubmit()}
+ okButtonProps={{ disabled: !canWriteSettings }}
confirmLoading={submitting}
destroyOnHidden
>
diff --git a/web/src/pages/settings/GeneralSettingsTab.tsx
b/web/src/pages/settings/GeneralSettingsTab.tsx
index 66cac55ff..f7f8d9d52 100644
--- a/web/src/pages/settings/GeneralSettingsTab.tsx
+++ b/web/src/pages/settings/GeneralSettingsTab.tsx
@@ -35,7 +35,7 @@ import type { GeneralSettings, GeneralSettingsUpdate } from
'../../api/settings'
import { useTheme } from '../../theme/useTheme';
import type { ThemeMode } from '../../theme/themePreference';
import { useLang } from '../../i18n/LangContext';
-import useAuthStore from '../../stores/authStore';
+import { useCanWriteSettings } from '../../hooks/useCanWriteSettings';
const { Text } = Typography;
@@ -71,12 +71,10 @@ export const GeneralSettingsTab = () => {
const [testingChannel, setTestingChannel] = useState<string>();
const securityInFlightRef = useRef(false);
const notifyInFlightRef = useRef(false);
- const authUserId = useAuthStore((state) => state.userId);
- const authAdmin = useAuthStore((state) => state.admin);
- // Mirrors the server policy: POST /api/settings/general/save is admin-only,
and the
- // server also hands redacted webhook values to readers. Without a signed-in
studio
- // user (auth disabled) the caller counts as system, so writes stay allowed.
- const canWriteSettings = !authUserId || authAdmin === true;
+ // Mirrors the server policy: POST /api/settings/general/save is admin-only,
and the server also
+ // hands redacted webhook values to readers. The shared hook documents why a
deployment without
+ // studio authentication keeps its writes enabled.
+ const canWriteSettings = useCanWriteSettings();
const translationRef = useRef(t);
const [securityForm] = Form.useForm();
const [notifyForm] = Form.useForm();
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index 599a32f9d..281d4c029 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -20,6 +20,7 @@ import { act, fireEvent, render, screen, waitFor, within }
from '@testing-librar
import userEvent from '@testing-library/user-event';
import { App, ConfigProvider, message } from 'antd';
import type { DataSource, DataSourcePage } from '../../../api/settings';
+import type { Instance } from '../../../api/instance';
import {
createDataSource,
deleteDataSource,
@@ -32,6 +33,9 @@ import { LangProvider } from '../../../i18n/LangContext';
import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
import { downloadCsv } from '../../../utils/download';
import { DataSourceTab } from '../DataSourceTab';
+import { listInstances } from '../../../services/instanceService';
+
+vi.mock('../../../services/instanceService', () => ({ listInstances: vi.fn()
}));
vi.mock('../../../api/settings', () => ({
createDataSource: vi.fn(),
@@ -78,6 +82,45 @@ const sourcePage: DataSourcePage = {
size: 20,
};
+const bindingInstances: Instance[] = [
+ {
+ id: 1,
+ name: 'apache-main',
+ vendor: 'APACHE',
+ type: 'DIRECT',
+ endpoint: 'nameserver.example:9876',
+ remark: null,
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-10-11T00:00:00',
+ gmtModified: '2026-10-11T00:00:00',
+ },
+ {
+ id: 7,
+ name: 'legacy-instance',
+ vendor: 'APACHE',
+ type: 'DIRECT',
+ endpoint: 'legacy.example:9876',
+ remark: null,
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-10-11T00:00:00',
+ gmtModified: '2026-10-11T00:00:00',
+ },
+ {
+ id: 2,
+ name: '1',
+ vendor: 'ALIYUN',
+ type: 'CLOUD',
+ endpoint: 'cloud.example:8080',
+ remark: null,
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-10-11T00:00:00',
+ gmtModified: '2026-10-11T00:00:00',
+ },
+];
+
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -114,6 +157,84 @@ describe('DataSourceTab', () => {
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
vi.mocked(listDataSourcesPage).mockResolvedValue(sourcePage);
vi.mocked(listAllDataSources).mockResolvedValue(sources);
+ vi.mocked(listInstances).mockResolvedValue([]);
+ });
+
+ it('keeps a numeric instance name in the table and edit selection Test',
async () => {
+ const boundSource = { ...sources[0], instanceIds: ['1'] };
+ vi.mocked(listInstances).mockResolvedValue(bindingInstances);
+ vi.mocked(listDataSourcesPage).mockResolvedValue({
+ ...sourcePage,
+ items: [boundSource],
+ total: 1,
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+
+ const row = await screen.findByRole('row', { name: /Prometheus prod/ });
+ await user.click(within(row).getByRole('button', { name: /编\s*辑/ }));
+ const dialog = await screen.findByRole('dialog');
+ expect(
+ within(dialog).getByText('1', { selector:
'.ant-select-selection-item-content' }),
+ ).toBeInTheDocument();
+ expect(within(row).getAllByRole('cell')[3]).toHaveTextContent(/^1$/);
+ });
+
+ it('exports the numeric instance name instead of another instance with that
ID Test', async () => {
+ const boundSource = { ...sources[0], instanceIds: ['1'] };
+ vi.mocked(listInstances).mockResolvedValue(bindingInstances);
+ vi.mocked(listDataSourcesPage).mockResolvedValue({
+ ...sourcePage,
+ items: [boundSource],
+ total: 1,
+ });
+ vi.mocked(listAllDataSources).mockResolvedValue([boundSource]);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+ await screen.findByText('Prometheus prod');
+ await user.click(screen.getByRole('button', { name: /编\s*辑/ }));
+ const dialog = await screen.findByRole('dialog');
+ await within(dialog).findByText('1', { selector:
'.ant-select-selection-item-content' });
+ await user.click(within(dialog).getByRole('button', { name: /Cancel|取\s*消/
}));
+ await user.click(screen.getByRole('button', { name: '导出' }));
+
+ await waitFor(() => expect(downloadCsv).toHaveBeenCalled());
+ const csv = vi.mocked(downloadCsv).mock.calls[0][1];
+ expect(csv).toContain('"Applicable Instances"');
+ expect(csv).toContain('"http://prometheus:9090","1","None"');
+ expect(csv).not.toContain('apache-main');
+ });
+
+ it('keeps legacy numeric bindings, unknown names, and global sources
readable Test', async () => {
+ vi.mocked(listInstances).mockResolvedValue(bindingInstances);
+ const boundSource = { ...sources[0], instanceIds: ['7',
'missing-instance'] };
+ vi.mocked(listDataSourcesPage).mockResolvedValue({
+ ...sourcePage,
+ items: [boundSource, sources[1]],
+ });
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+ <App>
+ <DataSourceTab />
+ </App>,
+ );
+
+ const row = await screen.findByRole('row', { name: /Prometheus prod/ });
+ await user.click(within(row).getByRole('button', { name: /编\s*辑/ }));
+ await screen.findByRole('dialog');
+ expect(within(row).getAllByRole('cell')[3]).toHaveTextContent(
+ 'legacy-instance、missing-instance',
+ );
+ const globalRow = screen.getByRole('row', { name: /Thanos DR/ });
+ expect(within(globalRow).getAllByRole('cell')[3]).toHaveTextContent('全局');
});
it.each([false, true])(
diff --git a/web/src/pages/settings/__tests__/SettingsWriteTabsReader.test.tsx
b/web/src/pages/settings/__tests__/SettingsWriteTabsReader.test.tsx
new file mode 100644
index 000000000..b78783d73
--- /dev/null
+++ b/web/src/pages/settings/__tests__/SettingsWriteTabsReader.test.tsx
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ReactElement } from 'react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { App } from 'antd';
+import { getLlmConfig, getLlmModels } from '../../../api/llm';
+import { listDataSourcesPage } from '../../../api/settings';
+import { listCloudCredentials } from '../../../api/cloudCredential';
+import { ThemeProvider } from '../../../theme/ThemeProvider';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import { AiAssistantTab } from '../AiAssistantTab';
+import { CloudCredentialTab } from '../CloudCredentialTab';
+import { DataSourceTab } from '../DataSourceTab';
+
+type MockAuthState = { admin: boolean | null; userId: number | null; logout:
() => void };
+const authState = vi.hoisted(() => ({
+ admin: false as boolean | null,
+ userId: 42 as number | null,
+ logout: vi.fn(),
+}));
+vi.mock('../../../stores/authStore', () => ({
+ default: (selector: (state: MockAuthState) => unknown) =>
selector(authState),
+}));
+
+vi.mock('../../../api/llm', () => ({
+ getLlmConfig: vi.fn(),
+ getLlmModels: vi.fn(),
+ saveLlmConfig: vi.fn(),
+ testLlmConnection: vi.fn(),
+}));
+
+vi.mock('../../../api/settings', () => ({
+ createDataSource: vi.fn(),
+ deleteDataSource: vi.fn(),
+ listAllDataSources: vi.fn(),
+ listDataSourcesPage: vi.fn(),
+ testDataSource: vi.fn(),
+ updateDataSource: vi.fn(),
+}));
+
+vi.mock('../../../api/cloudCredential', () => ({
+ createCloudCredential: vi.fn(),
+ deleteCloudCredential: vi.fn(),
+ exportCloudCredentials: vi.fn(),
+ listCloudCredentials: vi.fn(),
+ updateCloudCredential: vi.fn(),
+}));
+
+vi.mock('../../../services/instanceService', () => ({
+ listInstances: vi.fn().mockResolvedValue([]),
+}));
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const renderTab = (tab: ReactElement) =>
+ render(
+ <App>
+ <LangProvider>
+ <ThemeProvider>{tab}</ThemeProvider>
+ </LangProvider>
+ </App>,
+ );
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ authState.admin = false;
+ authState.userId = 42;
+});
+
+describe('AiAssistantTab reader access', () => {
+ beforeEach(() => {
+ vi.mocked(getLlmModels).mockResolvedValue({ status: 0, data: [] });
+ });
+
+ it('disables the admin-only save and test controls for readers', async () =>
{
+ // The whole LLM configuration surface is admin-only: GET /api/llm/config
is an admin-only
+ // read, so a reader lands on the load error, and POST /api/llm/config
rejects the write with
+ // 403 (AuthInterceptor). Neither button can ever complete for this role.
+ vi.mocked(getLlmConfig).mockRejectedValue({
+ response: { status: 403, data: { message: 'Forbidden' } },
+ });
+
+ renderTab(<AiAssistantTab />);
+
+ // The load error is what a reader always sees here, and it is also the
point at which the form
+ // is out of its loading state.
+ await screen.findByText('Failed to load AI config');
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Test connection/
})).toBeDisabled();
+ });
+
+ it('keeps the save and test controls usable for an admin', async () => {
+ authState.admin = true;
+ vi.mocked(getLlmConfig).mockResolvedValue({
+ provider: 'openai',
+ apiBase: 'https://example.com/v1',
+ model: 'gpt-4o-mini',
+ apiKeyConfigured: true,
+ maxTokens: 4096,
+ temperature: 0.7,
+ enabled: true,
+ });
+
+ renderTab(<AiAssistantTab />);
+
+ // The clear-key control renders only once the loaded config reports a
stored key, so awaiting it
+ // is also the wait for the load to settle (loading alone disables both
buttons).
+ expect(await screen.findByRole('button', { name: 'Clear key'
})).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: /Test connection/
})).toBeEnabled();
+ });
+});
+
+describe('DataSourceTab reader access', () => {
+ const dataSource = {
+ key: 'ds-1',
+ name: 'prod-prom',
+ type: 'PROMETHEUS',
+ url: 'http://prom:9090',
+ auth: 'NONE',
+ status: 'HEALTHY',
+ };
+
+ beforeEach(() => {
+ vi.mocked(listDataSourcesPage).mockResolvedValue({
+ items: [dataSource],
+ total: 1,
+ page: 1,
+ size: 10,
+ } as never);
+ });
+
+ it('disables the admin-only writes and the connection test for readers',
async () => {
+ renderTab(<DataSourceTab />);
+
+ // POST /api/settings/datasources/* falls through to the admin
requirement, the connection test
+ // included, so every one of these would answer 403 for this role.
+ expect(await screen.findByText('prod-prom')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Add data source/
})).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Test connection/
})).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Edit/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Delete/ })).toBeDisabled();
+ });
+
+ it('keeps them usable for an admin', async () => {
+ authState.admin = true;
+ renderTab(<DataSourceTab />);
+
+ await screen.findByText('prod-prom');
+ expect(screen.getByRole('button', { name: /Add data source/
})).toBeEnabled();
+ expect(screen.getByRole('button', { name: /Test connection/
})).toBeEnabled();
+ expect(screen.getByRole('button', { name: /Edit/ })).toBeEnabled();
+ expect(screen.getByRole('button', { name: /Delete/ })).toBeEnabled();
+ });
+});
+
+describe('CloudCredentialTab reader access', () => {
+ beforeEach(() => {
+ vi.mocked(listCloudCredentials).mockResolvedValue({
+ items: [
+ {
+ id: 1,
+ name: 'aliyun-prod',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI****',
+ remark: '',
+ gmtCreate: '2026-08-01T10:00:00Z',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 10,
+ } as never);
+ });
+
+ it('disables the admin-only writes for readers', async () => {
+ renderTab(<CloudCredentialTab />);
+
+ expect(await screen.findByText('aliyun-prod')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Add credential/
})).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Edit/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Delete/ })).toBeDisabled();
+ });
+
+ it('keeps them usable for an admin', async () => {
+ authState.admin = true;
+ renderTab(<CloudCredentialTab />);
+
+ await screen.findByText('aliyun-prod');
+ expect(screen.getByRole('button', { name: /Add credential/
})).toBeEnabled();
+ expect(screen.getByRole('button', { name: /Edit/ })).toBeEnabled();
+ expect(screen.getByRole('button', { name: /Delete/ })).toBeEnabled();
+ });
+});
diff --git a/web/src/utils/clientConnectionDiagnostics.test.ts
b/web/src/utils/clientConnectionDiagnostics.test.ts
index 5bf2ab3be..698f34046 100644
--- a/web/src/utils/clientConnectionDiagnostics.test.ts
+++ b/web/src/utils/clientConnectionDiagnostics.test.ts
@@ -33,6 +33,51 @@ const connection = (overrides: Partial<ClientConnection>):
ClientConnection => (
});
describe('client connection diagnostics', () => {
+ it.each([
+ ['IPv4', '192.0.2.10:51001', '192.0.2.10:51002'],
+ ['IPv4 socket notation', '/192.0.2.10:51001', '192.0.2.10:51002'],
+ ['IPv6', '[2001:db8::10]:51001', '[2001:db8::10]:51002'],
+ ])(
+ 'keeps one client connected to multiple brokers without a collision (%s)
Test',
+ (_, first, second) => {
+ const diagnostics = analyzeClientConnections([
+ connection({ clientId: '192.0.2.10@4242', protocol: 'Remoting',
address: first }),
+ connection({ clientId: '192.0.2.10@4242', protocol: 'Remoting',
address: second }),
+ ]);
+ expect(diagnostics.issues.map((item) =>
item.code)).not.toContain('CLIENT_ID_COLLISION');
+ expect(diagnostics.issues.map((item) => item.code)).not.toContain(
+ 'EXACT_DUPLICATE_CONNECTION',
+ );
+ expect(diagnostics.status).toBe('healthy');
+ expect(diagnostics.summary).toMatchObject({ totalConnections: 2,
uniqueAddressCount: 2 });
+ },
+ );
+
+ it.each([
+ ['192.0.2.10:51001', '192.0.2.11:51002'],
+ ['[2001:db8::10]:51001', '[2001:db8::11]:51002'],
+ ])('still reports the same client ID on different hosts (%s, %s) Test',
(first, second) => {
+ const diagnostics = analyzeClientConnections([
+ connection({ clientId: 'shared-client', address: first }),
+ connection({ clientId: 'shared-client', address: second }),
+ ]);
+ expect(diagnostics.issues).toContainEqual(
+ expect.objectContaining({
+ code: 'CLIENT_ID_COLLISION',
+ severity: 'critical',
+ evidence: [first, second],
+ }),
+ );
+ });
+
+ it('keeps distinct unrecognized addresses distinct Test', () => {
+ const diagnostics = analyzeClientConnections([
+ connection({ clientId: 'shared-client', address: 'unknown-a' }),
+ connection({ clientId: 'shared-client', address: 'unknown-b' }),
+ ]);
+ expect(diagnostics.issues.map((item) =>
item.code)).toContain('CLIENT_ID_COLLISION');
+ });
+
it('marks a consistent multi-client inventory as healthy', () => {
const diagnostics = analyzeClientConnections([
connection({ clientId: 'producer-a', address: '10.0.1.10:49152' }),
diff --git a/web/src/utils/clientConnectionDiagnostics.ts
b/web/src/utils/clientConnectionDiagnostics.ts
index 094f70c68..77cad4ec9 100644
--- a/web/src/utils/clientConnectionDiagnostics.ts
+++ b/web/src/utils/clientConnectionDiagnostics.ts
@@ -164,6 +164,13 @@ const connectionIdentity = (connection: ClientConnection):
string =>
const resourceKey = (connection: ClientConnection): string =>
`${normalizeText(connection.type)}:${normalizeText(connection.groupOrTopic)}`;
+const connectionHost = (address: string | null | undefined): string => {
+ const normalized = normalizeText(address).replace(/^\//, '');
+ // Broker connections use different TCP source ports for the same client
process.
+ const socket = /^(?:\[([^\]]+)\]|([^:]+)):\d+$/.exec(normalized);
+ return socket ? (socket[1] ?? socket[2]) : normalized;
+};
+
const groupConnections = (connections: ClientConnection[]): ConnectionGroup[]
=> {
const groups = new Map<string, ConnectionGroup>();
@@ -245,7 +252,10 @@ const addClientIdIssues = (connections:
ClientConnection[], issues: ClientConnec
connectionsByClient.forEach((clientConnections, clientId) => {
const addresses = uniqueSorted(clientConnections.map((connection) =>
connection.address));
- if (addresses.length > 1) {
+ const hosts = uniqueSorted(
+ clientConnections.map((connection) =>
connectionHost(connection.address)),
+ );
+ if (hosts.length > 1) {
issues.push(
issue(
'CLIENT_ID_COLLISION',