feat(hosts): add drag-and-drop and global subscription ordering (#6895)

Hosts already had one saved order on the Hosts page, but subscriptions honoured
it only inside each inbound, so a host of a later inbound could never come before
one of an earlier inbound (#6894). A new opt-in setting makes raw/base64,
Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds;
the default keeps today's inbound-first output unchanged. The Hosts table gains
drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook
the Routing Rules table now shares.
This commit is contained in:
Hamed
2026-10-24 08:57:47 +03:30
committed by GitHub
parent a69ae2b0ea
commit 136c1c4d50
51 changed files with 1643 additions and 149 deletions
+2 -1
View File
@@ -1893,7 +1893,8 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/hosts/reorder',
summary: 'Set host group sort order by the position of each groupId in the array.',
summary:
'Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.',
body: '{\n "ids": ["abc-123", "def-456"]\n}',
},
{
+18
View File
@@ -2,6 +2,24 @@
width: 100%;
}
.host-drag-handle {
cursor: grab;
touch-action: none;
}
.host-drag-handle:focus-visible {
outline: 2px solid var(--ant-color-primary);
outline-offset: 2px;
}
.host-dragging {
opacity: 0.5;
}
.host-drop-before > td {
border-top: 2px solid var(--ant-color-primary) !important;
}
.host-drop-after > td {
border-bottom: 2px solid var(--ant-color-primary) !important;
}
.host-remark-cell {
display: flex;
flex-direction: column;
+51 -6
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -8,11 +8,13 @@ import {
DeleteOutlined,
EditOutlined,
GlobalOutlined,
HolderOutlined,
PlusOutlined,
} from '@ant-design/icons';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import type { InboundOption } from '@/schemas/client';
import { usePointerRowReorder, type PointerRowDrop } from '@/hooks/usePointerRowReorder';
import './HostList.css';
interface HostListProps {
@@ -27,6 +29,8 @@ interface HostListProps {
onDelete: (host: HostRecord) => void;
onToggleEnable: (host: HostRecord, next: boolean) => void;
onMove: (host: HostRecord, dir: 'up' | 'down') => void;
onReorder: (groupIds: string[]) => void;
reordering?: boolean;
onBulkEnable: (enable: boolean) => void;
onBulkDelete: () => void;
}
@@ -52,10 +56,19 @@ export function sortHosts(hosts: HostRecord[]): HostRecord[] {
const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb;
return (a.remark || '').localeCompare(b.remark || '');
const ar = a.remark || '';
const br = b.remark || '';
if (ar !== br) return ar < br ? -1 : 1;
return a.groupId < b.groupId ? -1 : a.groupId > b.groupId ? 1 : 0;
});
}
function hostDropDestination(keys: string[], { from, to, after }: PointerRowDrop<string>) {
const source = keys.indexOf(from);
const slot = keys.indexOf(to) + (after ? 1 : 0);
return slot - (source < slot ? 1 : 0);
}
export default function HostList(props: HostListProps) {
const { t } = useTranslation();
const {
@@ -70,6 +83,8 @@ export default function HostList(props: HostListProps) {
onDelete,
onToggleEnable,
onMove,
onReorder,
reordering,
onBulkEnable,
onBulkDelete,
} = props;
@@ -81,23 +96,47 @@ export default function HostList(props: HostListProps) {
}, [inboundOptions]);
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
const tableRef = useRef<HTMLDivElement>(null);
const keys = sorted.map((h) => h.groupId);
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
containerRef: tableRef,
getRowKey: (row) => keys.find((key) => key === row.dataset.rowKey) ?? null,
disabled: reordering || loading,
isNoop: (drop) => keys.indexOf(drop.from) === hostDropDestination(keys, drop),
onDrop: (drop) => {
const ids = [...keys];
const destination = hostDropDestination(keys, drop);
ids.splice(ids.indexOf(drop.from), 1);
ids.splice(destination, 0, drop.from);
onReorder(ids);
},
});
const columns: ColumnsType<HostRecord> = [
{
title: t('pages.hosts.fields.actions'),
key: 'actions',
width: 168,
width: 196,
render: (_, h, idx) => {
const count = sorted.length;
return (
<Space size={2}>
<Button
size="small"
type="text"
className="host-drag-handle"
icon={<HolderOutlined aria-hidden />}
aria-label={t('pages.hosts.dragToReorder', { name: h.remark || h.groupId })}
disabled={reordering || loading}
onPointerDown={(event) => onHandlePointerDown(h.groupId, event)}
/>
<Tooltip title={t('pages.hosts.moveUp')}>
<Button
size="small"
type="text"
icon={<ArrowUpOutlined aria-hidden />}
aria-label={t('pages.hosts.moveUp')}
disabled={idx === 0}
disabled={reordering || loading || idx === 0}
onClick={() => onMove(h, 'up')}
/>
</Tooltip>
@@ -107,7 +146,7 @@ export default function HostList(props: HostListProps) {
type="text"
icon={<ArrowDownOutlined aria-hidden />}
aria-label={t('pages.hosts.moveDown')}
disabled={idx >= count - 1}
disabled={reordering || loading || idx >= count - 1}
onClick={() => onMove(h, 'down')}
/>
</Tooltip>
@@ -305,7 +344,7 @@ export default function HostList(props: HostListProps) {
);
return (
<Card size="small" hoverable title={toolbar} className="hosts-card">
<Card size="small" hoverable title={toolbar} className="hosts-card" ref={tableRef}>
<Table<HostRecord>
rowKey="groupId"
size="small"
@@ -313,6 +352,12 @@ export default function HostList(props: HostListProps) {
columns={columns}
dataSource={sorted}
pagination={false}
rowClassName={(h) =>
[
drag?.from === h.groupId ? 'host-dragging' : '',
drag?.to === h.groupId ? (drag.after ? 'host-drop-after' : 'host-drop-before') : '',
].join(' ')
}
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys: selectedGroupIds,
+35 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@@ -45,6 +45,35 @@ export default function HostsPage() {
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formHost, setFormHost] = useState<HostRecord | null>(null);
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
const savingOrder = useRef(false);
const visibleHosts = useMemo(
() =>
pendingOrder
? hosts.map((h) => ({ ...h, sortOrder: pendingOrder.indexOf(h.groupId) }))
: hosts,
[hosts, pendingOrder],
);
const onReorder = useCallback(
async (groupIds: string[]) => {
const current = sortHosts(hosts).map((h) => h.groupId);
if (savingOrder.current || current.every((id, i) => id === groupIds[i])) return;
savingOrder.current = true;
setPendingOrder(groupIds);
try {
const msg = await reorder(groupIds);
if (msg?.success) messageApi.success(t('pages.hosts.toasts.update'));
else messageApi.error(msg?.msg || t('somethingWentWrong'));
} catch {
messageApi.error(t('somethingWentWrong'));
} finally {
savingOrder.current = false;
setPendingOrder(null);
}
},
[hosts, reorder, messageApi, t],
);
const onAdd = useCallback(() => {
setFormMode('add');
@@ -99,9 +128,9 @@ export default function HostsPage() {
if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
const groupIds = sorted.map((h) => h.groupId);
[groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
await reorder(groupIds);
await onReorder(groupIds);
},
[hosts, reorder],
[hosts, onReorder],
);
const onBulkEnable = useCallback(
@@ -207,7 +236,7 @@ export default function HostsPage() {
<Col span={24}>
<HostList
hosts={hosts}
hosts={visibleHosts}
inboundOptions={inboundOptions}
loading={loading}
isMobile={isMobile}
@@ -218,6 +247,8 @@ export default function HostsPage() {
onDelete={onDelete}
onToggleEnable={onToggleEnable}
onMove={onMove}
onReorder={onReorder}
reordering={pendingOrder !== null}
onBulkEnable={onBulkEnable}
onBulkDelete={onBulkDelete}
/>
@@ -11,7 +11,7 @@ import {
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router';
import type { AllSetting } from '@/models/setting';
import type { SubProfileMode } from '@/schemas/setting';
import type { SubProfileMode, SubHostSortMode } from '@/schemas/setting';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form';
@@ -95,6 +95,22 @@ export default function SubscriptionGeneralTab({
}
/>
)}
<SettingListItem
paddings="small"
title={t('pages.settings.subHostSortMode')}
description={t('pages.settings.subHostSortModeDesc')}
>
<Select
id="sub-host-sort-mode"
aria-label={t('pages.settings.subHostSortMode')}
value={allSetting.subHostSortMode}
onChange={(value: SubHostSortMode) => updateSetting({ subHostSortMode: value })}
options={[
{ value: 'inbound-first', label: t('pages.settings.subHostSortInbound') },
{ value: 'hosts-first', label: t('pages.settings.subHostSortHosts') },
]}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subListen')}
+51 -91
View File
@@ -25,6 +25,7 @@ import { arrJoin, originalRuleIndex } from './helpers';
import type { RuleRow } from './types';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type { RuleObject } from '@/schemas/routing';
import { usePointerRowReorder } from '@/hooks/usePointerRowReorder';
import './RoutingTab.css';
interface RoutingTabProps {
@@ -50,19 +51,7 @@ export default function RoutingTab({
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const dragRef = useRef<{
from: number | null;
to: number | null;
startY: number;
moved: boolean;
}>({
from: null,
to: null,
startY: 0,
moved: false,
});
const rulesContainerRef = useRef<HTMLDivElement>(null);
const routingScript = templateSettings?.routing?.script ?? '';
const rules = useMemo(
@@ -273,43 +262,15 @@ export default function RoutingTab({
});
}
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
try {
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
} catch {
/* ignore */
}
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
setDraggedIndex(idx);
setDropTargetIndex(idx);
const onMove = (e: PointerEvent) => {
const state = dragRef.current;
if (state.from == null) return;
if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
state.moved = true;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return;
const newIdx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(newIdx) && newIdx !== state.to) {
state.to = newIdx;
setDropTargetIndex(newIdx);
}
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
const { from, to, moved } = dragRef.current;
dragRef.current = { from: null, to: null, startY: 0, moved: false };
setDraggedIndex(null);
setDropTargetIndex(null);
if (!moved || from == null || to == null || from === to) return;
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
containerRef: rulesContainerRef,
getRowKey: (row) => {
const key = Number(row.dataset.rowKey);
// Desktop table keys are original rule indices; mobile cards use visible positions.
const index = isMobile ? key : rows.findIndex((rule) => rule.key === key);
return Number.isInteger(index) && index >= 0 && index < rows.length ? index : null;
},
onDrop: ({ from, to }) => {
const fromOrig = originalRuleIndex(rowsRef.current, from);
const toOrig = originalRuleIndex(rowsRef.current, to);
mutate((tt) => {
@@ -318,12 +279,10 @@ export default function RoutingTab({
const [movedItem] = list.splice(fromOrig, 1);
list.splice(toOrig, 0, movedItem);
});
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}
},
});
const draggedIndex = drag?.from ?? null;
const dropTargetIndex = drag?.to ?? null;
const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
const hasBalancer = rows.some((r) => r.balancerTag);
@@ -412,41 +371,42 @@ export default function RoutingTab({
</Dropdown>
</Space>
{isMobile ? (
<RuleCardList
rows={rows}
draggedIndex={draggedIndex}
dropTargetIndex={dropTargetIndex}
onHandlePointerDown={onHandlePointerDown}
openEdit={openEdit}
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
scroll={{ x: tableScrollX }}
size="small"
className="routing-table"
onRow={(_record, index) => {
const classes: string[] = [];
const i = index ?? -1;
if (draggedIndex === i) classes.push('row-dragging');
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
}
return {
className: classes.join(' '),
'data-row-key': i,
} as React.HTMLAttributes<HTMLElement>;
}}
/>
)}
<div ref={rulesContainerRef}>
{isMobile ? (
<RuleCardList
rows={rows}
draggedIndex={draggedIndex}
dropTargetIndex={dropTargetIndex}
onHandlePointerDown={onHandlePointerDown}
openEdit={openEdit}
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
scroll={{ x: tableScrollX }}
size="small"
className="routing-table"
onRow={(_record, index) => {
const classes: string[] = [];
const i = index ?? -1;
if (draggedIndex === i) classes.push('row-dragging');
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
}
return {
className: classes.join(' '),
};
}}
/>
)}
</div>
</Space>
),
},