mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
feat(hosts): add drag-and-drop and global subscription ordering (#6895)
Hosts already had one saved order on the Hosts page, but subscriptions honoured it only inside each inbound, so a host of a later inbound could never come before one of an earlier inbound (#6894). A new opt-in setting makes raw/base64, Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds; the default keeps today's inbound-first output unchanged. The Hosts table gains drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook the Routing Rules table now shares.
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type HostService struct{}
|
||||
@@ -106,7 +107,10 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
||||
if res[i].SortOrder != res[j].SortOrder {
|
||||
return res[i].SortOrder < res[j].SortOrder
|
||||
}
|
||||
return res[i].Remark < res[j].Remark
|
||||
if res[i].Remark != res[j].Remark {
|
||||
return res[i].Remark < res[j].Remark
|
||||
}
|
||||
return res[i].GroupId < res[j].GroupId
|
||||
})
|
||||
|
||||
return res
|
||||
@@ -311,8 +315,33 @@ func (s *HostService) ReorderHostGroups(groupIds []string) error {
|
||||
if len(groupIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(groupIds))
|
||||
for _, id := range groupIds {
|
||||
if id == "" || seen[id] {
|
||||
return common.NewError("host group IDs must be nonempty and unique")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
for i, groupId := range groupIds {
|
||||
var rows []*model.Host
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
groups := groupHosts(rows)
|
||||
known := make(map[string]bool, len(groups))
|
||||
ordered := append([]string(nil), groupIds...)
|
||||
for _, group := range groups {
|
||||
known[group.GroupId] = true
|
||||
if !seen[group.GroupId] {
|
||||
ordered = append(ordered, group.GroupId)
|
||||
}
|
||||
}
|
||||
for _, id := range groupIds {
|
||||
if !known[id] {
|
||||
return common.NewError("host group not found")
|
||||
}
|
||||
}
|
||||
for i, groupId := range ordered {
|
||||
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -8,6 +9,43 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
)
|
||||
|
||||
func TestReorderHostsRejectsInvalidGroupsAtomically(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 46890, model.VLESS, `{"clients":[]}`)
|
||||
a := mkHost(t, svc, ib.Id, "a", 0)
|
||||
b := mkHost(t, svc, ib.Id, "b", 1)
|
||||
for _, invalid := range []struct {
|
||||
ids []string
|
||||
err string
|
||||
}{
|
||||
{[]string{b.GroupId, a.GroupId, b.GroupId}, "host group IDs must be nonempty and unique"},
|
||||
{[]string{b.GroupId, ""}, "host group IDs must be nonempty and unique"},
|
||||
{[]string{b.GroupId, "unknown"}, "host group not found"},
|
||||
} {
|
||||
if err := svc.ReorderHostGroups(invalid.ids); err == nil || err.Error() != invalid.err+"\n" {
|
||||
t.Fatalf("invalid group IDs %v: error = %v, want %q", invalid.ids, err, invalid.err)
|
||||
}
|
||||
groups, err := svc.GetHosts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if groups[0].GroupId != a.GroupId || groups[0].SortOrder != 0 || groups[1].GroupId != b.GroupId || groups[1].SortOrder != 1 {
|
||||
t.Fatalf("invalid request changed ordering: %+v", groups)
|
||||
}
|
||||
}
|
||||
if err := svc.ReorderHostGroups([]string{b.GroupId}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups, err := svc.GetHosts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := []string{groups[0].GroupId, groups[1].GroupId}; !reflect.DeepEqual(got, []string{b.GroupId, a.GroupId}) || groups[1].SortOrder != 1 {
|
||||
t.Fatalf("partial reorder lost omitted groups: %+v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *entity.HostGroup {
|
||||
t.Helper()
|
||||
created, err := svc.AddHostGroup(&entity.HostGroup{
|
||||
|
||||
@@ -83,6 +83,7 @@ var defaultValueMap = map[string]string{
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"subInfoNodeEnable": "false",
|
||||
"subHostSortMode": "inbound-first",
|
||||
"subCalendarExpireInclusive": "false",
|
||||
"subExpiredTemplate": DefaultSubExpiredTemplate,
|
||||
"subTrafficDepletedTemplate": DefaultSubTrafficDepletedTemplate,
|
||||
@@ -770,6 +771,10 @@ func (s *SettingService) GetSubInfoNodeEnable() (bool, error) {
|
||||
return s.getBool("subInfoNodeEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubHostSortMode() (string, error) {
|
||||
return s.getString("subHostSortMode")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubCalendarExpireInclusive() (bool, error) {
|
||||
return s.getBool("subCalendarExpireInclusive")
|
||||
}
|
||||
@@ -1662,6 +1667,13 @@ type SecretClears struct {
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||
switch allSetting.SubHostSortMode {
|
||||
case "":
|
||||
allSetting.SubHostSortMode = "inbound-first"
|
||||
case "inbound-first", "hosts-first":
|
||||
default:
|
||||
return errors.New("subscription host sort mode must be inbound-first or hosts-first")
|
||||
}
|
||||
switch allSetting.SubProfileMode {
|
||||
case "", SubProfileModeNone, SubProfileModeBuiltin, SubProfileModeCustom:
|
||||
allSetting.SubProfileMode = effectiveSubProfileMode(allSetting.SubProfileMode, allSetting.SubProfileUrl)
|
||||
|
||||
@@ -4,6 +4,34 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSubHostSortModePersistsAndRejectsInvalidValues(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
settings, err := s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.SubHostSortMode != "inbound-first" {
|
||||
t.Fatalf("default = %q", settings.SubHostSortMode)
|
||||
}
|
||||
settings.SubHostSortMode = "hosts-first"
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded, err := s.GetAllSetting()
|
||||
if err != nil || reloaded.SubHostSortMode != "hosts-first" {
|
||||
t.Fatalf("reloaded mode = %+v, err=%v", reloaded, err)
|
||||
}
|
||||
settings.SubHostSortMode = "invalid"
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil || err.Error() != "subscription host sort mode must be inbound-first or hosts-first" {
|
||||
t.Fatalf("invalid mode error = %v", err)
|
||||
}
|
||||
mode, err := s.GetSubHostSortMode()
|
||||
if err != nil || mode != "hosts-first" {
|
||||
t.Fatalf("invalid save changed mode: %q, %v", mode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubInfoNodeSettingsDefaultsAndPersists(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
|
||||
Reference in New Issue
Block a user