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
+1
View File
@@ -292,6 +292,7 @@ export const EXAMPLES: Record<string, unknown> = {
"shareAddrStrategy": "node",
"sniffing": null,
"streamSettings": null,
"subSortIndex": 1,
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
+7
View File
@@ -1319,6 +1319,12 @@ export const SCHEMAS: Record<string, unknown> = {
},
"sniffing": {},
"streamSettings": {},
"subSortIndex": {
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
"example": 1,
"minimum": 1,
"type": "integer"
},
"tag": {
"example": "in-443-tcp",
"type": "string"
@@ -1359,6 +1365,7 @@ export const SCHEMAS: Record<string, unknown> = {
"shareAddrStrategy",
"sniffing",
"streamSettings",
"subSortIndex",
"tag",
"total",
"trafficReset",
+1
View File
@@ -293,6 +293,7 @@ export interface Inbound {
shareAddrStrategy: string;
sniffing: unknown;
streamSettings: unknown;
subSortIndex: number;
tag: string;
total: number;
trafficReset: string;
+1
View File
@@ -314,6 +314,7 @@ export const InboundSchema = z.object({
shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
sniffing: z.unknown(),
streamSettings: z.unknown(),
subSortIndex: z.number().int().min(1),
tag: z.string(),
total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
@@ -39,6 +39,7 @@ export interface RawInboundRow {
nodeId?: number | null;
shareAddrStrategy?: string;
shareAddr?: string;
subSortIndex?: number;
clientStats?: unknown;
}
@@ -65,6 +66,7 @@ export interface WireInboundPayload {
nodeId?: number;
shareAddrStrategy: ShareAddrStrategy;
shareAddr: string;
subSortIndex: number;
}
function coerceJsonObject(value: unknown): Record<string, unknown> {
@@ -175,6 +177,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
nodeId: row.nodeId ?? null,
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
shareAddr: row.shareAddr ?? '',
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
protocol,
settings,
} as InboundFormValues;
@@ -322,6 +325,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
tag: values.tag,
shareAddrStrategy: values.shareAddrStrategy,
shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex,
};
if (values.nodeId != null) payload.nodeId = values.nodeId;
return payload;
+3
View File
@@ -42,6 +42,7 @@ export type DBInboundInit = Partial<{
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
}>;
@@ -88,6 +89,7 @@ export class DBInbound {
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
@@ -116,6 +118,7 @@ export class DBInbound {
this.nodeId = null;
this.shareAddrStrategy = "node";
this.shareAddr = "";
this.subSortIndex = 1;
this.originNodeGuid = "";
this.fallbackParent = null;
if (data == null) {
@@ -575,6 +575,14 @@ export default function InboundFormModal({
</Form.Item>
)}
<Form.Item
name="subSortIndex"
label={t('pages.inbounds.form.subSortIndex')}
extra={t('pages.inbounds.form.subSortIndexHelp')}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
name="port"
label={t('pages.inbounds.port')}
@@ -93,6 +93,11 @@ export default function InboundList({
[dbInbounds],
);
const hasAnySubSortIndex = useMemo(
() => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
[dbInbounds],
);
const toggleSelect = useCallback((id: number, checked: boolean) => {
setSelectedRowKeys((prev) => {
const next = new Set(prev);
@@ -115,6 +120,7 @@ export default function InboundList({
const columns = useInboundColumns({
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
clientCount,
@@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags {
id: number;
enable: boolean;
remark: string;
subSortIndex: number;
port: number;
protocol: string;
up: number;
@@ -1,6 +1,6 @@
import { useMemo, type ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, Switch, Tag, type TableColumnType } from 'antd';
import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd';
import { TeamOutlined } from '@ant-design/icons';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
@@ -21,6 +21,7 @@ import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
interface UseInboundColumnsParams {
hasAnyRemark: boolean;
hasAnySubSortIndex: boolean;
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
@@ -33,6 +34,7 @@ interface UseInboundColumnsParams {
export function useInboundColumns({
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
clientCount,
@@ -113,6 +115,20 @@ export function useInboundColumns({
});
}
if (hasAnySubSortIndex) {
cols.push({
title: (
<Tooltip title={t('pages.inbounds.form.subSortIndex')}>
{t('pages.inbounds.subSortIndex')}
</Tooltip>
),
dataIndex: 'subSortIndex',
key: 'subSortIndex',
align: 'right',
width: 70,
});
}
cols.push(
{
title: t('pages.inbounds.port'),
@@ -267,5 +283,5 @@ export function useInboundColumns({
);
return cols;
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}
@@ -39,6 +39,7 @@ export const InboundDbFieldsSchema = z.object({
nodeId: z.number().int().nullable().optional(),
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
shareAddr: z.string().default(''),
subSortIndex: z.number().int().min(1).default(1),
});
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
@@ -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);
});
});