diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c7148544..e4d41a204 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/content/docs/en/config/subscription.mdx b/docs/content/docs/en/config/subscription.mdx index fd7cfda30..f22d74653 100644 --- a/docs/content/docs/en/config/subscription.mdx +++ b/docs/content/docs/en/config/subscription.mdx @@ -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/` for a compatible profile. +### Subscription node ordering + +In **Settings → Subscription → Panel settings**, select **Subscription Node Ordering** +(`subHostSortMode`). **Inbound order (default)** preserves the existing subscription +sort index, then inbound ID, with each inbound's Hosts ordered inside that block. + +**Hosts order** (`hosts-first`) uses the global order on **Hosts**, including Host +groups attached to different inbounds. Drag a group's grip above or below another +row to save its position, or use the existing Up/Down buttons. A failed save restores +the previous displayed order. The next subscription fetch uses the saved order; +no inbound edit or Xray restart is needed. + +For example, these rows start with Host A (Inbound #5) above Host B (Inbound #20): + +![Hosts before reordering, with Host A above Host B](/images/hosts-order-before.png) + +Dragging Host B above Host A saves the order across both inbounds: + +![Hosts after reordering, with Host B above Host A](/images/hosts-order-after.png) + +These screenshots use example Hosts and synthetic API responses. + +Host groups sort by their persisted sort order. Ties use the group remark and group +ID, matching the Hosts table. Within a group, inbound subscription sort index, +inbound ID and Host row ID provide deterministic ordering for attachments and +addresses. Inbounds without applicable Hosts and legacy externalProxy entries +follow all Host entries in their previous relative order. External client links +remain appended, and the subscription info node remains first. + +This applies to Raw/Base64, Clash/Mihomo and JSON subscriptions. JSON balancer +documents keep their slots from the existing numeric balancer/inbound sort; only +the endpoint slots are rearranged, preserving the relative order of Host entries. +Eligibility, exclusions, credentials and protocol settings are unchanged. +Standalone share-link and QR previews retain their existing behavior. Client apps +may apply their own sorting after importing a subscription. + ### Base64 vs JSON The **Base64** body is just the newline-joined share links, standard-base64 diff --git a/docs/content/docs/en/reference/api/hosts.mdx b/docs/content/docs/en/reference/api/hosts.mdx index 0ff41553f..4f14d7b5e 100644 --- a/docs/content/docs/en/reference/api/hosts.mdx +++ b/docs/content/docs/en/reference/api/hosts.mdx @@ -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. diff --git a/docs/public/images/hosts-order-after.png b/docs/public/images/hosts-order-after.png new file mode 100644 index 000000000..da213347c Binary files /dev/null and b/docs/public/images/hosts-order-after.png differ diff --git a/docs/public/images/hosts-order-before.png b/docs/public/images/hosts-order-before.png new file mode 100644 index 000000000..8cc5d0ce7 Binary files /dev/null and b/docs/public/images/hosts-order-before.png differ diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 0ea084f34..2d2022b87 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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, diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 0ea084f34..2d2022b87 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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, diff --git a/frontend/src/api/queries/useHostMutations.ts b/frontend/src/api/queries/useHostMutations.ts index 9a842d031..dfac21944 100644 --- a/frontend/src/api/queries/useHostMutations.ts +++ b/frontend/src/api/queries/useHostMutations.ts @@ -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(); }, }); diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 85faa648b..ea20e92d8 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -100,6 +100,7 @@ export const EXAMPLES: Record = { "subHappTunMode": "", "subHappTunType": "", "subHideSettings": false, + "subHostSortMode": "inbound-first", "subIncyAnnounceUrl": "", "subIncyAppAutoDetect": false, "subIncyBannerBgColor": "", @@ -289,6 +290,7 @@ export const EXAMPLES: Record = { "subHappTunMode": "", "subHappTunType": "", "subHideSettings": false, + "subHostSortMode": "inbound-first", "subIncyAnnounceUrl": "", "subIncyAppAutoDetect": false, "subIncyBannerBgColor": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 0d684072c..ab2119314 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -324,6 +324,10 @@ export const SCHEMAS: Record = { "subHideSettings": { "type": "boolean" }, + "subHostSortMode": { + "example": "inbound-first", + "type": "string" + }, "subIncyAnnounceUrl": { "type": "string" }, @@ -679,6 +683,7 @@ export const SCHEMAS: Record = { "subHappTunMode", "subHappTunType", "subHideSettings", + "subHostSortMode", "subIncyAnnounceUrl", "subIncyAppAutoDetect", "subIncyBannerBgColor", @@ -1110,6 +1115,10 @@ export const SCHEMAS: Record = { "subHideSettings": { "type": "boolean" }, + "subHostSortMode": { + "example": "inbound-first", + "type": "string" + }, "subIncyAnnounceUrl": { "type": "string" }, @@ -1473,6 +1482,7 @@ export const SCHEMAS: Record = { "subHappTunMode", "subHappTunType", "subHideSettings", + "subHostSortMode", "subIncyAnnounceUrl", "subIncyAppAutoDetect", "subIncyBannerBgColor", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index e23ad9c37..845096c9d 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index a9c4296ec..aeb8a3c53 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -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(), diff --git a/frontend/src/hooks/usePointerRowReorder.ts b/frontend/src/hooks/usePointerRowReorder.ts new file mode 100644 index 000000000..03206ae52 --- /dev/null +++ b/frontend/src/hooks/usePointerRowReorder.ts @@ -0,0 +1,109 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'; + +export interface PointerRowDrop { + from: TKey; + to: TKey; + after: boolean; +} + +interface PointerRowDrag { + from: TKey; + to: TKey | null; + after: boolean; +} + +interface PointerRowReorderOptions { + containerRef: RefObject; + getRowKey: (row: HTMLElement) => TKey | null; + disabled?: boolean; + isNoop?: (drop: PointerRowDrop) => boolean; + onDrop: (drop: PointerRowDrop) => void; +} + +// Rows expose data-row-key; consumers retain their own insertion and persistence rules. +export function usePointerRowReorder( + options: PointerRowReorderOptions, +) { + const [drag, setDrag] = useState | 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) => { + 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 | null => { + const row = doc + .elementFromPoint(e.clientX, e.clientY) + ?.closest('[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 }; +} diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index f69f8a624..1dcf9abaf 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -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 = diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index b69a99f36..9765ed52b 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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}', }, { diff --git a/frontend/src/pages/hosts/HostList.css b/frontend/src/pages/hosts/HostList.css index aa52a50f0..50ebd5182 100644 --- a/frontend/src/pages/hosts/HostList.css +++ b/frontend/src/pages/hosts/HostList.css @@ -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; diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx index b10576b10..fff010bd0 100644 --- a/frontend/src/pages/hosts/HostList.tsx +++ b/frontend/src/pages/hosts/HostList.tsx @@ -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) { + 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(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 = [ { title: t('pages.hosts.fields.actions'), key: 'actions', - width: 168, + width: 196, render: (_, h, idx) => { const count = sorted.length; return ( +