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
+18 -3
View File
@@ -56,6 +56,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
}
var proxies []map[string]any
var ordered []orderedSubEntry[func() []map[string]any]
var hasInactiveExternal bool
var hasEnabledClient bool
@@ -72,15 +73,29 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
continue
}
subReq.projectThroughFallbackMaster(inbound)
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
injectExternalProxy(inbound, hostEps)
if !subReq.hostsFirst {
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
injectExternalProxy(inbound, hostEps)
}
}
for _, client := range clients {
if client.Enable {
hasEnabledClient = true
}
seenEmails[client.Email] = struct{}{}
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
if subReq.hostsFirst {
for _, variant := range subReq.subscriptionHostVariants(inbound, "clash") {
render := func() []map[string]any { return s.getProxies(subReq, variant.inbound(subReq, inbound), client, host) }
ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
}
} else {
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
}
}
}
if subReq.hostsFirst {
for _, render := range orderedSubValues(ordered) {
proxies = append(proxies, render()...)
}
}
for _, ext := range externalLinks {
+116
View File
@@ -0,0 +1,116 @@
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
}
+257
View File
@@ -0,0 +1,257 @@
package sub
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
"strings"
"testing"
"github.com/goccy/go-yaml"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func TestSubHostOrder_Postgres(t *testing.T) {
if os.Getenv("XUI_DB_TYPE") != "postgres" || os.Getenv("XUI_DB_DSN") == "" {
t.Skip("requires a disposable PostgreSQL database")
}
for _, scenario := range []struct {
name string
run func(*testing.T)
}{
{"global", TestSub_GlobalHostOrder},
{"filters", TestSub_HostOrderFiltersAndFallbacks},
{"base64", TestSub_HostOrderBase64AndInfoNode},
{"balancer", TestSub_HostOrderBalancerSlots},
} {
t.Run(scenario.name, func(t *testing.T) {
cleanup, err := testpg.IsolatePackage("host_order")
if err != nil {
t.Fatal(err)
}
t.Cleanup(cleanup)
scenario.run(t)
})
}
}
func TestSub_HostOrderBalancerSlots(t *testing.T) {
seedSubDB(t)
setHostSortMode(t, "hosts-first")
a := seedSubInbound(t, "order-sub", "a", 46910, 0, wsTLSStream)
b := seedSubInbound(t, "order-sub", "b", 46911, 10, wsTLSStream)
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "a", SortOrder: 1, Remark: "A", Address: "a.example.com"})
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "c", SortOrder: 2, Remark: "C", Address: "c.example.com"})
seedHost(t, &model.Host{InboundId: b.Id, GroupId: "b", SortOrder: 0, Remark: "B", Address: "b.example.com"})
bal := seedSubBalancer(t, &model.SubBalancer{Remark: "auto", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 5, Enabled: true})
body, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("order-sub", "sub.example.com", true)
if err != nil {
t.Fatal(err)
}
docs := parseSubJsonDocs(t, body)
var remarks []string
for _, doc := range docs {
if doc["remarks"] == "auto" {
remarks = append(remarks, "balancer")
continue
}
outbounds := doc["outbounds"].([]any)
settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
remarks = append(remarks, settings["address"].(string))
}
if !reflect.DeepEqual(remarks, []string{"b.example.com", "a.example.com", "balancer", "c.example.com"}) {
t.Fatalf("endpoint and balancer slots: %v", remarks)
}
prefix := fmt.Sprintf("bal-%d-vless", bal.Id)
wantTags := []string{prefix, prefix + "-2", prefix + "-3", "direct", "block"}
if got := docOutboundTags(docs[2]); !reflect.DeepEqual(got, wantTags) {
t.Fatalf("balancer tags: %v, want %v", got, wantTags)
}
}
func setHostSortMode(t *testing.T, mode string) {
t.Helper()
row := &model.Setting{Key: "subHostSortMode"}
if err := database.GetDB().Where("key = ?", row.Key).Assign(model.Setting{Value: mode}).FirstOrCreate(row).Error; err != nil {
t.Fatal(err)
}
}
func TestSub_HostOrderFiltersAndFallbacks(t *testing.T) {
seedSubDB(t)
setHostSortMode(t, "hosts-first")
ib := seedSubInbound(t, "order-sub", "eligible", 46900, 10, wsTLSStream)
seedSubInbound(t, "order-sub", "legacy", 46901, -10, `{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"legacy.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.example.com","port":443}]}`)
noHosts := seedSubInbound(t, "order-sub", "no-hosts", 46902, -20, wsTLSStream)
if err := database.GetDB().Model(noHosts).Update("listen", "198.51.100.20").Error; err != nil {
t.Fatal(err)
}
foreign := seedSubInbound(t, "other-sub", "foreign", 46903, -40, wsTLSStream)
excluded := seedSubInbound(t, "order-sub", "excluded", 46904, -30, wsTLSStream)
if err := database.GetDB().Model(excluded).Update("exclude_from_sub", true).Error; err != nil {
t.Fatal(err)
}
for _, h := range []model.Host{
{InboundId: ib.Id, GroupId: "main", SortOrder: 0, Address: "a.example.com"},
{InboundId: ib.Id, GroupId: "off", SortOrder: -5, Address: "off.example.com", IsDisabled: true},
{InboundId: ib.Id, GroupId: "format", SortOrder: -1, Address: "format.example.com", ExcludeFromSubTypes: []string{"clash"}},
{InboundId: ib.Id, GroupId: "hidden", SortOrder: 1, Address: "hidden.example.com", IsHidden: true},
{InboundId: foreign.Id, GroupId: "foreign", SortOrder: -10, Address: "foreign.example.com"},
{InboundId: excluded.Id, GroupId: "excluded", SortOrder: -20, Address: "excluded.example.com"},
} {
row := h
seedHost(t, &row)
}
db := database.GetDB()
queries := 0
const callback = "test:6894_hosts"
if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == "hosts" {
queries++
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Query().Remove(callback) })
for _, format := range []string{"raw", "clash", "json"} {
queries = 0
want := []string{"format.example.com", "a.example.com", "hidden.example.com", "198.51.100.20", "legacy.example.com"}
if format == "clash" {
want = want[1:]
}
if got := renderedHostAddresses(t, format, NewSubService("")); !reflect.DeepEqual(got, want) {
t.Fatalf("%s: %v, want %v", format, got, want)
}
if queries != 1 {
t.Fatalf("%s queried hosts %d times, want 1", format, queries)
}
}
}
func TestSub_HostOrderBase64AndInfoNode(t *testing.T) {
seedSubDB(t)
setHostSortMode(t, "hosts-first")
a := seedSubInbound(t, "order-sub", "a", 46905, 0, wsTLSStream)
b := seedSubInbound(t, "order-sub", "b", 46906, 10, wsTLSStream)
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "a", SortOrder: 1, Remark: "A", Address: "a.example.com"})
seedHost(t, &model.Host{InboundId: b.Id, GroupId: "b", SortOrder: 0, Remark: "B", Address: "b.example.com"})
row := &model.Setting{Key: "subInfoNodeEnable"}
if err := database.GetDB().Where("key = ?", row.Key).Assign(model.Setting{Value: "true"}).FirstOrCreate(row).Error; err != nil {
t.Fatal(err)
}
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/order-sub", nil))
if response.Code != http.StatusOK {
t.Fatalf("HTTP status %d: %s", response.Code, response.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(response.Body.String())
if err != nil {
t.Fatalf("base64 subscription: %v: %s", err, response.Body.String())
}
lines := strings.Split(strings.TrimSpace(string(decoded)), "\n")
if len(lines) != 3 || !strings.HasPrefix(lines[0], "socks://127.0.0.1") || !strings.Contains(lines[1], "b.example.com") || !strings.Contains(lines[2], "a.example.com") {
t.Fatalf("encoded order/info node: %q", lines)
}
}
func renderedHostAddresses(t *testing.T, format string, base *SubService) []string {
t.Helper()
var addresses []string
switch format {
case "raw":
links, _, _, _, err := base.GetSubs("order-sub", "sub.example.com")
if err != nil {
t.Fatal(err)
}
for _, line := range splitLinkLines(strings.Join(links, "\n")) {
u, err := url.Parse(line)
if err != nil {
t.Fatal(err)
}
addresses = append(addresses, u.Hostname())
}
case "clash":
body, _, err := NewSubClashService(false, "", false, base).GetClash("order-sub", "sub.example.com")
if err != nil {
t.Fatal(err)
}
var doc struct {
Proxies []struct{ Server string } `yaml:"proxies"`
}
if err := yaml.Unmarshal([]byte(body), &doc); err != nil {
t.Fatal(err)
}
for _, proxy := range doc.Proxies {
addresses = append(addresses, proxy.Server)
}
case "json":
body, _, err := NewSubJsonService("", "", "", "", base).GetJson("order-sub", "sub.example.com", true)
if err != nil {
t.Fatal(err)
}
var docs []struct {
Outbounds []struct {
Settings struct {
Address string `json:"address"`
} `json:"settings"`
} `json:"outbounds"`
}
if err := json.Unmarshal([]byte(body), &docs); err != nil {
t.Fatal(err)
}
for _, doc := range docs {
if len(doc.Outbounds) == 0 || doc.Outbounds[0].Settings.Address == "" {
t.Fatalf("JSON proxy shape: %s", body)
}
addresses = append(addresses, doc.Outbounds[0].Settings.Address)
}
}
return addresses
}
func TestSub_GlobalHostOrder(t *testing.T) {
seedSubDB(t)
first := seedSubInbound(t, "order-sub", "first", 46894, -10, wsTLSStream)
second := seedSubInbound(t, "order-sub", "second", 46895, 20, wsTLSStream)
seedSubInbound(t, "order-sub", "fallback", 46896, -20, wsTLSStream)
for _, host := range []model.Host{
{GroupId: "a", InboundId: first.Id, SortOrder: 1, Remark: "A", Address: "a.example.com"},
{GroupId: "b", InboundId: second.Id, SortOrder: 0, Remark: "B", Address: "b.example.com"},
{GroupId: "c", InboundId: second.Id, SortOrder: 2, Remark: "C", Address: "c.example.com"},
{GroupId: "shared", InboundId: first.Id, SortOrder: 1, Remark: "Z", Address: "shared1.example.com"},
{GroupId: "shared", InboundId: first.Id, SortOrder: 1, Remark: "Z", Address: "shared2.example.com"},
{GroupId: "shared", InboundId: second.Id, SortOrder: 1, Remark: "Z", Address: "shared1.example.com"},
} {
h := host
seedHost(t, &h)
}
base := NewSubService("")
assertAll := func(want []string) {
t.Helper()
for _, format := range []string{"raw", "clash", "json"} {
if got := renderedHostAddresses(t, format, base); !reflect.DeepEqual(got, want) {
t.Fatalf("%s addresses = %v, want %v", format, got, want)
}
}
}
assertAll([]string{"203.0.113.5", "a.example.com", "shared1.example.com", "shared2.example.com", "b.example.com", "shared1.example.com", "c.example.com"})
setHostSortMode(t, "hosts-first")
assertAll([]string{"b.example.com", "a.example.com", "shared1.example.com", "shared2.example.com", "shared1.example.com", "c.example.com", "203.0.113.5"})
if err := (&service.HostService{}).ReorderHostGroups([]string{"c", "b", "shared", "a"}); err != nil {
t.Fatal(err)
}
assertAll([]string{"c.example.com", "b.example.com", "shared1.example.com", "shared2.example.com", "shared1.example.com", "a.example.com", "203.0.113.5"})
setHostSortMode(t, "inbound-first")
assertAll([]string{"203.0.113.5", "shared1.example.com", "shared2.example.com", "a.example.com", "c.example.com", "b.example.com", "shared1.example.com"})
}
+4 -2
View File
@@ -19,7 +19,9 @@ import (
// inbounds.
func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[string]any {
var hosts []*model.Host
if err := database.GetDB().
if s.hostsByInbound != nil {
hosts = s.hostsByInbound[inbound.Id]
} else if err := database.GetDB().
Where("inbound_id = ? AND is_disabled = ?", inbound.Id, false).
Order("sort_order asc, id asc").
Find(&hosts).Error; err != nil {
@@ -32,7 +34,7 @@ func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[
defaultDest := s.resolveInboundAddress(inbound)
eps := make([]map[string]any, 0, len(hosts))
for _, h := range hosts {
if slices.Contains(h.ExcludeFromSubTypes, format) {
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
if IsCIDR(h.Address) {
+42 -3
View File
@@ -136,6 +136,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
seenEmails := make(map[string]struct{})
entries := make([]subConfigEntry, 0, len(inbounds))
var ordered []orderedSubEntry[func() subConfigEntry]
// Prepare Inbounds
for _, inbound := range inbounds {
clients := subReq.matchingClients(inbound, subId)
@@ -149,8 +150,10 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
continue
}
subReq.projectThroughFallbackMaster(inbound)
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
injectExternalProxy(inbound, hostEps)
if !subReq.hostsFirst {
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
injectExternalProxy(inbound, hostEps)
}
}
var inboundConfigs []json_util.RawMessage
@@ -159,7 +162,17 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
hasEnabledClient = true
}
seenEmails[client.Email] = struct{}{}
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
if subReq.hostsFirst {
for _, variant := range subReq.subscriptionHostVariants(inbound, "json") {
render := func() subConfigEntry {
configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host)
return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order}
}
ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render})
}
} else {
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
}
}
if len(inboundConfigs) > 0 {
entries = append(entries, subConfigEntry{
@@ -169,6 +182,14 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
})
}
}
if subReq.hostsFirst {
// Render in final endpoint order so first-link usage remarks stay first.
for _, render := range orderedSubValues(ordered) {
if entry := render(); len(entry.configs) > 0 {
entries = append(entries, entry)
}
}
}
entries = s.appendBalancerEntries(entries)
// Inbounds arrive sorted by (sub_sort_index, id); balancers interleave by
@@ -183,6 +204,23 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
return entries[i].id < entries[j].id
})
var configArray []json_util.RawMessage
if subReq.hostsFirst {
// Preserve balancer slots from the legacy sort; reorder endpoint slots only.
var endpoints []subConfigEntry
for _, entry := range entries {
if entry.kind == 0 {
endpoints = append(endpoints, entry)
}
}
sort.SliceStable(endpoints, func(i, j int) bool { return hostOrderLess(endpoints[i].hostOrder, endpoints[j].hostOrder) })
next := 0
for i := range entries {
if entries[i].kind == 0 {
entries[i] = endpoints[next]
next++
}
}
}
for _, entry := range entries {
configArray = append(configArray, entry.configs...)
}
@@ -255,6 +293,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
// subConfigEntry is one ordered block of the JSON subscription: an inbound's
// configs (kind 0) or a balancer config (kind 1).
type subConfigEntry struct {
hostOrder *hostEntryOrder
sortIndex int
kind int
id int
+34 -3
View File
@@ -77,6 +77,9 @@ type SubService struct {
// with the clients array left out; generators read only inbound-level
// fields (encryption, method, version, …) from it.
settingsByInbound map[int]map[string]any
hostsByInbound map[int][]*model.Host
hostGroupOrders map[string]hostEntryOrder
hostsFirst bool
}
// NewSubService creates a new subscription service with the given configuration.
@@ -112,6 +115,10 @@ func (s *SubService) PrepareForRequest(host string) {
s.clientsByInbound = map[int]map[string]model.Client{}
s.fullyPrimedInbounds = map[int]bool{}
s.settingsByInbound = map[int]map[string]any{}
s.hostsByInbound = nil
s.hostGroupOrders = nil
mode, _ := s.settingService.GetSubHostSortMode()
s.hostsFirst = mode == "hosts-first"
s.loadNodes()
s.loadRemarkSettings()
s.subCalendarExpireInclusive, _ = s.settingService.GetSubCalendarExpireInclusive()
@@ -464,6 +471,7 @@ func (s *SubService) resolveInfoNodeRemark(subId string, uniqueEmails []string,
func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
var result []string
var ordered []orderedSubEntry[func() string]
var emails []string
var traffic xray.ClientTraffic
var hasEnabledClient bool
@@ -495,22 +503,42 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
s.projectThroughFallbackMaster(inbound)
// Host overrides apply AFTER fallback projection so a host's
// address/TLS wins over the projected master stream.
hostEps := s.hostEndpoints(inbound, "raw")
var hostEps []map[string]any
if !s.hostsFirst {
hostEps = s.hostEndpoints(inbound, "raw")
}
for _, client := range clients {
if client.Enable {
hasEnabledClient = true
}
var link string
if len(hostEps) > 0 {
if s.hostsFirst {
for _, variant := range s.subscriptionHostVariants(inbound, "raw") {
render := func() string {
if variant.host != nil {
return s.linkFromHosts(inbound, client, variant.endpoint(s, inbound))
}
return s.GetLink(inbound, client.Email)
}
ordered = append(ordered, orderedSubEntry[func() string]{variant.order, render})
}
} else if len(hostEps) > 0 {
link = s.linkFromHosts(inbound, client, hostEps)
} else {
link = s.GetLink(inbound, client.Email)
}
result = append(result, link)
if !s.hostsFirst {
result = append(result, link)
}
emails = append(emails, client.Email)
seenEmails[client.Email] = struct{}{}
}
}
if s.hostsFirst {
for _, render := range orderedSubValues(ordered) {
result = append(result, render())
}
}
for _, ext := range externalLinks {
if ext.Enable {
hasEnabledClient = true
@@ -693,6 +721,9 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
return nil, err
}
s.indexStatsBySubId(subId)
if err := s.loadSubscriptionHosts(inbounds); err != nil {
return nil, err
}
return inbounds, nil
}