mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 01:55:25 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||

|
||||
|
||||
Dragging Host B above Host A saves the order across both inbounds:
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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": "الرعاة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "اسپانسرها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "スポンサー",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Спонсоры",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Спонсори",
|
||||
|
||||
@@ -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ợ",
|
||||
|
||||
@@ -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": "赞助商",
|
||||
|
||||
@@ -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": "贊助商",
|
||||
|
||||
Reference in New Issue
Block a user