Files
3x-ui/internal/sub/host_order.go
T
Hamed 136c1c4d50 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.
2026-10-24 07:27:47 +02:00

117 lines
3.3 KiB
Go

package sub
import (
"cmp"
"fmt"
"slices"
"sort"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type hostEntryOrder struct {
sortOrder, inboundSort, inboundID, hostID int
remark, groupID string
}
type orderedSubEntry[T any] struct {
order *hostEntryOrder
value T
}
// Group ties match the Hosts page, then inbound order and host row ID.
// Stable sorting preserves emission order inside each host.
func hostOrderLess(a, b *hostEntryOrder) bool {
if a == nil || b == nil {
return a != nil && b == nil
}
for _, c := range []int{
cmp.Compare(a.sortOrder, b.sortOrder), cmp.Compare(a.remark, b.remark), cmp.Compare(a.groupID, b.groupID),
cmp.Compare(a.inboundSort, b.inboundSort), cmp.Compare(a.inboundID, b.inboundID), cmp.Compare(a.hostID, b.hostID),
} {
if c != 0 {
return c < 0
}
}
return false
}
func orderedSubValues[T any](entries []orderedSubEntry[T]) []T {
sort.SliceStable(entries, func(i, j int) bool { return hostOrderLess(entries[i].order, entries[j].order) })
var out []T
for _, entry := range entries {
out = append(out, entry.value)
}
return out
}
func (s *SubService) loadSubscriptionHosts(inbounds []*model.Inbound) error {
s.hostsByInbound = make(map[int][]*model.Host, len(inbounds))
s.hostGroupOrders = make(map[string]hostEntryOrder)
ids := make([]int, len(inbounds))
for i, inbound := range inbounds {
ids[i] = inbound.Id
}
for lo := 0; lo < len(ids); lo += 400 {
var rows []*model.Host
if err := database.GetDB().Where("inbound_id IN ?", ids[lo:min(lo+400, len(ids))]).Order("inbound_id ASC, sort_order ASC, id ASC").Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
s.hostsByInbound[row.InboundId] = append(s.hostsByInbound[row.InboundId], row)
key := hostOrderGroupID(row)
if order, exists := s.hostGroupOrders[key]; exists {
order.sortOrder = min(order.sortOrder, row.SortOrder)
s.hostGroupOrders[key] = order
} else {
s.hostGroupOrders[key] = hostEntryOrder{sortOrder: row.SortOrder, remark: row.Remark, groupID: key}
}
}
}
return nil
}
type subscriptionHostVariant struct {
host *model.Host
order *hostEntryOrder
}
func hostOrderGroupID(h *model.Host) string {
if h.GroupId != "" {
return h.GroupId
}
return fmt.Sprintf("fallback_%d", h.Id)
}
func (s *SubService) subscriptionHostVariants(inbound *model.Inbound, format string) []subscriptionHostVariant {
var variants []subscriptionHostVariant
for _, h := range s.hostsByInbound[inbound.Id] {
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
order := s.hostGroupOrders[hostOrderGroupID(h)]
order.inboundSort, order.inboundID, order.hostID = inbound.SubSortIndex, inbound.Id, h.Id
variants = append(variants, subscriptionHostVariant{h, &order})
}
if len(variants) == 0 {
return []subscriptionHostVariant{{}}
}
return variants
}
func (v subscriptionHostVariant) endpoint(s *SubService, inbound *model.Inbound) []map[string]any {
if v.host == nil {
return nil
}
return []map[string]any{hostToExternalProxyMap(v.host, s.resolveInboundAddress(inbound), inbound.Port)}
}
func (v subscriptionHostVariant) inbound(s *SubService, inbound *model.Inbound) *model.Inbound {
clone := *inbound
if ep := v.endpoint(s, inbound); len(ep) > 0 {
injectExternalProxy(&clone, ep)
}
return &clone
}