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 07:27:47 +02:00
committed by GitHub
parent a69ae2b0ea
commit 136c1c4d50
51 changed files with 1643 additions and 149 deletions
+10
View File
@@ -92,6 +92,16 @@ jobs:
exit 1
fi
- name: PostgreSQL subscription Host ordering tests
run: |
set -o pipefail
go test ./internal/sub -run '^TestSubHostOrder_Postgres$' -count=1 -v | tee /tmp/postgres-host-order.log
passed=$(grep -c -- '^--- PASS' /tmp/postgres-host-order.log || true)
if [ "$passed" -ne 1 ]; then
echo "expected passing PostgreSQL Host ordering coverage, got $passed" >&2
exit 1
fi
- name: PostgreSQL Telegram lookup and legacy migration tests
run: |
set -o pipefail
+37 -1
View File
@@ -51,7 +51,7 @@ The **format is chosen by path**, each with its own enable toggle:
| **Clash for Windows (legacy)** | `/clash-legacy/` | `subClashEnable` | YAML limited to proxy types, transports, and ciphers supported by the legacy Clash core. |
Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, MTProto, TUIC, or Hysteria2**
appear in a subscription, ordered by their sub-sort index (TUIC and AmneziaWG are included in raw links and Clash/Mihomo profiles, but omitted from JSON endpoints; MTProto is included in raw links). Requesting `subPath`
appear in a subscription, ordered by their sub-sort index by default (TUIC and AmneziaWG are included in raw links and Clash/Mihomo profiles, but omitted from JSON endpoints; MTProto is included in raw links). Requesting `subPath`
with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
page instead of the raw body.
@@ -74,6 +74,42 @@ default matcher so existing subscription URLs continue returning YAML.
It does not distinguish legacy clients from Mihomo-based clients; Clash for
Windows users must use `/clash-legacy/<sub-id>` for a compatible profile.
### Subscription node ordering
In **Settings → Subscription → Panel settings**, select **Subscription Node Ordering**
(`subHostSortMode`). **Inbound order (default)** preserves the existing subscription
sort index, then inbound ID, with each inbound's Hosts ordered inside that block.
**Hosts order** (`hosts-first`) uses the global order on **Hosts**, including Host
groups attached to different inbounds. Drag a group's grip above or below another
row to save its position, or use the existing Up/Down buttons. A failed save restores
the previous displayed order. The next subscription fetch uses the saved order;
no inbound edit or Xray restart is needed.
For example, these rows start with Host A (Inbound #5) above Host B (Inbound #20):
![Hosts before reordering, with Host A above Host B](/images/hosts-order-before.png)
Dragging Host B above Host A saves the order across both inbounds:
![Hosts after reordering, with Host B above Host A](/images/hosts-order-after.png)
These screenshots use example Hosts and synthetic API responses.
Host groups sort by their persisted sort order. Ties use the group remark and group
ID, matching the Hosts table. Within a group, inbound subscription sort index,
inbound ID and Host row ID provide deterministic ordering for attachments and
addresses. Inbounds without applicable Hosts and legacy externalProxy entries
follow all Host entries in their previous relative order. External client links
remain appended, and the subscription info node remains first.
This applies to Raw/Base64, Clash/Mihomo and JSON subscriptions. JSON balancer
documents keep their slots from the existing numeric balancer/inbound sort; only
the endpoint slots are rearranged, preserving the relative order of Host entries.
Eligibility, exclusions, credentials and protocol settings are unchanged.
Standalone share-link and QR previews retain their existing behavior. Client apps
may apply their own sorting after importing a subscription.
### Base64 vs JSON
The **Base64** body is just the newline-joined share links, standard-base64
+6 -2
View File
@@ -35,7 +35,9 @@ _openapi:
url: '#enable-or-disable-a-host-group'
- depth: 2
title: Set host group sort order by the position of each groupId in the array.
url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array'
IDs must be unique and refer to existing groups; omitted groups follow
in their previous order.
url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array-ids-must-be-unique-and-refer-to-existing-groups-omitted-groups-follow-in-their-previous-order'
- depth: 2
title: Add a host group to inbounds (same as /add).
url: '#add-a-host-group-to-inbounds-same-as-add'
@@ -65,7 +67,9 @@ _openapi:
- content: Enable or disable a host group.
id: enable-or-disable-a-host-group
- content: Set host group sort order by the position of each groupId in the array.
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array
IDs must be unique and refer to existing groups; omitted groups follow
in their previous order.
id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array-ids-must-be-unique-and-refer-to-existing-groups-omitted-groups-follow-in-their-previous-order
- content: Add a host group to inbounds (same as /add).
id: add-a-host-group-to-inbounds-same-as-add
- content: Enable or disable many host groups in one call.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+11 -1
View File
@@ -350,6 +350,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -705,6 +709,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -1136,6 +1141,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -1499,6 +1508,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -12619,7 +12629,7 @@
"tags": [
"Hosts"
],
"summary": "Set host group sort order by the position of each groupId in the array.",
"summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
"operationId": "post_panel_api_hosts_reorder",
"requestBody": {
"required": true,
+11 -1
View File
@@ -350,6 +350,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -705,6 +709,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -1136,6 +1141,10 @@
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -1499,6 +1508,7 @@
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -12619,7 +12629,7 @@
"tags": [
"Hosts"
],
"summary": "Set host group sort order by the position of each groupId in the array.",
"summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
"operationId": "post_panel_api_hosts_reorder",
"requestBody": {
"required": true,
+1 -1
View File
@@ -45,7 +45,7 @@ export function useHostMutations() {
mutationFn: (groupIds: string[]) =>
HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
if (msg?.success) return invalidate();
},
});
+2
View File
@@ -100,6 +100,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subHappTunMode": "",
"subHappTunType": "",
"subHideSettings": false,
"subHostSortMode": "inbound-first",
"subIncyAnnounceUrl": "",
"subIncyAppAutoDetect": false,
"subIncyBannerBgColor": "",
@@ -289,6 +290,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subHappTunMode": "",
"subHappTunType": "",
"subHideSettings": false,
"subHostSortMode": "inbound-first",
"subIncyAnnounceUrl": "",
"subIncyAppAutoDetect": false,
"subIncyBannerBgColor": "",
+10
View File
@@ -324,6 +324,10 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -679,6 +683,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
@@ -1110,6 +1115,10 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings": {
"type": "boolean"
},
"subHostSortMode": {
"example": "inbound-first",
"type": "string"
},
"subIncyAnnounceUrl": {
"type": "string"
},
@@ -1473,6 +1482,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappTunMode",
"subHappTunType",
"subHideSettings",
"subHostSortMode",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
+2
View File
@@ -108,6 +108,7 @@ export interface AllSetting {
subHappTunMode: string;
subHappTunType: string;
subHideSettings: boolean;
subHostSortMode: string;
subIncyAnnounceUrl: string;
subIncyAppAutoDetect: boolean;
subIncyBannerBgColor: string;
@@ -298,6 +299,7 @@ export interface AllSettingView {
subHappTunMode: string;
subHappTunType: string;
subHideSettings: boolean;
subHostSortMode: string;
subIncyAnnounceUrl: string;
subIncyAppAutoDetect: boolean;
subIncyBannerBgColor: string;
+2
View File
@@ -124,6 +124,7 @@ export const AllSettingSchema = z.object({
subHappTunMode: z.string(),
subHappTunType: z.string(),
subHideSettings: z.boolean(),
subHostSortMode: z.string(),
subIncyAnnounceUrl: z.string(),
subIncyAppAutoDetect: z.boolean(),
subIncyBannerBgColor: z.string(),
@@ -315,6 +316,7 @@ export const AllSettingViewSchema = z.object({
subHappTunMode: z.string(),
subHappTunType: z.string(),
subHideSettings: z.boolean(),
subHostSortMode: z.string(),
subIncyAnnounceUrl: z.string(),
subIncyAppAutoDetect: z.boolean(),
subIncyBannerBgColor: z.string(),
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
export interface PointerRowDrop<TKey extends string | number> {
from: TKey;
to: TKey;
after: boolean;
}
interface PointerRowDrag<TKey extends string | number> {
from: TKey;
to: TKey | null;
after: boolean;
}
interface PointerRowReorderOptions<TKey extends string | number> {
containerRef: RefObject<HTMLElement | null>;
getRowKey: (row: HTMLElement) => TKey | null;
disabled?: boolean;
isNoop?: (drop: PointerRowDrop<TKey>) => boolean;
onDrop: (drop: PointerRowDrop<TKey>) => void;
}
// Rows expose data-row-key; consumers retain their own insertion and persistence rules.
export function usePointerRowReorder<TKey extends string | number>(
options: PointerRowReorderOptions<TKey>,
) {
const [drag, setDrag] = useState<PointerRowDrag<TKey> | null>(null);
const latest = useRef(options);
const cleanupRef = useRef<(() => void) | null>(null);
useLayoutEffect(() => {
latest.current = options;
if (cleanupRef.current && options.disabled) {
cleanupRef.current();
setDrag(null);
}
}, [options]);
useLayoutEffect(() => () => cleanupRef.current?.(), []);
const onPointerDown = useCallback((from: TKey, event: ReactPointerEvent<Element>) => {
const current = latest.current;
if (current.disabled || cleanupRef.current || event.isPrimary === false || event.button !== 0)
return;
event.preventDefault();
const handle = event.currentTarget;
const doc = handle.ownerDocument;
const pointerId = event.pointerId;
const startY = event.clientY;
let active = false;
const targetAt = (e: PointerEvent): PointerRowDrop<TKey> | null => {
const row = doc
.elementFromPoint(e.clientX, e.clientY)
?.closest<HTMLElement>('[data-row-key]');
const config = latest.current;
if (!row || !config.containerRef.current?.contains(row)) return null;
const to = config.getRowKey(row);
if (to === null) return null;
const rect = row.getBoundingClientRect();
return { from, to, after: e.clientY > rect.top + rect.height / 2 };
};
const move = (e: PointerEvent) => {
if (e.pointerId !== pointerId || (!active && Math.abs(e.clientY - startY) < 5)) return;
active = true;
setDrag(targetAt(e) ?? { from, to: null, after: false });
};
const cleanup = () => {
doc.removeEventListener('pointermove', move);
doc.removeEventListener('pointerup', up);
doc.removeEventListener('pointercancel', cancel);
handle.removeEventListener('lostpointercapture', cancel);
cleanupRef.current = null;
try {
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
} catch {
// Capture may already be released when the handle is detached.
}
};
const cancel = (e: Event) => {
if (!('pointerId' in e) || e.pointerId !== pointerId) return;
cleanup();
setDrag(null);
};
const up = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
const drop =
active && latest.current.containerRef.current?.contains(handle) ? targetAt(e) : null;
cleanup();
setDrag(null);
const config = latest.current;
if (!drop || config.disabled || drop.from === drop.to || config.isNoop?.(drop)) return;
config.onDrop(drop);
};
cleanupRef.current = cleanup;
doc.addEventListener('pointermove', move);
doc.addEventListener('pointerup', up);
doc.addEventListener('pointercancel', cancel);
handle.addEventListener('lostpointercapture', cancel);
try {
handle.setPointerCapture?.(pointerId);
} catch {
// Detached handles and synthetic pointers may not support capture.
}
}, []);
return { drag, onPointerDown };
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { ObjectUtil } from '@/utils';
import type { SubProfileMode } from '@/schemas/setting';
import type { SubProfileMode, SubHostSortMode } from '@/schemas/setting';
export class AllSetting {
webListen = '';
@@ -20,6 +20,7 @@ export class AllSetting {
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
subShowIdentityOnAllLinks = false;
subInfoNodeEnable = false;
subHostSortMode: SubHostSortMode = 'inbound-first';
subCalendarExpireInclusive = false;
subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}';
subTrafficDepletedTemplate =
+2 -1
View File
@@ -1893,7 +1893,8 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/hosts/reorder',
summary: 'Set host group sort order by the position of each groupId in the array.',
summary:
'Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.',
body: '{\n "ids": ["abc-123", "def-456"]\n}',
},
{
+18
View File
@@ -2,6 +2,24 @@
width: 100%;
}
.host-drag-handle {
cursor: grab;
touch-action: none;
}
.host-drag-handle:focus-visible {
outline: 2px solid var(--ant-color-primary);
outline-offset: 2px;
}
.host-dragging {
opacity: 0.5;
}
.host-drop-before > td {
border-top: 2px solid var(--ant-color-primary) !important;
}
.host-drop-after > td {
border-bottom: 2px solid var(--ant-color-primary) !important;
}
.host-remark-cell {
display: flex;
flex-direction: column;
+51 -6
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -8,11 +8,13 @@ import {
DeleteOutlined,
EditOutlined,
GlobalOutlined,
HolderOutlined,
PlusOutlined,
} from '@ant-design/icons';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import type { InboundOption } from '@/schemas/client';
import { usePointerRowReorder, type PointerRowDrop } from '@/hooks/usePointerRowReorder';
import './HostList.css';
interface HostListProps {
@@ -27,6 +29,8 @@ interface HostListProps {
onDelete: (host: HostRecord) => void;
onToggleEnable: (host: HostRecord, next: boolean) => void;
onMove: (host: HostRecord, dir: 'up' | 'down') => void;
onReorder: (groupIds: string[]) => void;
reordering?: boolean;
onBulkEnable: (enable: boolean) => void;
onBulkDelete: () => void;
}
@@ -52,10 +56,19 @@ export function sortHosts(hosts: HostRecord[]): HostRecord[] {
const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb;
return (a.remark || '').localeCompare(b.remark || '');
const ar = a.remark || '';
const br = b.remark || '';
if (ar !== br) return ar < br ? -1 : 1;
return a.groupId < b.groupId ? -1 : a.groupId > b.groupId ? 1 : 0;
});
}
function hostDropDestination(keys: string[], { from, to, after }: PointerRowDrop<string>) {
const source = keys.indexOf(from);
const slot = keys.indexOf(to) + (after ? 1 : 0);
return slot - (source < slot ? 1 : 0);
}
export default function HostList(props: HostListProps) {
const { t } = useTranslation();
const {
@@ -70,6 +83,8 @@ export default function HostList(props: HostListProps) {
onDelete,
onToggleEnable,
onMove,
onReorder,
reordering,
onBulkEnable,
onBulkDelete,
} = props;
@@ -81,23 +96,47 @@ export default function HostList(props: HostListProps) {
}, [inboundOptions]);
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
const tableRef = useRef<HTMLDivElement>(null);
const keys = sorted.map((h) => h.groupId);
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
containerRef: tableRef,
getRowKey: (row) => keys.find((key) => key === row.dataset.rowKey) ?? null,
disabled: reordering || loading,
isNoop: (drop) => keys.indexOf(drop.from) === hostDropDestination(keys, drop),
onDrop: (drop) => {
const ids = [...keys];
const destination = hostDropDestination(keys, drop);
ids.splice(ids.indexOf(drop.from), 1);
ids.splice(destination, 0, drop.from);
onReorder(ids);
},
});
const columns: ColumnsType<HostRecord> = [
{
title: t('pages.hosts.fields.actions'),
key: 'actions',
width: 168,
width: 196,
render: (_, h, idx) => {
const count = sorted.length;
return (
<Space size={2}>
<Button
size="small"
type="text"
className="host-drag-handle"
icon={<HolderOutlined aria-hidden />}
aria-label={t('pages.hosts.dragToReorder', { name: h.remark || h.groupId })}
disabled={reordering || loading}
onPointerDown={(event) => onHandlePointerDown(h.groupId, event)}
/>
<Tooltip title={t('pages.hosts.moveUp')}>
<Button
size="small"
type="text"
icon={<ArrowUpOutlined aria-hidden />}
aria-label={t('pages.hosts.moveUp')}
disabled={idx === 0}
disabled={reordering || loading || idx === 0}
onClick={() => onMove(h, 'up')}
/>
</Tooltip>
@@ -107,7 +146,7 @@ export default function HostList(props: HostListProps) {
type="text"
icon={<ArrowDownOutlined aria-hidden />}
aria-label={t('pages.hosts.moveDown')}
disabled={idx >= count - 1}
disabled={reordering || loading || idx >= count - 1}
onClick={() => onMove(h, 'down')}
/>
</Tooltip>
@@ -305,7 +344,7 @@ export default function HostList(props: HostListProps) {
);
return (
<Card size="small" hoverable title={toolbar} className="hosts-card">
<Card size="small" hoverable title={toolbar} className="hosts-card" ref={tableRef}>
<Table<HostRecord>
rowKey="groupId"
size="small"
@@ -313,6 +352,12 @@ export default function HostList(props: HostListProps) {
columns={columns}
dataSource={sorted}
pagination={false}
rowClassName={(h) =>
[
drag?.from === h.groupId ? 'host-dragging' : '',
drag?.to === h.groupId ? (drag.after ? 'host-drop-after' : 'host-drop-before') : '',
].join(' ')
}
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys: selectedGroupIds,
+35 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@@ -45,6 +45,35 @@ export default function HostsPage() {
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formHost, setFormHost] = useState<HostRecord | null>(null);
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
const savingOrder = useRef(false);
const visibleHosts = useMemo(
() =>
pendingOrder
? hosts.map((h) => ({ ...h, sortOrder: pendingOrder.indexOf(h.groupId) }))
: hosts,
[hosts, pendingOrder],
);
const onReorder = useCallback(
async (groupIds: string[]) => {
const current = sortHosts(hosts).map((h) => h.groupId);
if (savingOrder.current || current.every((id, i) => id === groupIds[i])) return;
savingOrder.current = true;
setPendingOrder(groupIds);
try {
const msg = await reorder(groupIds);
if (msg?.success) messageApi.success(t('pages.hosts.toasts.update'));
else messageApi.error(msg?.msg || t('somethingWentWrong'));
} catch {
messageApi.error(t('somethingWentWrong'));
} finally {
savingOrder.current = false;
setPendingOrder(null);
}
},
[hosts, reorder, messageApi, t],
);
const onAdd = useCallback(() => {
setFormMode('add');
@@ -99,9 +128,9 @@ export default function HostsPage() {
if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
const groupIds = sorted.map((h) => h.groupId);
[groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
await reorder(groupIds);
await onReorder(groupIds);
},
[hosts, reorder],
[hosts, onReorder],
);
const onBulkEnable = useCallback(
@@ -207,7 +236,7 @@ export default function HostsPage() {
<Col span={24}>
<HostList
hosts={hosts}
hosts={visibleHosts}
inboundOptions={inboundOptions}
loading={loading}
isMobile={isMobile}
@@ -218,6 +247,8 @@ export default function HostsPage() {
onDelete={onDelete}
onToggleEnable={onToggleEnable}
onMove={onMove}
onReorder={onReorder}
reordering={pendingOrder !== null}
onBulkEnable={onBulkEnable}
onBulkDelete={onBulkDelete}
/>
@@ -11,7 +11,7 @@ import {
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router';
import type { AllSetting } from '@/models/setting';
import type { SubProfileMode } from '@/schemas/setting';
import type { SubProfileMode, SubHostSortMode } from '@/schemas/setting';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form';
@@ -95,6 +95,22 @@ export default function SubscriptionGeneralTab({
}
/>
)}
<SettingListItem
paddings="small"
title={t('pages.settings.subHostSortMode')}
description={t('pages.settings.subHostSortModeDesc')}
>
<Select
id="sub-host-sort-mode"
aria-label={t('pages.settings.subHostSortMode')}
value={allSetting.subHostSortMode}
onChange={(value: SubHostSortMode) => updateSetting({ subHostSortMode: value })}
options={[
{ value: 'inbound-first', label: t('pages.settings.subHostSortInbound') },
{ value: 'hosts-first', label: t('pages.settings.subHostSortHosts') },
]}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subListen')}
+51 -91
View File
@@ -25,6 +25,7 @@ import { arrJoin, originalRuleIndex } from './helpers';
import type { RuleRow } from './types';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type { RuleObject } from '@/schemas/routing';
import { usePointerRowReorder } from '@/hooks/usePointerRowReorder';
import './RoutingTab.css';
interface RoutingTabProps {
@@ -50,19 +51,7 @@ export default function RoutingTab({
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const dragRef = useRef<{
from: number | null;
to: number | null;
startY: number;
moved: boolean;
}>({
from: null,
to: null,
startY: 0,
moved: false,
});
const rulesContainerRef = useRef<HTMLDivElement>(null);
const routingScript = templateSettings?.routing?.script ?? '';
const rules = useMemo(
@@ -273,43 +262,15 @@ export default function RoutingTab({
});
}
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
try {
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
} catch {
/* ignore */
}
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
setDraggedIndex(idx);
setDropTargetIndex(idx);
const onMove = (e: PointerEvent) => {
const state = dragRef.current;
if (state.from == null) return;
if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
state.moved = true;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return;
const newIdx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(newIdx) && newIdx !== state.to) {
state.to = newIdx;
setDropTargetIndex(newIdx);
}
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
const { from, to, moved } = dragRef.current;
dragRef.current = { from: null, to: null, startY: 0, moved: false };
setDraggedIndex(null);
setDropTargetIndex(null);
if (!moved || from == null || to == null || from === to) return;
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
containerRef: rulesContainerRef,
getRowKey: (row) => {
const key = Number(row.dataset.rowKey);
// Desktop table keys are original rule indices; mobile cards use visible positions.
const index = isMobile ? key : rows.findIndex((rule) => rule.key === key);
return Number.isInteger(index) && index >= 0 && index < rows.length ? index : null;
},
onDrop: ({ from, to }) => {
const fromOrig = originalRuleIndex(rowsRef.current, from);
const toOrig = originalRuleIndex(rowsRef.current, to);
mutate((tt) => {
@@ -318,12 +279,10 @@ export default function RoutingTab({
const [movedItem] = list.splice(fromOrig, 1);
list.splice(toOrig, 0, movedItem);
});
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}
},
});
const draggedIndex = drag?.from ?? null;
const dropTargetIndex = drag?.to ?? null;
const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
const hasBalancer = rows.some((r) => r.balancerTag);
@@ -412,41 +371,42 @@ export default function RoutingTab({
</Dropdown>
</Space>
{isMobile ? (
<RuleCardList
rows={rows}
draggedIndex={draggedIndex}
dropTargetIndex={dropTargetIndex}
onHandlePointerDown={onHandlePointerDown}
openEdit={openEdit}
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
scroll={{ x: tableScrollX }}
size="small"
className="routing-table"
onRow={(_record, index) => {
const classes: string[] = [];
const i = index ?? -1;
if (draggedIndex === i) classes.push('row-dragging');
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
}
return {
className: classes.join(' '),
'data-row-key': i,
} as React.HTMLAttributes<HTMLElement>;
}}
/>
)}
<div ref={rulesContainerRef}>
{isMobile ? (
<RuleCardList
rows={rows}
draggedIndex={draggedIndex}
dropTargetIndex={dropTargetIndex}
onHandlePointerDown={onHandlePointerDown}
openEdit={openEdit}
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
scroll={{ x: tableScrollX }}
size="small"
className="routing-table"
onRow={(_record, index) => {
const classes: string[] = [];
const i = index ?? -1;
if (draggedIndex === i) classes.push('row-dragging');
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
}
return {
className: classes.join(' '),
};
}}
/>
)}
</div>
</Space>
),
},
+4
View File
@@ -7,6 +7,9 @@ const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLead
export const SubProfileModeSchema = z.enum(['none', 'builtin', 'custom']);
export type SubProfileMode = z.infer<typeof SubProfileModeSchema>;
export const SubHostSortModeSchema = z.enum(['inbound-first', 'hosts-first']);
export type SubHostSortMode = z.infer<typeof SubHostSortModeSchema>;
export const AllSettingSchema = z
.object({
webListen: z.string().optional(),
@@ -26,6 +29,7 @@ export const AllSettingSchema = z
remarkTemplate: z.string().optional(),
subShowIdentityOnAllLinks: z.boolean().optional(),
subInfoNodeEnable: z.boolean().optional(),
subHostSortMode: SubHostSortModeSchema.optional(),
subCalendarExpireInclusive: z.boolean().optional(),
subExpiredTemplate: z.string().optional(),
subTrafficDepletedTemplate: z.string().optional(),
+183
View File
@@ -0,0 +1,183 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HostsPage from '@/pages/hosts/HostsPage';
import { HttpUtil, Msg } from '@/utils';
import { renderWithProviders } from './test-utils';
import { installPointerEvents, pointAt } from './pointer-events';
vi.mock('@/layouts/AppSidebar', () => ({ default: () => null }));
vi.mock('@/pages/hosts/HostFormModal', () => ({ default: () => null }));
const initial = [
{ groupId: 'a', remark: 'Host A', sortOrder: 0, inboundIds: [5], hosts: ['a.example.com'] },
{ groupId: 'b', remark: 'Host B', sortOrder: 1, inboundIds: [20], hosts: ['b.example.com'] },
{ groupId: 'c', remark: 'Host C', sortOrder: 2, inboundIds: [5, 20], hosts: ['c.example.com'] },
];
function rowOrder() {
return [...document.querySelectorAll('tr[data-row-key]')].map((row) =>
row.getAttribute('data-row-key'),
);
}
async function openHosts() {
const view = renderWithProviders(
<MemoryRouter>
<HostsPage />
</MemoryRouter>,
);
await screen.findByText('Host A');
return view;
}
function drag(from: string, to: string, after = false, cancel = false) {
const target = document.querySelector(`tr[data-row-key="${to}"]`);
if (!target) throw new Error('missing drop row');
pointAt(target);
fireEvent.pointerDown(
screen.getByRole('button', { name: `Drag to reorder Host ${from.toUpperCase()}` }),
{ button: 0, pointerId: 1, clientY: 0 },
);
fireEvent.pointerMove(document, { pointerId: 1, clientX: 10, clientY: after ? 48 : 32 });
fireEvent[cancel ? 'pointerCancel' : 'pointerUp'](document, {
pointerId: 1,
clientX: 10,
clientY: after ? 48 : 32,
});
}
describe('Hosts page reordering', () => {
afterEach(() => vi.unstubAllGlobals());
beforeEach(() => {
installPointerEvents();
let stored = initial.map((h) => ({ ...h }));
vi.spyOn(HttpUtil, 'get').mockImplementation(
async (url) => new Msg(true, '', url.includes('/hosts/list') ? stored : []),
);
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, body) => {
if (url === '/panel/api/hosts/reorder') {
const ids = (body as { ids: string[] }).ids;
stored = ids.map((id, sortOrder) => ({
...stored.find((h) => h.groupId === id)!,
sortOrder,
}));
}
return new Msg(true, '', {});
});
});
it('persists one cross-inbound reorder on drop and reloads the saved order', async () => {
await openHosts();
drag('b', 'a');
await waitFor(() =>
expect(HttpUtil.post).toHaveBeenCalledExactlyOnceWith(
'/panel/api/hosts/reorder',
{ ids: ['b', 'a', 'c'] },
{ headers: { 'Content-Type': 'application/json' } },
),
);
await waitFor(() => expect(rowOrder()).toEqual(['b', 'a', 'c']));
});
it('does not persist a no-op drop or a cancelled pointer', async () => {
await openHosts();
drag('a', 'a');
drag('a', 'b');
drag('b', 'a', true);
drag('b', 'a', false, true);
expect(HttpUtil.post).not.toHaveBeenCalled();
expect(rowOrder()).toEqual(['a', 'b', 'c']);
});
it.each([
[false, ['b', 'a', 'c']],
[true, ['b', 'c', 'a']],
])('inserts before/after the target (after=%s)', async (after, expected) => {
await openHosts();
drag('a', 'c', after);
await waitFor(() => expect(rowOrder()).toEqual(expected));
await waitFor(() =>
expect(HttpUtil.post).toHaveBeenCalledExactlyOnceWith(
'/panel/api/hosts/reorder',
{ ids: expected },
{ headers: { 'Content-Type': 'application/json' } },
),
);
});
it('persists a touch drag in the mobile RTL table', async () => {
const nativeMatchMedia = window.matchMedia;
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...nativeMatchMedia(query),
matches: query === '(max-width: 768px)',
}));
const { container } = await openHosts();
container.dir = 'rtl';
pointAt(document.querySelector('tr[data-row-key="a"]'));
const event = { pointerId: 4, button: 0, pointerType: 'touch', clientX: 10, clientY: 32 };
fireEvent.pointerDown(screen.getByRole('button', { name: 'Drag to reorder Host B' }), {
...event,
clientY: 0,
});
fireEvent.pointerMove(document, event);
fireEvent.pointerUp(document, event);
await waitFor(() => expect(rowOrder()).toEqual(['b', 'a', 'c']));
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(1));
});
it('cancels an active drag on unmount', async () => {
const view = await openHosts();
fireEvent.pointerDown(screen.getByRole('button', { name: 'Drag to reorder Host A' }), {
pointerId: 1,
button: 0,
clientY: 0,
});
view.unmount();
fireEvent.pointerMove(document, { pointerId: 1, clientY: 40, clientX: 10 });
fireEvent.pointerUp(document, { pointerId: 1 });
expect(HttpUtil.post).not.toHaveBeenCalled();
});
it('restores the visible order after failed persistence', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'Cannot save order'));
await openHosts();
drag('a', 'c', true);
await screen.findByText('Cannot save order');
expect(rowOrder()).toEqual(['a', 'b', 'c']);
});
it('prevents overlapping reorders while a save is pending', async () => {
let finish: ((result: Msg<unknown>) => void) | undefined;
vi.spyOn(HttpUtil, 'post').mockImplementation(
() =>
new Promise<Msg<unknown>>((resolve) => {
finish = resolve;
}),
);
await openHosts();
drag('b', 'a');
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(1));
expect(
(screen.getByRole('button', { name: 'Drag to reorder Host C' }) as HTMLButtonElement)
.disabled,
).toBe(true);
drag('c', 'a');
expect(HttpUtil.post).toHaveBeenCalledTimes(1);
await act(async () => {
finish?.(new Msg(false, 'Cannot save order'));
});
expect(rowOrder()).toEqual(['a', 'b', 'c']);
});
it('keeps Up/Down controls and row actions separate from dragging', async () => {
await openHosts();
fireEvent.click(screen.getAllByRole('button', { name: 'Move down' })[0]);
await waitFor(() => expect(rowOrder()).toEqual(['b', 'a', 'c']));
expect(HttpUtil.post).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]);
fireEvent.click(screen.getAllByRole('checkbox')[1]);
expect(HttpUtil.post).toHaveBeenCalledTimes(1);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { vi } from 'vitest';
export function installPointerEvents() {
class BrowserPointerEvent extends MouseEvent {
pointerId: number;
isPrimary: boolean;
pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
this.isPrimary = init.isPrimary ?? true;
this.pointerType = init.pointerType ?? 'mouse';
}
}
vi.stubGlobal('PointerEvent', BrowserPointerEvent);
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => null });
}
export function pointAt(row: Element | null) {
vi.spyOn(document, 'elementFromPoint').mockReturnValue(row);
if (row)
vi.spyOn(row, 'getBoundingClientRect').mockReturnValue({ top: 30, height: 20 } as DOMRect);
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import RoutingTab from '@/pages/xray/routing/RoutingTab';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
import { installPointerEvents, pointAt } from './pointer-events';
const initial = {
routing: {
domainStrategy: 'AsIs',
rules: [
{ type: 'field', outboundTag: 'a', enabled: true },
{ type: 'field', inboundTag: ['_bl_bal1'], outboundTag: 'hidden', enabled: true },
{ type: 'field', outboundTag: 'b', enabled: true },
{ type: 'field', outboundTag: 'c', enabled: true },
],
},
} as unknown as XraySettingsValue;
function Harness({ mobile }: { mobile: boolean }) {
const [settings, setSettings] = useState<XraySettingsValue | null>(initial);
return (
<>
<RoutingTab
templateSettings={settings}
setTemplateSettings={setSettings}
inboundTags={[]}
clientReverseTags={[]}
isMobile={mobile}
/>
<output data-testid="settings">{JSON.stringify(settings)}</output>
</>
);
}
function ruleOrder() {
const settings = JSON.parse(
screen.getByTestId('settings').textContent || '{}',
) as XraySettingsValue;
return settings.routing?.rules?.map((rule) => rule.outboundTag);
}
function openRouting(mobile: boolean) {
const view = renderWithProviders(<Harness mobile={mobile} />);
fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ }));
return {
...view,
rows: () =>
document.querySelectorAll(mobile ? '.rule-card' : '.routing-table tr[data-row-key]'),
};
}
function gesture(
rows: NodeListOf<Element>,
from: number,
to: number,
cancel = false,
touch = false,
) {
pointAt(rows[to]);
const event = { pointerId: 7, button: 0, pointerType: touch ? 'touch' : 'mouse' };
fireEvent.pointerDown(rows[from].querySelector('.drag-handle')!, { ...event, clientY: 0 });
fireEvent.pointerMove(document, { ...event, clientY: 32, clientX: 10 });
fireEvent[cancel ? 'pointerCancel' : 'pointerUp'](document, {
...event,
clientY: 32,
clientX: 10,
});
}
describe.each([false, true])('Routing reorder (mobile=%s)', (mobile) => {
beforeEach(installPointerEvents);
afterEach(() => vi.unstubAllGlobals());
it('maps visible positions through hidden loopback rules when moving upward', () => {
const view = openRouting(mobile);
expect(view.rows()).toHaveLength(3);
gesture(view.rows(), 1, 0, false, mobile);
expect(ruleOrder()).toEqual(['b', 'a', 'hidden', 'c']);
});
it('retains the old splice destination when moving downward', () => {
const view = openRouting(mobile);
gesture(view.rows(), 0, 2, false, mobile);
expect(ruleOrder()).toEqual(['hidden', 'b', 'c', 'a']);
});
it('maps a drop target after a hidden loopback back to the intended rule', () => {
const view = openRouting(mobile);
gesture(view.rows(), 0, 1, false, mobile);
expect(ruleOrder()).toEqual(['hidden', 'b', 'a', 'c']);
});
it('never moves rules when the active pointer is cancelled', () => {
const view = openRouting(mobile);
gesture(view.rows(), 1, 0, true, mobile);
expect(ruleOrder()).toEqual(['a', 'hidden', 'b', 'c']);
});
});
@@ -37,6 +37,27 @@ function LocationProbe() {
}
describe('SubscriptionGeneralTab', () => {
it('selects and reloads the saved subscription node ordering mode', () => {
const updateSetting = vi.fn();
const { unmount } = renderWithProviders(
<MemoryRouter>
<SubscriptionGeneralTab allSetting={new AllSetting()} updateSetting={updateSetting} />
</MemoryRouter>,
);
expect(screen.getByText('Inbound order (default)')).toBeTruthy();
chooseSelectOption('sub-host-sort-mode', 'Hosts order');
expect(updateSetting).toHaveBeenCalledWith({ subHostSortMode: 'hosts-first' });
unmount();
renderWithProviders(
<MemoryRouter>
<SubscriptionGeneralTab
allSetting={new AllSetting({ subHostSortMode: 'hosts-first' })}
updateSetting={updateSetting}
/>
</MemoryRouter>,
);
expect(screen.getByText('Hosts order')).toBeTruthy();
});
it('switches profile modes without losing the custom URL and warns only for the built-in page', () => {
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
const editedUrl = 'https://example.com/account/{{SUB_ID}}';
@@ -13,6 +13,30 @@ afterEach(() => {
});
describe('useAllSettings', () => {
it('persists the subscription host ordering mode and reloads it in a fresh session', async () => {
let storedMode = 'inbound-first';
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, body) => {
if (url === '/panel/api/setting/all')
return new Msg(true, '', { subHostSortMode: storedMode });
if (url === '/panel/api/setting/update')
storedMode = (body as { subHostSortMode: string }).subHostSortMode;
return new Msg(true, '', {});
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>
);
const first = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(first.result.current.fetched).toBe(true));
expect(first.result.current.allSetting.subHostSortMode).toBe('inbound-first');
act(() => first.result.current.updateSetting({ subHostSortMode: 'hosts-first' }));
await act(async () => {
await first.result.current.saveAll();
});
first.unmount();
const second = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(second.result.current.fetched).toBe(true));
expect(second.result.current.allSetting.subHostSortMode).toBe('hosts-first');
});
it('refreshes cached default settings after a successful save', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', {}));
const queryClient = makeTestQueryClient();
@@ -0,0 +1,238 @@
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePointerRowReorder } from '@/hooks/usePointerRowReorder';
import type { PointerRowDrop } from '@/hooks/usePointerRowReorder';
import { installPointerEvents, pointAt } from './pointer-events';
type Key = string | number;
function Harness({
onDrop,
disabled = false,
keys = ['a', 'b'],
}: {
onDrop: (drop: PointerRowDrop<Key>) => void;
disabled?: boolean;
keys?: Key[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const { drag, onPointerDown } = usePointerRowReorder({
containerRef,
getRowKey: (row) => keys.find((key) => String(key) === row.dataset.rowKey) ?? null,
disabled,
onDrop,
});
return (
<>
<div ref={containerRef}>
{keys.map((key) => (
<div key={key} data-row-key={key} data-testid={`row-${key}`}>
<button onPointerDown={(event) => onPointerDown(key, event)}>{key}</button>
</div>
))}
</div>
<div data-row-key="b" data-testid="outside" />
<output data-testid="drag">{drag ? JSON.stringify(drag) : 'idle'}</output>
</>
);
}
const pointer = { pointerId: 7, button: 0, clientX: 10, clientY: 32 };
const dragState = () => screen.getByTestId('drag').textContent;
function start(key: Key = 'a', init: PointerEventInit = {}) {
fireEvent.pointerDown(screen.getByRole('button', { name: String(key) }), {
...pointer,
clientY: 30,
...init,
});
}
describe('usePointerRowReorder', () => {
beforeEach(installPointerEvents);
afterEach(() => vi.unstubAllGlobals());
it('activates at five vertical pixels and stays active when returning near the origin', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 34 });
expect(dragState()).toBe('idle');
fireEvent.pointerMove(document, { ...pointer, clientY: 35 });
expect(dragState()).toBe('{"from":"a","to":"b","after":false}');
fireEvent.pointerMove(document, { ...pointer, clientY: 31 });
expect(dragState()).toBe('{"from":"a","to":"b","after":false}');
fireEvent.pointerUp(document, pointer);
expect(onDrop).toHaveBeenCalledExactlyOnceWith({ from: 'a', to: 'b', after: false });
});
it.each([{ button: 2 }, { isPrimary: false }])('rejects invalid pointers %j', (init) => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start('a', init);
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, pointer);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('ignores move, up and cancel from other pointers', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, pointerId: 8, clientY: 48 });
expect(dragState()).toBe('idle');
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, { ...pointer, pointerId: 8 });
fireEvent.pointerCancel(document, { ...pointer, pointerId: 8 });
expect(onDrop).not.toHaveBeenCalled();
expect(dragState()).toBe('{"from":"a","to":"b","after":true}');
fireEvent.pointerUp(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, { ...pointer, clientY: 48 });
expect(onDrop).toHaveBeenCalledExactlyOnceWith({ from: 'a', to: 'b', after: true });
});
it('cancels without committing and safely releases pointer capture', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
const handle = screen.getByRole('button', { name: 'a' });
handle.setPointerCapture = vi.fn();
handle.hasPointerCapture = vi.fn(() => true);
handle.releasePointerCapture = vi.fn();
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerCancel(document, pointer);
fireEvent.pointerUp(document, pointer);
expect(handle.setPointerCapture).toHaveBeenCalledWith(7);
expect(handle.releasePointerCapture).toHaveBeenCalledWith(7);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('does not reuse a previous valid target when released outside the container', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
pointAt(screen.getByTestId('outside'));
fireEvent.pointerMove(document, { ...pointer, clientY: 50 });
expect(dragState()).toBe('{"from":"a","to":null,"after":false}');
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
expect(dragState()).toBe('idle');
});
it('rechecks the release target even when no final move event arrived', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
pointAt(null);
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
});
it('cancels when pointer capture is lost', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.lostPointerCapture(screen.getByRole('button', { name: 'a' }), pointer);
fireEvent.pointerUp(document, pointer);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('does not drop on the source row or on a click below threshold', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-a'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, pointer);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
});
it('uses the latest committed callback during a drag', () => {
const oldDrop = vi.fn();
const newDrop = vi.fn();
const view = render(<Harness onDrop={oldDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
view.rerender(<Harness onDrop={newDrop} />);
fireEvent.pointerUp(document, pointer);
expect(oldDrop).not.toHaveBeenCalled();
expect(newDrop).toHaveBeenCalledExactlyOnceWith({ from: 'a', to: 'b', after: false });
});
it('blocks disabled gestures and cancels when disabled during a drag', () => {
const onDrop = vi.fn();
const view = render(<Harness onDrop={onDrop} disabled />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
expect(dragState()).toBe('idle');
view.rerender(<Harness onDrop={onDrop} />);
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
view.rerender(<Harness onDrop={onDrop} disabled />);
fireEvent.pointerUp(document, pointer);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('removes all gesture listeners and releases capture on unmount', () => {
const onDrop = vi.fn();
const view = render(<Harness onDrop={onDrop} />);
const handle = screen.getByRole('button', { name: 'a' });
handle.setPointerCapture = vi.fn();
handle.hasPointerCapture = vi.fn(() => true);
handle.releasePointerCapture = vi.fn();
const add = vi.spyOn(document, 'addEventListener');
const remove = vi.spyOn(document, 'removeEventListener');
const removeCapture = vi.spyOn(handle, 'removeEventListener');
start();
const registrations = add.mock.calls.filter(([type]) =>
['pointermove', 'pointerup', 'pointercancel'].includes(type),
);
expect(registrations.map(([type]) => type).sort()).toEqual([
'pointercancel',
'pointermove',
'pointerup',
]);
view.unmount();
for (const [type, listener] of registrations)
expect(remove).toHaveBeenCalledWith(type, listener);
expect(removeCapture).toHaveBeenCalledWith('lostpointercapture', expect.any(Function));
expect(handle.releasePointerCapture).toHaveBeenCalledWith(7);
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
});
it('supports numeric keys and touch when capture APIs throw', () => {
const onDrop = vi.fn();
render(<Harness keys={[0, 1]} onDrop={onDrop} />);
const handle = screen.getByRole('button', { name: '0' });
handle.setPointerCapture = () => {
throw new DOMException('inactive pointer');
};
pointAt(screen.getByTestId('row-1'));
start(0, { pointerType: 'touch' });
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, pointer);
expect(onDrop).toHaveBeenCalledExactlyOnceWith({ from: 0, to: 1, after: false });
});
});
+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
}
+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": "贊助商",