feat(sub): per-inbound sort order for subscription links

Add a subSortIndex field to inbounds that controls the order of links
in subscription output only: the raw sub body, the HTML sub page, and
the JSON/Clash formats (all served from the same query). Lower values
come first; ties keep id order. The panel inbound list is unaffected.

The value is editable in the inbound form next to the share-address
fields, propagates to nodes via wireInbound, and follows the usual
node-sync rules (copied on import, mirrored while not dirty, never a
structural change).

Rescoped from #5214 by @Ponywka.
This commit is contained in:
MHSanaei
2026-06-12 12:03:22 +02:00
parent 7ae3ea66d1
commit f1a4286e2f
36 changed files with 367 additions and 4 deletions
@@ -7,6 +7,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > http
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -22,6 +23,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -37,6 +39,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -52,6 +55,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -67,6 +71,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -82,6 +87,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -97,6 +103,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -112,6 +119,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -127,6 +135,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
@@ -142,6 +151,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
+33 -1
View File
@@ -6,7 +6,7 @@ import {
formValuesToWirePayload,
type RawInboundRow,
} from '@/lib/xray/inbound-form-adapter';
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -262,3 +262,35 @@ describe('formValuesToWirePayload', () => {
expect(replay.streamSettings).toEqual(original.streamSettings);
});
});
describe('subSortIndex', () => {
it('rawInboundToFormValues defaults to 1 when field is absent', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
expect(values.subSortIndex).toBe(1);
});
it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
});
it('formValuesToWirePayload includes subSortIndex in the payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
const payload = formValuesToWirePayload(values);
expect(payload.subSortIndex).toBe(3);
});
it('subSortIndex round-trips through raw → values → payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
const payload = formValuesToWirePayload(values);
const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
expect(replay.subSortIndex).toBe(42);
});
it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
});
});