feat: Support version history and rollback for traffic rules (#1477)
* chore(store): add ListResources + align gorm/memory empty-index semantics
* fix(discovery): nil-guard zk rule delete + emit registry context on events
* feat(versioning): backend immutable release ledger for traffic rules
* feat(versioning): UI history drawer, diff, and rollback for rule pages
* test(versioning): end-to-end rollback drill
* fix(versioning): close gaps surfaced by the smoke drill
Running the §9.4 smoke drill end-to-end uncovered three real defects
that the unit suite did not catch:
- RuleVersionSubscriber recorded a duplicate UPSTREAM row whenever the
registry echoed back a no-op change identical to the latest ledger
row (typically right after BOOTSTRAP). Now dedupes upstream events
whose content hash already matches the current head, with an explicit
test in versioning_test.go.
- writeVersioningResp mapped every bizerror to HTTP 200/UnknownError;
bizerror.InvalidArgument (eg. empty rollback reason) now returns
HTTP 400 with its original code so the frontend can act on it.
Covered by a new handler/rule_version_test.go.
- The 409 VERSION_CONFLICT toast auto-dismissed after the default
duration; users could miss the Reload button entirely. Pinned with
duration: 0 so the notification stays until acknowledged.
* fix(traffic): preserve priority/force/configVersion on rule edit forms
The §9.4 smoke drill expectation "after rollback, the rule should look
the same on UI refresh" exposed a pre-existing edit-form regression:
rollback was correct at the ledger and ZK levels, but the edit form
silently dropped `priority`, `force`, and (for condition routes)
`configVersion` because they were neither rendered in the GET response
nor re-sent on save.
This is not caused by versioning, but a true round-trip is the first
flow that forces every field through the loop. Adds the missing fields
to ConditionRuleResp / TagRuleResp on the backend, and reads/writes
them in updateByFormView.vue on the frontend so a "save → rollback →
reload" cycle is now lossless.
* chore: ignore local planning artifacts
Add /task_plan.md /findings.md /progress.md to .gitignore so the
planning-with-files workflow does not leak per-developer working
memory into the repo.
* fix(versioning): close owner review correctness gaps
* refactor(versioning): drop unused helpers and aliases
* fix(ui): plug rule diff editor leaks
* Remove local working memory files from .gitignore
* fix(versioning): add intent repair and abandon flows
* Revert "feat: support traffic rule version history and rollback"
* fix(lock): use acquisition-scoped leases
* refactor(versioning): split resource store responsibilities
* refactor(versioning): normalize events and reduce repair APIs
* fix(lock): stop writes after lease loss
* refactor(ui): remove unreachable state and simplify mocks
* test(versioning): add multi-instance and lease failure coverage
* docs(versioning): update default-enabled guarantees
* fix: address rule versioning review blockers
- require lease contexts for rule versioning mutations
- propagate contexts through resource manager and governor writes
- move repair/bootstrap reads inside rule locks
- use ledger snapshots for current and deleted state
- remove unused subscriber intent token path
- simplify rollback commit handling and add UI coverage
* fix: harden rule versioning correctness
* fix: address rule versioning review issues
* Fix rule version commit terminal semantics
* fix(versioning): harden rule intent recovery
* fix(versioning): close intent recovery race windows
* Fix rule intent storage CAS and subscriber fallback
* Fix rule version committing recovery ordering
* Fix Go CI cache restore
* Clean up PR-only test files
* docs(versioning): clarify invariants and recovery comments
* 20 maxVersion is suitable
* chore: limit rule version retention to 20
* refactor: simplify rule version history
* refactor: restore core mutation and lock interfaces
* refactor: enforce lightweight rule history semantics
* refactor: clean rule history frontend semantics
* docs: describe lightweight rule history config
* test: add frontend npm test script
* fix: address copilot review comments
* fix: make rule versioning fail closed
* refactor: collapse versioning store abstraction
* refactor: reuse resource helpers in rule versioning
- move resource key parsing next to BuildResourceKey
- centralize live rule lookup in console version service
- deduplicate RuleVersion resource conversion/sorting helpers
- no behavior/API/storage semantic changes
* fix: address traffic rule history review blockers
* fix: clear traffic view type errors
* fix: address remaining rule version review comments
* fix: add license header to js-yaml declaration
* style: refine version record tag and layout in traffic rule views
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4065f42..5aa4afc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,21 +69,9 @@
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- - name: Cache dependencies
- # ref: https://github.com/actions/cache/blob/main/examples.md#go---module
- uses: actions/cache@v4
- with:
- # Cache, works only on Linux
- path: |
- ~/.cache/go-build
- ~/go/pkg/mod
- # Cache key
- key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
- # An ordered list of keys to use for restoring the cache if no cache hit occurred for key
- restore-keys: |
- ${{ runner.os }}-go-
+ cache-dependency-path: go.sum
- name: Check Code Format
- run: make fmt && git status && [[ -z `git status -s` ]]
+ run: make fmt && git status && [[ -z $(git status -s) ]]
- name: Run Unit Test
run: make test
# TODO(marsevilspirit): add lint
diff --git a/api/mesh/v1alpha1/rule_version.pb.go b/api/mesh/v1alpha1/rule_version.pb.go
new file mode 100644
index 0000000..7030b9a
--- /dev/null
+++ b/api/mesh/v1alpha1/rule_version.pb.go
@@ -0,0 +1,265 @@
+//
+// 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.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.11
+// protoc v7.34.1
+// source: api/mesh/v1alpha1/rule_version.proto
+
+package v1alpha1
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// RuleVersion is a traffic-rule version entry. Entries are immutable after
+// creation. Rollback appends a new RuleVersion, while retention may delete the
+// oldest entries.
+type RuleVersion struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Parent rule information
+ ParentRuleKind string `protobuf:"bytes,1,opt,name=parent_rule_kind,json=parentRuleKind,proto3" json:"parent_rule_kind,omitempty"` // e.g., "ConditionRoute"
+ ParentRuleMesh string `protobuf:"bytes,2,opt,name=parent_rule_mesh,json=parentRuleMesh,proto3" json:"parent_rule_mesh,omitempty"` // Mesh name
+ ParentRuleName string `protobuf:"bytes,3,opt,name=parent_rule_name,json=parentRuleName,proto3" json:"parent_rule_name,omitempty"` // Rule name
+ // Version metadata
+ VersionNo int64 `protobuf:"varint,4,opt,name=version_no,json=versionNo,proto3" json:"version_no,omitempty"` // Sequential version number (1, 2, 3...)
+ ContentHash string `protobuf:"bytes,5,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"` // SHA256 of normalized spec JSON
+ // Spec snapshot
+ SpecJson string `protobuf:"bytes,6,opt,name=spec_json,json=specJson,proto3" json:"spec_json,omitempty"` // JSON-serialized rule spec at this version
+ // Mutation context
+ Operation string `protobuf:"bytes,7,opt,name=operation,proto3" json:"operation,omitempty"` // CREATE, UPDATE, DELETE
+ Source string `protobuf:"bytes,8,opt,name=source,proto3" json:"source,omitempty"` // ADMIN, BOOTSTRAP, ROLLBACK
+ Author string `protobuf:"bytes,9,opt,name=author,proto3" json:"author,omitempty"` // User or system identifier
+ Reason string `protobuf:"bytes,10,opt,name=reason,proto3" json:"reason,omitempty"` // Change description
+ // rolled_back_from_version_no records the historical version whose snapshot was
+ // re-published to produce this version. It is audit metadata only and MUST NOT
+ // be used as a live-state pointer.
+ RolledBackFromVersionNo int64 `protobuf:"varint,11,opt,name=rolled_back_from_version_no,json=rolledBackFromVersionNo,proto3" json:"rolled_back_from_version_no,omitempty"`
+ // Timestamps
+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
+ RecordedAt *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RuleVersion) Reset() {
+ *x = RuleVersion{}
+ mi := &file_api_mesh_v1alpha1_rule_version_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RuleVersion) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RuleVersion) ProtoMessage() {}
+
+func (x *RuleVersion) ProtoReflect() protoreflect.Message {
+ mi := &file_api_mesh_v1alpha1_rule_version_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RuleVersion.ProtoReflect.Descriptor instead.
+func (*RuleVersion) Descriptor() ([]byte, []int) {
+ return file_api_mesh_v1alpha1_rule_version_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *RuleVersion) GetParentRuleKind() string {
+ if x != nil {
+ return x.ParentRuleKind
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetParentRuleMesh() string {
+ if x != nil {
+ return x.ParentRuleMesh
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetParentRuleName() string {
+ if x != nil {
+ return x.ParentRuleName
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetVersionNo() int64 {
+ if x != nil {
+ return x.VersionNo
+ }
+ return 0
+}
+
+func (x *RuleVersion) GetContentHash() string {
+ if x != nil {
+ return x.ContentHash
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetSpecJson() string {
+ if x != nil {
+ return x.SpecJson
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetOperation() string {
+ if x != nil {
+ return x.Operation
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetSource() string {
+ if x != nil {
+ return x.Source
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetAuthor() string {
+ if x != nil {
+ return x.Author
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
+func (x *RuleVersion) GetRolledBackFromVersionNo() int64 {
+ if x != nil {
+ return x.RolledBackFromVersionNo
+ }
+ return 0
+}
+
+func (x *RuleVersion) GetCreatedAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.CreatedAt
+ }
+ return nil
+}
+
+func (x *RuleVersion) GetRecordedAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.RecordedAt
+ }
+ return nil
+}
+
+var File_api_mesh_v1alpha1_rule_version_proto protoreflect.FileDescriptor
+
+const file_api_mesh_v1alpha1_rule_version_proto_rawDesc = "" +
+ "\n" +
+ "$api/mesh/v1alpha1/rule_version.proto\x12\x13dubbo.mesh.v1alpha1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x86\x04\n" +
+ "\vRuleVersion\x12(\n" +
+ "\x10parent_rule_kind\x18\x01 \x01(\tR\x0eparentRuleKind\x12(\n" +
+ "\x10parent_rule_mesh\x18\x02 \x01(\tR\x0eparentRuleMesh\x12(\n" +
+ "\x10parent_rule_name\x18\x03 \x01(\tR\x0eparentRuleName\x12\x1d\n" +
+ "\n" +
+ "version_no\x18\x04 \x01(\x03R\tversionNo\x12!\n" +
+ "\fcontent_hash\x18\x05 \x01(\tR\vcontentHash\x12\x1b\n" +
+ "\tspec_json\x18\x06 \x01(\tR\bspecJson\x12\x1c\n" +
+ "\toperation\x18\a \x01(\tR\toperation\x12\x16\n" +
+ "\x06source\x18\b \x01(\tR\x06source\x12\x16\n" +
+ "\x06author\x18\t \x01(\tR\x06author\x12\x16\n" +
+ "\x06reason\x18\n" +
+ " \x01(\tR\x06reason\x12<\n" +
+ "\x1brolled_back_from_version_no\x18\v \x01(\x03R\x17rolledBackFromVersionNo\x129\n" +
+ "\n" +
+ "created_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12;\n" +
+ "\vrecorded_at\x18\r \x01(\v2\x1a.google.protobuf.TimestampR\n" +
+ "recordedAtB1Z/github.com/apache/dubbo-admin/api/mesh/v1alpha1b\x06proto3"
+
+var (
+ file_api_mesh_v1alpha1_rule_version_proto_rawDescOnce sync.Once
+ file_api_mesh_v1alpha1_rule_version_proto_rawDescData []byte
+)
+
+func file_api_mesh_v1alpha1_rule_version_proto_rawDescGZIP() []byte {
+ file_api_mesh_v1alpha1_rule_version_proto_rawDescOnce.Do(func() {
+ file_api_mesh_v1alpha1_rule_version_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_mesh_v1alpha1_rule_version_proto_rawDesc), len(file_api_mesh_v1alpha1_rule_version_proto_rawDesc)))
+ })
+ return file_api_mesh_v1alpha1_rule_version_proto_rawDescData
+}
+
+var file_api_mesh_v1alpha1_rule_version_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_api_mesh_v1alpha1_rule_version_proto_goTypes = []any{
+ (*RuleVersion)(nil), // 0: dubbo.mesh.v1alpha1.RuleVersion
+ (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp
+}
+var file_api_mesh_v1alpha1_rule_version_proto_depIdxs = []int32{
+ 1, // 0: dubbo.mesh.v1alpha1.RuleVersion.created_at:type_name -> google.protobuf.Timestamp
+ 1, // 1: dubbo.mesh.v1alpha1.RuleVersion.recorded_at:type_name -> google.protobuf.Timestamp
+ 2, // [2:2] is the sub-list for method output_type
+ 2, // [2:2] is the sub-list for method input_type
+ 2, // [2:2] is the sub-list for extension type_name
+ 2, // [2:2] is the sub-list for extension extendee
+ 0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_api_mesh_v1alpha1_rule_version_proto_init() }
+func file_api_mesh_v1alpha1_rule_version_proto_init() {
+ if File_api_mesh_v1alpha1_rule_version_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_mesh_v1alpha1_rule_version_proto_rawDesc), len(file_api_mesh_v1alpha1_rule_version_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 1,
+ NumExtensions: 0,
+ NumServices: 0,
+ },
+ GoTypes: file_api_mesh_v1alpha1_rule_version_proto_goTypes,
+ DependencyIndexes: file_api_mesh_v1alpha1_rule_version_proto_depIdxs,
+ MessageInfos: file_api_mesh_v1alpha1_rule_version_proto_msgTypes,
+ }.Build()
+ File_api_mesh_v1alpha1_rule_version_proto = out.File
+ file_api_mesh_v1alpha1_rule_version_proto_goTypes = nil
+ file_api_mesh_v1alpha1_rule_version_proto_depIdxs = nil
+}
diff --git a/api/mesh/v1alpha1/rule_version.proto b/api/mesh/v1alpha1/rule_version.proto
new file mode 100644
index 0000000..ce446cd
--- /dev/null
+++ b/api/mesh/v1alpha1/rule_version.proto
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+syntax = "proto3";
+
+package dubbo.mesh.v1alpha1;
+
+option go_package = "github.com/apache/dubbo-admin/api/mesh/v1alpha1";
+
+import "google/protobuf/timestamp.proto";
+
+// RuleVersion is a traffic-rule version entry. Entries are immutable after
+// creation. Rollback appends a new RuleVersion, while retention may delete the
+// oldest entries.
+message RuleVersion {
+ // Parent rule information
+ string parent_rule_kind = 1; // e.g., "ConditionRoute"
+ string parent_rule_mesh = 2; // Mesh name
+ string parent_rule_name = 3; // Rule name
+
+ // Version metadata
+ int64 version_no = 4; // Sequential version number (1, 2, 3...)
+ string content_hash = 5; // SHA256 of normalized spec JSON
+
+ // Spec snapshot
+ string spec_json = 6; // JSON-serialized rule spec at this version
+
+ // Mutation context
+ string operation = 7; // CREATE, UPDATE, DELETE
+ string source = 8; // ADMIN, BOOTSTRAP, ROLLBACK
+ string author = 9; // User or system identifier
+ string reason = 10; // Change description
+
+ // rolled_back_from_version_no records the historical version whose snapshot was
+ // re-published to produce this version. It is audit metadata only and MUST NOT
+ // be used as a live-state pointer.
+ int64 rolled_back_from_version_no = 11;
+
+ // Timestamps
+ google.protobuf.Timestamp created_at = 12;
+ google.protobuf.Timestamp recorded_at = 13;
+}
diff --git a/app/dubbo-admin/dubbo-admin.yaml b/app/dubbo-admin/dubbo-admin.yaml
index 1e74b47..a958833 100644
--- a/app/dubbo-admin/dubbo-admin.yaml
+++ b/app/dubbo-admin/dubbo-admin.yaml
@@ -63,6 +63,13 @@
# type: mysql
# address: root:123456@tcp(127.0.0.1:23306)/dubbo-admin?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai
+# [Optional] version history and rollback for traffic rules.
+# This records RuleVersion audit history. Supported governance rule mutations
+# fail closed if the version ledger is unavailable or cannot record the
+# mutation. Set maxVersionsPerRule to 0 to disable retention trimming.
+ruleVersioning:
+ maxVersionsPerRule: 20
+
# [Necessary] configs for service discovery
discovery:
# [Necessary] discovery type, options are nacos2, zookeeper, mock(only for dev)
diff --git a/docs/server-develop.md b/docs/server-develop.md
index 3109057..0c71ef6 100644
--- a/docs/server-develop.md
+++ b/docs/server-develop.md
@@ -43,8 +43,17 @@
2. Fill the block with the config that screenshot shows below:

3. Modify the config file(app/dubbo-admin/dubbo-admin.yaml), make sure that the discovery, engine, store is configured.
+ Traffic-rule version history records RuleVersion audit entries for history, diff, and rollback material. Supported governance rule mutations fail closed if the version ledger is unavailable or cannot record the mutation.
4. Run the application, you can open the browser and visit localhost:8888/admin if everything works.
+### Traffic-rule versioning notes
+
+Traffic-rule versioning is always enabled for supported governance rule mutations. It uses the existing `RuleVersion` resource store to create a baseline/import entry for existing `ConditionRoute`, `TagRoute`, and `DynamicConfig` rules that have no history yet. Bootstrap is not reconciliation and does not record external registry changes after history exists.
+
+Version entries are immutable after creation. Create, update, delete, and rollback first record the corresponding ledger entry and then use the normal ResourceManager write path. If the version ledger cannot record the entry, the registry mutation is rejected. DELETE versions are absence markers kept for audit/history continuity and cannot be used as rollback targets. `maxVersionsPerRule` retention may physically delete the oldest entries, so this is a bounded audit history and rollback aid, not a permanent compliance audit log or source of truth for current rule state.
+
+The traffic-form field preservation fix stays with this versioning PR because version history smoke tests depend on round-tripping `priority`, `force`, and `configVersion` without losing fields. These notes document the review boundary instead of splitting the PR.
+
### Project catalog
We are currently restructuring the entire project, so the directory structure of the project will be changed in the near future.
diff --git a/pkg/config/app/admin.go b/pkg/config/app/admin.go
index 71cf3f9..c2c05f2 100644
--- a/pkg/config/app/admin.go
+++ b/pkg/config/app/admin.go
@@ -31,6 +31,7 @@
"github.com/apache/dubbo-admin/pkg/config/log"
"github.com/apache/dubbo-admin/pkg/config/observability"
"github.com/apache/dubbo-admin/pkg/config/store"
+ "github.com/apache/dubbo-admin/pkg/config/versioning"
)
type AdminConfig struct {
@@ -51,6 +52,9 @@
Engine *engine.Config `json:"engine" yaml:"engine"`
// EventBus configuration
EventBus *eventbus.Config `json:"eventBus,omitempty" yaml:"eventBus,omitempty"`
+ // RuleVersioning records lightweight audit history for governor-managed traffic rules.
+ // Live rule state remains in ResourceManager/registry.
+ RuleVersioning *versioning.Config `json:"ruleVersioning,omitempty" yaml:"ruleVersioning,omitempty"`
}
var _ = &AdminConfig{}
@@ -58,17 +62,18 @@
var DefaultAdminConfig = func() AdminConfig {
eventBusCfg := eventbus.Default()
return AdminConfig{
- Log: log.DefaultLogConfig(),
- Store: store.DefaultStoreConfig(),
- Engine: engine.DefaultResourceEngineConfig(),
- Observability: observability.DefaultObservabilityConfig(),
- Diagnostics: diagnostics.DefaultDiagnosticsConfig(),
- Console: console.DefaultConsoleConfig(),
- EventBus: &eventBusCfg,
+ Log: log.DefaultLogConfig(),
+ Store: store.DefaultStoreConfig(),
+ Engine: engine.DefaultResourceEngineConfig(),
+ Observability: observability.DefaultObservabilityConfig(),
+ Diagnostics: diagnostics.DefaultDiagnosticsConfig(),
+ Console: console.DefaultConsoleConfig(),
+ EventBus: &eventBusCfg,
+ RuleVersioning: versioning.Default(),
}
}
-func (c AdminConfig) Sanitize() {
+func (c *AdminConfig) Sanitize() {
c.Engine.Sanitize()
for _, d := range c.Discovery {
d.Sanitize()
@@ -78,9 +83,13 @@
c.Observability.Sanitize()
c.Diagnostics.Sanitize()
c.Log.Sanitize()
+ if c.RuleVersioning == nil {
+ c.RuleVersioning = versioning.Default()
+ }
+ c.RuleVersioning.Sanitize()
}
-func (c AdminConfig) PreProcess() error {
+func (c *AdminConfig) PreProcess() error {
discoveryPreProcess := func() error {
for _, d := range c.Discovery {
if err := d.PreProcess(); err != nil {
@@ -89,6 +98,9 @@
}
return nil
}
+ if c.RuleVersioning == nil {
+ c.RuleVersioning = versioning.Default()
+ }
return multierr.Combine(
c.Engine.PreProcess(),
discoveryPreProcess(),
@@ -97,10 +109,11 @@
c.Observability.PreProcess(),
c.Diagnostics.PreProcess(),
c.Log.PreProcess(),
+ c.RuleVersioning.PreProcess(),
)
}
-func (c AdminConfig) PostProcess() error {
+func (c *AdminConfig) PostProcess() error {
discoveryPostProcess := func() error {
for _, d := range c.Discovery {
if err := d.PostProcess(); err != nil {
@@ -109,6 +122,9 @@
}
return nil
}
+ if c.RuleVersioning == nil {
+ c.RuleVersioning = versioning.Default()
+ }
return multierr.Combine(
c.Engine.PostProcess(),
discoveryPostProcess(),
@@ -117,10 +133,11 @@
c.Observability.PostProcess(),
c.Diagnostics.PostProcess(),
c.Log.PostProcess(),
+ c.RuleVersioning.PostProcess(),
)
}
-func (c AdminConfig) Validate() error {
+func (c *AdminConfig) Validate() error {
if c.Log == nil {
c.Log = log.DefaultLogConfig()
} else if err := c.Log.Validate(); err != nil {
@@ -171,11 +188,16 @@
} else if err := c.EventBus.Validate(); err != nil {
return bizerror.Wrap(err, bizerror.ConfigError, "event bus config validation failed")
}
+ if c.RuleVersioning == nil {
+ c.RuleVersioning = versioning.Default()
+ } else if err := c.RuleVersioning.Validate(); err != nil {
+ return bizerror.Wrap(err, bizerror.ConfigError, "versioning config validation failed")
+ }
return nil
}
// FindDiscovery finds the DiscoveryConfig by id, returns nil if not found
-func (c AdminConfig) FindDiscovery(id string) *discovery.Config {
+func (c *AdminConfig) FindDiscovery(id string) *discovery.Config {
for _, d := range c.Discovery {
if d.ID == id {
return d
@@ -185,7 +207,7 @@
}
// Meshes return the mesh id list of discoveries
-func (c AdminConfig) Meshes() []string {
+func (c *AdminConfig) Meshes() []string {
return slice.Map(c.Discovery, func(index int, item *discovery.Config) string {
return item.ID
})
diff --git a/pkg/config/app/admin_test.go b/pkg/config/app/admin_test.go
new file mode 100644
index 0000000..ceaf84d
--- /dev/null
+++ b/pkg/config/app/admin_test.go
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package app
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/dubbo-admin/pkg/config/versioning"
+)
+
+func TestAdminConfigSanitizeRetainsDefaultRuleVersioning(t *testing.T) {
+ cfg := DefaultAdminConfig()
+ cfg.RuleVersioning = nil
+
+ cfg.Sanitize()
+
+ require.NotNil(t, cfg.RuleVersioning)
+ assert.Equal(t, versioning.DefaultMaxVersionsPerRule, cfg.RuleVersioning.MaxVersionsPerRule)
+}
diff --git a/pkg/config/versioning/config.go b/pkg/config/versioning/config.go
new file mode 100644
index 0000000..0a112f5
--- /dev/null
+++ b/pkg/config/versioning/config.go
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package versioning
+
+import (
+ "encoding/json"
+
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ "github.com/apache/dubbo-admin/pkg/config"
+)
+
+const (
+ // DefaultMaxVersionsPerRule is the retention window used when configuration
+ // omits maxVersionsPerRule or provides a negative value.
+ DefaultMaxVersionsPerRule = int64(20)
+)
+
+// Config controls RuleVersion audit-history retention. Versioning is always on
+// for supported governance rule mutations; a zero MaxVersionsPerRule disables
+// cleanup only, not history recording.
+type Config struct {
+ config.BaseConfig
+ MaxVersionsPerRule int64 `json:"maxVersionsPerRule" yaml:"maxVersionsPerRule"`
+}
+
+func (c *Config) UnmarshalJSON(data []byte) error {
+ type config Config
+ defaults := Default()
+ *c = *defaults
+ return json.Unmarshal(data, (*config)(c))
+}
+
+// Default returns rule-history configuration with bounded retention enabled.
+func Default() *Config {
+ return &Config{
+ MaxVersionsPerRule: DefaultMaxVersionsPerRule,
+ }
+}
+
+// Sanitize normalizes invalid retention values to the default window.
+func (c *Config) Sanitize() {
+ if c.MaxVersionsPerRule < 0 {
+ c.MaxVersionsPerRule = DefaultMaxVersionsPerRule
+ }
+}
+
+// Validate rejects negative retention values before startup.
+func (c *Config) Validate() error {
+ if c.MaxVersionsPerRule < 0 {
+ return bizerror.New(bizerror.ConfigError, "ruleVersioning.maxVersionsPerRule must be greater than or equal to 0")
+ }
+ return nil
+}
diff --git a/pkg/console/context/context.go b/pkg/console/context/context.go
index f4c64ce..496011d 100644
--- a/pkg/console/context/context.go
+++ b/pkg/console/context/context.go
@@ -25,6 +25,7 @@
"github.com/apache/dubbo-admin/pkg/console/counter"
"github.com/apache/dubbo-admin/pkg/core/manager"
"github.com/apache/dubbo-admin/pkg/core/runtime"
+ "github.com/apache/dubbo-admin/pkg/core/versioning"
)
type Context interface {
@@ -35,6 +36,7 @@
AppContext() ctx.Context
LockManager() lock.Lock
+ RuleVersioning() *versioning.Service
}
var _ Context = &context{}
@@ -81,3 +83,15 @@
}
return distributedLock
}
+
+func (c *context) RuleVersioning() *versioning.Service {
+ comp, err := c.coreRt.GetComponent(versioning.ComponentType)
+ if err != nil {
+ return nil
+ }
+ versioningComp, ok := comp.(versioning.Component)
+ if !ok {
+ return nil
+ }
+ return versioningComp.Service()
+}
diff --git a/pkg/console/handler/condition_rule.go b/pkg/console/handler/condition_rule.go
index 653c12e..38309a3 100644
--- a/pkg/console/handler/condition_rule.go
+++ b/pkg/console/handler/condition_rule.go
@@ -94,8 +94,8 @@
util.HandleArgumentError(c, err)
return
}
-
- if err := service.UpdateConditionRule(cs, res); err != nil {
+ opts := mutationOptions(c)
+ if err := service.UpdateConditionRuleWithOptions(cs, res, opts); err != nil {
util.HandleServiceError(c, err)
return
} else {
@@ -118,8 +118,8 @@
util.HandleArgumentError(c, err)
return
}
-
- if err := service.CreateConditionRule(cs, res); err != nil {
+ opts := mutationOptions(c)
+ if err := service.CreateConditionRuleWithOptions(cs, res, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
} else {
@@ -137,7 +137,8 @@
fmt.Sprintf("ruleName must end with %s", constants.ConditionRuleDotSuffix))))
return
}
- if err := service.DeleteConditionRule(cs, ruleName, mesh); err != nil {
+ opts := mutationOptions(c)
+ if err := service.DeleteConditionRuleWithOptions(cs, ruleName, mesh, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
}
diff --git a/pkg/console/handler/configurator_rule.go b/pkg/console/handler/configurator_rule.go
index 0b80671..0323eaa 100644
--- a/pkg/console/handler/configurator_rule.go
+++ b/pkg/console/handler/configurator_rule.go
@@ -105,7 +105,8 @@
c.JSON(http.StatusOK, model.NewBizErrorResp(
bizerror.New(bizerror.NotFoundError, fmt.Sprintf("%s not found", ruleName))))
}
- if err = service.UpdateConfigurator(ctx, res); err != nil {
+ opts := mutationOptions(c)
+ if err = service.UpdateConfiguratorWithOptions(ctx, res, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
}
@@ -128,7 +129,8 @@
util.HandleArgumentError(c, err)
return
}
- if err = service.CreateConfigurator(ctx, res); err != nil {
+ opts := mutationOptions(c)
+ if err = service.CreateConfiguratorWithOptions(ctx, res, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
}
@@ -146,7 +148,8 @@
fmt.Sprintf("dynamic config name must end with %s", constants.ConfiguratorRuleDotSuffix))))
return
}
- if err := service.DeleteConfigurator(ctx, ruleName, mesh); err != nil {
+ opts := mutationOptions(c)
+ if err := service.DeleteConfiguratorWithOptions(ctx, ruleName, mesh, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
}
diff --git a/pkg/console/handler/overview.go b/pkg/console/handler/overview.go
index 1d9dc97..5f2e60f 100644
--- a/pkg/console/handler/overview.go
+++ b/pkg/console/handler/overview.go
@@ -40,18 +40,19 @@
var registryAddr string
var metadataAddr string
var configAddr string
- if d := ctx.Config().FindDiscovery(mesh); d != nil {
+ cfg := ctx.Config()
+ if d := cfg.FindDiscovery(mesh); d != nil {
registryAddr = d.Address.Registry
metadataAddr = d.Address.MetadataReport
configAddr = d.Address.ConfigCenter
}
var prometheusURL string
var grafanaURL string
- if ctx.Config().Observability.PrometheusBaseURL != nil {
- prometheusURL = ctx.Config().Observability.PrometheusBaseURL.String()
+ if cfg.Observability.PrometheusBaseURL != nil {
+ prometheusURL = cfg.Observability.PrometheusBaseURL.String()
}
- if ctx.Config().Observability.GrafanaBaseURL != nil {
- grafanaURL = ctx.Config().Observability.GrafanaBaseURL.String()
+ if cfg.Observability.GrafanaBaseURL != nil {
+ grafanaURL = cfg.Observability.GrafanaBaseURL.String()
}
metadata := model.AdminMetadata{
Registry: registryAddr,
diff --git a/pkg/console/handler/rule_version.go b/pkg/console/handler/rule_version.go
new file mode 100644
index 0000000..711a5e2
--- /dev/null
+++ b/pkg/console/handler/rule_version.go
@@ -0,0 +1,255 @@
+/*
+ * 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 handler
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+ "github.com/apache/dubbo-admin/pkg/console/model"
+ "github.com/apache/dubbo-admin/pkg/console/service"
+ "github.com/apache/dubbo-admin/pkg/console/util"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/versioning"
+)
+
+const maxRuleVersionReasonLength = 1024
+
+func ListRuleVersions(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !ensureVersioningAvailable(c, cs) {
+ return
+ }
+ resp, err := service.ListRuleVersions(cs, ruleRef(c, kind))
+ if writeVersioningError(c, err) {
+ return
+ }
+ c.JSON(http.StatusOK, model.NewSuccessResp(toRuleVersionListResp(resp)))
+ }
+}
+
+func GetRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !ensureVersioningAvailable(c, cs) {
+ return
+ }
+ versionNo, ok := parseVersionNo(c)
+ if !ok {
+ return
+ }
+ resp, err := service.GetRuleVersion(cs, ruleRef(c, kind), versionNo)
+ if writeVersioningError(c, err) {
+ return
+ }
+ c.JSON(http.StatusOK, model.NewSuccessResp(toRuleVersionResp(resp)))
+ }
+}
+
+func DiffRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !ensureVersioningAvailable(c, cs) {
+ return
+ }
+ versionNo, ok := parseVersionNo(c)
+ if !ok {
+ return
+ }
+ resp, err := service.DiffRuleVersion(cs, ruleRef(c, kind), versionNo, c.Query("against"))
+ if writeVersioningError(c, err) {
+ return
+ }
+ c.JSON(http.StatusOK, model.NewSuccessResp(toRuleVersionDiffResp(resp)))
+ }
+}
+
+func RollbackRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !ensureVersioningAvailable(c, cs) {
+ return
+ }
+ versionNo, ok := parseVersionNo(c)
+ if !ok {
+ return
+ }
+ req := model.RollbackRuleVersionReq{}
+ if err := c.ShouldBindJSON(&req); err != nil {
+ writeVersioningInvalidArgument(c, err.Error())
+ return
+ }
+ if !validateRuleVersionReasonLength(c, req.Reason) {
+ return
+ }
+ resp, err := service.RollbackRuleVersion(cs, ruleRef(c, kind), versionNo, req.Reason, currentUser(c))
+ if writeVersioningError(c, err) {
+ return
+ }
+ c.JSON(http.StatusOK, model.NewSuccessResp(toRollbackRuleVersionResp(resp)))
+ }
+}
+
+func validateRuleVersionReasonLength(c *gin.Context, reason string) bool {
+ if len(strings.TrimSpace(reason)) <= maxRuleVersionReasonLength {
+ return true
+ }
+ writeVersioningInvalidArgument(c, "reason must be at most 1024 characters")
+ return false
+}
+
+func mutationOptions(c *gin.Context) service.RuleMutationOptions {
+ return service.RuleMutationOptions{Author: currentUser(c)}
+}
+
+func ruleRef(c *gin.Context, kind coremodel.ResourceKind) service.RuleRef {
+ return service.RuleRef{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}
+}
+
+func parseVersionNo(c *gin.Context) (int64, bool) {
+ versionNo, err := parsePositiveInt64(c.Param("versionNo"))
+ if err != nil {
+ writeVersioningInvalidArgument(c, "versionNo must be a positive decimal string")
+ return 0, false
+ }
+ return versionNo, true
+}
+
+func parsePositiveInt64(raw string) (int64, error) {
+ if raw == "" {
+ return 0, fmt.Errorf("empty value")
+ }
+ for i := range raw {
+ if raw[i] < '0' || raw[i] > '9' {
+ return 0, fmt.Errorf("invalid decimal value")
+ }
+ }
+ if len(raw) > 1 && raw[0] == '0' {
+ return 0, fmt.Errorf("invalid leading zero")
+ }
+ value, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ if value <= 0 {
+ return 0, fmt.Errorf("value must be positive")
+ }
+ return value, nil
+}
+
+func currentUser(c *gin.Context) string {
+ session := sessions.Default(c)
+ if user, ok := session.Get("user").(string); ok && strings.TrimSpace(user) != "" {
+ return user
+ }
+ return "system:unknown"
+}
+
+func ensureVersioningAvailable(c *gin.Context, cs consolectx.Context) bool {
+ if cs.RuleVersioning() != nil {
+ return true
+ }
+ util.HandleServiceError(c, bizerror.New(bizerror.InternalError, "rule history service is unavailable"))
+ return false
+}
+
+func writeVersioningError(c *gin.Context, err error) bool {
+ if err == nil {
+ return false
+ }
+ util.HandleServiceError(c, versioningServiceError(err))
+ return true
+}
+
+func versioningServiceError(err error) error {
+ var bizErr bizerror.Error
+ switch {
+ case errors.Is(err, versioning.ErrVersionNotFound):
+ return bizerror.New(bizerror.NotFoundError, err.Error())
+ case errors.Is(err, versioning.ErrRollbackToDelete), errors.Is(err, versioning.ErrRollbackToCurrent):
+ return bizerror.New(bizerror.InvalidArgument, err.Error())
+ case errors.As(err, &bizErr):
+ return bizErr
+ default:
+ return err
+ }
+}
+
+func toRuleVersionResp(version *versioning.Version) model.RuleVersionResp {
+ return model.RuleVersionResp{
+ RuleKind: version.RuleKind,
+ Mesh: version.Mesh,
+ ResourceKey: version.ResourceKey,
+ RuleName: version.RuleName,
+ VersionNo: version.VersionNo,
+ ContentHash: version.ContentHash,
+ SpecJSON: version.SpecJSON,
+ Source: version.Source,
+ Operation: version.Operation,
+ Author: version.Author,
+ Reason: version.Reason,
+ RolledBackFromVersionNo: version.RolledBackFromVersionNo,
+ CreatedAt: version.CreatedAt,
+ RecordedAt: version.RecordedAt,
+ IsLatestRecorded: version.IsLatestRecorded,
+ }
+}
+
+func toRuleVersionListResp(result *versioning.ListResult) model.RuleVersionListResp {
+ items := make([]model.RuleVersionResp, 0, len(result.Items))
+ for i := range result.Items {
+ items = append(items, toRuleVersionResp(&result.Items[i]))
+ }
+ return model.RuleVersionListResp{
+ Items: items,
+ Total: result.Total,
+ LatestRecordedVersionNo: result.LatestRecordedVersionNo,
+ LatestRecordedDeleted: result.LatestRecordedDeleted,
+ }
+}
+
+func toRuleVersionDiffResp(result *versioning.DiffResult) model.RuleVersionDiffResp {
+ return model.RuleVersionDiffResp{
+ Left: toRuleVersionDiffSideResp(result.Left),
+ Right: toRuleVersionDiffSideResp(result.Right),
+ }
+}
+
+func toRuleVersionDiffSideResp(side versioning.DiffSide) model.RuleVersionDiffSideResp {
+ return model.RuleVersionDiffSideResp{
+ VersionNo: side.VersionNo,
+ SpecJSON: side.SpecJSON,
+ }
+}
+
+func toRollbackRuleVersionResp(result *service.RollbackResult) model.RollbackRuleVersionResp {
+ return model.RollbackRuleVersionResp{
+ RolledBackFromVersionNo: result.RolledBackFromVersionNo,
+ VersionNo: result.VersionNo,
+ Source: result.Source,
+ }
+}
+
+func writeVersioningInvalidArgument(c *gin.Context, message string) {
+ c.JSON(http.StatusBadRequest, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, message)))
+}
diff --git a/pkg/console/handler/rule_version_test.go b/pkg/console/handler/rule_version_test.go
new file mode 100644
index 0000000..648d7f2
--- /dev/null
+++ b/pkg/console/handler/rule_version_test.go
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ appcfg "github.com/apache/dubbo-admin/pkg/config/app"
+ "github.com/apache/dubbo-admin/pkg/console/counter"
+ "github.com/apache/dubbo-admin/pkg/console/model"
+ "github.com/apache/dubbo-admin/pkg/core/lock"
+ "github.com/apache/dubbo-admin/pkg/core/manager"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/versioning"
+)
+
+type ruleVersionHandlerTestContext struct {
+ versioningSvc *versioning.Service
+}
+
+func (ruleVersionHandlerTestContext) ResourceManager() manager.ResourceManager { return nil }
+func (ruleVersionHandlerTestContext) CounterManager() counter.CounterManager { return nil }
+func (ruleVersionHandlerTestContext) Config() appcfg.AdminConfig { return appcfg.AdminConfig{} }
+func (ruleVersionHandlerTestContext) AppContext() context.Context { return context.Background() }
+func (ruleVersionHandlerTestContext) LockManager() lock.Lock { return nil }
+func (c ruleVersionHandlerTestContext) RuleVersioning() *versioning.Service { return c.versioningSvc }
+
+func testGinContext() (*gin.Context, *httptest.ResponseRecorder) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ return ctx, recorder
+}
+
+func TestRollbackRuleVersionRejectsMalformedJSONWithBadRequest(t *testing.T) {
+ ctx, recorder := testGinContext()
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/versions/1/rollback", strings.NewReader("{"))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+ ctx.Params = gin.Params{
+ {Key: "ruleName", Value: "demo-rule"},
+ {Key: "versionNo", Value: "1"},
+ }
+ handler := RollbackRuleVersion(
+ ruleVersionHandlerTestContext{versioningSvc: versioning.NewService(0, nil)},
+ coremodel.ResourceKind("ConditionRoute"),
+ )
+
+ handler(ctx)
+
+ assert.Equal(t, http.StatusBadRequest, recorder.Code)
+ resp := decodeCommonResp(t, recorder)
+ assert.Equal(t, string(bizerror.InvalidArgument), resp.Code)
+}
+
+func decodeCommonResp(t *testing.T, recorder *httptest.ResponseRecorder) model.CommonResp {
+ t.Helper()
+ var resp model.CommonResp
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
+ return resp
+}
+
+func TestEnsureVersioningAvailableUsesCommonRespContract(t *testing.T) {
+ ctx, recorder := testGinContext()
+
+ ok := ensureVersioningAvailable(ctx, ruleVersionHandlerTestContext{})
+
+ require.False(t, ok)
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ resp := decodeCommonResp(t, recorder)
+ assert.Equal(t, string(bizerror.InternalError), resp.Code)
+ assert.Equal(t, "rule history service is unavailable", resp.Message)
+}
+
+func TestWriteVersioningRespMapsBusinessErrorsToCommonResp(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ code string
+ }{
+ {
+ name: "version not found",
+ err: versioning.ErrVersionNotFound,
+ code: string(bizerror.NotFoundError),
+ },
+ {
+ name: "rollback to current",
+ err: versioning.ErrRollbackToCurrent,
+ code: string(bizerror.InvalidArgument),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, recorder := testGinContext()
+
+ assert.True(t, writeVersioningError(ctx, tt.err))
+
+ assert.Equal(t, http.StatusOK, recorder.Code)
+ resp := decodeCommonResp(t, recorder)
+ assert.Equal(t, tt.code, resp.Code)
+ assert.Equal(t, tt.err.Error(), resp.Message)
+ })
+ }
+}
diff --git a/pkg/console/handler/tag_rule.go b/pkg/console/handler/tag_rule.go
index a6fe363..d07ad2c 100644
--- a/pkg/console/handler/tag_rule.go
+++ b/pkg/console/handler/tag_rule.go
@@ -103,7 +103,8 @@
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
}
- if err = service.UpdateTagRule(ctx, res); err != nil {
+ opts := mutationOptions(c)
+ if err = service.UpdateTagRuleWithOptions(ctx, res, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
} else {
@@ -127,7 +128,8 @@
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
}
- if err = service.CreateTagRule(ctx, res); err != nil {
+ opts := mutationOptions(c)
+ if err = service.CreateTagRuleWithOptions(ctx, res, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
} else {
@@ -145,7 +147,8 @@
c.JSON(http.StatusBadRequest, model.NewBizErrorResp(err))
return
}
- if err := service.DeleteTagRule(ctx, ruleName, mesh); err != nil {
+ opts := mutationOptions(c)
+ if err := service.DeleteTagRuleWithOptions(ctx, ruleName, mesh, opts); err != nil {
c.JSON(http.StatusOK, model.NewErrorResp(err.Error()))
return
}
diff --git a/pkg/console/model/condition_rule.go b/pkg/console/model/condition_rule.go
index 11b92fe..a0e479d 100644
--- a/pkg/console/model/condition_rule.go
+++ b/pkg/console/model/condition_rule.go
@@ -52,7 +52,9 @@
Conditions []string `json:"conditions"`
ConfigVersion string `json:"configVersion"`
Enabled bool `json:"enabled"`
+ Force bool `json:"force"`
Key string `json:"key"`
+ Priority int32 `json:"priority"`
Runtime bool `json:"runtime"`
Scope string `json:"scope"`
}
@@ -246,7 +248,9 @@
Conditions: data.Conditions,
ConfigVersion: data.ConfigVersion,
Enabled: data.Enabled,
+ Force: data.Force,
Key: data.Key,
+ Priority: data.Priority,
Runtime: data.Runtime,
Scope: data.Scope,
})
diff --git a/pkg/console/model/rule_version.go b/pkg/console/model/rule_version.go
new file mode 100644
index 0000000..00f6318
--- /dev/null
+++ b/pkg/console/model/rule_version.go
@@ -0,0 +1,70 @@
+/*
+ * 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 model
+
+import (
+ "time"
+
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/versioning"
+)
+
+type RollbackRuleVersionReq struct {
+ Reason string `json:"reason"`
+}
+
+type RuleVersionResp struct {
+ RuleKind coremodel.ResourceKind `json:"ruleKind"`
+ Mesh string `json:"mesh"`
+ ResourceKey string `json:"resourceKey"`
+ RuleName string `json:"ruleName"`
+ VersionNo int64 `json:"versionNo"`
+ ContentHash string `json:"contentHash"`
+ SpecJSON string `json:"specJson"`
+ Source versioning.Source `json:"source"`
+ Operation versioning.Operation `json:"operation"`
+ Author string `json:"author"`
+ Reason string `json:"reason,omitempty"`
+ RolledBackFromVersionNo *int64 `json:"rolledBackFromVersionNo,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ RecordedAt time.Time `json:"recordedAt"`
+ IsLatestRecorded bool `json:"isLatestRecorded"`
+}
+
+type RuleVersionListResp struct {
+ Items []RuleVersionResp `json:"items"`
+ Total int64 `json:"total"`
+ LatestRecordedVersionNo int64 `json:"latestRecordedVersionNo,omitempty"`
+ LatestRecordedDeleted bool `json:"latestRecordedDeleted"`
+}
+
+type RuleVersionDiffResp struct {
+ Left RuleVersionDiffSideResp `json:"left"`
+ Right RuleVersionDiffSideResp `json:"right"`
+}
+
+type RuleVersionDiffSideResp struct {
+ VersionNo int64 `json:"versionNo"`
+ SpecJSON string `json:"specJson"`
+}
+
+type RollbackRuleVersionResp struct {
+ RolledBackFromVersionNo int64 `json:"rolledBackFromVersionNo"`
+ VersionNo int64 `json:"versionNo"`
+ Source string `json:"source"`
+}
diff --git a/pkg/console/model/tag_rule.go b/pkg/console/model/tag_rule.go
index 9428771..b4d733f 100644
--- a/pkg/console/model/tag_rule.go
+++ b/pkg/console/model/tag_rule.go
@@ -31,7 +31,9 @@
type TagRuleResp struct {
ConfigVersion string `json:"configVersion"`
Enabled bool `json:"enabled"`
+ Force bool `json:"force"`
Key string `json:"key"`
+ Priority int32 `json:"priority"`
Runtime bool `json:"runtime"`
Scope string `json:"scope"`
Tags []RespTagElement `json:"tags"`
@@ -50,7 +52,9 @@
return NewSuccessResp(TagRuleResp{
ConfigVersion: pb.ConfigVersion,
Enabled: pb.Enabled,
+ Force: pb.Force,
Key: pb.Key,
+ Priority: pb.Priority,
Runtime: pb.Runtime,
Scope: constants.ScopeApplication,
Tags: tagToRespTagElement(pb.Tags),
diff --git a/pkg/console/router/router.go b/pkg/console/router/router.go
index 24cd7d4..85704c4 100644
--- a/pkg/console/router/router.go
+++ b/pkg/console/router/router.go
@@ -22,6 +22,7 @@
consolectx "github.com/apache/dubbo-admin/pkg/console/context"
"github.com/apache/dubbo-admin/pkg/console/handler"
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
)
func InitRouter(r *gin.Engine, ctx consolectx.Context) {
@@ -112,6 +113,10 @@
{
configuration := router.Group("/configurator")
configuration.GET("/search", handler.ConfiguratorSearch(ctx))
+ configuration.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.DynamicConfigKind))
+ configuration.GET("/:ruleName/versions/:versionNo", handler.GetRuleVersion(ctx, meshresource.DynamicConfigKind))
+ configuration.GET("/:ruleName/versions/:versionNo/diff", handler.DiffRuleVersion(ctx, meshresource.DynamicConfigKind))
+ configuration.POST("/:ruleName/versions/:versionNo/rollback", handler.RollbackRuleVersion(ctx, meshresource.DynamicConfigKind))
configuration.GET("/:ruleName", handler.GetConfiguratorWithRuleName(ctx))
configuration.PUT("/:ruleName", handler.PutConfiguratorWithRuleName(ctx))
configuration.POST("/:ruleName", handler.PostConfiguratorWithRuleName(ctx))
@@ -121,6 +126,10 @@
{
conditionRule := router.Group("/condition-rule")
conditionRule.GET("/search", handler.ConditionRuleSearch(ctx))
+ conditionRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.ConditionRouteKind))
+ conditionRule.GET("/:ruleName/versions/:versionNo", handler.GetRuleVersion(ctx, meshresource.ConditionRouteKind))
+ conditionRule.GET("/:ruleName/versions/:versionNo/diff", handler.DiffRuleVersion(ctx, meshresource.ConditionRouteKind))
+ conditionRule.POST("/:ruleName/versions/:versionNo/rollback", handler.RollbackRuleVersion(ctx, meshresource.ConditionRouteKind))
conditionRule.GET("/:ruleName", handler.GetConditionRuleWithRuleName(ctx))
conditionRule.PUT("/:ruleName", handler.PutConditionRuleWithRuleName(ctx))
conditionRule.POST("/:ruleName", handler.PostConditionRuleWithRuleName(ctx))
@@ -130,6 +139,10 @@
{
tagRule := router.Group("/tag-rule")
tagRule.GET("/search", handler.TagRuleSearch(ctx))
+ tagRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.TagRouteKind))
+ tagRule.GET("/:ruleName/versions/:versionNo", handler.GetRuleVersion(ctx, meshresource.TagRouteKind))
+ tagRule.GET("/:ruleName/versions/:versionNo/diff", handler.DiffRuleVersion(ctx, meshresource.TagRouteKind))
+ tagRule.POST("/:ruleName/versions/:versionNo/rollback", handler.RollbackRuleVersion(ctx, meshresource.TagRouteKind))
tagRule.GET("/:ruleName", handler.GetTagRuleWithRuleName(ctx))
tagRule.PUT("/:ruleName", handler.PutTagRuleWithRuleName(ctx))
tagRule.POST("/:ruleName", handler.PostTagRuleWithRuleName(ctx))
diff --git a/pkg/console/service/condition_rule.go b/pkg/console/service/condition_rule.go
index 9fae159..f69a63c 100644
--- a/pkg/console/service/condition_rule.go
+++ b/pkg/console/service/condition_rule.go
@@ -18,8 +18,6 @@
package service
import (
- "github.com/apache/dubbo-admin/pkg/common/constants"
- "github.com/apache/dubbo-admin/pkg/core/lock"
"github.com/duke-git/lancet/v2/slice"
"github.com/duke-git/lancet/v2/strutil"
@@ -108,18 +106,11 @@
}
func UpdateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return updateConditionRuleUnsafe(ctx, res)
- }
- lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name)
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return updateConditionRuleUnsafe(ctx, res)
- })
+ return UpdateConditionRuleWithOptions(ctx, res, RuleMutationOptions{})
}
-func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error {
- if err := ctx.ResourceManager().Update(res); err != nil {
+func UpdateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error {
+ if err := updateRule(ctx, res, opts); err != nil {
logger.Warnf("update %s condition failed with error: %s", res.Name, err.Error())
return err
}
@@ -127,18 +118,11 @@
}
func CreateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return createConditionRuleUnsafe(ctx, res)
- }
- lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name)
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return createConditionRuleUnsafe(ctx, res)
- })
+ return CreateConditionRuleWithOptions(ctx, res, RuleMutationOptions{})
}
-func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error {
- if err := ctx.ResourceManager().Add(res); err != nil {
+func CreateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error {
+ if err := createRule(ctx, res, opts); err != nil {
logger.Warnf("create %s condition failed with error: %s", res.Name, err.Error())
return err
}
@@ -146,18 +130,13 @@
}
func DeleteConditionRule(ctx context.Context, name string, mesh string) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return deleteConditionRuleUnsafe(ctx, name, mesh)
- }
- lockKey := lock.BuildConditionRuleLockKey(mesh, name)
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return deleteConditionRuleUnsafe(ctx, name, mesh)
- })
+ return DeleteConditionRuleWithOptions(ctx, name, mesh, RuleMutationOptions{})
}
-func deleteConditionRuleUnsafe(ctx context.Context, name string, mesh string) error {
- if err := ctx.ResourceManager().DeleteByKey(meshresource.ConditionRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil {
+func DeleteConditionRuleWithOptions(ctx context.Context, name string, mesh string, opts RuleMutationOptions) error {
+ ruleRef := RuleRef{Kind: meshresource.ConditionRouteKind, Mesh: mesh, Name: name}
+ if err := deleteRule(ctx, ruleRef, opts); err != nil {
+ logger.Warnf("delete %s condition failed with error: %s", name, err.Error())
return err
}
return nil
diff --git a/pkg/console/service/configurator_rule.go b/pkg/console/service/configurator_rule.go
index 13dd228..e7929e8 100644
--- a/pkg/console/service/configurator_rule.go
+++ b/pkg/console/service/configurator_rule.go
@@ -18,8 +18,6 @@
package service
import (
- "github.com/apache/dubbo-admin/pkg/common/constants"
- "github.com/apache/dubbo-admin/pkg/core/lock"
"github.com/duke-git/lancet/v2/slice"
"github.com/apache/dubbo-admin/pkg/common/bizerror"
@@ -116,18 +114,11 @@
}
func UpdateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return updateConfiguratorUnsafe(ctx, res)
- }
- lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name)
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return updateConfiguratorUnsafe(ctx, res)
- })
+ return UpdateConfiguratorWithOptions(ctx, res, RuleMutationOptions{})
}
-func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error {
- if err := ctx.ResourceManager().Update(res); err != nil {
+func UpdateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error {
+ if err := updateRule(ctx, res, opts); err != nil {
logger.Warnf("update %s configurator failed with error: %s", res.Name, err.Error())
return err
}
@@ -135,18 +126,11 @@
}
func CreateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return createConfiguratorUnsafe(ctx, res)
- }
- lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name)
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return createConfiguratorUnsafe(ctx, res)
- })
+ return CreateConfiguratorWithOptions(ctx, res, RuleMutationOptions{})
}
-func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error {
- if err := ctx.ResourceManager().Add(res); err != nil {
+func CreateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error {
+ if err := createRule(ctx, res, opts); err != nil {
logger.Warnf("create %s configurator failed with error: %s", res.Name, err.Error())
return err
}
@@ -154,18 +138,12 @@
}
func DeleteConfigurator(ctx consolectx.Context, name string, mesh string) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return deleteConfiguratorUnsafe(ctx, name, mesh)
- }
- lockKey := lock.BuildConfiguratorRuleLockKey(mesh, name)
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return deleteConfiguratorUnsafe(ctx, name, mesh)
- })
+ return DeleteConfiguratorWithOptions(ctx, name, mesh, RuleMutationOptions{})
}
-func deleteConfiguratorUnsafe(ctx consolectx.Context, name string, mesh string) error {
- if err := ctx.ResourceManager().DeleteByKey(meshresource.DynamicConfigKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil {
+func DeleteConfiguratorWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error {
+ ruleRef := RuleRef{Kind: meshresource.DynamicConfigKind, Mesh: mesh, Name: name}
+ if err := deleteRule(ctx, ruleRef, opts); err != nil {
logger.Warnf("delete %s configurator failed with error: %s", name, err.Error())
return err
}
diff --git a/pkg/console/service/rule_version.go b/pkg/console/service/rule_version.go
new file mode 100644
index 0000000..bbe5cf7
--- /dev/null
+++ b/pkg/console/service/rule_version.go
@@ -0,0 +1,308 @@
+/*
+ * 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 service
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ "github.com/apache/dubbo-admin/pkg/common/constants"
+ consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+ "github.com/apache/dubbo-admin/pkg/core/lock"
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/versioning"
+)
+
+type RuleMutationOptions struct {
+ Author string
+}
+
+type RuleRef struct {
+ Kind coremodel.ResourceKind
+ Mesh string
+ Name string
+}
+
+func ruleVersioning(ctx consolectx.Context) *versioning.Service {
+ if ctx == nil {
+ return nil
+ }
+ return ctx.RuleVersioning()
+}
+
+func requiredRuleVersioning(ctx consolectx.Context) (*versioning.Service, error) {
+ svc := ruleVersioning(ctx)
+ if svc == nil {
+ return nil, versioning.ErrVersionStoreError
+ }
+ return svc, nil
+}
+
+// getRuleIfExists reads the live ResourceManager state, not recorded history.
+func getRuleIfExists(ctx consolectx.Context, ruleRef RuleRef) (coremodel.Resource, bool, error) {
+ key := coremodel.BuildResourceKey(ruleRef.Mesh, ruleRef.Name)
+ res, exists, err := ctx.ResourceManager().GetByKey(ruleRef.Kind, key)
+ if err != nil {
+ return nil, false, err
+ }
+ if !exists || res == nil {
+ return nil, false, nil
+ }
+ return res, true, nil
+}
+
+func getExistingRule(ctx consolectx.Context, ruleRef RuleRef) (coremodel.Resource, error) {
+ res, exists, err := getRuleIfExists(ctx, ruleRef)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ key := coremodel.BuildResourceKey(ruleRef.Mesh, ruleRef.Name)
+ return nil, fmt.Errorf("%s %s does not exist", ruleRef.Kind, key)
+ }
+ return res, nil
+}
+
+func appendRuleHistory(ctx consolectx.Context, res coremodel.Resource, op versioning.Operation, source versioning.Source, author, reason string, rolledBackFromVersionNo *int64) (*versioning.Version, error) {
+ svc, err := requiredRuleVersioning(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return svc.Append(ctx.AppContext(), res, op, source, author, reason, rolledBackFromVersionNo)
+}
+
+func ensureBaselineHistory(ctx consolectx.Context, res coremodel.Resource) error {
+ svc, err := requiredRuleVersioning(ctx)
+ if err != nil {
+ return err
+ }
+ if res == nil {
+ return nil
+ }
+ hasHistory, err := svc.HasHistory(res.ResourceKind(), res.ResourceMesh(), res.ResourceMeta().Name)
+ if err != nil {
+ return err
+ }
+ if hasHistory {
+ return nil
+ }
+ if _, err := svc.Append(ctx.AppContext(), res, versioning.OperationCreate, versioning.SourceBootstrap, "system:baseline", "import existing rule before first edit", nil); err != nil {
+ return err
+ }
+ return nil
+}
+
+func withRuleLock(ctx consolectx.Context, ruleRef RuleRef, fn func() error) error {
+ lockMgr := ctx.LockManager()
+ if lockMgr == nil {
+ return fn()
+ }
+ lockKey := ruleLockKey(ruleRef)
+ return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, fn)
+}
+
+func ruleLockKey(ruleRef RuleRef) string {
+ switch ruleRef.Kind {
+ case meshresource.ConditionRouteKind:
+ return lock.BuildConditionRuleLockKey(ruleRef.Mesh, ruleRef.Name)
+ case meshresource.TagRouteKind:
+ return lock.BuildTagRouteLockKey(ruleRef.Mesh, ruleRef.Name)
+ case meshresource.DynamicConfigKind:
+ return lock.BuildConfiguratorRuleLockKey(ruleRef.Mesh, ruleRef.Name)
+ default:
+ return lock.BuildLockKey(ruleRef.Kind.ToString(), ruleRef.Mesh, ruleRef.Name)
+ }
+}
+
+func createRule(ctx consolectx.Context, res coremodel.Resource, opts RuleMutationOptions) error {
+ ruleRef := RuleRef{Kind: res.ResourceKind(), Mesh: res.ResourceMesh(), Name: res.ResourceMeta().Name}
+ return withRuleLock(ctx, ruleRef, func() error {
+ if _, err := appendRuleHistory(ctx, res, versioning.OperationCreate, versioning.SourceAdmin, opts.Author, "", nil); err != nil {
+ return err
+ }
+ if err := ctx.ResourceManager().Add(res); err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
+func updateRule(ctx consolectx.Context, res coremodel.Resource, opts RuleMutationOptions) error {
+ ruleRef := RuleRef{Kind: res.ResourceKind(), Mesh: res.ResourceMesh(), Name: res.ResourceMeta().Name}
+ return withRuleLock(ctx, ruleRef, func() error {
+ existing, err := getExistingRule(ctx, ruleRef)
+ if err != nil {
+ return err
+ }
+ if err := ensureBaselineHistory(ctx, existing); err != nil {
+ return err
+ }
+ if _, err := appendRuleHistory(ctx, res, versioning.OperationUpdate, versioning.SourceAdmin, opts.Author, "", nil); err != nil {
+ return err
+ }
+ if err := ctx.ResourceManager().Update(res); err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
+func deleteRule(ctx consolectx.Context, ruleRef RuleRef, opts RuleMutationOptions) error {
+ return withRuleLock(ctx, ruleRef, func() error {
+ resourceKey := coremodel.BuildResourceKey(ruleRef.Mesh, ruleRef.Name)
+ snapshot, exists, err := ctx.ResourceManager().GetByKey(ruleRef.Kind, resourceKey)
+ if err != nil {
+ return err
+ }
+ if !exists || snapshot == nil {
+ return nil
+ }
+ if err := ensureBaselineHistory(ctx, snapshot); err != nil {
+ return err
+ }
+ if _, err := appendRuleHistory(ctx, snapshot, versioning.OperationDelete, versioning.SourceAdmin, opts.Author, "", nil); err != nil {
+ return err
+ }
+ if err := ctx.ResourceManager().DeleteByKey(ruleRef.Kind, ruleRef.Mesh, resourceKey); err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
+func ListRuleVersions(ctx consolectx.Context, ruleRef RuleRef) (*versioning.ListResult, error) {
+ svc := ruleVersioning(ctx)
+ if svc == nil {
+ return nil, versioning.ErrVersionStoreError
+ }
+ return svc.List(ruleRef.Kind, ruleRef.Mesh, ruleRef.Name)
+}
+
+func GetRuleVersion(ctx consolectx.Context, ruleRef RuleRef, versionNo int64) (*versioning.Version, error) {
+ svc := ruleVersioning(ctx)
+ if svc == nil {
+ return nil, versioning.ErrVersionStoreError
+ }
+ return svc.Get(ruleRef.Kind, ruleRef.Mesh, ruleRef.Name, versionNo)
+}
+
+func DiffRuleVersion(ctx consolectx.Context, ruleRef RuleRef, versionNo int64, against string) (*versioning.DiffResult, error) {
+ svc := ruleVersioning(ctx)
+ if svc == nil {
+ return nil, versioning.ErrVersionStoreError
+ }
+ if against != "" && against != "current" {
+ return svc.DiffHistoryVersions(ruleRef.Kind, ruleRef.Mesh, ruleRef.Name, versionNo, against)
+ }
+ left, err := svc.Get(ruleRef.Kind, ruleRef.Mesh, ruleRef.Name, versionNo)
+ if err != nil {
+ return nil, err
+ }
+ current, exists, err := getRuleIfExists(ctx, ruleRef)
+ if err != nil {
+ return nil, err
+ }
+ specJSON := versioning.DeleteSpecJSON
+ if exists {
+ _, specJSON, err = versioning.NormalizeResource(current)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return &versioning.DiffResult{
+ Left: versioning.DiffSide{VersionNo: left.VersionNo, SpecJSON: left.SpecJSON},
+ Right: versioning.DiffSide{VersionNo: 0, SpecJSON: specJSON},
+ }, nil
+}
+
+// RollbackResult summarizes a rollback write for the API response.
+type RollbackResult struct {
+ RolledBackFromVersionNo int64
+ VersionNo int64
+ Source string
+}
+
+func RollbackRuleVersion(ctx consolectx.Context, ruleRef RuleRef, targetVersionNo int64, reason string, author string) (*RollbackResult, error) {
+ svc := ruleVersioning(ctx)
+ if svc == nil {
+ return nil, versioning.ErrVersionStoreError
+ }
+ reason = strings.TrimSpace(reason)
+ if reason == "" {
+ return nil, bizerror.New(bizerror.InvalidArgument, "rollback reason is required")
+ }
+
+ var result *RollbackResult
+ err := withRuleLock(ctx, ruleRef, func() error {
+ target, err := svc.Get(ruleRef.Kind, ruleRef.Mesh, ruleRef.Name, targetVersionNo)
+ if err != nil {
+ return err
+ }
+ if target.Operation == versioning.OperationDelete {
+ return versioning.ErrRollbackToDelete
+ }
+ if target.Operation != versioning.OperationCreate && target.Operation != versioning.OperationUpdate {
+ return bizerror.New(bizerror.InvalidArgument, "only CREATE or UPDATE rule versions can be rolled back")
+ }
+
+ current, exists, err := getRuleIfExists(ctx, ruleRef)
+ if err != nil {
+ return err
+ }
+ if exists {
+ hash, _, err := versioning.NormalizeResource(current)
+ if err != nil {
+ return err
+ }
+ if hash == target.ContentHash {
+ return versioning.ErrRollbackToCurrent
+ }
+ }
+
+ res, err := versioning.ResourceFromSpecJSON(ruleRef.Kind, ruleRef.Mesh, ruleRef.Name, target.SpecJSON)
+ if err != nil {
+ return err
+ }
+
+ operation := versioning.OperationUpdate
+ if !exists {
+ operation = versioning.OperationCreate
+ }
+ rolledBackFromVersionNo := target.VersionNo
+ appended, err := appendRuleHistory(ctx, res, operation, versioning.SourceRollback, author, reason, &rolledBackFromVersionNo)
+ if err != nil {
+ return err
+ }
+ if err := ctx.ResourceManager().Upsert(res); err != nil {
+ return err
+ }
+
+ result = &RollbackResult{
+ RolledBackFromVersionNo: rolledBackFromVersionNo,
+ Source: string(versioning.SourceRollback),
+ VersionNo: appended.VersionNo,
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return result, nil
+}
diff --git a/pkg/console/service/rule_version_rollback_test.go b/pkg/console/service/rule_version_rollback_test.go
new file mode 100644
index 0000000..c0e1f50
--- /dev/null
+++ b/pkg/console/service/rule_version_rollback_test.go
@@ -0,0 +1,573 @@
+/*
+ * 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 service
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ appcfg "github.com/apache/dubbo-admin/pkg/config/app"
+ versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning"
+ "github.com/apache/dubbo-admin/pkg/console/counter"
+ "github.com/apache/dubbo-admin/pkg/core/governor"
+ "github.com/apache/dubbo-admin/pkg/core/lock"
+ "github.com/apache/dubbo-admin/pkg/core/manager"
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/store"
+ "github.com/apache/dubbo-admin/pkg/core/versioning"
+ memoryst "github.com/apache/dubbo-admin/pkg/store/memory"
+)
+
+type testContext struct {
+ rm manager.ResourceManager
+ versioningSvc *versioning.Service
+ adapter *versioning.ResourceStoreAdapter
+ cfg *appcfg.AdminConfig
+ stores map[coremodel.ResourceKind]store.ResourceStore
+ gov *noopGovernor
+ lockMgr lock.Lock
+}
+
+func (c *testContext) ResourceManager() manager.ResourceManager { return c.rm }
+func (c *testContext) CounterManager() counter.CounterManager { return nil }
+func (c *testContext) Config() appcfg.AdminConfig { return *c.cfg }
+func (c *testContext) AppContext() context.Context { return context.Background() }
+func (c *testContext) LockManager() lock.Lock { return c.lockMgr }
+func (c *testContext) RuleVersioning() *versioning.Service { return c.versioningSvc }
+
+type recordingLock struct {
+ mu sync.Mutex
+ held bool
+ events []string
+}
+
+func (l *recordingLock) Lock(context.Context, string, time.Duration) error { return nil }
+
+func (l *recordingLock) TryLock(context.Context, string, time.Duration) (bool, error) {
+ return true, nil
+}
+
+func (l *recordingLock) Unlock(context.Context, string) error { return nil }
+
+func (l *recordingLock) Renew(context.Context, string, time.Duration) error { return nil }
+
+func (l *recordingLock) IsLocked(context.Context, string) (bool, error) { return false, nil }
+
+func (l *recordingLock) CleanupExpiredLocks(context.Context) error { return nil }
+
+func (l *recordingLock) WithLock(_ context.Context, key string, _ time.Duration, fn func() error) error {
+ l.mu.Lock()
+ l.events = append(l.events, "lock:"+key)
+ l.held = true
+ l.mu.Unlock()
+
+ err := fn()
+
+ l.mu.Lock()
+ l.held = false
+ l.events = append(l.events, "unlock:"+key)
+ l.mu.Unlock()
+ return err
+}
+
+func (l *recordingLock) record(event string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.held {
+ l.events = append(l.events, event+":locked")
+ return
+ }
+ l.events = append(l.events, event+":unlocked")
+}
+
+func (l *recordingLock) reset() {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.events = nil
+}
+
+func (l *recordingLock) snapshot() []string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return append([]string(nil), l.events...)
+}
+
+type testRouter struct {
+ stores map[coremodel.ResourceKind]store.ResourceStore
+}
+
+func (r *testRouter) ResourceRoute(res coremodel.Resource) (store.ResourceStore, error) {
+ return r.ResourceKindRoute(res.ResourceKind())
+}
+
+func (r *testRouter) ResourceKindRoute(kind coremodel.ResourceKind) (store.ResourceStore, error) {
+ s, ok := r.stores[kind]
+ if !ok {
+ return nil, bizerror.New(bizerror.InvalidArgument, "store not found for kind")
+ }
+ return s, nil
+}
+
+type noopGovernor struct {
+ stores map[coremodel.ResourceKind]store.ResourceStore
+ failNextCreate error
+ failNextUpdate error
+ failNextDelete error
+ trace *recordingLock
+}
+
+func (g *noopGovernor) CreateRule(res coremodel.Resource) error {
+ if g.trace != nil {
+ g.trace.record("registry:create")
+ }
+ if g.failNextCreate != nil {
+ err := g.failNextCreate
+ g.failNextCreate = nil
+ return err
+ }
+ s, ok := g.stores[res.ResourceKind()]
+ if !ok {
+ return bizerror.New(bizerror.InvalidArgument, "store not found")
+ }
+ return s.Add(res)
+}
+
+func (g *noopGovernor) UpdateRule(res coremodel.Resource) error {
+ if g.trace != nil {
+ g.trace.record("registry:update")
+ }
+ if g.failNextUpdate != nil {
+ err := g.failNextUpdate
+ g.failNextUpdate = nil
+ return err
+ }
+ s, ok := g.stores[res.ResourceKind()]
+ if !ok {
+ return bizerror.New(bizerror.InvalidArgument, "store not found")
+ }
+ return s.Update(res)
+}
+
+func (g *noopGovernor) DeleteRule(res coremodel.Resource) error {
+ if g.trace != nil {
+ g.trace.record("registry:delete")
+ }
+ if g.failNextDelete != nil {
+ err := g.failNextDelete
+ g.failNextDelete = nil
+ return err
+ }
+ s, ok := g.stores[res.ResourceKind()]
+ if !ok {
+ return bizerror.New(bizerror.InvalidArgument, "store not found")
+ }
+ return s.Delete(res)
+}
+
+type noopGovernorRouter struct {
+ gov *noopGovernor
+}
+
+func (r *noopGovernorRouter) ResourceRoute(coremodel.Resource) (governor.RuleGovernor, error) {
+ return r.gov, nil
+}
+
+func (r *noopGovernorRouter) ResourceMeshRoute(string) (governor.RuleGovernor, error) {
+ return r.gov, nil
+}
+
+type failingResourceStore struct {
+ store.ResourceStore
+ failNextAdd bool
+ err error
+}
+
+type recordingVersionStore struct {
+ store.ResourceStore
+ trace *recordingLock
+}
+
+func (s *recordingVersionStore) Add(obj interface{}) error {
+ if s.trace != nil {
+ s.trace.record("history:add")
+ }
+ return s.ResourceStore.Add(obj)
+}
+
+func (s *failingResourceStore) Add(obj interface{}) error {
+ if s.failNextAdd {
+ s.failNextAdd = false
+ return s.err
+ }
+ return s.ResourceStore.Add(obj)
+}
+
+func setupRollbackTestEnv(t *testing.T, wrapVersionStore ...func(store.ResourceStore) store.ResourceStore) *testContext {
+ conditionStore := memoryst.NewMemoryResourceStore(meshresource.ConditionRouteKind)
+ dynamicConfigStore := memoryst.NewMemoryResourceStore(meshresource.DynamicConfigKind)
+ tagStore := memoryst.NewMemoryResourceStore(meshresource.TagRouteKind)
+ versionStore := memoryst.NewMemoryResourceStore(meshresource.RuleVersionKind)
+ for _, s := range []store.ManagedResourceStore{conditionStore, dynamicConfigStore, tagStore, versionStore} {
+ require.NoError(t, s.Init(nil))
+ }
+
+ var versioningVersionStore store.ResourceStore = versionStore
+ if len(wrapVersionStore) > 0 && wrapVersionStore[0] != nil {
+ versioningVersionStore = wrapVersionStore[0](versionStore)
+ }
+ stores := map[coremodel.ResourceKind]store.ResourceStore{
+ meshresource.ConditionRouteKind: conditionStore,
+ meshresource.DynamicConfigKind: dynamicConfigStore,
+ meshresource.TagRouteKind: tagStore,
+ meshresource.RuleVersionKind: versioningVersionStore,
+ }
+
+ gov := &noopGovernor{stores: stores}
+ rm := manager.NewResourceManager(&testRouter{stores: stores}, &noopGovernorRouter{gov: gov})
+ adapter := versioning.NewResourceStoreAdapter(versioningVersionStore)
+ return &testContext{
+ rm: rm,
+ versioningSvc: versioning.NewService(5, adapter),
+ adapter: adapter,
+ cfg: &appcfg.AdminConfig{RuleVersioning: &versioningcfg.Config{MaxVersionsPerRule: 5}},
+ stores: stores,
+ gov: gov,
+ }
+}
+
+func conditionRule(name, payload string) *meshresource.ConditionRouteResource {
+ res := meshresource.NewConditionRouteResourceWithAttributes(name, "")
+ res.Spec = &meshproto.ConditionRoute{Enabled: true, Key: name, Conditions: []string{payload}}
+ return res
+}
+
+func dynamicConfigRule(name, payload string) *meshresource.DynamicConfigResource {
+ res := meshresource.NewDynamicConfigResourceWithAttributes(name, "")
+ res.Spec = &meshproto.DynamicConfig{Enabled: true, Key: name, ConfigVersion: payload}
+ return res
+}
+
+func tagRule(name, payload string) *meshresource.TagRouteResource {
+ res := meshresource.NewTagRouteResourceWithAttributes(name, "")
+ res.Spec = &meshproto.TagRoute{Enabled: true, Key: name, ConfigVersion: payload}
+ return res
+}
+
+func ruleRef(name string) RuleRef {
+ return RuleRef{Kind: meshresource.ConditionRouteKind, Name: name}
+}
+
+func TestRuleMutationsUsePerRuleDistributedLock(t *testing.T) {
+ tests := []struct {
+ name string
+ key string
+ create func(*testContext) error
+ update func(*testContext) error
+ remove func(*testContext) error
+ lockKey string
+ }{
+ {
+ name: "condition",
+ key: "condition-rule",
+ create: func(ctx *testContext) error {
+ return CreateConditionRuleWithOptions(ctx, conditionRule("condition-rule", "v1"), RuleMutationOptions{Author: "admin"})
+ },
+ update: func(ctx *testContext) error {
+ return UpdateConditionRuleWithOptions(ctx, conditionRule("condition-rule", "v2"), RuleMutationOptions{Author: "admin"})
+ },
+ remove: func(ctx *testContext) error {
+ return DeleteConditionRuleWithOptions(ctx, "condition-rule", "", RuleMutationOptions{Author: "admin"})
+ },
+ lockKey: lock.BuildConditionRuleLockKey("", "condition-rule"),
+ },
+ {
+ name: "dynamic config",
+ key: "dynamic-rule",
+ create: func(ctx *testContext) error {
+ return CreateConfiguratorWithOptions(ctx, dynamicConfigRule("dynamic-rule", "v1"), RuleMutationOptions{Author: "admin"})
+ },
+ update: func(ctx *testContext) error {
+ return UpdateConfiguratorWithOptions(ctx, dynamicConfigRule("dynamic-rule", "v2"), RuleMutationOptions{Author: "admin"})
+ },
+ remove: func(ctx *testContext) error {
+ return DeleteConfiguratorWithOptions(ctx, "dynamic-rule", "", RuleMutationOptions{Author: "admin"})
+ },
+ lockKey: lock.BuildConfiguratorRuleLockKey("", "dynamic-rule"),
+ },
+ {
+ name: "tag",
+ key: "tag-rule",
+ create: func(ctx *testContext) error {
+ return CreateTagRuleWithOptions(ctx, tagRule("tag-rule", "v1"), RuleMutationOptions{Author: "admin"})
+ },
+ update: func(ctx *testContext) error {
+ return UpdateTagRuleWithOptions(ctx, tagRule("tag-rule", "v2"), RuleMutationOptions{Author: "admin"})
+ },
+ remove: func(ctx *testContext) error {
+ return DeleteTagRuleWithOptions(ctx, "tag-rule", "", RuleMutationOptions{Author: "admin"})
+ },
+ lockKey: lock.BuildTagRouteLockKey("", "tag-rule"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ trace := &recordingLock{}
+ ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore {
+ return &recordingVersionStore{ResourceStore: base, trace: trace}
+ })
+ ctx.lockMgr = trace
+ ctx.gov.trace = trace
+
+ require.NoError(t, tt.create(ctx))
+ assert.Contains(t, trace.snapshot(), "lock:"+tt.lockKey)
+ assert.Contains(t, trace.snapshot(), "history:add:locked")
+ assert.Contains(t, trace.snapshot(), "registry:create:locked")
+
+ trace.reset()
+ require.NoError(t, tt.update(ctx))
+ assert.Contains(t, trace.snapshot(), "lock:"+tt.lockKey)
+ assert.Contains(t, trace.snapshot(), "history:add:locked")
+ assert.Contains(t, trace.snapshot(), "registry:update:locked")
+
+ trace.reset()
+ require.NoError(t, tt.remove(ctx))
+ assert.Contains(t, trace.snapshot(), "lock:"+tt.lockKey)
+ assert.Contains(t, trace.snapshot(), "history:add:locked")
+ assert.Contains(t, trace.snapshot(), "registry:delete:locked")
+ })
+ }
+}
+
+func TestRollbackRunsHistoryAppendAndRegistryMutationInsideRuleLock(t *testing.T) {
+ trace := &recordingLock{}
+ ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore {
+ return &recordingVersionStore{ResourceStore: base, trace: trace}
+ })
+ ctx.lockMgr = trace
+ ctx.gov.trace = trace
+
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ targetVersionNo := versions.Items[0].VersionNo
+ require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"}))
+
+ trace.reset()
+ result, err := RollbackRuleVersion(ctx, ruleRef("demo-rule"), targetVersionNo, "restore", "admin")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ events := trace.snapshot()
+ assert.Contains(t, events, "lock:"+lock.BuildConditionRuleLockKey("", "demo-rule"))
+ assert.Contains(t, events, "history:add:locked")
+ assert.Contains(t, events, "registry:update:locked")
+}
+
+func TestUpdateAppendsBaselineBeforeFirstHistory(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ require.NoError(t, ctx.stores[meshresource.ConditionRouteKind].Add(conditionRule("demo-rule", "v1")))
+
+ require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"}))
+
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ require.Len(t, versions.Items, 2)
+ assert.Equal(t, versioning.OperationUpdate, versions.Items[0].Operation)
+ assert.Equal(t, versioning.SourceAdmin, versions.Items[0].Source)
+ assert.Equal(t, versioning.OperationCreate, versions.Items[1].Operation)
+ assert.Equal(t, versioning.SourceBootstrap, versions.Items[1].Source)
+ assert.Contains(t, versions.Items[1].SpecJSON, "v1")
+}
+
+func TestCreateUpdateDeleteAppendHistory(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+ require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"}))
+ require.NoError(t, DeleteConditionRuleWithOptions(ctx, "demo-rule", "", RuleMutationOptions{Author: "admin"}))
+
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ require.Len(t, versions.Items, 3)
+ assert.Equal(t, versioning.OperationDelete, versions.Items[0].Operation)
+ assert.Equal(t, versioning.OperationUpdate, versions.Items[1].Operation)
+ assert.Equal(t, versioning.OperationCreate, versions.Items[2].Operation)
+ assert.Equal(t, versioning.SourceAdmin, versions.Items[0].Source)
+ assert.Equal(t, versioning.DeleteSpecJSON, versions.Items[0].SpecJSON)
+}
+
+func TestMutationFailsClosedWhenHistoryAppendFails(t *testing.T) {
+ appendErr := errors.New("history append failed")
+ failingVersionStore := &failingResourceStore{err: appendErr}
+ ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore {
+ failingVersionStore.ResourceStore = base
+ return failingVersionStore
+ })
+ failingVersionStore.failNextAdd = true
+
+ err := CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})
+ require.Error(t, err)
+ _, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule")
+ require.NoError(t, err)
+ require.False(t, exists)
+
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ assert.Empty(t, versions.Items)
+}
+
+func TestMutationFailsClosedWhenVersioningUnavailable(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ ctx.versioningSvc = nil
+
+ err := CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})
+ require.ErrorIs(t, err, versioning.ErrVersionStoreError)
+ _, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule")
+ require.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestZeroRetentionConfigStillRecordsVersions(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ ctx.versioningSvc = versioning.NewService(0, ctx.adapter)
+ ctx.cfg.RuleVersioning.MaxVersionsPerRule = 0
+
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ require.Len(t, versions.Items, 1)
+ assert.Equal(t, versioning.OperationCreate, versions.Items[0].Operation)
+}
+
+func TestRegistryWriteFailureReturnsErrorAfterLedgerAppend(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ ctx.gov.failNextCreate = errors.New("registry write failed")
+
+ err := CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})
+ require.Error(t, err)
+ _, exists, getErr := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule")
+ require.NoError(t, getErr)
+ assert.False(t, exists)
+ versions, listErr := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, listErr)
+ require.Len(t, versions.Items, 1)
+ assert.Equal(t, versioning.OperationCreate, versions.Items[0].Operation)
+}
+
+func TestDeleteMissingRuleDoesNotAppendHistory(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+
+ require.NoError(t, DeleteConditionRuleWithOptions(ctx, "missing-rule", "", RuleMutationOptions{Author: "admin"}))
+
+ versions, err := ListRuleVersions(ctx, ruleRef("missing-rule"))
+ require.NoError(t, err)
+ assert.Empty(t, versions.Items)
+}
+
+func TestRollbackUpsertsAndAppendsRollbackHistory(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ targetVersionNo := versions.Items[0].VersionNo
+ require.NoError(t, DeleteConditionRuleWithOptions(ctx, "demo-rule", "", RuleMutationOptions{Author: "admin"}))
+
+ result, err := RollbackRuleVersion(ctx, ruleRef("demo-rule"), targetVersionNo, "restore", "admin")
+ require.NoError(t, err)
+ assert.Equal(t, targetVersionNo, result.RolledBackFromVersionNo)
+ assert.NotZero(t, result.VersionNo)
+
+ current, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule")
+ require.NoError(t, err)
+ require.True(t, exists)
+ assert.Contains(t, current.String(), "v1")
+ versions, err = ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ assert.Equal(t, versioning.SourceRollback, versions.Items[0].Source)
+ assert.Equal(t, versioning.OperationCreate, versions.Items[0].Operation)
+ require.NotNil(t, versions.Items[0].RolledBackFromVersionNo)
+ assert.Equal(t, targetVersionNo, *versions.Items[0].RolledBackFromVersionNo)
+}
+
+func TestRollbackFailsClosedWhenHistoryAppendFails(t *testing.T) {
+ appendErr := errors.New("history append failed")
+ failingVersionStore := &failingResourceStore{err: appendErr}
+ ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore {
+ failingVersionStore.ResourceStore = base
+ return failingVersionStore
+ })
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+ require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"}))
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ targetVersionNo := versions.Items[1].VersionNo
+ failingVersionStore.failNextAdd = true
+
+ _, err = RollbackRuleVersion(ctx, ruleRef("demo-rule"), targetVersionNo, "restore", "admin")
+ require.Error(t, err)
+ current, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule")
+ require.NoError(t, err)
+ require.True(t, exists)
+ assert.Contains(t, current.String(), "v2")
+}
+
+func TestRollbackRejectsDeleteMarker(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+ require.NoError(t, DeleteConditionRuleWithOptions(ctx, "demo-rule", "", RuleMutationOptions{Author: "admin"}))
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ require.Equal(t, versioning.OperationDelete, versions.Items[0].Operation)
+
+ _, err = RollbackRuleVersion(ctx, ruleRef("demo-rule"), versions.Items[0].VersionNo, "restore delete marker", "admin")
+ require.ErrorIs(t, err, versioning.ErrRollbackToDelete)
+}
+
+func TestRollbackNoOpRejectedAgainstActualCurrent(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+
+ _, err = RollbackRuleVersion(ctx, ruleRef("demo-rule"), versions.Items[0].VersionNo, "same content", "admin")
+ require.ErrorIs(t, err, versioning.ErrRollbackToCurrent)
+}
+
+func TestDiffAgainstCurrentReadsLiveResourceManagerState(t *testing.T) {
+ ctx := setupRollbackTestEnv(t)
+ require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}))
+ versions, err := ListRuleVersions(ctx, ruleRef("demo-rule"))
+ require.NoError(t, err)
+ require.NoError(t, ctx.stores[meshresource.ConditionRouteKind].Update(conditionRule("demo-rule", "v2-outside-history")))
+
+ diff, err := DiffRuleVersion(ctx, ruleRef("demo-rule"), versions.Items[0].VersionNo, "current")
+ require.NoError(t, err)
+ assert.Contains(t, diff.Left.SpecJSON, "v1")
+ assert.Contains(t, diff.Right.SpecJSON, "v2-outside-history")
+}
diff --git a/pkg/console/service/tag_rule.go b/pkg/console/service/tag_rule.go
index a051117..ce6d716 100644
--- a/pkg/console/service/tag_rule.go
+++ b/pkg/console/service/tag_rule.go
@@ -18,8 +18,6 @@
package service
import (
- "github.com/apache/dubbo-admin/pkg/common/constants"
- "github.com/apache/dubbo-admin/pkg/core/lock"
"github.com/duke-git/lancet/v2/slice"
"github.com/apache/dubbo-admin/pkg/common/bizerror"
@@ -114,21 +112,11 @@
}
func UpdateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return updateTagRuleUnsafe(ctx, res)
- }
-
- lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name)
-
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return updateTagRuleUnsafe(ctx, res)
- })
+ return UpdateTagRuleWithOptions(ctx, res, RuleMutationOptions{})
}
-func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error {
- err := ctx.ResourceManager().Update(res)
- if err != nil {
+func UpdateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error {
+ if err := updateRule(ctx, res, opts); err != nil {
logger.Warnf("update tag rule %s error: %v", res.Name, err)
return err
}
@@ -136,21 +124,11 @@
}
func CreateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return createTagRuleUnsafe(ctx, res)
- }
-
- lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name)
-
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return createTagRuleUnsafe(ctx, res)
- })
+ return CreateTagRuleWithOptions(ctx, res, RuleMutationOptions{})
}
-func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error {
- err := ctx.ResourceManager().Add(res)
- if err != nil {
+func CreateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error {
+ if err := createRule(ctx, res, opts); err != nil {
logger.Warnf("create tag rule %s error: %v", res.Name, err)
return err
}
@@ -158,19 +136,12 @@
}
func DeleteTagRule(ctx consolectx.Context, name string, mesh string) error {
- lockMgr := ctx.LockManager()
- if lockMgr == nil {
- return deleteTagRuleUnsafe(ctx, name, mesh)
- }
- lockKey := lock.BuildTagRouteLockKey(mesh, name)
- return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error {
- return deleteTagRuleUnsafe(ctx, name, mesh)
- })
+ return DeleteTagRuleWithOptions(ctx, name, mesh, RuleMutationOptions{})
}
-func deleteTagRuleUnsafe(ctx consolectx.Context, name string, mesh string) error {
- err := ctx.ResourceManager().DeleteByKey(meshresource.TagRouteKind, mesh, coremodel.BuildResourceKey(mesh, name))
- if err != nil {
+func DeleteTagRuleWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error {
+ ruleRef := RuleRef{Kind: meshresource.TagRouteKind, Mesh: mesh, Name: name}
+ if err := deleteRule(ctx, ruleRef, opts); err != nil {
logger.Warnf("delete tag rule %s error: %v", name, err)
return err
}
diff --git a/pkg/core/bootstrap/bootstrap.go b/pkg/core/bootstrap/bootstrap.go
index d1ee2c0..a7b5439 100644
--- a/pkg/core/bootstrap/bootstrap.go
+++ b/pkg/core/bootstrap/bootstrap.go
@@ -27,6 +27,7 @@
"github.com/apache/dubbo-admin/pkg/core/lock"
"github.com/apache/dubbo-admin/pkg/core/logger"
"github.com/apache/dubbo-admin/pkg/core/runtime"
+ "github.com/apache/dubbo-admin/pkg/core/versioning"
"github.com/apache/dubbo-admin/pkg/diagnostics"
)
@@ -130,6 +131,7 @@
{"CounterManager", counter.ComponentType},
{"DiagnosticsServer", diagnostics.DiagnosticsServer},
{"DistributedLock", lock.DistributedLockComponent},
+ {"RuleVersioning", versioning.ComponentType},
}
for _, comp := range optionalComps {
diff --git a/pkg/core/bootstrap/init.go b/pkg/core/bootstrap/init.go
index c590b84..0fe16c1 100644
--- a/pkg/core/bootstrap/init.go
+++ b/pkg/core/bootstrap/init.go
@@ -35,6 +35,7 @@
_ "github.com/apache/dubbo-admin/pkg/governor/mock"
_ "github.com/apache/dubbo-admin/pkg/governor/nacos2"
_ "github.com/apache/dubbo-admin/pkg/governor/zk"
+ _ "github.com/apache/dubbo-admin/pkg/lock/gorm"
_ "github.com/apache/dubbo-admin/pkg/store/memory"
_ "github.com/apache/dubbo-admin/pkg/store/mysql"
_ "github.com/apache/dubbo-admin/pkg/store/postgres"
diff --git a/pkg/core/discovery/subscriber/zk_config.go b/pkg/core/discovery/subscriber/zk_config.go
index 07f9a26..f9e0eec 100644
--- a/pkg/core/discovery/subscriber/zk_config.go
+++ b/pkg/core/discovery/subscriber/zk_config.go
@@ -38,6 +38,13 @@
storeRouter store.Router
}
+// sourceRegistryZookeeper labels rule events coming from ZooKeeper so rule
+// history can attribute upstream writes to author "system:zookeeper". Other
+// registry subscribers (Nacos, Apollo, ...) should emit the equivalent
+// SourceRegistryContextKey on their ResourceChangedEvents; until they do, rule
+// history falls back to "system:upstream" for those sources.
+const sourceRegistryZookeeper = "zookeeper"
+
func NewZKConfigEventSubscriber(eventEmitter events.Emitter, storeRouter store.Router) *ZKConfigEventSubscriber {
return &ZKConfigEventSubscriber{
emitter: eventEmitter,
@@ -127,13 +134,13 @@
switch suffix {
case constants.TagRuleSuffix:
return processConfigDelete[*meshresource.TagRouteResource](
- configRes, meshresource.ToTagRouteResource, z.storeRouter, z.emitter)
+ configRes, meshresource.TagRouteKind, z.storeRouter, z.emitter)
case constants.ConditionRuleSuffix:
return processConfigDelete[*meshresource.ConditionRouteResource](
- configRes, meshresource.ToConditionRouteResource, z.storeRouter, z.emitter)
+ configRes, meshresource.ConditionRouteKind, z.storeRouter, z.emitter)
case constants.ConfiguratorsSuffix:
return processConfigDelete[*meshresource.DynamicConfigResource](
- configRes, meshresource.ToDynamicConfigResource, z.storeRouter, z.emitter)
+ configRes, meshresource.DynamicConfigKind, z.storeRouter, z.emitter)
default:
return bizerror.New(bizerror.UnknownError,
fmt.Sprintf("unknown rule type in mesh %s, skipped processing, node: %s",
@@ -167,7 +174,9 @@
logger.Errorf("add rule %s to store failed, cause: %s", newRuleRes.ResourceKey(), err.Error())
return err
}
- emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, newRuleRes))
+ emitter.Send(events.NewResourceChangedEventWithContext(cache.Added, nil, newRuleRes, map[string]string{
+ events.SourceRegistryContextKey: sourceRegistryZookeeper,
+ }))
return nil
}
@@ -184,39 +193,43 @@
return bizerror.NewAssertionError(reflect.TypeOf(oldMetadataRes), oldRes)
}
- emitter.Send(events.NewResourceChangedEvent(cache.Updated, oldMetadataRes, newRuleRes))
+ emitter.Send(events.NewResourceChangedEventWithContext(cache.Updated, oldMetadataRes, newRuleRes, map[string]string{
+ events.SourceRegistryContextKey: sourceRegistryZookeeper,
+ }))
return nil
}
func processConfigDelete[T coremodel.Resource](
configRes *meshresource.ZKConfigResource,
- toRuleRes meshresource.ToRuleResourceFunc,
+ ruleKind coremodel.ResourceKind,
router store.Router,
emitter events.Emitter) error {
- ruleRes := toRuleRes(configRes.Mesh, configRes.Name, configRes.Spec.NodeData)
- st, err := router.ResourceKindRoute(ruleRes.ResourceKind())
+ st, err := router.ResourceKindRoute(ruleKind)
if err != nil {
- logger.Errorf("get %s store failed, cause: %s", ruleRes.ResourceKind(), err.Error())
+ logger.Errorf("get %s store failed, cause: %s", ruleKind, err.Error())
return err
}
- oldRes, exists, err := st.GetByKey(ruleRes.ResourceKey())
+ resourceKey := coremodel.BuildResourceKey(configRes.Mesh, configRes.Name)
+ oldRes, exists, err := st.GetByKey(resourceKey)
if err != nil {
- logger.Errorf("get rule %s from store failed, cause: %s", ruleRes.ResourceKey(), err.Error())
+ logger.Errorf("get rule %s from store failed, cause: %s", resourceKey, err.Error())
return err
}
if !exists {
- logger.Infof("rule %s not exists in store, skipped deleting", ruleRes.ResourceKey())
+ logger.Infof("rule %s not exists in store, skipped deleting", resourceKey)
return nil
}
oldRuleRes, ok := oldRes.(T)
if !ok {
return bizerror.NewAssertionError(reflect.TypeOf(oldRuleRes), oldRes)
}
- err = st.Delete(ruleRes)
+ err = st.Delete(oldRuleRes)
if err != nil {
- logger.Errorf("delete rule %s from store failed, cause: %s", ruleRes.ResourceKey(), err.Error())
+ logger.Errorf("delete rule %s from store failed, cause: %s", resourceKey, err.Error())
return err
}
- emitter.Send(events.NewResourceChangedEvent(cache.Deleted, oldRuleRes, nil))
+ emitter.Send(events.NewResourceChangedEventWithContext(cache.Deleted, oldRuleRes, nil, map[string]string{
+ events.SourceRegistryContextKey: sourceRegistryZookeeper,
+ }))
return nil
}
diff --git a/pkg/core/events/eventbus.go b/pkg/core/events/eventbus.go
index 51393c4..42c06df 100644
--- a/pkg/core/events/eventbus.go
+++ b/pkg/core/events/eventbus.go
@@ -26,6 +26,9 @@
"github.com/apache/dubbo-admin/pkg/core/resource/model"
)
+// SourceRegistryContextKey identifies which registry produced a resource event.
+const SourceRegistryContextKey = "source-registry"
+
type Event interface {
// Type returns the type of the event, see definitions in cache.DeltaType
Type() cache.DeltaType
@@ -33,7 +36,7 @@
OldObj() model.Resource
// NewObj returns the new object, nil if event type is in [cache.Deleted]
NewObj() model.Resource
- // Context returns the context of the event, if event provider want to pass extra info to the consumer, just use context
+ // Context returns read-only event metadata. Subscribers must not mutate the returned map.
Context() map[string]string
// String returns the string representation of the event
String() string
diff --git a/pkg/core/resource/apis/mesh/v1alpha1/rule_version_types.go b/pkg/core/resource/apis/mesh/v1alpha1/rule_version_types.go
new file mode 100644
index 0000000..2fb5160
--- /dev/null
+++ b/pkg/core/resource/apis/mesh/v1alpha1/rule_version_types.go
@@ -0,0 +1,154 @@
+/*
+ * 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 v1alpha1
+
+import (
+ "encoding/json"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ k8sruntime "k8s.io/apimachinery/pkg/runtime"
+
+ meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ "github.com/apache/dubbo-admin/pkg/core/logger"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "google.golang.org/protobuf/proto"
+)
+
+const (
+ RuleVersionKind coremodel.ResourceKind = "RuleVersion"
+)
+
+func init() {
+ coremodel.RegisterResourceSchema(RuleVersionKind, NewRuleVersionResource, NewRuleVersionResourceList)
+}
+
+// RuleVersionResource stores one immutable history entry for a parent traffic
+// rule. The live rule state is always read from ResourceManager.
+type RuleVersionResource struct {
+ metav1.TypeMeta `json:",inline"`
+
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Mesh is the name of the dubbo mesh this resource belongs to.
+ Mesh string `json:"mesh,omitempty"`
+
+ // Spec is the specification of the Dubbo RuleVersion resource.
+ Spec *meshproto.RuleVersion `json:"spec,omitempty"`
+}
+
+func (r *RuleVersionResource) ResourceKind() coremodel.ResourceKind {
+ return RuleVersionKind
+}
+
+func (r *RuleVersionResource) ResourceMesh() string {
+ return r.Mesh
+}
+
+func (r *RuleVersionResource) ResourceKey() string {
+ return coremodel.BuildResourceKey(r.Mesh, r.Name)
+}
+
+func (r *RuleVersionResource) ResourceMeta() metav1.ObjectMeta {
+ return r.ObjectMeta
+}
+
+func (r *RuleVersionResource) ResourceSpec() coremodel.ResourceSpec {
+ return r.Spec
+}
+
+func (r *RuleVersionResource) String() string {
+ jsonStr, err := json.Marshal(r)
+ if err != nil {
+ logger.Errorf("failed to encode RuleVersionResource: %s to json, err: %v", r.ResourceKey(), err)
+ return ""
+ }
+ return string(jsonStr)
+}
+
+func (r *RuleVersionResource) DeepCopyObject() k8sruntime.Object {
+ out := &RuleVersionResource{
+ TypeMeta: r.TypeMeta,
+ ObjectMeta: *r.ObjectMeta.DeepCopy(),
+ Mesh: r.Mesh,
+ }
+ if r.Spec != nil {
+ out.Spec = proto.Clone(r.Spec).(*meshproto.RuleVersion)
+ }
+ return out
+}
+
+func NewRuleVersionResource() coremodel.Resource {
+ return &RuleVersionResource{}
+}
+
+func NewRuleVersionResourceWithAttributes(name, mesh string) *RuleVersionResource {
+ return &RuleVersionResource{
+ ObjectMeta: metav1.ObjectMeta{Name: name},
+ Mesh: mesh,
+ Spec: &meshproto.RuleVersion{},
+ }
+}
+
+type RuleVersionResourceList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []*RuleVersionResource `json:"items"`
+}
+
+func (r *RuleVersionResourceList) DeepCopyObject() k8sruntime.Object {
+ out := &RuleVersionResourceList{
+ TypeMeta: r.TypeMeta,
+ }
+ r.ListMeta.DeepCopyInto(&out.ListMeta)
+
+ if len(r.Items) == 0 {
+ return out
+ }
+ out.Items = make([]*RuleVersionResource, len(r.Items))
+ for i := range r.Items {
+ out.Items[i] = r.Items[i].DeepCopyObject().(*RuleVersionResource)
+ }
+ return out
+}
+
+func NewRuleVersionResourceList() coremodel.ResourceList {
+ return &RuleVersionResourceList{
+ TypeMeta: metav1.TypeMeta{
+ Kind: string(RuleVersionKind),
+ APIVersion: "v1alpha1",
+ },
+ Items: make([]*RuleVersionResource, 0),
+ }
+}
+
+func (r *RuleVersionResourceList) GetItems() []coremodel.Resource {
+ res := make([]coremodel.Resource, len(r.Items))
+ for i := range r.Items {
+ res[i] = r.Items[i]
+ }
+ return res
+}
+
+func (r *RuleVersionResourceList) SetItems(items []coremodel.Resource) {
+ r.Items = make([]*RuleVersionResource, len(items))
+ for i, res := range items {
+ if typed, ok := res.(*RuleVersionResource); ok {
+ r.Items[i] = typed
+ }
+ }
+}
diff --git a/pkg/core/resource/model/resource.go b/pkg/core/resource/model/resource.go
index 1ff3485..d940ca2 100644
--- a/pkg/core/resource/model/resource.go
+++ b/pkg/core/resource/model/resource.go
@@ -18,6 +18,8 @@
package model
import (
+ "strings"
+
k8smeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8sruntime "k8s.io/apimachinery/pkg/runtime"
@@ -61,3 +63,12 @@
func BuildResourceKey(mesh string, name string) string {
return mesh + constants.PathSeparator + name
}
+
+// ParseResourceKey is the inverse of BuildResourceKey for resource keys that
+// may be stored as either "mesh/name" or just "name".
+func ParseResourceKey(resourceKey string) (mesh string, name string) {
+ if idx := strings.Index(resourceKey, constants.PathSeparator); idx >= 0 {
+ return resourceKey[:idx], resourceKey[idx+len(constants.PathSeparator):]
+ }
+ return "", resourceKey
+}
diff --git a/pkg/core/resource/model/resource_test.go b/pkg/core/resource/model/resource_test.go
new file mode 100644
index 0000000..3353c88
--- /dev/null
+++ b/pkg/core/resource/model/resource_test.go
@@ -0,0 +1,47 @@
+/*
+ * 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 model
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseResourceKey(t *testing.T) {
+ tests := []struct {
+ name string
+ resourceKey string
+ wantMesh string
+ wantName string
+ }{
+ {name: "mesh and name", resourceKey: "default/demo", wantMesh: "default", wantName: "demo"},
+ {name: "name only", resourceKey: "demo", wantMesh: "", wantName: "demo"},
+ {name: "empty mesh", resourceKey: "/demo", wantMesh: "", wantName: "demo"},
+ {name: "empty name", resourceKey: "default/", wantMesh: "default", wantName: ""},
+ {name: "empty key", resourceKey: "", wantMesh: "", wantName: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotMesh, gotName := ParseResourceKey(tt.resourceKey)
+ assert.Equal(t, tt.wantMesh, gotMesh)
+ assert.Equal(t, tt.wantName, gotName)
+ })
+ }
+}
diff --git a/pkg/core/runtime/builder.go b/pkg/core/runtime/builder.go
index 191470b..8a79333 100644
--- a/pkg/core/runtime/builder.go
+++ b/pkg/core/runtime/builder.go
@@ -34,6 +34,7 @@
Config() app.AdminConfig
GetActivatedComponent(typ ComponentType) (Component, error)
ActivateComponent(comp Component) error
+ AppContext() context.Context
}
var _ BuilderContext = &Builder{}
@@ -54,6 +55,10 @@
return comp, nil
}
+func (b *Builder) AppContext() context.Context {
+ return b.appCtx
+}
+
func BuilderFor(appCtx context.Context, cfg app.AdminConfig) (*Builder, error) {
hostname, err := os.Hostname()
if err != nil {
diff --git a/pkg/core/store/index/rule_version.go b/pkg/core/store/index/rule_version.go
new file mode 100644
index 0000000..696c80f
--- /dev/null
+++ b/pkg/core/store/index/rule_version.go
@@ -0,0 +1,51 @@
+/*
+ * 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 index
+
+import (
+ "fmt"
+
+ "k8s.io/client-go/tools/cache"
+
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+)
+
+const (
+ ByParentRuleIndexName = "ByParentRule"
+)
+
+func init() {
+ RegisterIndexers(meshresource.RuleVersionKind, map[string]cache.IndexFunc{
+ ByParentRuleIndexName: byParentRule,
+ })
+}
+
+// byParentRule indexes RuleVersion resources by their parent rule.
+// Index key format: "<parent_kind>/<parent_mesh>/<parent_name>"
+func byParentRule(obj interface{}) ([]string, error) {
+ rv, ok := obj.(*meshresource.RuleVersionResource)
+ if !ok || rv.Spec == nil {
+ return nil, nil
+ }
+ key := fmt.Sprintf("%s/%s/%s",
+ rv.Spec.ParentRuleKind,
+ rv.Spec.ParentRuleMesh,
+ rv.Spec.ParentRuleName,
+ )
+ return []string{key}, nil
+}
diff --git a/pkg/core/store/store.go b/pkg/core/store/store.go
index 8923f65..2df9fc8 100644
--- a/pkg/core/store/store.go
+++ b/pkg/core/store/store.go
@@ -77,7 +77,9 @@
return fmt.Errorf("resource not found: type=%q name=%q mesh=%q", rt, name, mesh)
}
-var ErrorInvalidOffset = errors.New("invalid offset")
+var (
+ ErrorInvalidOffset = errors.New("invalid offset")
+)
func IsResourceNotFound(err error) bool {
return err != nil && strings.HasPrefix(err.Error(), "Resource not found")
diff --git a/pkg/core/versioning/bootstrap.go b/pkg/core/versioning/bootstrap.go
new file mode 100644
index 0000000..06c8dfd
--- /dev/null
+++ b/pkg/core/versioning/bootstrap.go
@@ -0,0 +1,69 @@
+/*
+ * 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 versioning
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+)
+
+// RecordBootstrapState creates a baseline history entry for a rule that already
+// exists before rule history starts tracking it. Bootstrap is not reconciliation:
+// once any history exists, startup must not record the current registry state as
+// an UPDATE.
+func RecordBootstrapState(ctx context.Context, store *ResourceStoreAdapter, maxVersions int64, res coremodel.Resource) error {
+ if store == nil {
+ return ErrVersionStoreError
+ }
+ if res == nil {
+ return nil
+ }
+ latest, err := store.LatestVersion(res.ResourceKind(), res.ResourceKey())
+ if err != nil && !errors.Is(err, ErrVersionNotFound) {
+ return err
+ }
+ if latest != nil {
+ return nil
+ }
+
+ hash, specJSON, err := NormalizeResource(res)
+ if err != nil {
+ return err
+ }
+ req := InsertRequest{
+ RuleKind: res.ResourceKind(),
+ Mesh: res.ResourceMesh(),
+ ResourceKey: res.ResourceKey(),
+ RuleName: res.ResourceMeta().Name,
+ SpecJSON: specJSON,
+ ContentHash: hash,
+ Source: SourceBootstrap,
+ Operation: OperationCreate,
+ Author: "system:bootstrap",
+ Reason: "initial rule history baseline",
+ CreatedAt: time.Now(),
+ }
+ if _, err := store.InsertVersion(ctx, req, maxVersions); err != nil {
+ return fmt.Errorf("bootstrap version for %s failed: %w", res.ResourceKey(), err)
+ }
+ return nil
+}
diff --git a/pkg/core/versioning/component.go b/pkg/core/versioning/component.go
new file mode 100644
index 0000000..fed475e
--- /dev/null
+++ b/pkg/core/versioning/component.go
@@ -0,0 +1,160 @@
+/*
+ * 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 versioning
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "reflect"
+
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning"
+ "github.com/apache/dubbo-admin/pkg/core/governor"
+ "github.com/apache/dubbo-admin/pkg/core/logger"
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+ "github.com/apache/dubbo-admin/pkg/core/runtime"
+ "github.com/apache/dubbo-admin/pkg/core/store"
+)
+
+const ComponentType runtime.ComponentType = "rule versioning"
+
+func init() {
+ runtime.RegisterComponent(&component{})
+}
+
+type Component interface {
+ runtime.Component
+ Service() *Service
+}
+
+type component struct {
+ service *Service
+ store *ResourceStoreAdapter
+}
+
+func (c *component) Type() runtime.ComponentType {
+ return ComponentType
+}
+
+func (c *component) Order() int {
+ return math.MaxInt - 5
+}
+
+func (c *component) RequiredDependencies() []runtime.ComponentType {
+ return []runtime.ComponentType{
+ runtime.ResourceStore,
+ }
+}
+
+func (c *component) Init(ctx runtime.BuilderContext) error {
+ cfg := ctx.Config().RuleVersioning
+ if cfg == nil {
+ cfg = versioningcfg.Default()
+ }
+
+ storeComponent, err := ctx.GetActivatedComponent(runtime.ResourceStore)
+ if err != nil {
+ return err
+ }
+ storeRouter, ok := storeComponent.(store.Router)
+ if !ok {
+ return bizerror.NewAssertionError("store.Router", reflect.TypeOf(storeComponent).Name())
+ }
+ rvStore, err := storeRouter.ResourceKindRoute(meshresource.RuleVersionKind)
+ if err != nil {
+ return fmt.Errorf("failed to get RuleVersion store: %w", err)
+ }
+ if rvStore == nil {
+ return fmt.Errorf("RuleVersion store not available - versioning requires resource store")
+ }
+
+ store := NewResourceStoreAdapter(rvStore)
+ if err := store.ensureStores(); err != nil {
+ return err
+ }
+ c.store = store
+ c.service = NewService(cfg.MaxVersionsPerRule, store)
+ logger.Infof("Using resource store for rule history (RuleVersion)")
+ return nil
+}
+
+func (c *component) Start(rt runtime.Runtime, stop <-chan struct{}) error {
+ cfg := rt.Config().RuleVersioning
+ if cfg == nil {
+ cfg = versioningcfg.Default()
+ }
+ ctx, cancel := contextWithStop(rt.AppContext(), stop)
+ defer cancel()
+ storeComponent, err := rt.GetComponent(runtime.ResourceStore)
+ if err != nil {
+ return err
+ }
+ storeRouter, ok := storeComponent.(store.Router)
+ if !ok {
+ return bizerror.NewAssertionError("store.Router", reflect.TypeOf(storeComponent).Name())
+ }
+ if err := c.bootstrapExistingRules(ctx, storeRouter, cfg.MaxVersionsPerRule); err != nil {
+ logger.Warnf("rule history bootstrap failed: %v", err)
+ }
+ return nil
+}
+
+func (c *component) Service() *Service {
+ return c.service
+}
+
+func (c *component) bootstrapExistingRules(ctx context.Context, storeRouter store.Router, maxVersions int64) error {
+ for _, kind := range governor.RuleResourceKinds.Values() {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ rs, err := storeRouter.ResourceKindRoute(kind)
+ if err != nil {
+ return err
+ }
+ if rs == nil {
+ continue
+ }
+ resources, err := rs.GetByKeys(rs.ListKeys())
+ if err != nil {
+ return err
+ }
+ for _, res := range resources {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if err := RecordBootstrapState(ctx, c.store, maxVersions, res); err != nil {
+ logger.Warnf("rule history bootstrap skipped for %s: %v", res.ResourceKey(), err)
+ }
+ }
+ }
+ return nil
+}
+
+func contextWithStop(parent context.Context, stop <-chan struct{}) (context.Context, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(parent)
+ go func() {
+ select {
+ case <-stop:
+ cancel()
+ case <-ctx.Done():
+ }
+ }()
+ return ctx, cancel
+}
diff --git a/pkg/core/versioning/normalize.go b/pkg/core/versioning/normalize.go
new file mode 100644
index 0000000..34cfce7
--- /dev/null
+++ b/pkg/core/versioning/normalize.go
@@ -0,0 +1,130 @@
+/*
+ * 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 versioning
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+
+ "google.golang.org/protobuf/encoding/protojson"
+ "google.golang.org/protobuf/proto"
+
+ meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+)
+
+// DeleteSpecJSON is the canonical snapshot stored for a rule delete marker.
+const DeleteSpecJSON = "{}"
+
+// NormalizeSpec returns the spec hash followed by canonical JSON for stable comparisons.
+// It does not validate whether the spec is acceptable to the registry.
+func NormalizeSpec(spec coremodel.ResourceSpec) (specHash string, specJSON string, err error) {
+ if spec == nil {
+ return HashSpecJSON(DeleteSpecJSON), DeleteSpecJSON, nil
+ }
+ var raw []byte
+ if msg, ok := spec.(proto.Message); ok {
+ var err error
+ raw, err = protojson.MarshalOptions{
+ UseProtoNames: false,
+ EmitUnpopulated: false,
+ }.Marshal(msg)
+ if err != nil {
+ return "", "", err
+ }
+ } else {
+ var err error
+ raw, err = json.Marshal(spec)
+ if err != nil {
+ return "", "", err
+ }
+ }
+ var v any
+ if err := json.Unmarshal(raw, &v); err != nil {
+ return "", "", err
+ }
+ canonical, err := json.Marshal(v)
+ if err != nil {
+ return "", "", err
+ }
+ specJSON = string(canonical)
+ return HashSpecJSON(specJSON), specJSON, nil
+}
+
+// HashSpecJSON hashes canonical spec JSON for comparison and dedup filters.
+// It is not sufficient on its own to prove operation source.
+func HashSpecJSON(specJSON string) string {
+ sum := sha256.Sum256([]byte(specJSON))
+ return hex.EncodeToString(sum[:])
+}
+
+func NormalizeResource(res coremodel.Resource) (string, string, error) {
+ if res == nil {
+ return "", "", fmt.Errorf("resource is nil")
+ }
+ return NormalizeSpec(res.ResourceSpec())
+}
+
+// ResourceFromSpecJSON rebuilds a typed rule Resource from a stored version's
+// spec JSON. Used by rollback to re-publish a historical snapshot through the
+// normal ResourceManager mutation path. Only the three governor-managed rule
+// kinds are supported. protojson is tried first (matching how specs are
+// normalized), falling back to plain JSON for resilience.
+func ResourceFromSpecJSON(kind coremodel.ResourceKind, mesh, ruleName, specJSON string) (coremodel.Resource, error) {
+ switch kind {
+ case meshresource.ConditionRouteKind:
+ res := meshresource.NewConditionRouteResourceWithAttributes(ruleName, mesh)
+ var spec meshproto.ConditionRoute
+ if err := unmarshalSpec(specJSON, &spec); err != nil {
+ return nil, err
+ }
+ res.Spec = &spec
+ return res, nil
+ case meshresource.TagRouteKind:
+ res := meshresource.NewTagRouteResourceWithAttributes(ruleName, mesh)
+ var spec meshproto.TagRoute
+ if err := unmarshalSpec(specJSON, &spec); err != nil {
+ return nil, err
+ }
+ res.Spec = &spec
+ return res, nil
+ case meshresource.DynamicConfigKind:
+ res := meshresource.NewDynamicConfigResourceWithAttributes(ruleName, mesh)
+ var spec meshproto.DynamicConfig
+ if err := unmarshalSpec(specJSON, &spec); err != nil {
+ return nil, err
+ }
+ res.Spec = &spec
+ return res, nil
+ default:
+ return nil, bizerror.New(bizerror.InvalidArgument, "unsupported rule kind")
+ }
+}
+
+func unmarshalSpec(specJSON string, spec proto.Message) error {
+ if err := protojson.Unmarshal([]byte(specJSON), spec); err != nil {
+ if jsonErr := json.Unmarshal([]byte(specJSON), spec); jsonErr != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/pkg/core/versioning/resource_store_adapter.go b/pkg/core/versioning/resource_store_adapter.go
new file mode 100644
index 0000000..2d783f5
--- /dev/null
+++ b/pkg/core/versioning/resource_store_adapter.go
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package versioning
+
+import (
+ "fmt"
+ "hash/fnv"
+ "sync"
+
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/store"
+)
+
+const parentLockStripes = 256
+const maxVersionAllocateAttempts = 16
+
+// ResourceStoreAdapter routes RuleVersion resources through the existing
+// resource store. The striped mutexes only keep a single adapter instance
+// internally consistent while it derives monotonically increasing version
+// numbers for audit entries.
+type ResourceStoreAdapter struct {
+ versionStore store.ResourceStore
+ parentLocks [parentLockStripes]sync.Mutex
+}
+
+func NewResourceStoreAdapter(versionStore store.ResourceStore) *ResourceStoreAdapter {
+ return &ResourceStoreAdapter{
+ versionStore: versionStore,
+ }
+}
+
+func (a *ResourceStoreAdapter) ensureStores() error {
+ if a == nil || a.versionStore == nil {
+ return fmt.Errorf("%w: RuleVersion store is required", ErrVersionStoreError)
+ }
+ return nil
+}
+
+func (a *ResourceStoreAdapter) LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) {
+ snapshot, err := a.HistorySnapshot(kind, resourceKey)
+ if err != nil {
+ return nil, err
+ }
+ if snapshot.Head == nil {
+ return nil, ErrVersionNotFound
+ }
+ return snapshot.Head, nil
+}
+
+func (a *ResourceStoreAdapter) withParentLock(kind coremodel.ResourceKind, resourceKey string, fn func() error) error {
+ key := fmt.Sprintf("%s/%s", kind, resourceKey)
+ mu := &a.parentLocks[parentLockIndex(key)]
+ mu.Lock()
+ defer mu.Unlock()
+ return fn()
+}
+
+func parentLockIndex(key string) uint32 {
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(key))
+ return h.Sum32() % parentLockStripes
+}
diff --git a/pkg/core/versioning/resource_store_adapter_test.go b/pkg/core/versioning/resource_store_adapter_test.go
new file mode 100644
index 0000000..ff412ba
--- /dev/null
+++ b/pkg/core/versioning/resource_store_adapter_test.go
@@ -0,0 +1,228 @@
+/*
+ * 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 versioning
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/store"
+ memoryst "github.com/apache/dubbo-admin/pkg/store/memory"
+)
+
+func TestResourceStoreAdapter_AppendsAndListsByParentRule(t *testing.T) {
+ versionStore := newVersionStore(t)
+ adapter := NewResourceStoreAdapter(versionStore)
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+
+ v1, err := adapter.InsertVersion(context.Background(), insertRequestForTest(t, res, OperationCreate), 10)
+ require.NoError(t, err)
+ updated := conditionRouteForVersionTest("demo-rule", "v2")
+ v2, err := adapter.InsertVersion(context.Background(), insertRequestForTest(t, updated, OperationUpdate), 10)
+ require.NoError(t, err)
+
+ snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey())
+ require.NoError(t, err)
+ require.Len(t, snapshot.Versions, 2)
+ assert.Equal(t, v2.VersionNo, snapshot.Head.VersionNo)
+ assert.Equal(t, int64(2), snapshot.Head.VersionNo)
+ assert.True(t, snapshot.Versions[0].IsLatestRecorded)
+ assert.False(t, snapshot.Versions[1].IsLatestRecorded)
+ assert.Equal(t, int64(1), v1.VersionNo)
+ assert.Equal(t, int64(2), v2.VersionNo)
+}
+
+func TestResourceStoreAdapter_UsesDeterministicVersionResourceKey(t *testing.T) {
+ versionStore := newVersionStore(t)
+ adapter := NewResourceStoreAdapter(versionStore)
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+
+ version, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, res, OperationCreate), 10)
+ require.NoError(t, err)
+
+ item, exists, err := versionStore.GetByKey(buildVersionResourceKey(res.ResourceKind(), res.ResourceKey(), version.VersionNo))
+ require.NoError(t, err)
+ require.True(t, exists)
+ ruleVersion := item.(*meshresource.RuleVersionResource)
+ assert.Empty(t, ruleVersion.Annotations)
+}
+
+func TestResourceStoreAdapter_GetVersionValidatesDeterministicKeyContents(t *testing.T) {
+ versionStore := newVersionStore(t)
+ adapter := NewResourceStoreAdapter(versionStore)
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+ req := insertRequestForTest(t, res, OperationCreate)
+ ruleVersion := newRuleVersionResource(req, 1, time.Now(), time.Now())
+ ruleVersion.Spec.ParentRuleName = "different-parent"
+ require.NoError(t, versionStore.Add(ruleVersion))
+
+ _, err := adapter.GetVersion(res.ResourceKind(), res.ResourceKey(), 1)
+ require.ErrorIs(t, err, ErrVersionStoreError)
+}
+
+func TestResourceStoreAdapter_RetriesVersionNumberAfterDeterministicKeyConflict(t *testing.T) {
+ versionStore := newVersionStore(t)
+ conflictingStore := &addConflictOnceStore{ResourceStore: versionStore}
+ adapter := NewResourceStoreAdapter(conflictingStore)
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+
+ version, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, res, OperationCreate), 10)
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), version.VersionNo)
+
+ versions, err := adapter.ListVersions(res.ResourceKind(), res.ResourceKey())
+ require.NoError(t, err)
+ require.Len(t, versions, 2)
+ assert.Equal(t, int64(2), versions[0].VersionNo)
+ assert.Equal(t, int64(1), versions[1].VersionNo)
+}
+
+func TestResourceStoreAdapter_RetentionDoesNotReuseVersionNumbers(t *testing.T) {
+ adapter := NewResourceStoreAdapter(newVersionStore(t))
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+
+ for i := 0; i < 4; i++ {
+ res = conditionRouteForVersionTest("demo-rule", string(rune('a'+i)))
+ version, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, res, OperationUpdate), 2)
+ require.NoError(t, err)
+ assert.Equal(t, int64(i+1), version.VersionNo)
+ }
+
+ versions, err := adapter.ListVersions(res.ResourceKind(), res.ResourceKey())
+ require.NoError(t, err)
+ require.Len(t, versions, 2)
+ assert.Equal(t, []int64{4, 3}, []int64{versions[0].VersionNo, versions[1].VersionNo})
+}
+
+func TestResourceStoreAdapter_ZeroRetentionLimitStillRecordsHistory(t *testing.T) {
+ adapter := NewResourceStoreAdapter(newVersionStore(t))
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+
+ for i := 0; i < 3; i++ {
+ _, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, res, OperationUpdate), 0)
+ require.NoError(t, err)
+ }
+
+ versions, err := adapter.ListVersions(res.ResourceKind(), res.ResourceKey())
+ require.NoError(t, err)
+ assert.Len(t, versions, 3)
+}
+
+func TestResourceStoreAdapter_DeleteVersionStoresAbsenceMarker(t *testing.T) {
+ versionStore := newVersionStore(t)
+ adapter := NewResourceStoreAdapter(versionStore)
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+ _, err := adapter.InsertVersion(context.Background(), insertRequestForTest(t, res, OperationCreate), 10)
+ require.NoError(t, err)
+ deleteReq := insertRequestForTest(t, res, OperationDelete)
+ _, err = adapter.InsertVersion(context.Background(), deleteReq, 10)
+ require.NoError(t, err)
+
+ snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey())
+ require.NoError(t, err)
+ require.True(t, snapshot.Deleted)
+ require.NotNil(t, snapshot.Head)
+ assert.Equal(t, OperationDelete, snapshot.Head.Operation)
+ assert.Equal(t, DeleteSpecJSON, snapshot.Head.SpecJSON)
+}
+
+func TestRecordBootstrapStateCreatesOnlyInitialBaseline(t *testing.T) {
+ versionStore := newVersionStore(t)
+ adapter := NewResourceStoreAdapter(versionStore)
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+
+ require.NoError(t, RecordBootstrapState(context.Background(), adapter, 10, res))
+
+ snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey())
+ require.NoError(t, err)
+ require.Len(t, snapshot.Versions, 1)
+ assert.Equal(t, OperationCreate, snapshot.Versions[0].Operation)
+ assert.Equal(t, SourceBootstrap, snapshot.Versions[0].Source)
+ assert.Contains(t, snapshot.Versions[0].SpecJSON, "v1")
+}
+
+func TestRecordBootstrapStateDoesNotReconcileWhenHistoryExists(t *testing.T) {
+ versionStore := newVersionStore(t)
+ adapter := NewResourceStoreAdapter(versionStore)
+ res := conditionRouteForVersionTest("demo-rule", "v1")
+ require.NoError(t, RecordBootstrapState(context.Background(), adapter, 10, res))
+
+ require.NoError(t, RecordBootstrapState(context.Background(), adapter, 10, conditionRouteForVersionTest("demo-rule", "v2-outside-history")))
+
+ snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey())
+ require.NoError(t, err)
+ require.Len(t, snapshot.Versions, 1)
+ assert.Equal(t, OperationCreate, snapshot.Versions[0].Operation)
+ assert.Contains(t, snapshot.Versions[0].SpecJSON, "v1")
+}
+
+func newVersionStore(t *testing.T) store.ManagedResourceStore {
+ t.Helper()
+ s := memoryst.NewMemoryResourceStore(meshresource.RuleVersionKind)
+ require.NoError(t, s.Init(nil))
+ return s
+}
+
+func conditionRouteForVersionTest(name, payload string) *meshresource.ConditionRouteResource {
+ res := meshresource.NewConditionRouteResourceWithAttributes(name, "")
+ res.Spec = &meshproto.ConditionRoute{Enabled: true, Key: name, Conditions: []string{payload}}
+ return res
+}
+
+func insertRequestForTest(t *testing.T, res *meshresource.ConditionRouteResource, op Operation) InsertRequest {
+ t.Helper()
+ req, err := BuildInsertRequest(res, op, SourceAdmin, "admin", "", nil, time.Now())
+ require.NoError(t, err)
+ return req
+}
+
+type addConflictOnceStore struct {
+ store.ResourceStore
+ once sync.Once
+}
+
+func (s *addConflictOnceStore) Add(obj interface{}) error {
+ var (
+ first bool
+ addErr error
+ conflict error
+ )
+ s.once.Do(func() {
+ first = true
+ res := obj.(coremodel.Resource)
+ addErr = s.ResourceStore.Add(obj)
+ if addErr == nil {
+ conflict = store.ErrorResourceAlreadyExists(res.ResourceKind().ToString(), res.ResourceMeta().Name, res.ResourceMesh())
+ }
+ })
+ if first {
+ if addErr != nil {
+ return addErr
+ }
+ return conflict
+ }
+ return s.ResourceStore.Add(obj)
+}
diff --git a/pkg/core/versioning/resource_store_convert.go b/pkg/core/versioning/resource_store_convert.go
new file mode 100644
index 0000000..894b9c5
--- /dev/null
+++ b/pkg/core/versioning/resource_store_convert.go
@@ -0,0 +1,120 @@
+/*
+ * 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 versioning
+
+import (
+ "fmt"
+ "time"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+)
+
+func buildVersionResourceKey(kind coremodel.ResourceKind, parentResourceKey string, versionNo int64) string {
+ name := fmt.Sprintf("%s-%s-version-%d", kind, extractName(parentResourceKey), versionNo)
+ return coremodel.BuildResourceKey(extractMesh(parentResourceKey), name)
+}
+
+func buildParentIndexKey(kind coremodel.ResourceKind, resourceKey string) string {
+ mesh := extractMesh(resourceKey)
+ name := extractName(resourceKey)
+ return fmt.Sprintf("%s/%s/%s", kind, mesh, name)
+}
+
+func extractMesh(resourceKey string) string {
+ mesh, _ := coremodel.ParseResourceKey(resourceKey)
+ return mesh
+}
+
+func extractName(resourceKey string) string {
+ _, name := coremodel.ParseResourceKey(resourceKey)
+ return name
+}
+
+func protoToVersion(spec *meshproto.RuleVersion) (*Version, error) {
+ if spec == nil {
+ return nil, bizerror.New(bizerror.InvalidArgument, "RuleVersion spec is nil")
+ }
+
+ var rolledBackFromVersionNo *int64
+ if spec.RolledBackFromVersionNo != 0 {
+ v := spec.RolledBackFromVersionNo
+ rolledBackFromVersionNo = &v
+ }
+
+ createdAt := timestampAsTime(spec.CreatedAt)
+ recordedAt := timestampAsTime(spec.RecordedAt)
+ if recordedAt.IsZero() {
+ recordedAt = createdAt
+ }
+
+ return &Version{
+ RuleKind: coremodel.ResourceKind(spec.ParentRuleKind),
+ Mesh: spec.ParentRuleMesh,
+ ResourceKey: coremodel.BuildResourceKey(spec.ParentRuleMesh, spec.ParentRuleName),
+ RuleName: spec.ParentRuleName,
+ VersionNo: spec.VersionNo,
+ ContentHash: spec.ContentHash,
+ SpecJSON: spec.SpecJson,
+ Operation: Operation(spec.Operation),
+ Source: Source(spec.Source),
+ Author: spec.Author,
+ Reason: spec.Reason,
+ RolledBackFromVersionNo: rolledBackFromVersionNo,
+ CreatedAt: createdAt,
+ RecordedAt: recordedAt,
+ IsLatestRecorded: false,
+ }, nil
+}
+
+func historySnapshotFromState(state *historyState) *HistorySnapshot {
+ if state == nil {
+ return &HistorySnapshot{}
+ }
+ snapshot := &HistorySnapshot{
+ Versions: append([]Version(nil), state.Versions...),
+ }
+ if len(snapshot.Versions) == 0 {
+ return snapshot
+ }
+ head := snapshot.Versions[0]
+ snapshot.Head = &head
+ snapshot.Deleted = head.Operation == OperationDelete
+ snapshot.Versions[0].IsLatestRecorded = true
+ return snapshot
+}
+
+func duplicateVersionNoError(kind coremodel.ResourceKind, resourceKey string, versionNo int64) error {
+ return fmt.Errorf("%w: duplicate version number for kind=%s mesh=%s rule=%s versionNo=%d",
+ ErrVersionStoreError,
+ kind,
+ extractMesh(resourceKey),
+ extractName(resourceKey),
+ versionNo,
+ )
+}
+
+func timestampAsTime(ts *timestamppb.Timestamp) time.Time {
+ if ts == nil {
+ return time.Time{}
+ }
+ return ts.AsTime()
+}
diff --git a/pkg/core/versioning/resource_store_version.go b/pkg/core/versioning/resource_store_version.go
new file mode 100644
index 0000000..f686a2b
--- /dev/null
+++ b/pkg/core/versioning/resource_store_version.go
@@ -0,0 +1,384 @@
+/*
+ * 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 versioning
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "time"
+
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ "github.com/apache/dubbo-admin/pkg/core/logger"
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/store/index"
+)
+
+func (a *ResourceStoreAdapter) GetVersion(kind coremodel.ResourceKind, resourceKey string, versionNo int64) (*Version, error) {
+ if err := a.ensureStores(); err != nil {
+ return nil, err
+ }
+ rv, err := a.getVersionResourceForRule(kind, resourceKey, versionNo)
+ if err != nil {
+ return nil, err
+ }
+ return protoToVersion(rv.Spec)
+}
+
+func (a *ResourceStoreAdapter) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) {
+ if err := a.ensureStores(); err != nil {
+ return nil, err
+ }
+ snapshot, err := a.HistorySnapshot(kind, resourceKey)
+ if err != nil {
+ return nil, err
+ }
+ return snapshot.Versions, nil
+}
+
+func (a *ResourceStoreAdapter) ListLatestVersions(kind coremodel.ResourceKind) ([]Version, error) {
+ if err := a.ensureStores(); err != nil {
+ return nil, err
+ }
+ // ListLatestVersions builds the global latest list, so it scans all stored
+ // RuleVersion resources and groups by parent. ByParentRule is only suitable
+ // for a single parent query; this pass intentionally avoids a new index.
+ keys := a.versionStore.ListKeys()
+ objs, err := a.versionStore.GetByKeys(keys)
+ if err != nil {
+ return nil, err
+ }
+ versions, err := versionsFromResources(objs, kind, "", false, "")
+ if err != nil {
+ return nil, err
+ }
+ byParent := make(map[string][]Version)
+ for _, version := range versions {
+ byParent[version.ResourceKey] = append(byParent[version.ResourceKey], version)
+ }
+
+ latest := make([]Version, 0, len(byParent))
+ for resourceKey, versions := range byParent {
+ if err := validateAndSortVersions(kind, resourceKey, versions); err != nil {
+ return nil, err
+ }
+ if len(versions) > 0 {
+ latest = append(latest, versions[0])
+ }
+ }
+ sort.Slice(latest, func(i, j int) bool {
+ if latest[i].ResourceKey == latest[j].ResourceKey {
+ return latest[i].VersionNo > latest[j].VersionNo
+ }
+ return latest[i].ResourceKey < latest[j].ResourceKey
+ })
+ return latest, nil
+}
+
+func (a *ResourceStoreAdapter) HistorySnapshot(kind coremodel.ResourceKind, resourceKey string) (*HistorySnapshot, error) {
+ if err := a.ensureStores(); err != nil {
+ return nil, err
+ }
+ var snapshot *HistorySnapshot
+ err := a.withParentLock(kind, resourceKey, func() error {
+ state, err := a.historyState(kind, resourceKey)
+ if err != nil {
+ return err
+ }
+ snapshot = historySnapshotFromState(state)
+ return nil
+ })
+ return snapshot, err
+}
+
+func (a *ResourceStoreAdapter) latestVersionLocked(kind coremodel.ResourceKind, resourceKey string) (*Version, error) {
+ state, err := a.historyState(kind, resourceKey)
+ if err != nil {
+ return nil, err
+ }
+ if state.Latest == nil {
+ return nil, ErrVersionNotFound
+ }
+ return state.Latest, nil
+}
+
+func (a *ResourceStoreAdapter) historyState(kind coremodel.ResourceKind, resourceKey string) (*historyState, error) {
+ parentKey := buildParentIndexKey(kind, resourceKey)
+ objs, err := a.versionStore.ByIndex(index.ByParentRuleIndexName, parentKey)
+ if err != nil {
+ return nil, err
+ }
+
+ versions, err := versionsFromIndexObjects(objs, kind, resourceKey, true, "parent "+parentKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := validateAndSortVersions(kind, resourceKey, versions); err != nil {
+ return nil, err
+ }
+
+ state := &historyState{Versions: versions}
+ if len(versions) > 0 {
+ state.Latest = &versions[0]
+ state.MaxVersionNo = versions[0].VersionNo
+ }
+ return state, nil
+}
+
+func versionsFromResources(objs []coremodel.Resource, kind coremodel.ResourceKind, resourceKey string, filterParent bool, specContext string) ([]Version, error) {
+ versions := make([]Version, 0, len(objs))
+ for _, obj := range objs {
+ version, include, err := versionFromObject(obj, kind, resourceKey, filterParent, specContext)
+ if err != nil {
+ return nil, err
+ }
+ if include {
+ versions = append(versions, version)
+ }
+ }
+ return versions, nil
+}
+
+func versionsFromIndexObjects(objs []interface{}, kind coremodel.ResourceKind, resourceKey string, filterParent bool, specContext string) ([]Version, error) {
+ versions := make([]Version, 0, len(objs))
+ for _, obj := range objs {
+ version, include, err := versionFromObject(obj, kind, resourceKey, filterParent, specContext)
+ if err != nil {
+ return nil, err
+ }
+ if include {
+ versions = append(versions, version)
+ }
+ }
+ return versions, nil
+}
+
+func versionFromObject(obj interface{}, kind coremodel.ResourceKind, resourceKey string, filterParent bool, specContext string) (Version, bool, error) {
+ rv, ok := obj.(*meshresource.RuleVersionResource)
+ if !ok {
+ return Version{}, false, fmt.Errorf("%w: expected RuleVersionResource, got %T", ErrVersionStoreError, obj)
+ }
+ if rv.Spec == nil {
+ if specContext == "" {
+ specContext = rv.ResourceKey()
+ }
+ return Version{}, false, fmt.Errorf("%w: RuleVersion spec is nil for %s", ErrVersionStoreError, specContext)
+ }
+ if rv.Spec.ParentRuleKind != string(kind) {
+ return Version{}, false, nil
+ }
+ if filterParent && !versionResourceMatchesParent(rv, kind, resourceKey) {
+ return Version{}, false, nil
+ }
+ v, err := protoToVersion(rv.Spec)
+ if err != nil {
+ return Version{}, false, err
+ }
+ return *v, true, nil
+}
+
+func validateAndSortVersions(kind coremodel.ResourceKind, resourceKey string, versions []Version) error {
+ seenVersionNo := make(map[int64]struct{}, len(versions))
+ for _, version := range versions {
+ if _, ok := seenVersionNo[version.VersionNo]; ok {
+ return duplicateVersionNoError(kind, resourceKey, version.VersionNo)
+ }
+ seenVersionNo[version.VersionNo] = struct{}{}
+ }
+ sort.Slice(versions, func(i, j int) bool {
+ return versions[i].VersionNo > versions[j].VersionNo
+ })
+ return nil
+}
+
+func (a *ResourceStoreAdapter) InsertVersion(ctx context.Context, req InsertRequest, maxVersions int64) (*Version, error) {
+ if err := a.ensureStores(); err != nil {
+ return nil, err
+ }
+ var version *Version
+ err := a.withParentLock(req.RuleKind, req.ResourceKey, func() error {
+ var inner error
+ version, inner = a.insertVersionLocked(ctx, req, maxVersions)
+ return inner
+ })
+ return version, err
+}
+
+func (a *ResourceStoreAdapter) insertVersionLocked(ctx context.Context, req InsertRequest, maxVersions int64) (*Version, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ state, err := a.historyState(req.RuleKind, req.ResourceKey)
+ if err != nil {
+ return nil, err
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ versionNo := state.MaxVersionNo + 1
+
+ createdAt := req.CreatedAt
+ if createdAt.IsZero() {
+ createdAt = time.Now()
+ }
+ recordedAt := time.Now()
+
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ var rv *meshresource.RuleVersionResource
+ var addErr error
+ for attempt := 0; attempt < maxVersionAllocateAttempts; attempt++ {
+ rv = newRuleVersionResource(req, versionNo, createdAt, recordedAt)
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ addErr = a.versionStore.Add(rv)
+ if addErr == nil {
+ break
+ }
+
+ // A concurrent database writer can win the deterministic ResourceKey.
+ // Confirm that key now exists, then re-read the parent history before
+ // allocating the next monotonically increasing VersionNo.
+ if _, err := a.getVersionResourceForRule(req.RuleKind, req.ResourceKey, versionNo); err != nil {
+ if errors.Is(err, ErrVersionNotFound) {
+ return nil, fmt.Errorf("failed to add rule version %d: %w", versionNo, addErr)
+ }
+ return nil, err
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ state, err = a.historyState(req.RuleKind, req.ResourceKey)
+ if err != nil {
+ return nil, err
+ }
+ nextVersionNo := state.MaxVersionNo + 1
+ if nextVersionNo <= versionNo {
+ return nil, fmt.Errorf("failed to allocate unique rule version number %d: %w", versionNo, addErr)
+ }
+ versionNo = nextVersionNo
+ }
+ if addErr != nil {
+ return nil, fmt.Errorf("failed to allocate unique rule version number after %d attempts: %w", maxVersionAllocateAttempts, addErr)
+ }
+
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ if maxVersions > 0 {
+ if err := a.trimVersionsLocked(ctx, req.RuleKind, req.ResourceKey, maxVersions); err != nil {
+ logger.Warnf("rule version retention cleanup failed for kind=%s resourceKey=%s recordedVersionNo=%d: %v", req.RuleKind, req.ResourceKey, versionNo, err)
+ }
+ }
+
+ return protoToVersion(rv.Spec)
+}
+
+func (a *ResourceStoreAdapter) trimVersionsLocked(ctx context.Context, kind coremodel.ResourceKind, resourceKey string, keep int64) error {
+ state, err := a.historyState(kind, resourceKey)
+ if err != nil {
+ return err
+ }
+ versions := state.Versions
+ if int64(len(versions)) <= keep {
+ return nil
+ }
+
+ // Retention runs after the new version is durable and only removes entries
+ // beyond the configured window. Cleanup failure is reported to logs by the
+ // caller and does not roll back the already-written rule mutation.
+ toDelete := versions[int(keep):]
+ for _, v := range toDelete {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ rv, err := a.getVersionResourceForRule(kind, resourceKey, v.VersionNo)
+ if err != nil {
+ return err
+ }
+ if err := a.versionStore.Delete(rv); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (a *ResourceStoreAdapter) getVersionResourceForRule(kind coremodel.ResourceKind, resourceKey string, versionNo int64) (*meshresource.RuleVersionResource, error) {
+ versionResourceKey := buildVersionResourceKey(kind, resourceKey, versionNo)
+ obj, exists, err := a.versionStore.GetByKey(versionResourceKey)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ return nil, ErrVersionNotFound
+ }
+ rv, ok := obj.(*meshresource.RuleVersionResource)
+ if !ok {
+ return nil, fmt.Errorf("%w: expected RuleVersionResource, got %T", ErrVersionStoreError, obj)
+ }
+ if rv.Spec == nil {
+ return nil, fmt.Errorf("%w: RuleVersion spec is nil for key %s", ErrVersionStoreError, versionResourceKey)
+ }
+ if !versionResourceMatchesParent(rv, kind, resourceKey) || rv.Spec.VersionNo != versionNo {
+ return nil, fmt.Errorf("%w: RuleVersion key %s does not match parent or version number", ErrVersionStoreError, versionResourceKey)
+ }
+ return rv, nil
+}
+
+func versionResourceMatchesParent(rv *meshresource.RuleVersionResource, kind coremodel.ResourceKind, resourceKey string) bool {
+ return rv != nil &&
+ rv.Spec != nil &&
+ rv.Spec.ParentRuleKind == string(kind) &&
+ rv.Spec.ParentRuleMesh == extractMesh(resourceKey) &&
+ rv.Spec.ParentRuleName == extractName(resourceKey)
+}
+
+func newRuleVersionResource(req InsertRequest, versionNo int64, createdAt, recordedAt time.Time) *meshresource.RuleVersionResource {
+ _, name := coremodel.ParseResourceKey(buildVersionResourceKey(req.RuleKind, req.ResourceKey, versionNo))
+ rv := meshresource.NewRuleVersionResourceWithAttributes(
+ name,
+ extractMesh(req.ResourceKey),
+ )
+ rv.Spec = &meshproto.RuleVersion{
+ ParentRuleKind: string(req.RuleKind),
+ ParentRuleMesh: extractMesh(req.ResourceKey),
+ ParentRuleName: extractName(req.ResourceKey),
+ VersionNo: versionNo,
+ ContentHash: req.ContentHash,
+ SpecJson: req.SpecJSON,
+ Operation: string(req.Operation),
+ Source: string(req.Source),
+ Author: req.Author,
+ Reason: req.Reason,
+ CreatedAt: timestamppb.New(createdAt),
+ RecordedAt: timestamppb.New(recordedAt),
+ }
+ if req.RolledBackFromVersionNo != nil {
+ rv.Spec.RolledBackFromVersionNo = *req.RolledBackFromVersionNo
+ }
+ return rv
+}
diff --git a/pkg/core/versioning/service.go b/pkg/core/versioning/service.go
new file mode 100644
index 0000000..25147c3
--- /dev/null
+++ b/pkg/core/versioning/service.go
@@ -0,0 +1,204 @@
+/*
+ * 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 versioning
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apache/dubbo-admin/pkg/common/bizerror"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+)
+
+// Service provides rule history operations. RuleVersion entries are audit
+// records and rollback material; live current state is still owned by
+// ResourceManager and the backing registry.
+type Service struct {
+ maxVersions int64
+ store *ResourceStoreAdapter
+}
+
+func NewService(maxVersions int64, store *ResourceStoreAdapter) *Service {
+ return &Service{
+ maxVersions: maxVersions,
+ store: store,
+ }
+}
+
+func (s *Service) ensureAvailable() error {
+ if s == nil || s.store == nil {
+ return ErrVersionStoreError
+ }
+ return nil
+}
+
+func (s *Service) List(kind coremodel.ResourceKind, mesh, ruleName string) (*ListResult, error) {
+ if err := s.ensureAvailable(); err != nil {
+ return nil, err
+ }
+ resourceKey := coremodel.BuildResourceKey(mesh, ruleName)
+ snapshot, err := s.store.HistorySnapshot(kind, resourceKey)
+ if err != nil {
+ return nil, err
+ }
+ result := &ListResult{Items: snapshot.Versions, Total: int64(len(snapshot.Versions)), LatestRecordedDeleted: snapshot.Deleted}
+ if snapshot.Head != nil {
+ result.LatestRecordedVersionNo = snapshot.Head.VersionNo
+ }
+ return result, nil
+}
+
+func (s *Service) Get(kind coremodel.ResourceKind, mesh, ruleName string, versionNo int64) (*Version, error) {
+ if err := s.ensureAvailable(); err != nil {
+ return nil, err
+ }
+ resourceKey := coremodel.BuildResourceKey(mesh, ruleName)
+ version, err := s.store.GetVersion(kind, resourceKey, versionNo)
+ if err != nil {
+ return nil, err
+ }
+ snapshot, err := s.store.HistorySnapshot(kind, resourceKey)
+ if err != nil {
+ return nil, err
+ }
+ if snapshot.Head != nil {
+ version.IsLatestRecorded = version.VersionNo == snapshot.Head.VersionNo
+ }
+ return version, nil
+}
+
+func (s *Service) DiffHistoryVersions(kind coremodel.ResourceKind, mesh, ruleName string, versionNo int64, against string) (*DiffResult, error) {
+ if err := s.ensureAvailable(); err != nil {
+ return nil, err
+ }
+ left, err := s.Get(kind, mesh, ruleName, versionNo)
+ if err != nil {
+ return nil, err
+ }
+ right, err := s.diffRight(kind, mesh, ruleName, versionNo, against)
+ if err != nil {
+ return nil, err
+ }
+ return &DiffResult{
+ Left: DiffSide{VersionNo: left.VersionNo, SpecJSON: left.SpecJSON},
+ Right: DiffSide{VersionNo: right.VersionNo, SpecJSON: right.SpecJSON},
+ }, nil
+}
+
+func (s *Service) diffRight(kind coremodel.ResourceKind, mesh, ruleName string, versionNo int64, against string) (*Version, error) {
+ switch against {
+ case "previous":
+ list, err := s.store.ListVersions(kind, coremodel.BuildResourceKey(mesh, ruleName))
+ if err != nil {
+ return nil, err
+ }
+ sort.Slice(list, func(i, j int) bool {
+ return list[i].VersionNo > list[j].VersionNo
+ })
+ for i := range list {
+ if list[i].VersionNo != versionNo {
+ continue
+ }
+ if i+1 >= len(list) {
+ return nil, ErrVersionNotFound
+ }
+ return &list[i+1], nil
+ }
+ return nil, ErrVersionNotFound
+ case "", "current":
+ return nil, bizerror.New(bizerror.InvalidArgument, "current diff requires live ResourceManager state and is implemented by the console service")
+ default:
+ againstVersionNo, err := strconv.ParseInt(against, 10, 64)
+ if err != nil || againstVersionNo <= 0 {
+ return nil, bizerror.New(bizerror.InvalidArgument, "against must be 'current', 'previous', or a version number")
+ }
+ return s.Get(kind, mesh, ruleName, againstVersionNo)
+ }
+}
+
+func (s *Service) Append(ctx context.Context, res coremodel.Resource, op Operation, source Source, author, reason string, rolledBackFromVersionNo *int64) (*Version, error) {
+ if err := s.ensureAvailable(); err != nil {
+ return nil, err
+ }
+ req, err := BuildInsertRequest(res, op, source, author, reason, rolledBackFromVersionNo, time.Now())
+ if err != nil {
+ return nil, err
+ }
+ return s.store.InsertVersion(ctx, req, s.maxVersions)
+}
+
+func (s *Service) LatestVersion(kind coremodel.ResourceKind, mesh, ruleName string) (*Version, error) {
+ if err := s.ensureAvailable(); err != nil {
+ return nil, err
+ }
+ return s.store.LatestVersion(kind, coremodel.BuildResourceKey(mesh, ruleName))
+}
+
+func (s *Service) HasHistory(kind coremodel.ResourceKind, mesh, ruleName string) (bool, error) {
+ if err := s.ensureAvailable(); err != nil {
+ return false, err
+ }
+ _, err := s.store.LatestVersion(kind, coremodel.BuildResourceKey(mesh, ruleName))
+ if err == nil {
+ return true, nil
+ }
+ if errors.Is(err, ErrVersionNotFound) {
+ return false, nil
+ }
+ return false, err
+}
+
+func BuildInsertRequest(res coremodel.Resource, op Operation, source Source, author, reason string, rolledBackFromVersionNo *int64, createdAt time.Time) (InsertRequest, error) {
+ if res == nil {
+ return InsertRequest{}, bizerror.New(bizerror.InvalidArgument, "rule resource is required")
+ }
+ hash, specJSON, err := NormalizeResource(res)
+ if err != nil {
+ return InsertRequest{}, err
+ }
+ if op == OperationDelete {
+ specJSON = DeleteSpecJSON
+ hash = HashSpecJSON(specJSON)
+ }
+ if strings.TrimSpace(author) == "" {
+ author = "system:unknown"
+ } else {
+ author = strings.TrimSpace(author)
+ }
+ if source == "" {
+ source = SourceAdmin
+ }
+ return InsertRequest{
+ RuleKind: res.ResourceKind(),
+ Mesh: res.ResourceMesh(),
+ ResourceKey: res.ResourceKey(),
+ RuleName: res.ResourceMeta().Name,
+ SpecJSON: specJSON,
+ ContentHash: hash,
+ Source: source,
+ Operation: op,
+ Author: author,
+ Reason: reason,
+ RolledBackFromVersionNo: rolledBackFromVersionNo,
+ CreatedAt: createdAt,
+ }, nil
+}
diff --git a/pkg/core/versioning/service_test.go b/pkg/core/versioning/service_test.go
new file mode 100644
index 0000000..2c175f9
--- /dev/null
+++ b/pkg/core/versioning/service_test.go
@@ -0,0 +1,71 @@
+/*
+ * 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 versioning
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+)
+
+func TestDiffHistoryVersionsPreviousUsesPriorRecordedVersion(t *testing.T) {
+ adapter := NewResourceStoreAdapter(newVersionStore(t))
+ svc := NewService(10, adapter)
+ v1, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v1"), OperationCreate), 10)
+ require.NoError(t, err)
+ v2, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v2"), OperationUpdate), 10)
+ require.NoError(t, err)
+ v3, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v3"), OperationUpdate), 10)
+ require.NoError(t, err)
+ require.NotEqual(t, v1.VersionNo, v2.VersionNo)
+
+ diff, err := svc.DiffHistoryVersions(meshresource.ConditionRouteKind, "", "demo-rule", v3.VersionNo, "previous")
+ require.NoError(t, err)
+ assert.Equal(t, v3.VersionNo, diff.Left.VersionNo)
+ assert.Equal(t, v2.VersionNo, diff.Right.VersionNo)
+ assert.Contains(t, diff.Right.SpecJSON, "v2")
+}
+
+func TestDiffHistoryVersionsUsesExplicitVersionNo(t *testing.T) {
+ adapter := NewResourceStoreAdapter(newVersionStore(t))
+ svc := NewService(10, adapter)
+ v1, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v1"), OperationCreate), 10)
+ require.NoError(t, err)
+ v2, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v2"), OperationUpdate), 10)
+ require.NoError(t, err)
+
+ diff, err := svc.DiffHistoryVersions(meshresource.ConditionRouteKind, "", "demo-rule", v2.VersionNo, strconv.FormatInt(v1.VersionNo, 10))
+ require.NoError(t, err)
+ assert.Equal(t, v2.VersionNo, diff.Left.VersionNo)
+ assert.Equal(t, v1.VersionNo, diff.Right.VersionNo)
+}
+
+func TestDiffHistoryVersionsRejectsNonPositiveAgainstVersionNo(t *testing.T) {
+ adapter := NewResourceStoreAdapter(newVersionStore(t))
+ svc := NewService(10, adapter)
+ version, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v1"), OperationCreate), 10)
+ require.NoError(t, err)
+
+ _, err = svc.DiffHistoryVersions(meshresource.ConditionRouteKind, "", "demo-rule", version.VersionNo, "0")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "version number")
+}
diff --git a/pkg/core/versioning/types.go b/pkg/core/versioning/types.go
new file mode 100644
index 0000000..34ca33e
--- /dev/null
+++ b/pkg/core/versioning/types.go
@@ -0,0 +1,124 @@
+/*
+ * 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 versioning
+
+import (
+ "errors"
+ "time"
+
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+)
+
+// Source identifies where a rule mutation originated.
+// Used for auditing and distinguishing user-initiated changes from system-generated ones.
+type Source string
+
+const (
+ SourceAdmin Source = "ADMIN" // User edit via Admin UI/API
+ // SourceUpstream marks a version imported from an upstream registry event.
+ SourceUpstream Source = "UPSTREAM"
+ // SourceBootstrap represents a baseline record for an existing rule when it
+ // is first brought under version tracking.
+ SourceBootstrap Source = "BOOTSTRAP"
+ // SourceRollback marks a version produced by re-publishing a historical
+ // snapshot. Rollback records a new version and does not rewrite history.
+ SourceRollback Source = "ROLLBACK"
+)
+
+type Operation string
+
+const (
+ OperationCreate Operation = "CREATE"
+ OperationUpdate Operation = "UPDATE"
+ OperationDelete Operation = "DELETE"
+)
+
+var (
+ ErrVersionNotFound = errors.New("rule version not found")
+ ErrVersionStoreError = errors.New("rule version store error")
+ ErrRollbackToDelete = errors.New("cannot roll back to a DELETE marker")
+ ErrRollbackToCurrent = errors.New("cannot roll back to a version identical to current")
+)
+
+// Version represents a snapshot of a rule's spec at a point in time. Version
+// entries are immutable after creation. Rollback appends a new version, while
+// retention may delete the oldest entries. IsLatestRecorded is derived from
+// history only; it is not proof that this snapshot equals the live rule.
+type Version struct {
+ RuleKind coremodel.ResourceKind `json:"ruleKind"`
+ Mesh string `json:"mesh"`
+ ResourceKey string `json:"resourceKey"`
+ RuleName string `json:"ruleName"`
+ VersionNo int64 `json:"versionNo"`
+ ContentHash string `json:"contentHash"`
+ SpecJSON string `json:"specJson"`
+ Source Source `json:"source"`
+ Operation Operation `json:"operation"`
+ Author string `json:"author"`
+ Reason string `json:"reason,omitempty"`
+ // RolledBackFromVersionNo records the historical version whose snapshot was
+ // re-published to produce this version. It is audit metadata only.
+ RolledBackFromVersionNo *int64 `json:"rolledBackFromVersionNo,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ RecordedAt time.Time `json:"recordedAt"`
+ IsLatestRecorded bool `json:"isLatestRecorded"`
+}
+
+type historyState struct {
+ Versions []Version
+ Latest *Version
+ MaxVersionNo int64
+}
+
+type HistorySnapshot struct {
+ Versions []Version
+ Head *Version
+ Deleted bool
+}
+
+type InsertRequest struct {
+ RuleKind coremodel.ResourceKind
+ Mesh string
+ ResourceKey string
+ RuleName string
+ SpecJSON string
+ ContentHash string
+ Source Source
+ Operation Operation
+ Author string
+ Reason string
+ RolledBackFromVersionNo *int64
+ CreatedAt time.Time
+}
+
+type ListResult struct {
+ Items []Version `json:"items"`
+ Total int64 `json:"total"`
+ LatestRecordedVersionNo int64 `json:"latestRecordedVersionNo,omitempty"`
+ LatestRecordedDeleted bool `json:"latestRecordedDeleted"`
+}
+
+type DiffResult struct {
+ Left DiffSide `json:"left"`
+ Right DiffSide `json:"right"`
+}
+
+type DiffSide struct {
+ VersionNo int64 `json:"versionNo"`
+ SpecJSON string `json:"specJson"`
+}
diff --git a/pkg/store/dbcommon/gorm_store.go b/pkg/store/dbcommon/gorm_store.go
index cedc201..ccf54cf 100644
--- a/pkg/store/dbcommon/gorm_store.go
+++ b/pkg/store/dbcommon/gorm_store.go
@@ -594,7 +594,7 @@
func (gs *GormStore) getKeysByIndexes(indexes []index.IndexCondition) ([]string, error) {
if len(indexes) == 0 {
- return gs.ListKeys(), nil
+ return []string{}, nil
}
var keySet map[string]struct{}
diff --git a/pkg/store/dbcommon/gorm_store_test.go b/pkg/store/dbcommon/gorm_store_test.go
index b1dd1b8..238113c 100644
--- a/pkg/store/dbcommon/gorm_store_test.go
+++ b/pkg/store/dbcommon/gorm_store_test.go
@@ -21,11 +21,13 @@
"encoding/json"
"fmt"
"os"
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
+ "gorm.io/gorm"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -118,12 +120,26 @@
// setupTestStore creates a new GormStore with an in-memory SQLite database for testing
func setupTestStore(t *testing.T) (*GormStore, func()) {
// Create temporary SQLite database file for better isolation and reliability
- tmpFile, err := os.CreateTemp("", fmt.Sprintf("test-db-%s-*.db", t.Name()))
+ dbPath := tempSQLitePath(t)
+ dialector := sqlite.Open(dbPath)
+ return setupTestStoreWithDialector(t, dialector)
+}
+
+func tempSQLitePath(t *testing.T) string {
+ t.Helper()
+ safeName := strings.NewReplacer("/", "_", "\\", "_").Replace(t.Name())
+ tmpFile, err := os.CreateTemp("", fmt.Sprintf("test-db-%s-*.db", safeName))
require.NoError(t, err)
dbPath := tmpFile.Name()
- tmpFile.Close()
+ require.NoError(t, tmpFile.Close())
+ t.Cleanup(func() {
+ _ = os.Remove(dbPath)
+ })
+ return dbPath
+}
- dialector := sqlite.Open(dbPath)
+func setupTestStoreWithDialector(t *testing.T, dialector gorm.Dialector) (*GormStore, func()) {
+ t.Helper()
pool, err := NewConnectionPool(dialector, storecfg.MySQL, t.Name(), DefaultConnectionPoolConfig())
require.NoError(t, err)
@@ -146,8 +162,7 @@
// Cleanup function
cleanup := func() {
- pool.Close()
- os.Remove(dbPath)
+ _ = pool.Close()
}
return store, cleanup
@@ -802,10 +817,46 @@
err = store.Add(mockRes)
require.NoError(t, err)
- // List with empty indexes should return all resources
+ // Empty index conditions preserve memory-store semantics: no indexed query means no results.
resources, err := store.ListByIndexes([]index.IndexCondition{})
assert.NoError(t, err)
- assert.Len(t, resources, 1)
+ assert.Empty(t, resources)
+}
+
+func TestGormStore_ListResourcesSorted(t *testing.T) {
+ store, cleanup := setupTestStore(t)
+ defer cleanup()
+
+ err := store.Init(nil)
+ require.NoError(t, err)
+
+ mockRes1 := &mockResource{
+ Kind: "TestResource",
+ Key: "mesh/test-key-2",
+ Mesh: "mesh",
+ Meta: metav1.ObjectMeta{Name: "test-resource-2"},
+ }
+ mockRes2 := &mockResource{
+ Kind: "TestResource",
+ Key: "mesh/test-key-1",
+ Mesh: "mesh",
+ Meta: metav1.ObjectMeta{Name: "test-resource-1"},
+ }
+
+ err = store.Add(mockRes1)
+ require.NoError(t, err)
+ err = store.Add(mockRes2)
+ require.NoError(t, err)
+
+ resources := store.List()
+ require.Len(t, resources, 2)
+ // List() returns resources in arbitrary order, so check both are present
+ keys := []string{
+ resources[0].(model.Resource).ResourceKey(),
+ resources[1].(model.Resource).ResourceKey(),
+ }
+ assert.Contains(t, keys, "mesh/test-key-1")
+ assert.Contains(t, keys, "mesh/test-key-2")
}
func TestGormStore_PageListByIndexes(t *testing.T) {
diff --git a/pkg/store/memory/store_test.go b/pkg/store/memory/store_test.go
index 8ad4019..9bb51af 100644
--- a/pkg/store/memory/store_test.go
+++ b/pkg/store/memory/store_test.go
@@ -227,6 +227,44 @@
assert.Contains(t, list, mockRes2)
}
+func TestResourceStore_ListResourcesSortedAndEmptyIndexes(t *testing.T) {
+ store := NewMemoryResourceStore("TestResource")
+ err := store.Init(nil)
+ assert.NoError(t, err)
+
+ mockRes1 := &mockResource{
+ kind: "TestResource",
+ key: "mesh/test-key-2",
+ mesh: "mesh",
+ meta: metav1.ObjectMeta{Name: "test-resource-2"},
+ }
+ mockRes2 := &mockResource{
+ kind: "TestResource",
+ key: "mesh/test-key-1",
+ mesh: "mesh",
+ meta: metav1.ObjectMeta{Name: "test-resource-1"},
+ }
+
+ err = store.Add(mockRes1)
+ assert.NoError(t, err)
+ err = store.Add(mockRes2)
+ assert.NoError(t, err)
+
+ resources := store.List()
+ assert.Len(t, resources, 2)
+ // List() returns resources in arbitrary order, so check both are present
+ keys := []string{
+ resources[0].(model.Resource).ResourceKey(),
+ resources[1].(model.Resource).ResourceKey(),
+ }
+ assert.Contains(t, keys, "mesh/test-key-1")
+ assert.Contains(t, keys, "mesh/test-key-2")
+
+ indexed, err := store.ListByIndexes([]index.IndexCondition{})
+ assert.NoError(t, err)
+ assert.Empty(t, indexed)
+}
+
func TestResourceStore_ListKeys(t *testing.T) {
store := NewMemoryResourceStore("TestResource")
err := store.Init(nil)
diff --git a/release/kubernetes/dubbo-system/dubbo-admin.yaml b/release/kubernetes/dubbo-system/dubbo-admin.yaml
index 54216c4..517cdfe 100644
--- a/release/kubernetes/dubbo-system/dubbo-admin.yaml
+++ b/release/kubernetes/dubbo-system/dubbo-admin.yaml
@@ -96,12 +96,14 @@
observability:
grafana: http://grafana.monitoringg.svc:3000
prometheus: http://prometheus-k8s.monitoring.svc:9090/
- console:
- auth:
- user: admin
- password: dubbo@2025
- expirationTime: 3600
- discovery:
+ console:
+ auth:
+ user: admin
+ password: dubbo@2025
+ expirationTime: 3600
+ ruleVersioning:
+ maxVersionsPerRule: 20
+ discovery:
- type: nacos2
name: nacos2.5-standalone
id: nacos2.5
diff --git a/ui-vue3/package.json b/ui-vue3/package.json
index 70b06d7..4eec7fe 100644
--- a/ui-vue3/package.json
+++ b/ui-vue3/package.json
@@ -8,6 +8,7 @@
"dev:mock": "vite --mode mock",
"check:i18n": "node --loader ts-node/esm src/base/i18n/sortI18n.ts",
"preview": "vite preview",
+ "test": "vitest run",
"test:unit": "vitest",
"build": "prettier --write src/ && vite build",
"format": "prettier --write src/",
diff --git a/ui-vue3/src/api/service/traffic.ts b/ui-vue3/src/api/service/traffic.ts
index 9d4ff0a..06dd416 100644
--- a/ui-vue3/src/api/service/traffic.ts
+++ b/ui-vue3/src/api/service/traffic.ts
@@ -17,6 +17,100 @@
import request from '@/base/http/request'
+export type TrafficRuleKind = 'condition-rule' | 'tag-rule' | 'configurator'
+
+export interface RuleVersion {
+ ruleKind: string
+ mesh: string
+ resourceKey: string
+ ruleName: string
+ versionNo: number
+ contentHash: string
+ specJson: string
+ source: 'ADMIN' | 'UPSTREAM' | 'BOOTSTRAP' | 'ROLLBACK' | string
+ operation: 'CREATE' | 'UPDATE' | 'DELETE' | string
+ author: string
+ reason?: string
+ rolledBackFromVersionNo?: number
+ createdAt: string
+ recordedAt?: string
+ isLatestRecorded: boolean
+}
+
+export interface RuleVersionList {
+ items: RuleVersion[]
+ total: number
+ latestRecordedVersionNo?: number
+ latestRecordedDeleted?: boolean
+}
+
+export interface RuleVersionDiffSide {
+ versionNo: number
+ specJson: string
+}
+
+export interface RuleVersionDiff {
+ left: RuleVersionDiffSide
+ right: RuleVersionDiffSide
+}
+
+export interface RollbackRuleVersionResult {
+ rolledBackFromVersionNo: number
+ versionNo: number
+ source: 'ROLLBACK' | string
+}
+
+const ruleNameForPath = (kind: TrafficRuleKind, ruleName: string): string => {
+ return kind === 'configurator' ? encodeURIComponent(ruleName) : ruleName
+}
+
+export const listRuleVersionsAPI = (
+ kind: TrafficRuleKind,
+ ruleName: string
+): Promise<{ code: string; data: RuleVersionList }> => {
+ return request({
+ url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions`,
+ method: 'get'
+ })
+}
+
+export const getRuleVersionAPI = (
+ kind: TrafficRuleKind,
+ ruleName: string,
+ versionNo: number
+): Promise<{ code: string; data: RuleVersion }> => {
+ return request({
+ url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionNo}`,
+ method: 'get'
+ })
+}
+
+export const diffRuleVersionAPI = (
+ kind: TrafficRuleKind,
+ ruleName: string,
+ versionNo: number,
+ against: 'current' | 'previous' | number = 'current'
+): Promise<{ code: string; data: RuleVersionDiff }> => {
+ return request({
+ url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionNo}/diff`,
+ method: 'get',
+ params: { against }
+ })
+}
+
+export const rollbackRuleVersionAPI = (
+ kind: TrafficRuleKind,
+ ruleName: string,
+ versionNo: number,
+ reason: string
+): Promise<{ code: string; data: RollbackRuleVersionResult }> => {
+ return request({
+ url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionNo}/rollback`,
+ method: 'post',
+ data: { reason }
+ })
+}
+
export const searchRoutingRule = (params: any): Promise<any> => {
return request({
url: '/condition-rule/search',
@@ -149,5 +243,3 @@
method: 'delete'
})
}
-
-// TODO Perform front-end and back-end joint debugging
diff --git a/ui-vue3/src/base/http/request.ts b/ui-vue3/src/base/http/request.ts
index 094a76c..bc5956e 100644
--- a/ui-vue3/src/base/http/request.ts
+++ b/ui-vue3/src/base/http/request.ts
@@ -39,6 +39,10 @@
return SILENT_ERROR_URLS.some((silentUrl) => url.includes(silentUrl))
}
+const shouldShowErrorMessage = (url?: string): boolean => {
+ return !isSilentErrorUrl(url)
+}
+
const service: AxiosInstance = axios.create({
baseURL: '/api/v1',
timeout: 30 * 1000
@@ -82,7 +86,7 @@
// Show error toast message
const errorMsg = `${response.data.code}:${response.data.message}`
- if (!isSilentErrorUrl(response.config.url)) {
+ if (shouldShowErrorMessage(response.config.url)) {
message.error(errorMsg)
}
console.error(errorMsg)
@@ -120,7 +124,7 @@
}
if (response?.data) {
const errorMsg = `${response.data?.code}:${response.data?.message}`
- if (!isSilentErrorUrl(error.config?.url)) {
+ if (shouldShowErrorMessage(error.config?.url)) {
message.error(errorMsg)
}
console.error(errorMsg)
diff --git a/ui-vue3/src/base/i18n/en.ts b/ui-vue3/src/base/i18n/en.ts
index 1ae6ec1..ebdf344 100644
--- a/ui-vue3/src/base/i18n/en.ts
+++ b/ui-vue3/src/base/i18n/en.ts
@@ -110,7 +110,7 @@
flowControlDomain: {
actuatingRange: 'Actuating range',
notSet: 'Not set',
- versionRecords: 'Version records',
+ versionRecords: 'Version history',
YAMLView: 'YAML View',
addConfiguration: 'Add configuration',
addConfigurationItem: 'Add configurationItem',
@@ -554,6 +554,44 @@
copy: 'You have successfully copied a piece of information'
}
},
+ ruleVersionDomain: {
+ versionRecords: 'Version history',
+ versionJson: 'Version JSON',
+ versionDiff: 'Version Diff',
+ currentRule: 'Current rule',
+ latestRecorded: 'latest recorded',
+ latestRecordedVersionBadge: 'latest recorded v{versionNo}',
+ latestRecordedVersion: 'Latest recorded version',
+ targetVersion: 'Target version',
+ empty: 'No version records',
+ view: 'View',
+ diffCurrent: 'Diff current',
+ rollback: 'Rollback',
+ rollbackConfirmTitle: 'Confirm rollback',
+ rollbackAppendHint:
+ 'Rollback creates a new version from the historical snapshot. Existing history is not modified.',
+ rollbackReason: 'Rollback reason',
+ rollbackReasonPlaceholder: 'Enter a rollback reason',
+ rollbackReasonRequired: 'Rollback reason is required',
+ rollbackSuccess: 'Rollback succeeded and created a new version',
+ rollbackSuccessWithVersion: 'Rollback succeeded and created v{versionNo}',
+ rollbackFailed: 'Rollback failed',
+ diffFailed: 'Failed to load version diff',
+ rollbackDeleteDisabled: 'Delete markers cannot be rolled back',
+ source: 'Source',
+ author: 'Author',
+ createdAt: 'Created at',
+ modifiedAt: 'Modified at',
+ sourceAdmin: 'Console',
+ sourceUpstream: 'Upstream',
+ sourceBootstrap: 'Baseline',
+ sourceRollback: 'Rollback',
+ changeReason: 'Change reason',
+ reason: 'Reason',
+ none: 'None',
+ reload: 'Reload',
+ cancel: 'Cancel'
+ },
backHome: 'Back Home',
noPageTip: 'Sorry, the page you visited does not exist.',
globalSearchTip: 'Search ip, application, instance, service',
diff --git a/ui-vue3/src/base/i18n/zh.ts b/ui-vue3/src/base/i18n/zh.ts
index c355a42..776cf59 100644
--- a/ui-vue3/src/base/i18n/zh.ts
+++ b/ui-vue3/src/base/i18n/zh.ts
@@ -530,6 +530,44 @@
copy: '您已经成功复制一条信息'
}
},
+ ruleVersionDomain: {
+ versionRecords: '版本记录',
+ versionJson: '版本 JSON',
+ versionDiff: '版本差异',
+ currentRule: '当前规则',
+ latestRecorded: '最新记录',
+ latestRecordedVersionBadge: '最新记录 v{versionNo}',
+ latestRecordedVersion: '最新记录版本',
+ targetVersion: '目标版本',
+ empty: '暂无版本记录',
+ view: '查看',
+ diffCurrent: '对比当前',
+ rollback: '回滚',
+ rollbackConfirmTitle: '确认回滚',
+ rollbackDeletedWarning: '当前规则已删除,回滚会重新创建该规则',
+ rollbackAppendHint: '回滚会基于该历史版本创建一条新的版本记录,已有历史记录不会被修改。',
+ rollbackReason: '回滚原因',
+ rollbackReasonPlaceholder: '请输入回滚原因(必填)',
+ rollbackReasonRequired: '请输入回滚原因',
+ rollbackSuccess: '回滚成功,已创建新版本',
+ rollbackSuccessWithVersion: '回滚成功,已创建 v{versionNo}',
+ rollbackFailed: '回滚失败',
+ diffFailed: '加载版本差异失败',
+ rollbackDeleteDisabled: '删除标记不能回滚',
+ source: '来源',
+ author: '作者',
+ createdAt: '创建时间',
+ modifiedAt: '修改时间',
+ sourceAdmin: '控制台',
+ sourceUpstream: '上游',
+ sourceBootstrap: '基线',
+ sourceRollback: '回滚',
+ changeReason: '变更原因',
+ reason: '原因',
+ none: '暂无',
+ reload: '重新加载',
+ cancel: '取消'
+ },
backHome: '回到首页',
noPageTip: '抱歉,你访问的页面不存在',
globalSearchTip: '搜索ip,应用,实例,服务',
diff --git a/ui-vue3/src/mocks/handlers.ts b/ui-vue3/src/mocks/handlers.ts
index f46ff3b..8691eb4 100644
--- a/ui-vue3/src/mocks/handlers.ts
+++ b/ui-vue3/src/mocks/handlers.ts
@@ -27,6 +27,7 @@
import { dynamicConfigHandlers } from './handlers/dynamicConfig'
import { routingRuleHandlers } from './handlers/routingRule'
import { tagRuleHandlers } from './handlers/tagRule'
+import { ruleVersionHandlers } from './handlers/ruleVersion'
import { destinationRuleHandlers, virtualServiceHandlers } from './handlers/istio'
import { promQLHandlers } from './handlers/promQL'
import { serverHandlers } from './handlers/server'
@@ -46,6 +47,7 @@
...dynamicConfigHandlers,
...routingRuleHandlers,
...tagRuleHandlers,
+ ...ruleVersionHandlers,
...destinationRuleHandlers,
...virtualServiceHandlers,
...promQLHandlers,
diff --git a/ui-vue3/src/mocks/handlers/dynamicConfig.ts b/ui-vue3/src/mocks/handlers/dynamicConfig.ts
index 3e5b355..83ed38a 100644
--- a/ui-vue3/src/mocks/handlers/dynamicConfig.ts
+++ b/ui-vue3/src/mocks/handlers/dynamicConfig.ts
@@ -28,6 +28,14 @@
return Array.from({ length: len }, () => String.fromCharCode(97 + randomInt(0, 25))).join('')
}
+const decodeRuleName = (raw: string) => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+}
+
export const dynamicConfigHandlers: HttpHandler[] = [
http.get(`${base}/configurator/search`, () => {
const total = randomInt(8, 1000)
@@ -45,7 +53,7 @@
http.get(`${base}/configurator/:ruleName`, ({ params }) => {
const detail: ConfiguratorDetail = {
- name: params.ruleName as string,
+ name: decodeRuleName(params.ruleName as string),
configs: [{ side: 'provider', timeout: 3000, retries: 2, loadbalance: 'roundrobin' }]
}
return success(detail)
diff --git a/ui-vue3/src/mocks/handlers/ruleVersion.ts b/ui-vue3/src/mocks/handlers/ruleVersion.ts
new file mode 100644
index 0000000..0eddadc
--- /dev/null
+++ b/ui-vue3/src/mocks/handlers/ruleVersion.ts
@@ -0,0 +1,211 @@
+/*
+ * 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 { http, HttpResponse, type HttpHandler } from 'msw'
+import { success, base } from '../utils'
+import type {
+ RuleVersion,
+ RuleVersionDiff,
+ RuleVersionList,
+ TrafficRuleKind
+} from '@/api/service/traffic'
+
+const KINDS: TrafficRuleKind[] = ['condition-rule', 'tag-rule', 'configurator']
+const SCENARIOS = ['normal', 'deleted', 'empty', 'backend-error', 'diff'] as const
+
+type Scenario = (typeof SCENARIOS)[number]
+
+const scenarioOf = (ruleName: string): Scenario =>
+ SCENARIOS.find((scenario) => ruleName.includes(`-${scenario}`)) ?? 'normal'
+
+const decodeName = (raw: string) => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+}
+
+const spec = (ruleName: string, marker: string) =>
+ JSON.stringify({ configVersion: 'v3.0', key: ruleName, marker })
+
+const version = (
+ kind: TrafficRuleKind,
+ ruleName: string,
+ versionNo: number,
+ operation: RuleVersion['operation'],
+ source: RuleVersion['source'],
+ isLatestRecorded: boolean,
+ marker = `v${versionNo}`
+): RuleVersion => ({
+ ruleKind: kind,
+ mesh: 'default',
+ resourceKey: `/${ruleName}`,
+ ruleName,
+ versionNo,
+ contentHash: `sha256:${ruleName}:${marker}`,
+ specJson: spec(ruleName, marker),
+ source,
+ operation,
+ author: 'user name',
+ createdAt: `2026-05-${(20 + versionNo).toString().padStart(2, '0')}T08:00:00Z`,
+ recordedAt: `2026-05-${(20 + versionNo).toString().padStart(2, '0')}T08:01:00Z`,
+ isLatestRecorded
+})
+
+const fixtureVersions = (kind: TrafficRuleKind, ruleName: string): RuleVersion[] => {
+ switch (scenarioOf(ruleName)) {
+ case 'empty':
+ return []
+ case 'deleted':
+ return [
+ version(kind, ruleName, 3, 'DELETE', 'ADMIN', true, 'deleted'),
+ version(kind, ruleName, 2, 'UPDATE', 'ADMIN', false),
+ version(kind, ruleName, 1, 'CREATE', 'BOOTSTRAP', false)
+ ]
+ case 'diff':
+ return [
+ version(kind, ruleName, 2, 'UPDATE', 'ADMIN', true, 'right'),
+ version(kind, ruleName, 1, 'CREATE', 'BOOTSTRAP', false, 'left')
+ ]
+ default:
+ return [
+ version(kind, ruleName, 5, 'UPDATE', 'ADMIN', true),
+ version(kind, ruleName, 4, 'UPDATE', 'ADMIN', false),
+ version(kind, ruleName, 3, 'UPDATE', 'ADMIN', false),
+ version(kind, ruleName, 2, 'UPDATE', 'ADMIN', false),
+ version(kind, ruleName, 1, 'CREATE', 'BOOTSTRAP', false)
+ ]
+ }
+}
+
+const latestRecordedVersionOf = (versions: RuleVersion[]) =>
+ versions.find((version) => version.isLatestRecorded)
+
+const versionList = (versions: RuleVersion[]): RuleVersionList => {
+ const latestRecorded = latestRecordedVersionOf(versions)
+ const head = versions[0]
+ return {
+ items: versions,
+ total: versions.length,
+ latestRecordedVersionNo: latestRecorded?.versionNo,
+ latestRecordedDeleted: Boolean(head?.operation === 'DELETE')
+ }
+}
+
+const bizError = (code: string, message: string, status = 200) =>
+ HttpResponse.json({ code, message, data: null }, { status })
+
+const notFoundResp = (message: string) => bizError('NotFoundError', message, 404)
+
+const readJsonBody = async (request: Request): Promise<Record<string, unknown>> => {
+ try {
+ const body = (await request.json()) as Record<string, unknown> | null
+ return body ?? {}
+ } catch {
+ return {}
+ }
+}
+
+const validateReason = (reason: string) => {
+ const trimmed = reason.trim()
+ if (!trimmed) return bizError('InvalidArgument', 'reason must not be empty', 400)
+ if (trimmed.length > 1024)
+ return bizError('InvalidArgument', 'reason must be at most 1024 characters', 400)
+ return null
+}
+
+const buildVersionHandlersForKind = (kind: TrafficRuleKind): HttpHandler[] => [
+ http.get(`${base}/${kind}/:ruleName/versions`, ({ params }) => {
+ const ruleName = decodeName(params.ruleName as string)
+ if (scenarioOf(ruleName) === 'backend-error')
+ return bizError('InternalError', 'backend error', 500)
+ const versions = fixtureVersions(kind, ruleName)
+ return success<RuleVersionList>(versionList(versions))
+ }),
+
+ http.get(`${base}/${kind}/:ruleName/versions/:versionNo`, ({ params }) => {
+ const ruleName = decodeName(params.ruleName as string)
+ const versionNo = Number(params.versionNo)
+ if (!Number.isInteger(versionNo) || versionNo <= 0)
+ return bizError('InvalidArgument', 'versionNo must be a positive integer', 400)
+ const found = fixtureVersions(kind, ruleName).find((item) => item.versionNo === versionNo)
+ return found ? success(found) : notFoundResp('rule version not found')
+ }),
+
+ http.get(`${base}/${kind}/:ruleName/versions/:versionNo/diff`, ({ params, request }) => {
+ const ruleName = decodeName(params.ruleName as string)
+ const versionNo = Number(params.versionNo)
+ if (!Number.isInteger(versionNo) || versionNo <= 0)
+ return bizError('InvalidArgument', 'versionNo must be a positive integer', 400)
+ const versions = fixtureVersions(kind, ruleName)
+ const left = versions.find((item) => item.versionNo === versionNo)
+ if (!left) return notFoundResp('rule version not found')
+ const against = new URL(request.url).searchParams.get('against') || 'current'
+ if (against !== 'current' && against !== 'previous' && !/^\d+$/.test(against)) {
+ return bizError(
+ 'InvalidArgument',
+ "against must be 'current', 'previous', or a version number",
+ 400
+ )
+ }
+ const leftIndex = versions.findIndex((item) => item.versionNo === versionNo)
+ const right =
+ against === 'current'
+ ? latestRecordedVersionOf(versions)
+ : against === 'previous'
+ ? versions[leftIndex + 1]
+ : versions.find((item) => item.versionNo === Number(against))
+ if (!right) return notFoundResp('rule version not found')
+ return success<RuleVersionDiff>({
+ left: { versionNo: left.versionNo, specJson: left.specJson },
+ right: { versionNo: right.versionNo, specJson: right.specJson }
+ })
+ }),
+
+ http.post(
+ `${base}/${kind}/:ruleName/versions/:versionNo/rollback`,
+ async ({ params, request }) => {
+ const ruleName = decodeName(params.ruleName as string)
+ const body = await readJsonBody(request)
+ const reasonErr = validateReason(typeof body.reason === 'string' ? body.reason : '')
+ if (reasonErr) return reasonErr
+
+ const versions = fixtureVersions(kind, ruleName)
+ const targetVersionNo = Number(params.versionNo)
+ const target = versions.find((item) => item.versionNo === targetVersionNo)
+ if (!target) return notFoundResp('rule version not found')
+ if (target.operation === 'DELETE')
+ return bizError('InvalidArgument', 'cannot roll back to a DELETE marker', 400)
+ const latestRecorded = latestRecordedVersionOf(versions)
+
+ return success({
+ rolledBackFromVersionNo: target.versionNo,
+ versionNo: (latestRecorded?.versionNo ?? 0) + 1,
+ source: 'ROLLBACK'
+ })
+ }
+ )
+]
+
+export const ruleVersionHandlers: HttpHandler[] = KINDS.flatMap(buildVersionHandlersForKind)
+
+export const ruleVersionMock = {
+ scenarios: SCENARIOS,
+ scenarioOf,
+ reset() {}
+}
diff --git a/ui-vue3/src/types/js-yaml.d.ts b/ui-vue3/src/types/js-yaml.d.ts
new file mode 100644
index 0000000..f7f0ee1
--- /dev/null
+++ b/ui-vue3/src/types/js-yaml.d.ts
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+declare module 'js-yaml' {
+ export function load(input: string): any
+ export function dump(input: unknown): string
+
+ const yaml: {
+ load: typeof load
+ dump: typeof dump
+ }
+ export default yaml
+}
diff --git a/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue b/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue
new file mode 100644
index 0000000..e57b450
--- /dev/null
+++ b/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue
@@ -0,0 +1,116 @@
+<!--
+ ~ 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.
+-->
+
+<template>
+ <div ref="editorEl" class="rule-diff-editor" :style="{ height: editorHeight }"></div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import * as monaco from 'monaco-editor'
+
+const props = defineProps({
+ original: {
+ type: String,
+ default: ''
+ },
+ modified: {
+ type: String,
+ default: ''
+ },
+ height: {
+ type: [String, Number],
+ default: '420px'
+ }
+})
+
+let diffEditor: monaco.editor.IStandaloneDiffEditor | null = null
+const editorEl = ref<HTMLElement | null>(null)
+const editorHeight = computed(() =>
+ typeof props.height === 'number' ? `${props.height}px` : props.height
+)
+const isJsonDocument = (value: string) => {
+ const trimmed = value.trim()
+ if (!trimmed) {
+ return false
+ }
+ try {
+ JSON.parse(trimmed)
+ return true
+ } catch {
+ return false
+ }
+}
+const editorLanguage = computed(() =>
+ isJsonDocument(props.original || '') && isJsonDocument(props.modified || '')
+ ? 'json'
+ : 'plaintext'
+)
+
+const disposeModels = () => {
+ // The diff editor does not own models passed through setModel. Dispose the
+ // previous pair before each rebuild so Monaco workers do not retain snapshots.
+ const model = diffEditor?.getModel()
+ model?.original.dispose()
+ model?.modified.dispose()
+}
+
+const render = () => {
+ if (!diffEditor) {
+ return
+ }
+ disposeModels()
+ // Models are recreated when language changes; a disposed model must never be
+ // reused after Monaco has detached it from the editor.
+ const originalModel = monaco.editor.createModel(props.original || '', editorLanguage.value)
+ const modifiedModel = monaco.editor.createModel(props.modified || '', editorLanguage.value)
+ diffEditor.setModel({ original: originalModel, modified: modifiedModel })
+}
+
+onMounted(() => {
+ if (!editorEl.value) {
+ return
+ }
+ diffEditor = monaco.editor.createDiffEditor(editorEl.value, {
+ automaticLayout: true,
+ renderSideBySide: true,
+ readOnly: true,
+ minimap: { enabled: false },
+ renderOverviewRuler: false,
+ overviewRulerLanes: 0,
+ overviewRulerBorder: false
+ })
+ render()
+})
+
+watch(
+ () => [props.original, props.modified, editorLanguage.value],
+ () => render()
+)
+
+onBeforeUnmount(() => {
+ disposeModels()
+ diffEditor?.dispose()
+ diffEditor = null
+})
+</script>
+
+<style scoped lang="less">
+.rule-diff-editor {
+ width: 100%;
+}
+</style>
diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue
new file mode 100644
index 0000000..60f5484
--- /dev/null
+++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue
@@ -0,0 +1,162 @@
+<!--
+ ~ 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.
+-->
+
+<template>
+ <a-drawer
+ :open="open"
+ placement="right"
+ width="min(92vw, 480px)"
+ :destroyOnClose="false"
+ @close="$emit('update:open', false)"
+ >
+ <template #title>
+ <div class="drawer-title">
+ <a-typography-text strong>{{ title }}</a-typography-text>
+ <a-tag v-if="latestRecordedVersionNo !== undefined" color="blue">{{
+ t('ruleVersionDomain.latestRecordedVersionBadge', {
+ versionNo: latestRecordedVersionNo
+ })
+ }}</a-tag>
+ </div>
+ </template>
+
+ <a-spin :spinning="loading">
+ <a-empty v-if="!items.length" :description="t('ruleVersionDomain.empty')" />
+ <div v-else class="history-list">
+ <div
+ v-for="item in items"
+ :key="item.versionNo"
+ class="history-item"
+ :class="{ current: item.isLatestRecorded }"
+ >
+ <div class="history-head">
+ <a-space wrap>
+ <a-tag color="geekblue">v{{ item.versionNo }}</a-tag>
+ <a-tag>{{ sourceLabel(item.source) }}</a-tag>
+ <a-tag v-if="item.isLatestRecorded" color="green">{{
+ t('ruleVersionDomain.latestRecorded')
+ }}</a-tag>
+ </a-space>
+ </div>
+ <div class="history-body">
+ <div>{{ authorLabel(item.author) }}</div>
+ <div>{{ t('ruleVersionDomain.modifiedAt') }}: {{ createdAtLabel(item.createdAt) }}</div>
+ <div v-if="item.reason">{{ reasonLabel(item) }}: {{ item.reason }}</div>
+ </div>
+ <a-space>
+ <a-button type="link" @click="$emit('view-json', item)">{{
+ t('ruleVersionDomain.view')
+ }}</a-button>
+ <a-button type="link" @click="$emit('diff-current', item)">{{
+ t('ruleVersionDomain.diffCurrent')
+ }}</a-button>
+ <a-tooltip v-if="isRollbackDisabled(item)" :title="rollbackDisabledReason(item)">
+ <span>
+ <a-button type="link" disabled>{{ t('ruleVersionDomain.rollback') }}</a-button>
+ </span>
+ </a-tooltip>
+ <a-button v-else type="link" @click="$emit('rollback', item)">{{
+ t('ruleVersionDomain.rollback')
+ }}</a-button>
+ </a-space>
+ </div>
+ </div>
+ </a-spin>
+ </a-drawer>
+</template>
+
+<script setup lang="ts">
+import type { RuleVersion } from '@/api/service/traffic'
+import dayjs from 'dayjs'
+import { useI18n } from 'vue-i18n'
+
+defineProps<{
+ open: boolean
+ title: string
+ items: RuleVersion[]
+ latestRecordedVersionNo?: number
+ loading?: boolean
+}>()
+
+defineEmits(['update:open', 'view-json', 'diff-current', 'rollback'])
+
+const { t } = useI18n()
+
+const sourceLabels: Record<string, string> = {
+ ADMIN: 'ruleVersionDomain.sourceAdmin',
+ UPSTREAM: 'ruleVersionDomain.sourceUpstream',
+ BOOTSTRAP: 'ruleVersionDomain.sourceBootstrap',
+ ROLLBACK: 'ruleVersionDomain.sourceRollback'
+}
+
+const sourceLabel = (source: string) => (sourceLabels[source] ? t(sourceLabels[source]) : source)
+
+const authorLabel = (author: string) => author.replace(/^system:/, '')
+
+const createdAtLabel = (createdAt: string) => dayjs(createdAt).format('YYYY/M/D HH:mm:ss')
+
+const isRollbackDisabled = (item: RuleVersion) => item.operation === 'DELETE'
+
+const rollbackDisabledReason = (item: RuleVersion) => {
+ if (item.operation === 'DELETE') return t('ruleVersionDomain.rollbackDeleteDisabled')
+ return ''
+}
+
+const reasonLabel = (item: RuleVersion) => {
+ if (item.source === 'ROLLBACK') return t('ruleVersionDomain.rollbackReason')
+ if (item.operation === 'CREATE' || item.operation === 'UPDATE') {
+ return t('ruleVersionDomain.changeReason')
+ }
+ return t('ruleVersionDomain.reason')
+}
+</script>
+
+<style scoped lang="less">
+.drawer-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.history-item {
+ padding: 12px 14px;
+ background: #fff;
+ border: 1px solid #f0f0f0;
+ border-radius: 6px;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
+}
+
+.history-item.current {
+ border-color: #b7eb8f;
+ background: #fcfffa;
+}
+
+.history-head,
+.history-body {
+ margin-bottom: 8px;
+}
+
+.history-body {
+ line-height: 1.7;
+}
+</style>
diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts
new file mode 100644
index 0000000..36e0b4c
--- /dev/null
+++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts
@@ -0,0 +1,390 @@
+/*
+ * 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 { flushPromises, mount } from '@vue/test-utils'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h, nextTick } from 'vue'
+import { message } from 'ant-design-vue'
+import { HTTP_STATUS } from '@/base/http/constants'
+import type { RuleVersion } from '@/api/service/traffic'
+import type RuleHistoryDrawerType from './RuleHistoryDrawer.vue'
+import type RuleHistoryPanelType from './RuleHistoryPanel.vue'
+
+const mocks = vi.hoisted(() => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined
+ },
+ configurable: true
+ })
+
+ return {
+ listRuleVersionsAPI: vi.fn(),
+ rollbackRuleVersionAPI: vi.fn(),
+ diffRuleVersionAPI: vi.fn()
+ }
+})
+
+vi.mock('@/api/service/traffic', async (importOriginal) => {
+ const actual = await importOriginal<typeof import('@/api/service/traffic')>()
+ return {
+ ...actual,
+ listRuleVersionsAPI: mocks.listRuleVersionsAPI,
+ rollbackRuleVersionAPI: mocks.rollbackRuleVersionAPI,
+ diffRuleVersionAPI: mocks.diffRuleVersionAPI
+ }
+})
+
+vi.mock('ant-design-vue', async (importOriginal) => {
+ const actual = await importOriginal<typeof import('ant-design-vue')>()
+ return {
+ ...actual,
+ message: {
+ success: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn()
+ }
+ }
+})
+
+vi.mock('@/components/editor/MonacoEditor.vue', () => ({
+ default: {
+ name: 'MonacoEditor',
+ template: '<div data-test="monaco-editor" />'
+ }
+}))
+
+vi.mock('./RuleDiffEditor.vue', () => ({
+ default: {
+ name: 'RuleDiffEditor',
+ template: '<div data-test="rule-diff-editor" />'
+ }
+}))
+
+const version = (
+ versionNo: number,
+ isLatestRecorded: boolean,
+ overrides: Partial<RuleVersion> = {}
+): RuleVersion => ({
+ ruleKind: 'ConditionRoute',
+ mesh: '',
+ resourceKey: '/demo-rule',
+ ruleName: 'demo-rule',
+ versionNo,
+ contentHash: `hash-${versionNo}`,
+ specJson: '{"key":"demo-rule"}',
+ source: 'ADMIN',
+ operation: 'UPDATE',
+ author: 'admin',
+ createdAt: '2026-06-19T00:00:00Z',
+ isLatestRecorded,
+ ...overrides
+})
+
+const drawerStub = defineComponent({
+ props: ['items'],
+ emits: ['rollback'],
+ setup(props, { emit }) {
+ return () =>
+ h(
+ 'div',
+ { 'data-test': 'history-drawer' },
+ (props.items as RuleVersion[]).map((item) =>
+ h(
+ 'button',
+ {
+ type: 'button',
+ 'data-test': `rollback-${item.versionNo}`,
+ onClick: () => emit('rollback', item)
+ },
+ `rollback-${item.versionNo}`
+ )
+ )
+ )
+ }
+})
+
+const modalStub = defineComponent({
+ props: ['open'],
+ emits: ['ok'],
+ setup(props, { emit, slots }) {
+ return () =>
+ props.open
+ ? h('div', { 'data-test': 'modal' }, [
+ slots.default?.(),
+ h(
+ 'button',
+ { type: 'button', 'data-test': 'modal-ok', onClick: () => emit('ok') },
+ 'ok'
+ )
+ ])
+ : null
+ }
+})
+
+const textAreaStub = defineComponent({
+ emits: ['update:value'],
+ setup(_props, { emit }) {
+ return () =>
+ h('textarea', {
+ 'data-test': 'rollback-reason',
+ onInput: (event: Event) => {
+ emit('update:value', (event.target as HTMLTextAreaElement).value)
+ }
+ })
+ }
+})
+
+const mountPanel = (props: Partial<InstanceType<typeof RuleHistoryPanelType>['$props']> = {}) =>
+ mount(RuleHistoryPanel, {
+ props: {
+ open: true,
+ kind: 'condition-rule',
+ ruleName: 'demo-rule',
+ title: 'History',
+ ...props
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ RuleHistoryDrawer: drawerStub,
+ MonacoEditor: true,
+ RuleDiffEditor: true,
+ AModal: modalStub,
+ 'a-modal': modalStub,
+ AAlert: { template: '<div />' },
+ 'a-alert': { template: '<div />' },
+ ATypographyText: { template: '<span><slot /></span>' },
+ 'a-typography-text': { template: '<span><slot /></span>' },
+ AForm: { template: '<form><slot /></form>' },
+ 'a-form': { template: '<form><slot /></form>' },
+ AFormItem: { template: '<label><slot /></label>' },
+ 'a-form-item': { template: '<label><slot /></label>' },
+ ATextarea: textAreaStub,
+ 'a-textarea': textAreaStub
+ }
+ }
+ })
+
+let i18n: typeof import('@/base/i18n').i18n
+let RuleHistoryDrawer: typeof RuleHistoryDrawerType
+let RuleHistoryPanel: typeof RuleHistoryPanelType
+
+beforeAll(async () => {
+ i18n = (await import('@/base/i18n')).i18n
+ RuleHistoryDrawer = (await import('./RuleHistoryDrawer.vue')).default
+ RuleHistoryPanel = (await import('./RuleHistoryPanel.vue')).default
+})
+
+beforeEach(() => {
+ mocks.listRuleVersionsAPI.mockReset()
+ mocks.rollbackRuleVersionAPI.mockReset()
+ mocks.diffRuleVersionAPI.mockReset()
+ vi.mocked(message.error).mockClear()
+ vi.mocked(message.success).mockClear()
+ vi.mocked(message.warning).mockClear()
+})
+
+const mountDrawer = (items: RuleVersion[]) =>
+ mount(RuleHistoryDrawer, {
+ props: {
+ open: true,
+ title: 'History',
+ items
+ },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ ADrawer: {
+ props: ['open'],
+ template: '<div v-if="open"><slot name="title" /><slot /></div>'
+ },
+ 'a-drawer': {
+ props: ['open'],
+ template: '<div v-if="open"><slot name="title" /><slot /></div>'
+ },
+ ASpin: { template: '<div><slot /></div>' },
+ 'a-spin': { template: '<div><slot /></div>' },
+ AEmpty: { template: '<div data-test="empty" />' },
+ 'a-empty': { template: '<div data-test="empty" />' },
+ ASpace: { template: '<div><slot /></div>' },
+ 'a-space': { template: '<div><slot /></div>' },
+ ATag: { template: '<span><slot /></span>' },
+ 'a-tag': { template: '<span><slot /></span>' },
+ ATooltip: { template: '<div><slot /></div>' },
+ 'a-tooltip': { template: '<div><slot /></div>' },
+ AButton: {
+ props: {
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ emits: ['click'],
+ template:
+ '<button :disabled="disabled" @click="$emit(\'click\', $event)"><slot /></button>'
+ },
+ 'a-button': {
+ props: {
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ emits: ['click'],
+ template:
+ '<button :disabled="disabled" @click="$emit(\'click\', $event)"><slot /></button>'
+ },
+ ATypographyText: { template: '<span><slot /></span>' },
+ 'a-typography-text': { template: '<span><slot /></span>' }
+ }
+ }
+ })
+
+const rollbackButton = (wrapper: ReturnType<typeof mountDrawer>) => wrapper.findAll('button').at(-1)
+
+describe('RuleHistoryPanel', () => {
+ it('allows rollback for latest recorded non-delete versions', () => {
+ const wrapper = mountDrawer([version(3, true)])
+
+ rollbackButton(wrapper)?.trigger('click')
+
+ expect(wrapper.emitted('rollback')?.[0][0]).toMatchObject({ versionNo: 3 })
+ })
+
+ it('disables rollback for delete markers', () => {
+ const wrapper = mountDrawer([version(4, true, { operation: 'DELETE', specJson: '<deleted>' })])
+
+ rollbackButton(wrapper)?.trigger('click')
+
+ expect(wrapper.emitted('rollback')).toBeUndefined()
+ })
+
+ it('ignores stale history responses after ruleName changes', async () => {
+ let resolveFirst: (value: unknown) => void = () => undefined
+ mocks.listRuleVersionsAPI
+ .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve)))
+ .mockResolvedValueOnce({
+ code: HTTP_STATUS.SUCCESS,
+ data: {
+ items: [version(7, true)],
+ total: 1,
+ latestRecordedVersionNo: 7,
+ latestRecordedDeleted: false
+ }
+ })
+
+ const wrapper = mountPanel({ ruleName: 'old-rule' })
+ await wrapper.setProps({ ruleName: 'new-rule' })
+ await flushPromises()
+
+ resolveFirst({
+ code: HTTP_STATUS.SUCCESS,
+ data: {
+ items: [version(3, true)],
+ total: 1,
+ latestRecordedVersionNo: 3,
+ latestRecordedDeleted: false
+ }
+ })
+ await flushPromises()
+
+ expect(wrapper.emitted('latest-recorded-version-no-change')?.at(-1)).toEqual([7])
+ expect(wrapper.text()).toContain('rollback-7')
+ expect(wrapper.text()).not.toContain('rollback-3')
+ })
+
+ it('ignores stale rollback success after ruleName changes', async () => {
+ mocks.listRuleVersionsAPI
+ .mockResolvedValueOnce({
+ code: HTTP_STATUS.SUCCESS,
+ data: {
+ items: [version(1, false)],
+ total: 1,
+ latestRecordedVersionNo: 2,
+ latestRecordedDeleted: false
+ }
+ })
+ .mockResolvedValueOnce({
+ code: HTTP_STATUS.SUCCESS,
+ data: {
+ items: [version(3, false)],
+ total: 1,
+ latestRecordedVersionNo: 4,
+ latestRecordedDeleted: false
+ }
+ })
+ let resolveRollback: (value: unknown) => void = () => undefined
+ mocks.rollbackRuleVersionAPI.mockReturnValueOnce(
+ new Promise((resolve) => (resolveRollback = resolve))
+ )
+
+ const wrapper = mountPanel({ ruleName: 'old-rule' })
+ await flushPromises()
+ await wrapper.get('[data-test="rollback-1"]').trigger('click')
+ await nextTick()
+ await wrapper.get('[data-test="rollback-reason"]').setValue('restore old')
+ await wrapper.get('[data-test="modal-ok"]').trigger('click')
+
+ await wrapper.setProps({ ruleName: 'new-rule' })
+ await flushPromises()
+ await wrapper.get('[data-test="rollback-3"]').trigger('click')
+ await nextTick()
+
+ resolveRollback({
+ code: HTTP_STATUS.SUCCESS,
+ data: {
+ rolledBackFromVersionNo: 1,
+ versionNo: 5,
+ source: 'ROLLBACK'
+ }
+ })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('rollback-3')
+ expect(wrapper.find('[data-test="modal"]').exists()).toBe(true)
+ expect(mocks.listRuleVersionsAPI).toHaveBeenCalledTimes(2)
+ })
+
+ it('shows backend rollback rejection errors', async () => {
+ mocks.listRuleVersionsAPI.mockResolvedValue({
+ code: HTTP_STATUS.SUCCESS,
+ data: {
+ items: [version(1, true)],
+ total: 1,
+ latestRecordedVersionNo: 1,
+ latestRecordedDeleted: false
+ }
+ })
+ mocks.rollbackRuleVersionAPI.mockRejectedValue({
+ code: 'InvalidArgument',
+ message: 'cannot roll back to a version identical to current'
+ })
+
+ const wrapper = mountPanel()
+ await flushPromises()
+ await wrapper.get('[data-test="rollback-1"]').trigger('click')
+ await nextTick()
+ await wrapper.get('[data-test="rollback-reason"]').setValue('same content')
+ await wrapper.get('[data-test="modal-ok"]').trigger('click')
+ await flushPromises()
+
+ expect(message.error).toHaveBeenCalledWith('cannot roll back to a version identical to current')
+ })
+})
diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue
new file mode 100644
index 0000000..e3ddb33
--- /dev/null
+++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue
@@ -0,0 +1,367 @@
+<!--
+ ~ 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.
+-->
+
+<template>
+ <RuleHistoryDrawer
+ v-model:open="openProxy"
+ :title="title"
+ :items="items"
+ :latest-recorded-version-no="latestRecordedVersionNo"
+ :loading="loading"
+ @view-json="openVersionJson"
+ @diff-current="openVersionDiff"
+ @rollback="openRollbackConfirm"
+ />
+
+ <a-modal
+ v-model:open="versionJsonOpen"
+ :title="t('ruleVersionDomain.versionJson')"
+ width="900px"
+ :footer="null"
+ >
+ <MonacoEditor
+ v-model:modelValue="versionJson"
+ language="json"
+ theme="vs-dark"
+ height="500px"
+ :readonly="true"
+ />
+ </a-modal>
+
+ <a-modal
+ v-model:open="versionDiffOpen"
+ :title="t('ruleVersionDomain.versionDiff')"
+ width="1100px"
+ :footer="null"
+ >
+ <div class="version-diff-labels">
+ <span>{{ versionDiffLeftLabel }}</span>
+ <span>{{ versionDiffRightLabel }}</span>
+ </div>
+ <RuleDiffEditor :original="versionDiffLeft" :modified="versionDiffRight" height="520px" />
+ </a-modal>
+
+ <a-modal
+ v-model:open="rollbackConfirmOpen"
+ :title="t('ruleVersionDomain.rollbackConfirmTitle')"
+ width="700px"
+ @ok="handleRollbackConfirm"
+ :confirmLoading="rollbackLoading"
+ >
+ <div v-if="rollbackTarget" style="margin-bottom: 16px">
+ <div>
+ <strong>{{ t('ruleVersionDomain.targetVersion') }}:</strong>
+ v{{ rollbackTarget.versionNo }}
+ </div>
+ <div>
+ <strong>{{ t('ruleVersionDomain.latestRecordedVersion') }}:</strong>
+ {{ latestRecordedVersionNo ? `v${latestRecordedVersionNo}` : t('ruleVersionDomain.none') }}
+ </div>
+ <div>
+ <strong>{{ t('ruleVersionDomain.source') }}:</strong>
+ {{ sourceLabel(rollbackTarget.source) }}
+ </div>
+ <div>
+ <strong>{{ t('ruleVersionDomain.author') }}:</strong>
+ {{ authorLabel(rollbackTarget.author) }}
+ </div>
+ <div>
+ <strong>{{ t('ruleVersionDomain.createdAt') }}:</strong>
+ {{ createdAtLabel(rollbackTarget.createdAt) }}
+ </div>
+ </div>
+ <a-typography-text type="secondary" class="rollback-hint">
+ {{ t('ruleVersionDomain.rollbackAppendHint') }}
+ </a-typography-text>
+ <a-form layout="vertical">
+ <a-form-item :label="t('ruleVersionDomain.rollbackReason')" required>
+ <a-textarea
+ v-model:value="rollbackReason"
+ :placeholder="t('ruleVersionDomain.rollbackReasonPlaceholder')"
+ :rows="3"
+ :maxlength="1024"
+ />
+ </a-form-item>
+ </a-form>
+ </a-modal>
+</template>
+
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import { message } from 'ant-design-vue'
+import { useI18n } from 'vue-i18n'
+import MonacoEditor from '@/components/editor/MonacoEditor.vue'
+import { HTTP_STATUS } from '@/base/http/constants'
+import {
+ diffRuleVersionAPI,
+ listRuleVersionsAPI,
+ rollbackRuleVersionAPI,
+ type RuleVersion,
+ type TrafficRuleKind
+} from '@/api/service/traffic'
+import RuleHistoryDrawer from './RuleHistoryDrawer.vue'
+import RuleDiffEditor from './RuleDiffEditor.vue'
+import {
+ latestRecordedStateFromList,
+ formatRuleSpec,
+ isLatestRecordedHistoryRequest,
+ versionDiffLabel
+} from './ruleVersion'
+import dayjs from 'dayjs'
+
+const props = defineProps<{
+ open: boolean
+ kind: TrafficRuleKind
+ ruleName: string
+ title: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'update:open', value: boolean): void
+ (e: 'latest-recorded-version-no-change', value: number | undefined): void
+}>()
+
+const { t } = useI18n()
+
+const openProxy = computed({
+ get: () => props.open,
+ set: (value: boolean) => emit('update:open', value)
+})
+
+const items = ref<RuleVersion[]>([])
+const latestRecordedVersionNo = ref<number | undefined>(undefined)
+const loading = ref(false)
+const versionJsonOpen = ref(false)
+const versionJson = ref('')
+const versionDiffOpen = ref(false)
+const versionDiffLeft = ref('')
+const versionDiffRight = ref('')
+const versionDiffLeftLabel = ref(t('ruleVersionDomain.targetVersion'))
+const versionDiffRightLabel = ref(t('ruleVersionDomain.currentRule'))
+const rollbackConfirmOpen = ref(false)
+const rollbackTarget = ref<RuleVersion | null>(null)
+const rollbackReason = ref('')
+const rollbackLoading = ref(false)
+let requestSeq = 0
+let operationSeq = 0
+let disposed = false
+
+const sourceLabels: Record<string, string> = {
+ ADMIN: 'ruleVersionDomain.sourceAdmin',
+ UPSTREAM: 'ruleVersionDomain.sourceUpstream',
+ BOOTSTRAP: 'ruleVersionDomain.sourceBootstrap',
+ ROLLBACK: 'ruleVersionDomain.sourceRollback'
+}
+
+const sourceLabel = (source: string) => (sourceLabels[source] ? t(sourceLabels[source]) : source)
+const authorLabel = (author: string) => author.replace(/^system:/, '')
+const createdAtLabel = (createdAt: string) => dayjs(createdAt).format('YYYY/M/D HH:mm:ss')
+
+type OperationToken = {
+ seq: number
+ open: boolean
+ kind: TrafficRuleKind
+ ruleName: string
+ targetVersionNo?: number
+}
+
+const nextOperationToken = (targetVersionNo?: number): OperationToken => ({
+ seq: ++operationSeq,
+ open: props.open,
+ kind: props.kind,
+ ruleName: props.ruleName,
+ targetVersionNo
+})
+
+// Async drawer actions outlive loading flags when the drawer is closed or a
+// different rule is selected. The token keeps stale responses from reopening
+// modals or overwriting state for the next rule.
+const isLatestRecordedOperation = (
+ token: OperationToken,
+ targetVersionNo = token.targetVersionNo
+) =>
+ !disposed &&
+ token.seq === operationSeq &&
+ token.open &&
+ props.open &&
+ token.kind === props.kind &&
+ token.ruleName === props.ruleName &&
+ token.targetVersionNo === targetVersionNo
+
+async function loadHistory() {
+ // Loading alone cannot distinguish an older request from a newer one. The
+ // sequence guard lets the newest rule/open state own the history snapshot.
+ const seq = ++requestSeq
+ const kind = props.kind
+ const ruleName = props.ruleName
+ if (!props.open || !ruleName || ruleName === '_tmp') {
+ items.value = []
+ latestRecordedVersionNo.value = undefined
+ emit('latest-recorded-version-no-change', undefined)
+ return
+ }
+
+ loading.value = true
+ try {
+ const res = await listRuleVersionsAPI(kind, ruleName)
+ if (!isLatestRecordedHistoryRequest(seq, requestSeq, disposed)) {
+ return
+ }
+ if (res?.code === HTTP_STATUS.SUCCESS) {
+ items.value = res.data?.items || []
+ const latestRecorded = latestRecordedStateFromList(res.data)
+ latestRecordedVersionNo.value = latestRecorded.versionNo
+ emit('latest-recorded-version-no-change', latestRecordedVersionNo.value)
+ }
+ } catch (e: any) {
+ if (!isLatestRecordedHistoryRequest(seq, requestSeq, disposed)) {
+ return
+ }
+ throw e
+ } finally {
+ if (isLatestRecordedHistoryRequest(seq, requestSeq, disposed)) {
+ loading.value = false
+ }
+ }
+}
+
+const openVersionJson = (item: RuleVersion) => {
+ versionJson.value = formatRuleSpec(item.specJson)
+ versionJsonOpen.value = true
+}
+
+const openVersionDiff = async (item: RuleVersion) => {
+ const token = nextOperationToken(item.versionNo)
+ try {
+ const res = await diffRuleVersionAPI(token.kind, token.ruleName, item.versionNo)
+ if (!isLatestRecordedOperation(token, item.versionNo)) {
+ return
+ }
+ if (res?.code === HTTP_STATUS.SUCCESS) {
+ versionDiffLeft.value = formatRuleSpec(res.data.left.specJson)
+ versionDiffRight.value = formatRuleSpec(res.data.right.specJson)
+ versionDiffLeftLabel.value = versionDiffLabel(
+ t('ruleVersionDomain.targetVersion'),
+ res.data.left?.versionNo
+ )
+ versionDiffRightLabel.value = versionDiffLabel(
+ t('ruleVersionDomain.currentRule'),
+ res.data.right?.versionNo
+ )
+ versionDiffOpen.value = true
+ }
+ } catch (e: any) {
+ if (isLatestRecordedOperation(token, item.versionNo)) {
+ message.error(e?.message || t('ruleVersionDomain.diffFailed'))
+ }
+ }
+}
+
+const openRollbackConfirm = (item: RuleVersion) => {
+ rollbackTarget.value = item
+ rollbackReason.value = ''
+ rollbackConfirmOpen.value = true
+}
+
+const handleRollbackConfirm = async () => {
+ if (!rollbackTarget.value) return
+ if (!rollbackReason.value.trim()) {
+ message.warning(t('ruleVersionDomain.rollbackReasonRequired'))
+ return
+ }
+
+ const target = rollbackTarget.value
+ const token = nextOperationToken(target.versionNo)
+ rollbackLoading.value = true
+ try {
+ const res = await rollbackRuleVersionAPI(
+ token.kind,
+ token.ruleName,
+ target.versionNo,
+ rollbackReason.value
+ )
+ if (
+ !isLatestRecordedOperation(token, target.versionNo) ||
+ rollbackTarget.value?.versionNo !== target.versionNo
+ ) {
+ return
+ }
+ if (res?.code === HTTP_STATUS.SUCCESS) {
+ const versionNo = res.data?.versionNo
+ message.success(
+ versionNo
+ ? t('ruleVersionDomain.rollbackSuccessWithVersion', { versionNo })
+ : t('ruleVersionDomain.rollbackSuccess')
+ )
+ rollbackConfirmOpen.value = false
+ await loadHistory()
+ }
+ } catch (e: any) {
+ if (
+ !isLatestRecordedOperation(token, target.versionNo) ||
+ rollbackTarget.value?.versionNo !== target.versionNo
+ ) {
+ return
+ }
+ message.error(e?.message || t('ruleVersionDomain.rollbackFailed'))
+ } finally {
+ if (isLatestRecordedOperation(token, target.versionNo)) {
+ rollbackLoading.value = false
+ }
+ }
+}
+
+watch(
+ () => [props.open, props.kind, props.ruleName] as const,
+ ([open]) => {
+ if (open) {
+ loadHistory()
+ } else {
+ // Closing the drawer invalidates in-flight history, diff, and rollback
+ // responses so they cannot update a later open cycle.
+ requestSeq++
+ operationSeq++
+ loading.value = false
+ }
+ },
+ { immediate: true }
+)
+
+onBeforeUnmount(() => {
+ disposed = true
+ requestSeq++
+ operationSeq++
+})
+</script>
+
+<style scoped>
+.version-diff-labels {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ margin-bottom: 8px;
+ color: var(--el-text-color-regular, rgba(0, 0, 0, 0.65));
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.rollback-hint {
+ display: block;
+ margin: -4px 0 12px;
+ font-size: 13px;
+}
+</style>
diff --git a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts
new file mode 100644
index 0000000..adb6999
--- /dev/null
+++ b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ listRuleVersionsAPI,
+ type RuleVersion,
+ type RuleVersionList,
+ type TrafficRuleKind
+} from '@/api/service/traffic'
+import { HTTP_STATUS } from '@/base/http/constants'
+
+export interface LatestRecordedState {
+ versionNo?: number
+ latestRecordedDeleted: boolean
+}
+
+export const latestRecordedStateFromItems = (items: RuleVersion[]): LatestRecordedState => {
+ const latestRecorded = items.find((item) => item.isLatestRecorded)
+ const head = items[0]
+ return {
+ versionNo: latestRecorded?.versionNo,
+ latestRecordedDeleted: Boolean(head?.operation === 'DELETE')
+ }
+}
+
+export const latestRecordedStateFromList = (list?: RuleVersionList): LatestRecordedState => {
+ if (!list) {
+ return { latestRecordedDeleted: false }
+ }
+ if (list.latestRecordedVersionNo !== undefined || list.latestRecordedDeleted !== undefined) {
+ return {
+ versionNo: list.latestRecordedVersionNo,
+ latestRecordedDeleted: Boolean(list.latestRecordedDeleted)
+ }
+ }
+ return latestRecordedStateFromItems(list.items || [])
+}
+
+export const versionDiffLabel = (prefix: string, versionNo?: number): string =>
+ typeof versionNo === 'number' && versionNo > 0 ? `${prefix} v${versionNo}` : prefix
+
+export const isLatestRecordedHistoryRequest = (
+ requestSeq: number,
+ latestSeq: number,
+ disposed: boolean
+) => {
+ return !disposed && requestSeq === latestSeq
+}
+
+export const fetchLatestRecordedState = async (
+ kind: TrafficRuleKind,
+ ruleName: string
+): Promise<LatestRecordedState> => {
+ const res = await listRuleVersionsAPI(kind, ruleName)
+ if (res.code === HTTP_STATUS.SUCCESS) {
+ return latestRecordedStateFromList(res.data)
+ }
+ return { latestRecordedDeleted: false }
+}
+
+export const formatRuleSpec = (specJson?: string): string => {
+ if (!specJson) {
+ return ''
+ }
+ try {
+ return JSON.stringify(JSON.parse(specJson), null, 2)
+ } catch (e) {
+ return specJson
+ }
+}
diff --git a/ui-vue3/src/views/traffic/dynamicConfig/index.vue b/ui-vue3/src/views/traffic/dynamicConfig/index.vue
index baad34d..731c7b8 100644
--- a/ui-vue3/src/views/traffic/dynamicConfig/index.vue
+++ b/ui-vue3/src/views/traffic/dynamicConfig/index.vue
@@ -73,11 +73,12 @@
import { useRouter } from 'vue-router'
import { PRIMARY_COLOR } from '@/base/constants'
import { Icon } from '@iconify/vue'
+import { message } from 'ant-design-vue'
const router = useRouter()
let __null = PRIMARY_COLOR
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
TAB_STATE.dynamicConfigForm = reactive({})
let columns = [
{
@@ -92,7 +93,7 @@
title: 'ruleGranularity',
key: 'ruleGranularity',
dataIndex: 'ruleGranularity',
- render: (text, record) => (record.isService ? '服务' : '应用'),
+ render: (_text: any, record: any) => (record.isService ? '服务' : '应用'),
width: 100
// sorter: (a: any, b: any) => sortString(a.instanceNum, b.instanceNum)
},
@@ -107,7 +108,7 @@
title: 'enabled',
key: 'enabled',
dataIndex: 'enabled',
- render: (text, record) => (record.enabled ? '是' : '否'),
+ render: (_text: any, record: any) => (record.enabled ? '是' : '否'),
width: 120
// sorter: (a: any, b: any) => sortString(a.instanceNum, b.instanceNum)
},
@@ -143,8 +144,12 @@
})
const delDynamicConfig = async (record: any) => {
- await delConfiguratorDetail({ name: record.ruleName })
- await searchDomain.onSearch()
+ try {
+ await delConfiguratorDetail({ name: record.ruleName })
+ await searchDomain.onSearch()
+ } catch (e: any) {
+ message.error(e?.message || String(e))
+ }
}
provide(PROVIDE_INJECT_KEY.SEARCH_DOMAIN, searchDomain)
diff --git a/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.spec.ts b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.spec.ts
new file mode 100644
index 0000000..de13c4f
--- /dev/null
+++ b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.spec.ts
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { ViewDataModel } from './ConfigModel'
+
+vi.hoisted(() => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined
+ },
+ configurable: true
+ })
+})
+
+describe('ViewDataModel traffic form compatibility', () => {
+ it('round-trips dynamic config configVersion and config row fields', () => {
+ const model = new ViewDataModel()
+ model.fromApiOutput({
+ ruleName: 'demo.configurators',
+ scope: 'service',
+ key: 'demo',
+ enabled: true,
+ configVersion: 'v3.1',
+ configs: [
+ {
+ enabled: false,
+ side: 'consumer',
+ match: {
+ application: {
+ oneof: [{ exact: 'shop-web' }]
+ }
+ },
+ parameters: {
+ timeout: '3000',
+ retries: '2'
+ }
+ }
+ ]
+ })
+
+ const payload = model.toApiInput()
+
+ expect(payload.configVersion).toBe('v3.1')
+ expect(payload.enabled).toBe(true)
+ expect(payload.configs[0].enabled).toBe(false)
+ expect(payload.configs[0].side).toBe('consumer')
+ expect(payload.configs[0].parameters).toEqual({ timeout: '3000', retries: '2' })
+ })
+})
diff --git a/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts
index ec9e1c8..61e4cfd 100644
--- a/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts
+++ b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts
@@ -18,6 +18,8 @@
import { i18n } from '@/base/i18n'
export class ConfigModel {
+ [key: string]: any
+
enabled: boolean = true
hasMatch: boolean = false
side: string = 'provider'
@@ -103,7 +105,7 @@
constructor(obj: any) {
if (obj) {
for (const key of Object.keys(this)) {
- if (obj[key]) {
+ if (obj[key] !== undefined) {
this[key] = obj[key]
}
}
@@ -112,17 +114,17 @@
}
descMatches() {
- const desc = []
+ const desc: string[] = []
for (const key in this.matchesValue) {
const tmp = this.matchesValue[key]
if (!this.matchesKeys.includes(key)) continue
if (tmp.type === 'obj') {
desc.push(`${key} ${tmp.relation} ${tmp.value}`)
} else if (tmp.type === 'arr') {
- const oneof = tmp.arr.map((x) => `${x.relation} ${x.value}`).join(', ')
+ const oneof = tmp.arr.map((x: any) => `${x.relation} ${x.value}`).join(', ')
desc.push(`${key} oneof [${oneof}]`)
} else {
- const allof = tmp.arr.map((x) => `${x.key} ${x.relation} ${x.value}`).join(', ')
+ const allof = tmp.arr.map((x: any) => `${x.key} ${x.relation} ${x.value}`).join(', ')
desc.push(`${key} allof [${allof}]`)
}
}
@@ -130,14 +132,14 @@
}
descParameters() {
- const desc = []
+ const desc: string[] = []
for (const key in this.parametersValue) {
const tmp = this.parametersValue[key]
if (!this.parametersKeys.includes(key)) continue
if (tmp.type === 'obj') {
desc.push(`${key} = ${tmp.value}`)
} else {
- desc.push(...tmp.arr.map((x) => `${x.key} ${x.relation} ${x.value}`))
+ desc.push(...tmp.arr.map((x: any) => `${x.key} ${x.relation} ${x.value}`))
}
}
return desc
@@ -147,7 +149,7 @@
obj[key].arr.splice(idx, 1)
}
- addArrConfig(obj: any, key: string, idx: number, val: any | {}) {
+ addArrConfig(obj: any, key: string, idx: number, val: any = {}) {
obj[key].arr.splice(idx + 1, 0, {
key: '',
relation: val?.relation || '',
@@ -217,7 +219,7 @@
}
}
- checkArrConfig(prefix: string, keys: [any], obj: any, errorMsg: []) {
+ checkArrConfig(prefix: string, keys: any[], obj: any, errorMsg: string[]) {
for (const key of keys) {
const item = obj[key]
if (item.type === 'obj') {
@@ -273,18 +275,18 @@
}
export class DynamicConfigBasicInfo {
- ruleName: 'org.apache.dubbo.samples.UserService::.configurator'
- scope: '服务'
- configVersion: 'v3.0'
- key: 'org.apache.dubbo.samples.UserService'
- effectTime: '20230/12/19 22:09:34'
- enabled: true
+ ruleName: string = 'org.apache.dubbo.samples.UserService::.configurator'
+ scope: string = '服务'
+ configVersion: string = 'v3.0'
+ key: string = 'org.apache.dubbo.samples.UserService'
+ effectTime: string = '20230/12/19 22:09:34'
+ enabled: boolean = true
}
export class ViewDataModel {
basicInfo: DynamicConfigBasicInfo = new DynamicConfigBasicInfo()
config: ConfigModel[] = []
- errorMsg = []
+ errorMsg: string[] = []
isAdd: boolean = false
constructor() {}
@@ -296,7 +298,7 @@
}
fromApiOutput(data: any) {
- this.basicInfo.configVerison = data.configVerison || 'v3.0'
+ this.basicInfo.configVersion = data.configVersion || 'v3.0'
this.basicInfo.scope = data.scope
this.basicInfo.key = data.key
this.basicInfo.enabled = data.enabled || false
@@ -325,8 +327,8 @@
scope: this.basicInfo.scope,
key: this.basicInfo.key,
enabled: this.basicInfo.enabled,
- configVersion: this.basicInfo.configVerison || 'v3.0',
- configs: this.config.map((x: configModel, idx: number) => {
+ configVersion: this.basicInfo.configVersion || 'v3.0',
+ configs: this.config.map((x: ConfigModel, idx: number) => {
const match: any = {}
const parameters: any = {}
if (check) {
@@ -334,7 +336,6 @@
this.errorMsg.push(
`配置 ${idx + 1}${i18n.global.t('dynamicConfigDomain.configType')} 不能为空`
)
- loading.value = false
throw new Error('数据检查失败')
}
if (
diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue
index df8ea52..3e24e0d 100644
--- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue
+++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue
@@ -21,19 +21,20 @@
<a-flex style="width: 100%">
<a-col :span="isDrawerOpened ? 24 - sliderSpan : 24" class="left">
<a-flex vertical align="end">
- <a-row style="width: 100%" justify="space-between">
- <a-col :span="12"> </a-col>
- <a-col :span="12">
- <!-- todo 版本记录后续添加-->
- <!-- <a-button-->
- <!-- type="text"-->
- <!-- style="color: #0a90d5; float: right; margin-top: -5px"-->
- <!-- @click="isDrawerOpened = !isDrawerOpened"-->
- <!-- >-->
- <!-- {{ $t('flowControlDomain.versionRecords') }}-->
- <!-- <DoubleLeftOutlined v-if="!isDrawerOpened" />-->
- <!-- <DoubleRightOutlined v-else />-->
- <!-- </a-button>-->
+ <a-row style="width: 100%" justify="end">
+ <a-col v-if="!viewData.isAdd">
+ <a-space>
+ <a-tag v-if="latestRecordedVersionNo !== undefined">
+ {{
+ $t('ruleVersionDomain.latestRecordedVersionBadge', {
+ versionNo: latestRecordedVersionNo
+ })
+ }}
+ </a-tag>
+ <a-button type="text" style="color: #0a90d5" @click="isHistoryOpen = true">
+ {{ $t('flowControlDomain.versionRecords') }}
+ </a-button>
+ </a-space>
</a-col>
</a-row>
@@ -71,11 +72,19 @@
<a-button type="primary" @click="saveConfig">保存</a-button>
<a-button style="margin-left: 30px" @click="resetConfig">重置</a-button>
</a-flex>
+
+ <RuleHistoryPanel
+ v-if="!viewData.isAdd"
+ v-model:open="isHistoryOpen"
+ kind="configurator"
+ :rule-name="pathId"
+ :title="ruleName || pathId"
+ @latest-recorded-version-no-change="latestRecordedVersionNo = $event"
+ />
</template>
<script setup lang="ts">
import MonacoEditor from '@/components/editor/MonacoEditor.vue'
-import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'
import { computed, inject, nextTick, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
@@ -89,17 +98,21 @@
import { message } from 'ant-design-vue'
import { PRIMARY_COLOR } from '@/base/constants'
import { ViewDataModel } from '@/views/traffic/dynamicConfig/model/ConfigModel'
+import RuleHistoryPanel from '../../_shared/RuleHistoryPanel.vue'
const route = useRoute()
+const pathId = computed(() => String(route.params?.pathId || ''))
const isEdit = ref(route.params.isEdit === '1')
const isDrawerOpened = ref(false)
+const isHistoryOpen = ref(false)
const loading = ref(false)
const sliderSpan = ref(8)
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
-const YAMLValue = ref()
-const initValue = ref()
+const YAMLValue = ref('')
+const initValue = ref('')
const ruleName = ref('')
+const latestRecordedVersionNo = ref<number | undefined>(undefined)
onMounted(async () => {
await initConfig()
@@ -112,23 +125,21 @@
if (TAB_STATE.dynamicConfigForm?.data) {
viewData.fromData(TAB_STATE.dynamicConfigForm.data)
} else {
- if (route.params?.pathId === '_tmp') {
+ if (pathId.value === '_tmp') {
isEdit.value = true
viewData.isAdd = true
} else {
- console.log('666')
-
viewData.isAdd = false
- const res = await getConfiguratorDetail({ name: route.params?.pathId })
+ const res = await getConfiguratorDetail({ name: pathId.value })
viewData.fromApiOutput(res.data)
}
TAB_STATE.dynamicConfigForm = reactive({
data: viewData
})
}
- const toApiInput = viewData.toApiInput()
+ const toApiInput = viewData.toApiInput() as Record<string, any>
ruleName.value = toApiInput.ruleName
- toApiInput.ruleName = undefined
+ delete toApiInput.ruleName
const json = yaml.dump(toApiInput) // 输出为 json 格式
initValue.value = JSON.stringify(json)
YAMLValue.value = json
@@ -144,37 +155,43 @@
}
}
const router = useRouter()
+const parseYAMLObject = (): Record<string, any> => {
+ const data = yaml.load(YAMLValue.value)
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
+ throw new Error('YAML content must be an object')
+ }
+ return data
+}
+
async function saveConfig() {
loading.value = true
- let data = yaml.load(YAMLValue.value)
try {
+ const data = parseYAMLObject()
if (viewData.isAdd === true) {
- addConfiguratorDetail({ name: viewData.basicInfo.key + '.configurators' }, data)
- .then((res) => {
- TAB_STATE.dynamicConfigForm.data = null
- nextTick(() => {
- router.replace('/traffic/dynamicConfig')
- message.success('config add success')
- })
- })
- .catch((e) => {
- message.error('添加失败: ' + e.msg)
- })
+ await addConfiguratorDetail({ name: viewData.basicInfo.key + '.configurators' }, data)
+ TAB_STATE.dynamicConfigForm.data = null
+ nextTick(() => {
+ router.replace('/traffic/dynamicConfig')
+ message.success('config add success')
+ })
return
}
- await saveConfiguratorDetail({ name: route.params?.pathId }, data)
+ await saveConfiguratorDetail({ name: pathId.value }, data)
message.success('config save success')
- // 延迟 2 秒后再获取数据,确保数据库已更新
- await new Promise((resolve) => setTimeout(resolve, 2000))
TAB_STATE.dynamicConfigForm.data = null
await initConfig()
+ } catch (e: any) {
+ if (e instanceof Error) {
+ message.error(e.message)
+ }
+ console.error(e)
} finally {
loading.value = false
}
}
-function changeEditor(val) {
- viewData.fromApiOutput(yaml.load(YAMLValue.value))
+function changeEditor(val: string) {
+ viewData.fromApiOutput(yaml.load(val) as any)
}
</script>
diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue
index f01a11f..acb136c 100644
--- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue
+++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue
@@ -243,7 +243,7 @@
import { getCurrentInstance, nextTick, onMounted, reactive, ref } from 'vue'
import { PRIMARY_COLOR } from '@/base/constants'
import useClipboard from 'vue-clipboard3'
-import { FormInstance, message, Modal } from 'ant-design-vue'
+import { type FormInstance, message, Modal } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { addConfiguratorDetail, saveConfiguratorDetail } from '@/api/service/traffic'
import gsap from 'gsap'
@@ -323,7 +323,7 @@
}
]
const baseInfoRef = ref<FormInstance>()
-const delConfig = (idx) => {
+const delConfig = (idx: number) => {
Modal.confirm({
title: '确认删除该配置么?',
onOk() {
@@ -372,7 +372,7 @@
function transApiData(data: any) {
if (data) {
- formViewData.basicInfo.configVerison = data.configVerison
+ formViewData.basicInfo.configVersion = data.configVersion
formViewData.basicInfo.scope = data.scope
formViewData.basicInfo.key = data.key
formViewData.basicInfo.enabled = data.enabled
diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue
index bc4595e..8402bca 100644
--- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue
+++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue
@@ -55,13 +55,35 @@
:disabled="!isEdit"
/>
</a-descriptions-item>
+ <a-descriptions-item
+ v-if="!formViewData.isAdd"
+ :label="$t('flowControlDomain.versionRecords')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-space>
+ <a-tag v-if="latestRecordedVersionNo !== undefined">
+ {{
+ $t('ruleVersionDomain.latestRecordedVersionBadge', {
+ versionNo: latestRecordedVersionNo
+ })
+ }}
+ </a-tag>
+ <a-button type="text" style="color: #0a90d5" @click="isHistoryOpen = true">
+ {{ $t('flowControlDomain.versionRecords') }}
+ </a-button>
+ </a-space>
+ </a-descriptions-item>
</a-descriptions>
</div>
</a-card>
<a-spin :spinning="loading">
<a-form ref="formRef">
- <a-card v-for="(config, index) in formViewEdit.config" class="dynamic-config-card">
+ <a-card
+ v-for="(config, index) in formViewEdit.config"
+ :key="index"
+ class="dynamic-config-card"
+ >
<template #title>
<a-button
v-if="!isEdit"
@@ -76,11 +98,15 @@
对于{{ formViewData?.basicInfo?.scope === 'application' ? '应用' : '服务' }}的{{
config.side === 'provider' ? '提供者' : '消费者'
}},将满足
- <a-tag :color="PRIMARY_COLOR" v-for="item in descMatchesComputed(config)">
+ <a-tag v-for="item in descMatchesComputed(config)" :key="item" :color="PRIMARY_COLOR">
{{ item }}
</a-tag>
的实例,配置
- <a-tag :color="PRIMARY_COLOR" v-for="item in descParametersComputed(config)">
+ <a-tag
+ v-for="item in descParametersComputed(config)"
+ :key="item"
+ :color="PRIMARY_COLOR"
+ >
{{ item }}
</a-tag>
</div>
@@ -189,6 +215,7 @@
<a-input-group
:disabled="!isEdit"
v-for="(item, idx) in config.matchesValue[key].arr"
+ :key="idx"
style="margin-bottom: 5px"
compact
>
@@ -304,6 +331,7 @@
</template>
<a-input-group
v-for="(item, idx) in config.parametersValue[key].arr"
+ :key="idx"
style="margin-bottom: 5px"
compact
>
@@ -376,14 +404,21 @@
}}</a-button>
</a-flex>
</a-card>
+
+ <RuleHistoryPanel
+ v-if="!formViewData.isAdd"
+ v-model:open="isHistoryOpen"
+ kind="configurator"
+ :rule-name="pathId"
+ :title="formViewData.basicInfo.ruleName || pathId"
+ @latest-recorded-version-no-change="latestRecordedVersionNo = $event"
+ />
</div>
</template>
<script lang="ts" setup>
-import type { ComponentInternalInstance } from 'vue'
-import { computed, getCurrentInstance, inject, nextTick, onMounted, reactive, ref } from 'vue'
+import { computed, inject, nextTick, onMounted, reactive, ref } from 'vue'
import { PRIMARY_COLOR } from '@/base/constants'
-import useClipboard from 'vue-clipboard3'
import { message, Modal } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import {
@@ -395,25 +430,16 @@
import { Icon } from '@iconify/vue'
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
import { ConfigModel, ViewDataModel } from '@/views/traffic/dynamicConfig/model/ConfigModel'
+import RuleHistoryPanel from '../../_shared/RuleHistoryPanel.vue'
-let __ = PRIMARY_COLOR
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
-const {
- appContext: {
- config: { globalProperties }
- }
-} = <ComponentInternalInstance>getCurrentInstance()
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
const route = useRoute()
const router = useRouter()
+const pathId = computed(() => String(route.params?.pathId || ''))
const isEdit = ref(route.params.isEdit === '1')
-
-const toClipboard = useClipboard().toClipboard
-
-function copyIt(v: string) {
- message.success(globalProperties.$t('messageDomain.success.copy'))
- toClipboard(v)
-}
+const isHistoryOpen = ref(false)
+const latestRecordedVersionNo = ref<number | undefined>(undefined)
const formViewData: ViewDataModel = reactive(new ViewDataModel())
@@ -480,10 +506,10 @@
]
const descMatchesComputed = computed(() => {
- return (config) => config.descMatches()
+ return (config: ConfigModel) => config.descMatches()
})
const descParametersComputed = computed(() => {
- return (config) => config.descParameters()
+ return (config: ConfigModel) => config.descParameters()
})
const addConfig = () => {
@@ -499,7 +525,7 @@
}
const handleChange = (index: number, name: string, keys: string) => {
- const config: ConfigModel = formViewData.config[index]
+ const config = formViewData.config[index] as any
config[name] = config[name].filter((item: any) => {
return config[keys].find((i: any) => {
return i === item.key
@@ -526,12 +552,10 @@
}
}
-const hasUnsavedChanges = ref(true)
-
onMounted(async () => {
await initConfig()
})
-const delConfig = (idx) => {
+const delConfig = (idx: number) => {
Modal.confirm({
title: '确认删除该配置么?',
onOk() {
@@ -556,8 +580,8 @@
if (TAB_STATE.dynamicConfigForm?.data) {
formViewData.fromData(TAB_STATE.dynamicConfigForm.data)
} else {
- if (route.params?.pathId !== '_tmp') {
- const res = await getConfiguratorDetail({ name: route.params?.pathId })
+ if (pathId.value !== '_tmp') {
+ const res = await getConfiguratorDetail({ name: pathId.value })
transApiData(res.data)
} else {
formViewData.basicInfo.ruleName = '_tmp'
@@ -576,27 +600,20 @@
try {
let data = formViewEdit.toApiInput(true)
if (formViewData.isAdd === true) {
- addConfiguratorDetail({ name: formViewEdit.basicInfo.key + '.configurators' }, data)
- .then((res) => {
- TAB_STATE.dynamicConfigForm.data = null
- nextTick(() => {
- router.replace('/traffic/dynamicConfig')
- message.success('config add success')
- })
- })
- .catch((e) => {
- message.error('添加失败: ' + e.msg)
- })
+ await addConfiguratorDetail({ name: formViewEdit.basicInfo.key + '.configurators' }, data)
+ TAB_STATE.dynamicConfigForm.data = null
+ nextTick(() => {
+ router.replace('/traffic/dynamicConfig')
+ message.success('config add success')
+ })
return
}
- await saveConfiguratorDetail({ name: route.params?.pathId }, data)
+ await saveConfiguratorDetail({ name: pathId.value }, data)
message.success('config save success')
- // 延迟 2 秒后再获取数据,确保数据库已更新
- await new Promise((resolve) => setTimeout(resolve, 2000))
TAB_STATE.dynamicConfigForm.data = null
await initConfig()
- } catch (e) {
- message.error(formViewEdit.errorMsg.join(';'))
+ } catch (e: any) {
+ message.error(formViewEdit.errorMsg.join(';') || e?.message || String(e))
console.error(e)
} finally {
loading.value = false
diff --git a/ui-vue3/src/views/traffic/routingRule/index.vue b/ui-vue3/src/views/traffic/routingRule/index.vue
index e67ad24..eb111b9 100644
--- a/ui-vue3/src/views/traffic/routingRule/index.vue
+++ b/ui-vue3/src/views/traffic/routingRule/index.vue
@@ -82,7 +82,8 @@
import { PRIMARY_COLOR } from '@/base/constants'
import { formattedDate } from '@/utils/DateUtil'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+import { message } from 'ant-design-vue'
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
let columns = [
{
title: 'ruleName',
@@ -95,7 +96,7 @@
title: 'ruleGranularity',
key: 'ruleGranularity',
dataIndex: 'ruleGranularity',
- render: (text, record) =>
+ render: (_text: any, record: any) =>
record.isService ? t('routingRuleDomain.service') : t('routingRuleDomain.application'),
width: 100
// sorter: (a: any, b: any) => sortString(a.instanceNum, b.instanceNum)
@@ -140,9 +141,13 @@
//Delete conditional routing
const deleteRule = async (ruleName: string) => {
- const res = await deleteConditionRuleAPI(ruleName)
- if (res.code === HTTP_STATUS.SUCCESS) {
- await searchDomain.onSearch()
+ try {
+ const res = await deleteConditionRuleAPI(ruleName)
+ if (res.code === HTTP_STATUS.SUCCESS) {
+ await searchDomain.onSearch()
+ }
+ } catch (e: any) {
+ message.error(e?.message || String(e))
}
}
@@ -156,7 +161,7 @@
}
})
-const confirm = (ruleName) => {
+const confirm = (ruleName: string) => {
deleteRule(ruleName)
}
diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue
index dd5adb2..180ba50 100644
--- a/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue
+++ b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue
@@ -157,7 +157,7 @@
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
const routingRuleLogic = useRoutingRule()
const {
@@ -177,7 +177,7 @@
conditions &&
conditions.length &&
- conditions.forEach((item, index) => {
+ conditions.forEach((item: string, index: number) => {
const conditionArr = item.split(' => ')
const match = conditionArr[0]?.trim()
const to = conditionArr[1]?.trim()
@@ -307,23 +307,6 @@
const res = await addConditionRuleAPI(ruleName, data)
if (res?.code === HTTP_STATUS.SUCCESS) {
message.success('add success')
- // 延迟 2 秒后再获取数据,确保数据库已更新
- await new Promise((resolve) => setTimeout(resolve, 2000))
- // Back to list or something? The original code didn't seem to navigate back, just stayed there?
- // Ah, maybe it's supposed to clear the form.
- // But looking at the original it didn't seem to do much after success except message.
- // Wait, original `addRoutingRule` was missing from the `view_file` output (it was truncated probably, or I didn't see it).
- // Let's assume standard behavior: probably redirect?
- // Wait, I should check if I missed `addRoutingRule` implementation in Step 7.
- // Yes, step 7 showed up to line 800. `addRoutingRule` was likely at the end.
- // I should have checked the end of the file.
- // But based on `updateByFormView`, it waits and refreshes.
- // For ADD, it probably should go back to list.
- // But for now I'll just keep it basic as I don't want to change behavior blindly.
- // Actually, I can check the original file content again if I want to be 100% sure about `addRoutingRule` implementation.
- // However, I can infer it.
- // Or I can add a `console.log` or something if I am unsure.
- // Let's try to view the end of the file `addByFormView.vue`.
}
}
</script>
diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/addByYAMLView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/addByYAMLView.vue
index cb9dfdc..e7465d3 100644
--- a/ui-vue3/src/views/traffic/routingRule/tabs/addByYAMLView.vue
+++ b/ui-vue3/src/views/traffic/routingRule/tabs/addByYAMLView.vue
@@ -89,7 +89,7 @@
import { message } from 'ant-design-vue'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
const router = useRouter()
const isReadonly = ref(false)
@@ -135,7 +135,7 @@
}
})
-const changeEditor = (val) => {
+const changeEditor = (val: string) => {
TAB_STATE.conditionRule = yaml.load(YAMLValue.value)
}
diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue
index fb42ce9..562ec73 100644
--- a/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue
+++ b/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue
@@ -18,15 +18,22 @@
<template>
<div class="__container_routingRule_detail">
<a-flex style="width: 100%">
- <a-col :span="isDrawerOpened ? 24 - sliderSpan : 24" class="left">
+ <a-col :span="24" class="left">
<a-row>
<a-flex justify="space-between" style="width: 100%">
<a-typography-title :level="3"> 基础信息</a-typography-title>
- <!-- <a-button type="text" style="color: #0a90d5" @click="isDrawerOpened = !isDrawerOpened">-->
- <!-- {{ $t('flowControlDomain.versionRecords') }}-->
- <!-- <DoubleLeftOutlined v-if="!isDrawerOpened" />-->
- <!-- <DoubleRightOutlined v-else />-->
- <!-- </a-button>-->
+ <a-space :size="8">
+ <a-tag v-if="latestRecordedVersionNo !== undefined">
+ {{
+ $t('ruleVersionDomain.latestRecordedVersionBadge', {
+ versionNo: latestRecordedVersionNo
+ })
+ }}
+ </a-tag>
+ <a-button type="text" style="color: #0a90d5" @click="isHistoryOpen = true">
+ {{ $t('flowControlDomain.versionRecords') }}
+ </a-button>
+ </a-space>
</a-flex>
<a-card class="_detail">
<a-descriptions :column="2" layout="vertical" title="">
@@ -178,21 +185,15 @@
</a-space>
</a-card>
</a-col>
-
- <a-col :span="isDrawerOpened ? sliderSpan : 0" class="right">
- <a-card v-if="isDrawerOpened" class="sliderBox">
- <a-card v-for="i in 2" :key="i">
- <p>修改时间: 2024/3/20 15:20:31</p>
- <p>版本号: xo842xqpx834</p>
-
- <a-flex justify="flex-end">
- <a-button type="text" style="color: #0a90d5">查看</a-button>
- <a-button type="text" style="color: #0a90d5">回滚</a-button>
- </a-flex>
- </a-card>
- </a-card>
- </a-col>
</a-flex>
+
+ <RuleHistoryPanel
+ v-model:open="isHistoryOpen"
+ :title="conditionRuleDetail.key || ruleName"
+ kind="condition-rule"
+ :rule-name="ruleName"
+ @latest-recorded-version-no-change="latestRecordedVersionNo = $event"
+ />
</div>
</template>
@@ -208,23 +209,32 @@
import { CopyOutlined } from '@ant-design/icons-vue'
import useClipboard from 'vue-clipboard3'
import { message } from 'ant-design-vue'
-import { PRIMARY_COLOR } from '@/base/constants'
import { getConditionRuleDetailAPI } from '@/api/service/traffic'
import { useRoute } from 'vue-router'
import { HTTP_STATUS } from '@/base/http/constants'
+import RuleHistoryPanel from '../../_shared/RuleHistoryPanel.vue'
+
+interface ConditionRuleDetail {
+ key: string
+ scope: string
+ version: string
+ group: string
+ force?: boolean
+ enabled?: boolean
+ runtime?: boolean
+ conditions: string[]
+}
const {
appContext: {
config: { globalProperties }
}
-} = <ComponentInternalInstance>getCurrentInstance()
+} = getCurrentInstance() as ComponentInternalInstance
const route = useRoute()
+const ruleName = computed(() => String(route.params?.ruleName || ''))
-const isDrawerOpened = ref(false)
-
-const sliderSpan = ref(8)
-
-let __ = PRIMARY_COLOR
+const isHistoryOpen = ref(false)
+const latestRecordedVersionNo = ref<number | undefined>(undefined)
const toClipboard = useClipboard().toClipboard
@@ -234,13 +244,17 @@
}
// Condition routing details
-const conditionRuleDetail = reactive({})
+const conditionRuleDetail = reactive<ConditionRuleDetail>({
+ key: '',
+ scope: '',
+ version: '',
+ group: '',
+ conditions: []
+})
const actionObj = computed(() => {
const key = conditionRuleDetail.key || ''
const arr = typeof key === 'string' ? key.split(':') : []
- conditionRuleDetail.version = arr[1] || ''
- conditionRuleDetail.group = arr[2] || ''
return arr[0] || ''
})
@@ -252,11 +266,13 @@
// Get condition routing details
async function getRoutingRuleDetail() {
- let res = await getConditionRuleDetailAPI(<string>route.params?.ruleName)
+ let res = await getConditionRuleDetailAPI(ruleName.value)
if (res?.code === HTTP_STATUS.SUCCESS) {
Object.assign(conditionRuleDetail, res?.data || {})
- conditionRuleDetail.conditions.forEach((item: any, index: number) => {
+ requestParameterMatch.value = []
+ addressSubsetMatch.value = []
+ conditionRuleDetail.conditions.forEach((item: any) => {
const arr = item.split(' => ')
const addressArr = arr[1]?.split(' & ')
const requestMatchArr = arr[0]?.split(' & ')
@@ -267,11 +283,11 @@
}
const getVersionAndGroup = () => {
- const conditionName = route.params?.ruleName
+ const conditionName = ruleName.value
if (conditionName && conditionRuleDetail.scope === 'service') {
const arr = conditionName?.split(':')
- conditionRuleDetail.version = arr[1]
- conditionRuleDetail.group = arr[2].split('.')[0]
+ conditionRuleDetail.version = arr[1] || ''
+ conditionRuleDetail.group = arr[2]?.split('.')[0] || ''
}
}
diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.spec.ts b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.spec.ts
new file mode 100644
index 0000000..71163c6
--- /dev/null
+++ b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.spec.ts
@@ -0,0 +1,171 @@
+/*
+ * 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 { flushPromises, mount } from '@vue/test-utils'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h } from 'vue'
+import { HTTP_STATUS } from '@/base/http/constants'
+import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
+import type UpdateByFormViewType from './updateByFormView.vue'
+
+const mocks = vi.hoisted(() => ({
+ updateConditionRuleAPI: vi.fn(),
+ getConditionRuleDetailAPI: vi.fn()
+}))
+
+vi.hoisted(() => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: {
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined
+ },
+ configurable: true
+ })
+})
+
+vi.mock('vue-router', async (importOriginal) => {
+ const actual = await importOriginal<typeof import('vue-router')>()
+ return {
+ ...actual,
+ useRoute: () => ({ params: { ruleName: 'demo-rule' } })
+ }
+})
+
+vi.mock('@/api/service/traffic', async (importOriginal) => {
+ const actual = await importOriginal<typeof import('@/api/service/traffic')>()
+ return {
+ ...actual,
+ updateConditionRuleAPI: mocks.updateConditionRuleAPI,
+ getConditionRuleDetailAPI: mocks.getConditionRuleDetailAPI
+ }
+})
+
+vi.mock('ant-design-vue', async (importOriginal) => {
+ const actual = await importOriginal<typeof import('ant-design-vue')>()
+ return {
+ ...actual,
+ message: {
+ success: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn()
+ }
+ }
+})
+
+const passthrough = defineComponent({
+ setup(_props, { slots }) {
+ return () => h('div', slots.default?.())
+ }
+})
+
+const buttonStub = defineComponent({
+ emits: ['click'],
+ setup(_props, { emit, slots }) {
+ return () => h('button', { type: 'button', onClick: () => emit('click') }, slots.default?.())
+ }
+})
+
+let i18n: typeof import('@/base/i18n').i18n
+let UpdateByFormView: typeof UpdateByFormViewType
+
+beforeAll(async () => {
+ i18n = (await import('@/base/i18n')).i18n
+ UpdateByFormView = (await import('./updateByFormView.vue')).default
+})
+
+beforeEach(() => {
+ mocks.updateConditionRuleAPI.mockReset()
+ mocks.getConditionRuleDetailAPI.mockReset()
+})
+
+describe('condition route form compatibility', () => {
+ it('preserves priority force and configVersion during edit round-trip', async () => {
+ mocks.updateConditionRuleAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS })
+ mocks.getConditionRuleDetailAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS, data: {} })
+ const tabState = {
+ conditionRule: {
+ configVersion: 'v3.1',
+ priority: 12,
+ enabled: true,
+ force: true,
+ key: 'demo-service',
+ scope: 'service',
+ runtime: false,
+ conditions: ['host=1.1.1.1 => host=2.2.2.2']
+ }
+ }
+
+ const wrapper = mount(UpdateByFormView, {
+ global: {
+ plugins: [i18n],
+ provide: {
+ [PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE]: tabState
+ },
+ stubs: {
+ RoutingRuleList: passthrough,
+ AFlex: passthrough,
+ 'a-flex': passthrough,
+ ACol: passthrough,
+ 'a-col': passthrough,
+ ACard: passthrough,
+ 'a-card': passthrough,
+ ASpace: passthrough,
+ 'a-space': passthrough,
+ ARow: passthrough,
+ 'a-row': passthrough,
+ AForm: passthrough,
+ 'a-form': passthrough,
+ AFormItem: passthrough,
+ 'a-form-item': passthrough,
+ ADescriptions: passthrough,
+ 'a-descriptions': passthrough,
+ ADescriptionsItem: passthrough,
+ 'a-descriptions-item': passthrough,
+ ASelect: passthrough,
+ 'a-select': passthrough,
+ AInput: passthrough,
+ 'a-input': passthrough,
+ ASwitch: passthrough,
+ 'a-switch': passthrough,
+ AInputNumber: passthrough,
+ 'a-input-number': passthrough,
+ AButton: buttonStub,
+ 'a-button': buttonStub,
+ DoubleLeftOutlined: passthrough,
+ DoubleRightOutlined: passthrough
+ }
+ }
+ })
+ await flushPromises()
+
+ const submitButton = wrapper.findAll('button')[1]
+ await submitButton.trigger('click')
+ await flushPromises()
+
+ expect(mocks.updateConditionRuleAPI).toHaveBeenCalledWith(
+ 'demo-rule',
+ expect.objectContaining({
+ configVersion: 'v3.1',
+ priority: 12,
+ force: true,
+ runtime: false,
+ conditions: ['host=1.1.1.1 => host=2.2.2.2']
+ })
+ )
+ })
+})
diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue
index 42795ad..fd58df4 100644
--- a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue
+++ b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue
@@ -144,19 +144,9 @@
</template>
<script lang="ts" setup>
-import {
- ComponentInternalInstance,
- getCurrentInstance,
- onMounted,
- reactive,
- ref,
- inject,
- watch
-} from 'vue'
+import { onMounted, reactive, ref, inject, watch } from 'vue'
import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'
-import useClipboard from 'vue-clipboard3'
import { message } from 'ant-design-vue'
-import { PRIMARY_COLOR } from '@/base/constants'
import { useRoute } from 'vue-router'
import { getConditionRuleDetailAPI, updateConditionRuleAPI } from '@/api/service/traffic'
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
@@ -167,7 +157,7 @@
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
const loading = ref(false)
const routingRuleLogic = useRoutingRule()
@@ -178,58 +168,70 @@
parseConditionToStringToArray
} = routingRuleLogic
+// Raw conditions as fetched. When configVersion is unsupported the form cannot
+// parse them into routeList, so we send these back untouched on save instead of
+// letting mergeConditions() overwrite the rule with an empty list.
+const originalConditions = ref<string[]>([])
+const conditionsEditable = ref(true)
+
onMounted(async () => {
if (!isNil(TAB_STATE.conditionRule)) {
- const { enabled = true, key, scope, runtime = true, conditions } = TAB_STATE.conditionRule
+ const {
+ configVersion,
+ priority,
+ enabled = true,
+ force = false,
+ key,
+ scope,
+ runtime = true,
+ conditions
+ } = TAB_STATE.conditionRule
+ baseInfo.configVersion = configVersion
+ baseInfo.priority = priority
baseInfo.enable = enabled
+ baseInfo.faultTolerantProtection = force
baseInfo.objectOfAction = key
baseInfo.ruleGranularity = scope
baseInfo.runtime = runtime
// Clear and rebuild routeList based on conditions
if (conditions && conditions.length > 0) {
- routeList.value = []
- conditions.forEach((item, index) => {
- // Add new route item for each condition
- routeList.value.push({
- selectedMatchConditionTypes: [],
- requestMatch: [],
- selectedRouteDistributeMatchTypes: [],
- routeDistribute: []
- })
+ originalConditions.value = conditions
+ if (configVersion !== 'v3.0') {
+ conditionsEditable.value = false
+ console.warn(
+ `skip condition route form parsing for unsupported configVersion: ${configVersion}`
+ )
+ } else {
+ routeList.value = []
+ conditions.forEach((item: string, index: number) => {
+ // Add new route item for each condition
+ routeList.value.push({
+ selectedMatchConditionTypes: [],
+ requestMatch: [],
+ selectedRouteDistributeMatchTypes: [],
+ routeDistribute: []
+ })
- const conditionArr = item.split(' => ')
- const match = conditionArr[0]?.trim()
- const to = conditionArr[1]?.trim()
- routeList.value[index].requestMatch = parseConditionMatchStringToArray(match, index)
- routeList.value[index].routeDistribute = parseConditionToStringToArray(to, index)
- })
+ const conditionArr = item.split(' => ')
+ const match = conditionArr[0]?.trim()
+ const to = conditionArr[1]?.trim()
+ routeList.value[index].requestMatch = parseConditionMatchStringToArray(match, index)
+ routeList.value[index].routeDistribute = parseConditionToStringToArray(to, index)
+ })
+ }
}
} else {
await getRoutingRuleDetail()
}
getVersionAndGroup()
})
-const {
- appContext: {
- config: { globalProperties }
- }
-} = <ComponentInternalInstance>getCurrentInstance()
const route = useRoute()
const isDrawerOpened = ref(false)
const sliderSpan = ref(8)
-let __ = PRIMARY_COLOR
-
-const toClipboard = useClipboard().toClipboard
-
-function copyIt(v: string) {
- message.success(globalProperties.$t('messageDomain.success.copy'))
- toClipboard(v)
-}
-
// base info
const baseInfo = reactive({
version: '',
@@ -239,6 +241,7 @@
faultTolerantProtection: false,
runtime: true,
priority: null,
+ configVersion: '',
group: ''
})
@@ -267,14 +270,9 @@
}
])
-enum ruleGranularityEnum {
- application = '应用',
- service = '服务'
-}
-
watch(
routeList,
- (newVal) => {
+ () => {
TAB_STATE.conditionRule = {
...TAB_STATE.conditionRule,
conditions: mergeConditions()
@@ -287,23 +285,32 @@
// Get condition routing details
async function getRoutingRuleDetail() {
- let res = await getConditionRuleDetailAPI(<string>route.params?.ruleName)
- // console.log(res)
+ let res = await getConditionRuleDetailAPI(route.params?.ruleName as string)
if (res?.code === HTTP_STATUS.SUCCESS) {
- console.log('res', res.data)
- const { conditions, configVersion, enabled, force, key, runtime, scope } = res?.data
+ const { conditions, configVersion, priority, enabled, force, key, runtime, scope } =
+ res.data || {}
baseInfo.ruleGranularity = scope
baseInfo.objectOfAction = key
baseInfo.enable = enabled
baseInfo.faultTolerantProtection = force
baseInfo.runtime = runtime
baseInfo.configVersion = configVersion
+ baseInfo.priority = priority
// format conditions data
- if (configVersion == 'v3.0' && conditions && conditions.length > 0) {
+ if (conditions && conditions.length > 0) {
+ originalConditions.value = conditions
+ if (configVersion !== 'v3.0') {
+ conditionsEditable.value = false
+ console.warn(
+ `skip condition route form parsing for unsupported configVersion: ${configVersion}`
+ )
+ return
+ }
+ conditionsEditable.value = true
// Clear and rebuild routeList based on conditions
routeList.value = []
- conditions.forEach((item, index) => {
+ conditions.forEach((item: string, index: number) => {
// Add new route item for each condition
routeList.value.push({
selectedMatchConditionTypes: [],
@@ -327,32 +334,40 @@
loading.value = true
try {
const { ruleName } = route.params
- const { version, ruleGranularity, objectOfAction, enable, faultTolerantProtection, runtime } =
- baseInfo
+ const {
+ ruleGranularity,
+ objectOfAction,
+ enable,
+ faultTolerantProtection,
+ runtime,
+ priority,
+ configVersion
+ } = baseInfo
const data = {
- configVersion: 'v3.0',
+ configVersion: configVersion || 'v3.0',
+ priority,
scope: ruleGranularity,
key: objectOfAction,
enabled: enable,
force: faultTolerantProtection,
runtime,
- conditions: mergeConditions()
+ conditions: conditionsEditable.value ? mergeConditions() : originalConditions.value
}
- const res = await updateConditionRuleAPI(<string>ruleName, data)
+ const res = await updateConditionRuleAPI(ruleName as string, data)
if (res?.code === HTTP_STATUS.SUCCESS) {
message.success('update success')
- // 延迟 2 秒后再获取数据,确保数据库已更新
- await new Promise((resolve) => setTimeout(resolve, 2000))
TAB_STATE.conditionRule = null
await getRoutingRuleDetail()
}
+ } catch (e: any) {
+ message.error(e?.message || String(e))
} finally {
loading.value = false
}
}
const getVersionAndGroup = () => {
- const conditionName = route.params?.ruleName
+ const conditionName = String(route.params?.ruleName || '')
// console.log('lll', baseInfo)
if (conditionName && baseInfo.ruleGranularity === 'service') {
const arr = conditionName.split(':')
diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue
index 435a3a6..0ce0138 100644
--- a/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue
+++ b/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue
@@ -82,7 +82,7 @@
<script setup lang="ts">
import MonacoEditor from '@/components/editor/MonacoEditor.vue'
import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'
-import { onMounted, reactive, ref, inject } from 'vue'
+import { onMounted, ref, inject } from 'vue'
import { getConditionRuleDetailAPI, updateConditionRuleAPI } from '@/api/service/traffic'
import { useRoute } from 'vue-router'
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
@@ -90,7 +90,7 @@
import { isNil } from 'lodash'
import { message } from 'ant-design-vue'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
const route = useRoute()
const isReadonly = ref(false)
@@ -102,25 +102,33 @@
const YAMLValue = ref('')
-onMounted(() => {
+onMounted(async () => {
if (!isNil(TAB_STATE.conditionRule)) {
const data = TAB_STATE.conditionRule
YAMLValue.value = yaml.dump(data)
} else {
YAMLValue.value = ``
- getRoutingRuleDetail()
+ await getRoutingRuleDetail()
}
})
-const changeEditor = (val) => {
+const changeEditor = () => {
TAB_STATE.conditionRule = yaml.load(YAMLValue.value)
}
+const parseYAMLObject = (): Record<string, any> => {
+ const data = yaml.load(YAMLValue.value)
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
+ throw new Error('YAML content must be an object')
+ }
+ return data
+}
+
// Get condition routing details
async function getRoutingRuleDetail() {
- let res = await getConditionRuleDetailAPI(<string>route.params?.ruleName)
+ let res = await getConditionRuleDetailAPI(route.params?.ruleName as string)
if (res?.code === HTTP_STATUS.SUCCESS) {
- const conditionName = route.params?.ruleName
+ const conditionName = String(route.params?.ruleName || '')
if (conditionName && res.data.scope === 'service') {
const arr = conditionName?.split(':')
res.data.group = arr[2]?.split('.')[0]
@@ -132,16 +140,19 @@
const updateRoutingRule = async () => {
loading.value = true
try {
- const data = yaml.load(YAMLValue.value)
+ const data = parseYAMLObject()
data.configVersion = 'v3.0'
- const res = await updateConditionRuleAPI(<string>route.params?.ruleName, data)
+ const res = await updateConditionRuleAPI(route.params?.ruleName as string, data)
if (res.code === HTTP_STATUS.SUCCESS) {
message.success('update success')
- // 延迟 2 秒后再获取数据,确保数据库已更新
- await new Promise((resolve) => setTimeout(resolve, 2000))
TAB_STATE.conditionRule = null
await getRoutingRuleDetail()
}
+ } catch (e: any) {
+ if (e instanceof Error) {
+ message.error(e.message)
+ }
+ console.error(e)
} finally {
loading.value = false
}
diff --git a/ui-vue3/src/views/traffic/tagRule/index.vue b/ui-vue3/src/views/traffic/tagRule/index.vue
index e726c6e..7ab9f96 100644
--- a/ui-vue3/src/views/traffic/tagRule/index.vue
+++ b/ui-vue3/src/views/traffic/tagRule/index.vue
@@ -76,7 +76,8 @@
import { formattedDate } from '@/utils/DateUtil'
import { useRoute } from 'vue-router'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+import { message } from 'ant-design-vue'
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
onMounted(() => {
TAB_STATE.tagRule = null
@@ -131,9 +132,13 @@
// Delete tag routing.
const deleteTagRule = async (ruleName: string) => {
- const res = await deleteTagRuleAPI(ruleName)
- if (res.code === HTTP_STATUS.SUCCESS) {
- await searchDomain.onSearch()
+ try {
+ const res = await deleteTagRuleAPI(ruleName)
+ if (res.code === HTTP_STATUS.SUCCESS) {
+ await searchDomain.onSearch()
+ }
+ } catch (e: any) {
+ message.error(e?.message || String(e))
}
}
diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue
index b10b6b6..a9d4fe3 100644
--- a/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue
+++ b/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue
@@ -241,7 +241,7 @@
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
onMounted(() => {
if (!isNil(TAB_STATE.tagRule)) {
@@ -252,7 +252,7 @@
baseInfo.runtime = runtime
tags &&
tags.length &&
- tags.forEach((tagItem, tagIndex) => {
+ tags.forEach((tagItem: any, tagIndex: number) => {
tagList.value.push({
tagName: tagItem.name,
scope: {
@@ -267,7 +267,7 @@
const { match } = tagItem
let formatLabels: any[] = []
- match.forEach((matchItem, matchIndex) => {
+ match.forEach((matchItem: any, _matchIndex: number) => {
formatLabels.push({
myKey: matchItem.key,
condition: Object.keys(matchItem.value)[0],
@@ -470,24 +470,24 @@
])
// tag list
-const tagList: any[] = ref([])
+const tagList = ref<any[]>([])
watch(
tagList,
(newVal) => {
console.log(newVal)
- const tags = []
+ const tags: any[] = []
newVal.forEach((tagItem) => {
const { tagName, scope } = tagItem
const labels = scope.labels
const newTagItem = {
name: tagName,
- match: []
+ match: [] as any[]
}
if (labels && labels.length > 0) {
- labels.forEach((labelItem) => {
+ labels.forEach((labelItem: any) => {
newTagItem.match.push({
key: labelItem.myKey,
value: {
@@ -567,17 +567,17 @@
key: objectOfAction,
enabled: enable,
runtime,
- tags: []
+ tags: [] as any[]
}
- tagList.value.forEach((tagItem, tagIndex) => {
+ tagList.value.forEach((tagItem: any, _tagIndex: number) => {
const tag = {
name: tagItem.tagName,
- match: []
+ match: [] as any[]
}
- tagItem.scope.labels.forEach((labelItem, labelIndex) => {
+ tagItem.scope.labels.forEach((labelItem: any, _labelIndex: number) => {
const matchItem = {
key: labelItem.myKey,
- value: {}
+ value: {} as Record<string, any>
}
matchItem.value[labelItem.condition] = labelItem.value
tag.match.push(matchItem)
diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue
index 2552413..94deb2b 100644
--- a/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue
+++ b/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue
@@ -88,7 +88,7 @@
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
const router = useRouter()
@@ -121,7 +121,7 @@
}
})
-const changeEditor = (val) => {
+const changeEditor = (val: string) => {
TAB_STATE.tagRule = yaml.load(YAMLValue.value)
}
diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/formView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/formView.vue
index b37e0eb..888a128 100644
--- a/ui-vue3/src/views/traffic/tagRule/tabs/formView.vue
+++ b/ui-vue3/src/views/traffic/tagRule/tabs/formView.vue
@@ -88,14 +88,31 @@
<a-descriptions-item
:label="$t('flowControlDomain.runTimeEffective')"
:labelStyle="{ fontWeight: 'bold' }"
+ :contentStyle="{ verticalAlign: 'middle' }"
>
- <a-typography-paragraph>
- {{
- tagRuleDetail.runtime
- ? $t('flowControlDomain.opened')
- : $t('flowControlDomain.closed')
- }}
- </a-typography-paragraph>
+ <span>{{
+ tagRuleDetail.runtime
+ ? $t('flowControlDomain.opened')
+ : $t('flowControlDomain.closed')
+ }}</span>
+ </a-descriptions-item>
+ <a-descriptions-item
+ :label="$t('flowControlDomain.versionRecords')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ :contentStyle="{ verticalAlign: 'middle' }"
+ >
+ <a-tag v-if="latestRecordedVersionNo !== undefined">{{
+ $t('ruleVersionDomain.latestRecordedVersionBadge', {
+ versionNo: latestRecordedVersionNo
+ })
+ }}</a-tag>
+ <a-button
+ type="text"
+ style="color: #0a90d5; height: auto; padding: 0"
+ @click="isHistoryOpen = true"
+ >
+ {{ $t('flowControlDomain.versionRecords') }}
+ </a-button>
</a-descriptions-item>
<!-- priority -->
@@ -110,9 +127,17 @@
</a-descriptions>
</a-card>
</a-flex>
+ <RuleHistoryPanel
+ v-model:open="isHistoryOpen"
+ :title="tagRuleDetail.key || ruleName"
+ kind="tag-rule"
+ :rule-name="ruleName"
+ @latest-recorded-version-no-change="latestRecordedVersionNo = $event"
+ />
<a-card
v-for="(item, index) in tagRuleDetail.tags"
+ :key="index"
:title="`标签【${index + 1}】`"
style="margin-top: 10px"
class="_detail"
@@ -146,22 +171,26 @@
computed,
getCurrentInstance,
onMounted,
- reactive
+ reactive,
+ ref
} from 'vue'
import { PRIMARY_COLOR } from '@/base/constants'
import { getTagRuleDetailAPI } from '@/api/service/traffic'
import { useRoute } from 'vue-router'
import { HTTP_STATUS } from '@/base/http/constants'
+import RuleHistoryPanel from '../../_shared/RuleHistoryPanel.vue'
const route = useRoute()
+const ruleName = computed(() => String(route.params?.ruleName || ''))
const {
appContext: {
config: { globalProperties }
}
-} = <ComponentInternalInstance>getCurrentInstance()
+} = getCurrentInstance() as ComponentInternalInstance
-let __ = PRIMARY_COLOR
const toClipboard = useClipboard().toClipboard
+const isHistoryOpen = ref(false)
+const latestRecordedVersionNo = ref<number | undefined>(undefined)
function copyIt(v: string) {
message.success(globalProperties.$t('messageDomain.success.copy'))
@@ -197,7 +226,7 @@
// Get label routing details
const getTagRuleDetail = async () => {
- const res = await getTagRuleDetailAPI(<string>route.params?.ruleName)
+ const res = await getTagRuleDetailAPI(ruleName.value)
if (res.code === HTTP_STATUS.SUCCESS) {
Object.assign(tagRuleDetail, res.data || {})
}
diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue
index 3c11272..2405ae7 100644
--- a/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue
+++ b/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue
@@ -131,7 +131,7 @@
:columns="labelsColumns"
:data-source="tagItem.scope?.labels"
>
- <template #bodyCell="{ column, record, text, index: labelItemIndex }">
+ <template #bodyCell="{ column, record, index: labelItemIndex }">
<template v-if="column.key === 'myKey'">
<a-input placeholder="label key" v-model:value="record.myKey" />
</template>
@@ -225,40 +225,41 @@
</template>
<script setup lang="ts">
-import {
- type ComponentInternalInstance,
- getCurrentInstance,
- inject,
- onMounted,
- reactive,
- ref,
- watch
-} from 'vue'
+import { inject, onMounted, reactive, ref, watch } from 'vue'
import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'
-import useClipboard from 'vue-clipboard3'
import { message } from 'ant-design-vue'
-import { PRIMARY_COLOR } from '@/base/constants'
-import { useRoute, useRouter } from 'vue-router'
+import { useRoute } from 'vue-router'
import { Icon } from '@iconify/vue'
import { getTagRuleDetailAPI, updateTagRuleAPI } from '@/api/service/traffic'
import { isNil } from 'lodash'
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
-const router = useRouter()
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
onMounted(async () => {
if (!isNil(TAB_STATE.tagRule)) {
- const { enabled = true, key, scope, runtime = true, tags } = TAB_STATE.tagRule
+ const {
+ configVersion,
+ priority,
+ enabled = true,
+ force = false,
+ key,
+ scope,
+ runtime = true,
+ tags
+ } = TAB_STATE.tagRule
+ baseInfo.configVersion = configVersion
+ baseInfo.priority = priority
baseInfo.enable = enabled
+ baseInfo.faultTolerantProtection = force
baseInfo.objectOfAction = key
baseInfo.ruleGranularity = scope
baseInfo.runtime = runtime
console.log('tags', tags)
tags &&
tags.length &&
- tags.forEach((tagItem, tagIndex) => {
+ tags.forEach((tagItem: any, tagIndex: number) => {
tagList.value.push({
tagName: tagItem.name,
scope: {
@@ -273,7 +274,7 @@
const { match } = tagItem
let formatLabels: any[] = []
- match.forEach((matchItem, matchIndex) => {
+ match.forEach((matchItem: any) => {
formatLabels.push({
myKey: matchItem.key,
condition: Object.keys(matchItem.value)[0],
@@ -288,11 +289,6 @@
await getTagRuleDetail()
}
})
-const {
- appContext: {
- config: { globalProperties }
- }
-} = <ComponentInternalInstance>getCurrentInstance()
const route = useRoute()
const isDrawerOpened = ref(false)
@@ -300,15 +296,6 @@
const sliderSpan = ref(8)
-let __ = PRIMARY_COLOR
-
-const toClipboard = useClipboard().toClipboard
-
-function copyIt(v: string) {
- message.success(globalProperties.$t('messageDomain.success.copy'))
- toClipboard(v)
-}
-
const generateDescription = (tagItem: any, serviceName: string): string => {
let description = `对于应用 ${serviceName || '未指定'},将满足 `
const conditions: string[] = []
@@ -472,24 +459,24 @@
])
// tag list
-const tagList: any[] = ref([])
+const tagList = ref<any[]>([])
watch(
tagList,
(newVal) => {
console.log(newVal)
- const tags = []
+ const tags: any[] = []
newVal.forEach((tagItem) => {
const { tagName, scope } = tagItem
const labels = scope.labels
const newTagItem = {
name: tagName,
- match: []
+ match: [] as any[]
}
if (labels && labels.length > 0) {
- labels.forEach((labelItem) => {
+ labels.forEach((labelItem: any) => {
newTagItem.match.push({
key: labelItem.myKey,
value: {
@@ -554,17 +541,18 @@
// Get label routing details
const getTagRuleDetail = async () => {
- const res = await getTagRuleDetailAPI(<string>route.params?.ruleName)
+ const res = await getTagRuleDetailAPI(route.params?.ruleName as string)
if (res.code === HTTP_STATUS.SUCCESS) {
- const { configVersion, enabled, key, runtime, scope, tags } = res?.data
+ const { configVersion, priority, enabled, force, key, runtime, scope, tags } = res.data || {}
baseInfo.configVersion = configVersion
+ baseInfo.priority = priority
baseInfo.enable = enabled
- // baseInfo.faultTolerantProtection =
+ baseInfo.faultTolerantProtection = force
baseInfo.runtime = runtime
baseInfo.ruleGranularity = scope
baseInfo.objectOfAction = key
tagList.value = []
- tags.forEach((tagItem, tagIndex) => {
+ tags.forEach((tagItem: any, tagIndex: number) => {
tagList.value.push({
tagName: tagItem.name,
scope: {
@@ -579,7 +567,7 @@
const { match } = tagItem
let formatLabels: any[] = []
- match.forEach((matchItem, matchIndex) => {
+ match.forEach((matchItem: any) => {
formatLabels.push({
myKey: matchItem.key,
condition: Object.keys(matchItem.value)[0],
@@ -609,33 +597,35 @@
configVersion,
scope: ruleGranularity,
key: objectOfAction,
+ priority,
enabled: enable,
+ force: faultTolerantProtection,
runtime,
- tags: []
+ tags: [] as any[]
}
- tagList.value.forEach((tagItem, tagIndex) => {
+ tagList.value.forEach((tagItem: any) => {
const tag = {
name: tagItem.tagName,
- match: []
+ match: [] as any[]
}
- tagItem.scope.labels.forEach((labelItem, labelIndex) => {
+ tagItem.scope.labels.forEach((labelItem: any) => {
const matchItem = {
key: labelItem.myKey,
- value: {}
+ value: {} as Record<string, any>
}
matchItem.value[labelItem.condition] = labelItem.value
tag.match.push(matchItem)
})
data.tags.push(tag)
})
- const res = await updateTagRuleAPI(route.params?.ruleName, data)
+ const res = await updateTagRuleAPI(route.params?.ruleName as string, data)
if (res.code === HTTP_STATUS.SUCCESS) {
message.success('update success')
- // 延迟 2 秒后再获取数据,确保数据库已更新
- await new Promise((resolve) => setTimeout(resolve, 2000))
TAB_STATE.tagRule = null
await getTagRuleDetail()
}
+ } catch (e: any) {
+ message.error(e?.message || String(e))
} finally {
loading.value = false
}
diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue
index d50cbbc..403e01f 100644
--- a/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue
+++ b/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue
@@ -80,19 +80,18 @@
<script setup lang="ts">
import MonacoEditor from '@/components/editor/MonacoEditor.vue'
import { DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'
-import { inject, onMounted, reactive, ref } from 'vue'
+import { inject, onMounted, ref } from 'vue'
import { getTagRuleDetailAPI, updateTagRuleAPI } from '@/api/service/traffic'
-import { useRoute, useRouter } from 'vue-router'
+import { useRoute } from 'vue-router'
import yaml from 'js-yaml'
import { isNil } from 'lodash'
import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject'
import { message } from 'ant-design-vue'
import { HTTP_STATUS } from '@/base/http/constants'
-const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE)
+const TAB_STATE = inject(PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE) as any
const route = useRoute()
-const router = useRouter()
const isReadonly = ref(false)
const isDrawerOpened = ref(false)
@@ -100,20 +99,28 @@
const sliderSpan = ref(8)
-onMounted(() => {
+onMounted(async () => {
if (!isNil(TAB_STATE.tagRule)) {
const data = TAB_STATE.tagRule
YAMLValue.value = yaml.dump(data)
} else {
YAMLValue.value = ``
- getTagRuleDetail()
+ await getTagRuleDetail()
}
})
-const changeEditor = (val) => {
+const changeEditor = () => {
TAB_STATE.tagRule = yaml.load(YAMLValue.value)
}
+const parseYAMLObject = (): Record<string, any> => {
+ const data = yaml.load(YAMLValue.value)
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
+ throw new Error('YAML content must be an object')
+ }
+ return data
+}
+
const YAMLValue = ref(
'configVersion: v3.0\n' +
'force: true\n' +
@@ -128,7 +135,7 @@
)
async function getTagRuleDetail() {
- let res = await getTagRuleDetailAPI(<string>route.params?.ruleName)
+ let res = await getTagRuleDetailAPI(route.params?.ruleName as string)
if (res?.code === HTTP_STATUS.SUCCESS) {
YAMLValue.value = yaml.dump(res?.data)
}
@@ -137,15 +144,18 @@
const updateTagRule = async () => {
loading.value = true
try {
- const data = yaml.load(YAMLValue.value)
- const res = await updateTagRuleAPI(<string>route.params?.ruleName, data)
+ const data = parseYAMLObject()
+ const res = await updateTagRuleAPI(route.params?.ruleName as string, data)
if (res.code === HTTP_STATUS.SUCCESS) {
message.success('update success')
- // 延迟 2 秒后再获取数据,确保数据库已更新
- await new Promise((resolve) => setTimeout(resolve, 2000))
TAB_STATE.tagRule = null
await getTagRuleDetail()
}
+ } catch (e: any) {
+ if (e instanceof Error) {
+ message.error(e.message)
+ }
+ console.error(e)
} finally {
loading.value = false
}