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:
Hamed
2026-10-24 08:57:47 +03:30
committed by GitHub
parent a69ae2b0ea
commit 136c1c4d50
51 changed files with 1643 additions and 149 deletions
+1
View File
@@ -38,6 +38,7 @@ type AllSetting struct {
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
SubInfoNodeEnable bool `json:"subInfoNodeEnable" form:"subInfoNodeEnable"`
SubHostSortMode string `json:"subHostSortMode" form:"subHostSortMode" example:"inbound-first"`
SubCalendarExpireInclusive bool `json:"subCalendarExpireInclusive" form:"subCalendarExpireInclusive"`
SubExpiredTemplate string `json:"subExpiredTemplate" form:"subExpiredTemplate"`
SubTrafficDepletedTemplate string `json:"subTrafficDepletedTemplate" form:"subTrafficDepletedTemplate"`
+31 -2
View File
@@ -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
}
+38
View File
@@ -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{
+12
View File
@@ -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{}
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "رابط سيرفر DoH، غالبًا ينتهي بـ /dns-query.",
"subIncyResolveDnsIp": "IP سيرفر DoH",
"subIncyResolveDnsIpDesc": "عنوان IP لسيرفر DoH للتهيئة.",
"subIncyGroupRouting": "التوجيه"
"subIncyGroupRouting": "التوجيه",
"subHostSortMode": "ترتيب عقد الاشتراك",
"subHostSortInbound": "ترتيب الاتصالات الواردة (الافتراضي)",
"subHostSortHosts": "ترتيب المضيفين",
"subHostSortModeDesc": "يتبع ترتيب المضيفين صفحة المضيفين عبر جميع الاتصالات الواردة. يحدد هذا ترتيب مخرجات الخادم؛ وقد تطبق تطبيقات العميل ترتيبًا آخر."
},
"xray": {
"save": "احفظ",
@@ -2465,7 +2469,8 @@
"delete": "حذف المضيف",
"badTag": "وسم غير صالح",
"badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
}
},
"dragToReorder": "اسحب لتغيير ترتيب {name}"
},
"sponsors": {
"title": "الرعاة",
+7 -2
View File
@@ -1202,7 +1202,8 @@
"delete": "Host deleted successfully",
"badTag": "Invalid tag",
"badVlessRoute": "Enter a single number between 0 and 65535"
}
},
"dragToReorder": "Drag to reorder {name}"
},
"nodes": {
"addNode": "Add Node",
@@ -1945,7 +1946,11 @@
"subIncyResolveDnsDomainDesc": "DoH server URL, usually ending in /dns-query.",
"subIncyResolveDnsIp": "DoH server IP",
"subIncyResolveDnsIpDesc": "IP of the DoH server, used for bootstrap.",
"subIncyGroupRouting": "Routing"
"subIncyGroupRouting": "Routing",
"subHostSortMode": "Subscription Node Ordering",
"subHostSortInbound": "Inbound order (default)",
"subHostSortHosts": "Hosts order",
"subHostSortModeDesc": "Hosts order follows the Hosts page across inbounds. This controls the order emitted by the server; client apps may apply their own sorting."
},
"xray": {
"save": "Save",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "URL del servidor DoH, normalmente acaba en /dns-query.",
"subIncyResolveDnsIp": "IP del servidor DoH",
"subIncyResolveDnsIpDesc": "IP del servidor DoH para el arranque.",
"subIncyGroupRouting": "Enrutado"
"subIncyGroupRouting": "Enrutado",
"subHostSortMode": "Orden de nodos de suscripción",
"subHostSortInbound": "Orden de entradas (predeterminado)",
"subHostSortHosts": "Orden de hosts",
"subHostSortModeDesc": "Sigue el orden de la página de hosts entre distintas entradas. Determina el orden emitido por el servidor; las aplicaciones cliente pueden aplicar su propio orden."
},
"xray": {
"save": "Guardar configuración",
@@ -2465,7 +2469,8 @@
"delete": "Eliminar host",
"badTag": "Etiqueta no válida",
"badVlessRoute": "Introduce un único número entre 0 y 65535"
}
},
"dragToReorder": "Arrastra para reordenar {name}"
},
"sponsors": {
"title": "Patrocinadores",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "آدرس سرور DoH، معمولاً به /dns-query ختم میشود.",
"subIncyResolveDnsIp": "آیپی سرور DoH",
"subIncyResolveDnsIpDesc": "آیپی سرور DoH برای بوتاسترپ.",
"subIncyGroupRouting": "مسیریابی"
"subIncyGroupRouting": "مسیریابی",
"subHostSortMode": "ترتیب گره‌های اشتراک",
"subHostSortInbound": "ترتیب ورودی‌ها (پیش‌فرض)",
"subHostSortHosts": "ترتیب میزبان‌ها",
"subHostSortModeDesc": "ترتیب میزبان‌ها در صفحهٔ میزبان‌ها، میان همهٔ ورودی‌ها اعمال می‌شود. این تنظیم ترتیب خروجی سرور را تعیین می‌کند؛ برنامهٔ کلاینت ممکن است ترتیب دیگری اعمال کند."
},
"xray": {
"save": "ذخیره",
@@ -2465,7 +2469,8 @@
"delete": "حذف میزبان",
"badTag": "برچسب نامعتبر",
"badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
}
},
"dragToReorder": "برای تغییر ترتیب {name} بکشید"
},
"sponsors": {
"title": "اسپانسرها",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "URL server DoH, biasanya berakhiran /dns-query.",
"subIncyResolveDnsIp": "IP server DoH",
"subIncyResolveDnsIpDesc": "IP server DoH untuk bootstrap.",
"subIncyGroupRouting": "Perutean"
"subIncyGroupRouting": "Perutean",
"subHostSortMode": "Urutan node langganan",
"subHostSortInbound": "Urutan inbound (bawaan)",
"subHostSortHosts": "Urutan host",
"subHostSortModeDesc": "Mengikuti urutan di halaman Host untuk semua inbound. Ini menentukan urutan keluaran server; aplikasi klien dapat menerapkan urutan sendiri."
},
"xray": {
"save": "Simpan",
@@ -2465,7 +2469,8 @@
"delete": "Hapus host",
"badTag": "Tag tidak valid",
"badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
}
},
"dragToReorder": "Seret untuk mengurutkan ulang {name}"
},
"sponsors": {
"title": "Sponsor",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "DoH サーバー URL。通常は /dns-query で終わります。",
"subIncyResolveDnsIp": "DoH サーバー IP",
"subIncyResolveDnsIpDesc": "ブートストラップ用の DoH サーバー IP。",
"subIncyGroupRouting": "ルーティング"
"subIncyGroupRouting": "ルーティング",
"subHostSortMode": "サブスクリプションのノード順序",
"subHostSortInbound": "インバウンド順(既定)",
"subHostSortHosts": "ホスト順",
"subHostSortModeDesc": "異なるインバウンド間でもホストページの順序に従います。サーバーが出力する順序を指定しますが、クライアントアプリが独自に並べ替える場合があります。"
},
"xray": {
"importRules": "ルールをインポート",
@@ -2465,7 +2469,8 @@
"delete": "ホストを削除",
"badTag": "無効なタグ",
"badVlessRoute": "0〜65535 の単一の数値を入力してください"
}
},
"dragToReorder": "ドラッグして {name} を並べ替え"
},
"sponsors": {
"title": "スポンサー",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "URL do servidor DoH, geralmente terminando em /dns-query.",
"subIncyResolveDnsIp": "IP do servidor DoH",
"subIncyResolveDnsIpDesc": "IP do servidor DoH para bootstrap.",
"subIncyGroupRouting": "Roteamento"
"subIncyGroupRouting": "Roteamento",
"subHostSortMode": "Ordem dos nós da assinatura",
"subHostSortInbound": "Ordem das entradas (padrão)",
"subHostSortHosts": "Ordem dos hosts",
"subHostSortModeDesc": "Segue a ordem da página de hosts entre diferentes entradas. Define a ordem emitida pelo servidor; os aplicativos clientes podem aplicar sua própria ordenação."
},
"xray": {
"importRules": "Importar regras",
@@ -2465,7 +2469,8 @@
"delete": "Excluir host",
"badTag": "Tag inválida",
"badVlessRoute": "Insira um único número entre 0 e 65535"
}
},
"dragToReorder": "Arraste para reordenar {name}"
},
"sponsors": {
"title": "Patrocinadores",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "URL DoH-сервера, обычно заканчивается на /dns-query.",
"subIncyResolveDnsIp": "IP DoH-сервера",
"subIncyResolveDnsIpDesc": "IP DoH-сервера, нужен для bootstrap.",
"subIncyGroupRouting": "Маршрутизация"
"subIncyGroupRouting": "Маршрутизация",
"subHostSortMode": "Порядок узлов подписки",
"subHostSortInbound": "Порядок входящих подключений (по умолчанию)",
"subHostSortHosts": "Порядок хостов",
"subHostSortModeDesc": "Используется порядок со страницы хостов для всех входящих подключений. Настройка задаёт порядок выдачи сервером; клиентские приложения могут сортировать узлы самостоятельно."
},
"xray": {
"importRules": "Импорт правил",
@@ -2465,7 +2469,8 @@
"delete": "Удалить хост",
"badTag": "Недопустимый тег",
"badVlessRoute": "Введите одно число от 0 до 65535"
}
},
"dragToReorder": "Перетащите, чтобы изменить порядок {name}"
},
"sponsors": {
"title": "Спонсоры",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "DoH sunucu URL'si, genelde /dns-query ile biter.",
"subIncyResolveDnsIp": "DoH sunucu IP'si",
"subIncyResolveDnsIpDesc": "Bootstrap için DoH sunucu IP'si.",
"subIncyGroupRouting": "Yönlendirme"
"subIncyGroupRouting": "Yönlendirme",
"subHostSortMode": "Abonelik düğümü sıralaması",
"subHostSortInbound": "Gelen bağlantı sırası (varsayılan)",
"subHostSortHosts": "Ana bilgisayar sırası",
"subHostSortModeDesc": "Farklı gelen bağlantılarda Ana Bilgisayarlar sayfasının sırasını izler. Sunucunun çıktı sırasını belirler; istemci uygulamaları kendi sıralamalarını uygulayabilir."
},
"xray": {
"save": "Kaydet",
@@ -2465,7 +2469,8 @@
"delete": "Host'u sil",
"badTag": "Geçersiz etiket",
"badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
}
},
"dragToReorder": "{name} öğesini yeniden sıralamak için sürükleyin"
},
"sponsors": {
"title": "Sponsorlar",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "URL DoH-сервера, зазвичай закінчується на /dns-query.",
"subIncyResolveDnsIp": "IP DoH-сервера",
"subIncyResolveDnsIpDesc": "IP DoH-сервера для bootstrap.",
"subIncyGroupRouting": "Маршрутизація"
"subIncyGroupRouting": "Маршрутизація",
"subHostSortMode": "Порядок вузлів підписки",
"subHostSortInbound": "Порядок вхідних підключень (типово)",
"subHostSortHosts": "Порядок хостів",
"subHostSortModeDesc": "Використовується порядок зі сторінки хостів для всіх вхідних підключень. Це визначає порядок видачі сервером; клієнтські застосунки можуть сортувати вузли самостійно."
},
"xray": {
"save": "Зберегти",
@@ -2465,7 +2469,8 @@
"delete": "Видалити хост",
"badTag": "Недійсний тег",
"badVlessRoute": "Введіть одне число від 0 до 65535"
}
},
"dragToReorder": "Перетягніть, щоб змінити порядок {name}"
},
"sponsors": {
"title": "Спонсори",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "URL máy chủ DoH, thường kết thúc bằng /dns-query.",
"subIncyResolveDnsIp": "IP máy chủ DoH",
"subIncyResolveDnsIpDesc": "IP máy chủ DoH để khởi tạo.",
"subIncyGroupRouting": "Định tuyến"
"subIncyGroupRouting": "Định tuyến",
"subHostSortMode": "Thứ tự nút đăng ký",
"subHostSortInbound": "Thứ tự kết nối vào (mặc định)",
"subHostSortHosts": "Thứ tự máy chủ",
"subHostSortModeDesc": "Theo thứ tự trên trang Máy chủ giữa các kết nối vào. Thiết lập này quyết định thứ tự do máy chủ xuất ra; ứng dụng khách có thể tự sắp xếp lại."
},
"xray": {
"importRules": "Nhập quy tắc",
@@ -2465,7 +2469,8 @@
"delete": "Xóa host",
"badTag": "Tag không hợp lệ",
"badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
}
},
"dragToReorder": "Kéo để sắp xếp lại {name}"
},
"sponsors": {
"title": "Nhà tài trợ",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "DoH 服务器 URL,通常以 /dns-query 结尾。",
"subIncyResolveDnsIp": "DoH 服务器 IP",
"subIncyResolveDnsIpDesc": "用于引导的 DoH 服务器 IP。",
"subIncyGroupRouting": "路由"
"subIncyGroupRouting": "路由",
"subHostSortMode": "订阅节点排序",
"subHostSortInbound": "入站顺序(默认)",
"subHostSortHosts": "主机顺序",
"subHostSortModeDesc": "跨入站使用主机页面中的排序。此设置决定服务器输出顺序;客户端应用仍可能自行排序。"
},
"xray": {
"importRules": "导入规则",
@@ -2465,7 +2469,8 @@
"delete": "删除主机",
"badTag": "无效的标签",
"badVlessRoute": "请输入 0 到 65535 之间的单个数字"
}
},
"dragToReorder": "拖动以重新排列 {name}"
},
"sponsors": {
"title": "赞助商",
+7 -2
View File
@@ -1826,7 +1826,11 @@
"subIncyResolveDnsDomainDesc": "DoH 伺服器 URL,通常以 /dns-query 結尾。",
"subIncyResolveDnsIp": "DoH 伺服器 IP",
"subIncyResolveDnsIpDesc": "用於引導的 DoH 伺服器 IP。",
"subIncyGroupRouting": "路由"
"subIncyGroupRouting": "路由",
"subHostSortMode": "訂閱節點排序",
"subHostSortInbound": "入站順序(預設)",
"subHostSortHosts": "主機順序",
"subHostSortModeDesc": "跨入站使用主機頁面中的排序。此設定決定伺服器輸出順序;用戶端應用程式仍可能自行排序。"
},
"xray": {
"save": "儲存",
@@ -2465,7 +2469,8 @@
"delete": "刪除 Host",
"badTag": "無效的標籤",
"badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
}
},
"dragToReorder": "拖曳以重新排列 {name}"
},
"sponsors": {
"title": "贊助商",