feat(hosts): bulk-add multiple hosts to multiple inbounds (#5677)

* feat(hosts): bulk-add multiple hosts to multiple inbounds

Allow users to select multiple inbound IDs and enter multiple host
addresses (with optional per-host port override) in a single form
submission.

- Add BulkAddHostReq entity and POST /panel/api/hosts/bulk/add endpoint
- Add AddHostsBulk service with GORM transaction safety
- Add parseHostAndPort helper (IPv4, bracketed/bracketless IPv6, port)
- Update HostFormModal to multi-select inbounds and tag-input hosts
- Wire bulkCreate mutation in HostsPage with existing-host suggestions
- Register endpoint in api-docs/endpoints.ts and regenerate OpenAPI/Zod

* feat(hosts): group override records by group_id and support group editing

* fix: import Popover in HostList

* fix: use messageApi in HostFormModal

* fix(hosts): resolve 4 bugs found in host-group code review

- fix(schema): allow empty hosts array in BulkAddHostSchema so users can
  save a host without an address (inherits inbound endpoint). The old
  .min(1) was never enforced at runtime since the schema is only used for
  type inference, but the type was incorrect.

- fix(service): validate new inbound IDs in UpdateHostGroup before deleting
  old rows, matching the same check already present in AddHostGroup. Prevents
  orphaned host rows when an invalid inbound ID is supplied on edit.

- fix(service): replace full-table scan in GetHostsByInbound with two
  targeted queries (DISTINCT group_id WHERE inbound_id=?, then
  WHERE group_id IN ?) to avoid loading every host in the DB.

- fix(mutations): remove unused createMut / create export from
  useHostMutations. The /hosts/add endpoint is identical to /hosts/bulk/add;
  only bulkCreate is used by the UI.

* fix(hosts): address code review feedback (optimize bulk inserts, add validation tests, and remove comments)

* fix(fmt): apply gofumpt formatting to model.go and db.go

The previous merge commit incorrectly applied gofmt (tab-aligned) to
these files. The repository's golangci config requires gofumpt+goimports
which produces space-aligned struct fields. This commit restores the
correct gofumpt formatting that matches upstream/main.

* chore(frontend): regenerate API schemas and update lockfile

* fix

* refactor(hosts): dedupe host-group service and tidy frontend

AddHostGroup and UpdateHostGroup shared an identical ~35-field
model.Host construction and hand-rolled transaction boilerplate
(tx.Begin plus a committed flag plus a deferred recover/rollback).
Extract buildHostRows, validateInboundsExist and formatHostAddr, and
run every mutation through db.Transaction. groupHosts collapses its
duplicated address/port formatting and create/append fork into one
path using slices.Contains. Behavior-preserving: host.go drops ~90
lines with the existing service/controller tests green.

Frontend: drop the Partial union and two as-casts in HostsPage.onSave
(the modal always passes a full BulkAddHostValues), and remove the
movable index map in HostList in favor of the table render index arg.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
AmirRnz
2026-07-09 01:05:20 +03:30
committed by GitHub
parent f431e9cc03
commit 42690e1b8c
33 changed files with 1842 additions and 1108 deletions
+35 -25
View File
@@ -1029,26 +1029,26 @@ export const sections: readonly Section[] = [
method: 'GET',
path: '/panel/api/hosts/list',
summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.',
responseSchema: 'Host',
responseSchema: 'HostGroup',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/hosts/get/:id',
summary: 'Fetch a single host by ID.',
path: '/panel/api/hosts/get/:groupId',
summary: 'Fetch a single host group by Group ID.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
{ name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
],
responseSchema: 'Host',
responseSchema: 'HostGroup',
},
{
method: 'GET',
path: '/panel/api/hosts/byInbound/:inboundId',
summary: "Fetch one inbound's hosts, ordered by sort order then id.",
summary: "Fetch one inbound's hosts, grouped by host group.",
params: [
{ name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
responseSchema: 'Host',
responseSchema: 'HostGroup',
responseSchemaArray: true,
},
{
@@ -1060,54 +1060,64 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/hosts/add',
summary: 'Create a host on an inbound. inboundId and remark are required; security defaults to "same" (inherit the inbound).',
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
summary: 'Create a host group on inbounds.',
body: '{\n "inboundIds": [1],\n "remark": "cdn-front",\n "hosts": ["cdn.example.com"],\n "port": 8443,\n "security": "same",\n "tags": ["CDN"]\n}',
responseSchema: 'Host',
responseSchemaArray: true,
},
{
method: 'POST',
path: '/panel/api/hosts/update/:id',
summary: 'Replace a hosts content. The inbound and sort order are immutable here (use /reorder for ordering).',
path: '/panel/api/hosts/update/:groupId',
summary: 'Replace a host groups content.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
{ name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
],
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
body: '{\n "inboundIds": [1],\n "remark": "cdn-front",\n "hosts": ["cdn.example.com"],\n "port": 8443,\n "security": "same",\n "tags": ["CDN"]\n}',
responseSchema: 'Host',
responseSchemaArray: true,
},
{
method: 'POST',
path: '/panel/api/hosts/del/:id',
summary: 'Delete a host.',
path: '/panel/api/hosts/del/:groupId',
summary: 'Delete a host group.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
{ name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
],
},
{
method: 'POST',
path: '/panel/api/hosts/setEnable/:id',
summary: 'Enable or disable a single host (disabled hosts are skipped in subscriptions).',
path: '/panel/api/hosts/setEnable/:groupId',
summary: 'Enable or disable a host group.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
{ name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
],
body: '{\n "enable": true\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/reorder',
summary: 'Set host sort order by the position of each id in the array.',
body: '{\n "ids": [3, 1, 2]\n}',
summary: 'Set host group sort order by the position of each groupId in the array.',
body: '{\n "ids": ["abc-123", "def-456"]\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/bulk/add',
summary: 'Add a host group to inbounds (same as /add).',
body: '{\n "inboundIds": [1, 2],\n "hosts": ["cdn.example.com", "cdn2.example.com:443"],\n "remark": "Cloudflare CDN",\n "port": 0,\n "security": "same",\n "isDisabled": false\n}',
responseSchema: 'Host',
responseSchemaArray: true,
},
{
method: 'POST',
path: '/panel/api/hosts/bulk/setEnable',
summary: 'Enable or disable many hosts in one call.',
body: '{\n "ids": [1, 2, 3],\n "enable": false\n}',
summary: 'Enable or disable many host groups in one call.',
body: '{\n "ids": ["abc-123", "def-456"],\n "enable": false\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/bulk/del',
summary: 'Delete many hosts in one call.',
body: '{\n "ids": [1, 2, 3]\n}',
summary: 'Delete many host groups in one call.',
body: '{\n "ids": ["abc-123", "def-456"]\n}',
},
],
},
+67 -35
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
import {
@@ -14,7 +14,7 @@ import {
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import { HostFormSchema, type HostFormValues } from '@/schemas/api/host';
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
@@ -23,18 +23,17 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
/*
* inboundId is optional in the form so a new host starts unselected (the Select
* shows its placeholder instead of 0); the required rule enforces it on submit.
*/
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
type FormShape = Omit<BulkAddHostValues, 'isDisabled'> & {
enable: boolean;
};
interface HostFormModalProps {
open: boolean;
mode: 'add' | 'edit';
host: HostRecord | null;
inboundOptions: InboundOption[];
save: (payload: Partial<HostFormValues>) => Promise<{ success?: boolean; msg?: string } | undefined>;
existingHosts: HostRecord[];
save: (payload: BulkAddHostValues) => Promise<{ success?: boolean; msg?: string } | undefined>;
onOpenChange: (open: boolean) => void;
}
@@ -42,21 +41,21 @@ const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
function defaultsFor(host: HostRecord | null): FormShape {
return {
inboundId: host?.inboundId,
inboundIds: host?.inboundIds ?? [],
hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''),
sortOrder: host?.sortOrder ?? 0,
remark: host?.remark ?? '',
serverDescription: host?.serverDescription ?? '',
enable: host ? !host.isDisabled : true,
isHidden: host?.isHidden ?? false,
tags: host?.tags ?? [],
address: host?.address ?? '',
port: host?.port ?? 0,
security: (host?.security as HostFormValues['security']) ?? 'same',
security: (host?.security as BulkAddHostValues['security']) ?? 'same',
sni: host?.sni ?? '',
hostHeader: host?.hostHeader ?? '',
path: host?.path ?? '',
alpn: (host?.alpn as HostFormValues['alpn']) ?? [],
fingerprint: host?.fingerprint as HostFormValues['fingerprint'],
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
keepSniBlank: host?.keepSniBlank ?? false,
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
@@ -67,31 +66,30 @@ function defaultsFor(host: HostRecord | null): FormShape {
sockoptParams: asString(host?.sockoptParams),
finalMask: host?.finalMask ?? '',
vlessRoute: host?.vlessRoute ?? '',
excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [],
excludeFromSubTypes: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
nodeGuids: host?.nodeGuids ?? [],
mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'],
mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
mihomoX25519: host?.mihomoX25519 ?? false,
shuffleHost: host?.shuffleHost ?? false,
};
}
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
/*
* Drive conditional field visibility off the selected security, like the
* legacy externalProxy form: same/none inherit fully and hide every TLS/cert
* field; reality shows only the reality-relevant subset (its keys are
* inherited from the inbound); tls shows the full TLS override set.
*/
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
const showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls';
useEffect(() => {
if (open) methods.reset(defaultsFor(host));
if (open) {
methods.reset(defaultsFor(host));
setLoading(false);
}
}, [open, host, methods]);
const { nodes } = useNodesQuery();
@@ -114,15 +112,42 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
const hostOptions = useMemo(() => {
const addresses = new Set<string>();
for (const h of existingHosts || []) {
if (h.hosts) {
for (const addr of h.hosts) {
if (addr && addr.trim() !== '') {
addresses.add(addr);
}
}
}
}
return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
}, [existingHosts]);
const onFinish = async (values: FormShape) => {
if (loading) return;
const { enable, ...rest } = values;
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
const res = await save(payload);
if (res?.success) {
message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
onOpenChange(false);
} else if (res?.msg) {
message.error(res.msg);
const isDisabled = !enable;
const payload: BulkAddHostValues = {
...rest,
hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
isDisabled,
};
setLoading(true);
try {
const res = await save(payload);
if (res?.success) {
messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
onOpenChange(false);
} else if (res?.msg) {
messageApi.error(res.msg);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
@@ -132,12 +157,14 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
onOk={methods.handleSubmit(onFinish)}
onCancel={() => onOpenChange(false)}
confirmLoading={loading}
okText={t('save')}
cancelText={t('cancel')}
destroyOnHidden
width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
>
{messageContextHolder}
<FormProvider {...methods}>
<Form
colon={false}
@@ -154,23 +181,28 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.remark) }}>
<FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}>
<Input maxLength={256} />
</FormField>
<FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<Input maxLength={64} />
</FormField>
<FormField name="inboundId" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.inboundId) }}>
<FormField name="inboundIds" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}>
<Select
mode="multiple"
options={inboundSelectOptions}
showSearch
optionFilterProp="label"
disabled={mode === 'edit'}
placeholder={t('pages.hosts.selectInbound')}
/>
</FormField>
<FormField name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
<Input placeholder="cdn.example.com" />
<FormField name="hosts" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}>
<Select
mode="tags"
options={hostOptions}
tokenSeparators={[',', ';', ' ']}
placeholder="cdn.example.com, cdn2.example.com:443"
/>
</FormField>
<FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<InputNumber min={0} max={65535} />
+92 -37
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Space, Switch, Table, Tag, Tooltip } from 'antd';
import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ArrowDownOutlined,
@@ -20,8 +20,8 @@ interface HostListProps {
inboundOptions: InboundOption[];
loading?: boolean;
isMobile?: boolean;
selectedIds: number[];
onSelectionChange: (ids: number[]) => void;
selectedGroupIds: string[];
onSelectionChange: (groupIds: string[]) => void;
onAdd: () => void;
onEdit: (host: HostRecord) => void;
onDelete: (host: HostRecord) => void;
@@ -31,56 +31,50 @@ interface HostListProps {
onBulkDelete: () => void;
}
// Sorted by inbound then sort_order then id — the same order the subscription
// renderer uses, so the list mirrors the emitted link order.
function sortHosts(hosts: HostRecord[]): HostRecord[] {
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
vless: 'blue',
vmess: 'geekblue',
trojan: 'volcano',
shadowsocks: 'magenta',
hysteria: 'cyan',
hysteria2: 'green',
wireguard: 'gold',
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
};
export function sortHosts(hosts: HostRecord[]): HostRecord[] {
return [...hosts].sort((a, b) => {
if (a.inboundId !== b.inboundId) return a.inboundId - b.inboundId;
const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb;
return a.id - b.id;
return (a.remark || '').localeCompare(b.remark || '');
});
}
export default function HostList(props: HostListProps) {
const { t } = useTranslation();
const {
hosts, inboundOptions, loading, isMobile, selectedIds, onSelectionChange,
hosts, inboundOptions, loading, isMobile, selectedGroupIds, onSelectionChange,
onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
} = props;
const inboundLabel = useMemo(() => {
const map = new Map<number, string>();
for (const ib of inboundOptions) map.set(ib.id, ib.remark || ib.tag || `#${ib.id}`);
const inboundsMap = useMemo(() => {
const map = new Map<number, InboundOption>();
for (const ib of inboundOptions) map.set(ib.id, ib);
return map;
}, [inboundOptions]);
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
// Move is bounded to neighbours within the same inbound (sort_order is per-inbound).
const movable = useMemo(() => {
const byInbound = new Map<number, number>();
const idxInGroup = new Map<number, number>();
const counters = new Map<number, number>();
for (const h of sorted) byInbound.set(h.inboundId, (byInbound.get(h.inboundId) ?? 0) + 1);
for (const h of sorted) {
const c = counters.get(h.inboundId) ?? 0;
idxInGroup.set(h.id, c);
counters.set(h.inboundId, c + 1);
}
return { byInbound, idxInGroup };
}, [sorted]);
// Column order requested: Actions, Enable, then the rest.
const columns: ColumnsType<HostRecord> = [
{
title: t('pages.hosts.fields.actions'),
key: 'actions',
width: 168,
render: (_, h) => {
const idx = movable.idxInGroup.get(h.id) ?? 0;
const count = movable.byInbound.get(h.inboundId) ?? 1;
render: (_, h, idx) => {
const count = sorted.length;
return (
<Space size={2}>
<Tooltip title={t('pages.hosts.moveUp')}>
@@ -121,12 +115,73 @@ export default function HostList(props: HostListProps) {
{
title: t('pages.hosts.fields.endpoint'),
key: 'endpoint',
render: (_, h) => <span className="host-endpoint">{`${h.address || '—'}${h.port ? `:${h.port}` : ''}`}</span>,
render: (_, h) => {
const addrs = h.hosts?.filter(a => a.trim() !== '') || [];
if (addrs.length === 0) return <Tag color="orange">{t('pages.hosts.fields.inheritAddress') || 'inherits'}</Tag>;
const visible = addrs.slice(0, 1);
const overflow = addrs.slice(1);
return (
<>
{visible.map((addr) => <Tag key={addr}>{addr}</Tag>)}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map((addr) => <Tag key={addr}>{addr}</Tag>)}
</div>
}
>
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
);
},
},
{
title: t('pages.hosts.fields.inbound'),
key: 'inbound',
render: (_, h) => inboundLabel.get(h.inboundId) ?? `#${h.inboundId}`,
render: (_, h) => {
const ids = h.inboundIds || [];
if (ids.length === 0) return <span className="host-muted"></span>;
const visible = ids.slice(0, 1);
const overflow = ids.slice(1);
const chip = (id: number) => {
const ib = inboundsMap.get(id);
const label = ib ? (ib.remark || ib.tag || `#${id}`) : `#${id}`;
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
return (
<Tooltip key={id} title={label}>
<Tag color={color} style={{ margin: 2 }}>{label}</Tag>
</Tooltip>
);
};
return (
<>
{visible.map(chip)}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map(chip)}
</div>
}
>
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
);
},
},
{
title: t('pages.hosts.fields.security'),
@@ -145,7 +200,7 @@ export default function HostList(props: HostListProps) {
const toolbar = (
<div className="card-toolbar">
{selectedIds.length === 0 ? (
{selectedGroupIds.length === 0 ? (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{!isMobile && t('pages.hosts.addHost')}
</Button>
@@ -157,7 +212,7 @@ export default function HostList(props: HostListProps) {
onClose={() => onSelectionChange([])}
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
>
{t('pages.hosts.selectedCount', { count: selectedIds.length })}
{t('pages.hosts.selectedCount', { count: selectedGroupIds.length })}
</Tag>
<Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
<Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
@@ -170,7 +225,7 @@ export default function HostList(props: HostListProps) {
return (
<Card size="small" hoverable title={toolbar} className="hosts-card">
<Table<HostRecord>
rowKey="id"
rowKey="groupId"
size="small"
loading={loading}
columns={columns}
@@ -178,8 +233,8 @@ export default function HostList(props: HostListProps) {
pagination={false}
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => onSelectionChange(keys as number[]),
selectedRowKeys: selectedGroupIds,
onChange: (keys) => onSelectionChange(keys as string[]),
}}
locale={{
emptyText: (
+29 -40
View File
@@ -10,22 +10,10 @@ import { useHostMutations } from '@/api/queries/useHostMutations';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import AppSidebar from '@/layouts/AppSidebar';
import { setMessageInstance } from '@/utils/messageBus';
import type { HostFormValues } from '@/schemas/api/host';
import HostList from './HostList';
import type { BulkAddHostValues } from '@/schemas/api/host';
import HostList, { sortHosts } from './HostList';
import HostFormModal from './HostFormModal';
// Hosts for one inbound in render order — used to compute a reorder payload.
function inboundHostsInOrder(hosts: HostRecord[], inboundId: number): HostRecord[] {
return hosts
.filter((h) => h.inboundId === inboundId)
.sort((a, b) => {
const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb;
return a.id - b.id;
});
}
export default function HostsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -35,13 +23,13 @@ export default function HostsPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
const { create, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
const { data: inboundOptions = [] } = useInboundOptions();
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formHost, setFormHost] = useState<HostRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
const onAdd = useCallback(() => {
setFormMode('add');
@@ -55,12 +43,12 @@ export default function HostsPage() {
setFormOpen(true);
}, []);
const onSave = useCallback(async (payload: Partial<HostFormValues>) => {
if (formMode === 'edit' && formHost?.id) {
return update(formHost.id, payload);
const onSave = useCallback(async (payload: BulkAddHostValues) => {
if (formMode === 'edit' && formHost?.groupId) {
return update(formHost.groupId, payload);
}
return create(payload);
}, [formMode, formHost, update, create]);
return bulkCreate(payload);
}, [formMode, formHost, update, bulkCreate]);
const onDelete = useCallback((host: HostRecord) => {
modal.confirm({
@@ -69,48 +57,48 @@ export default function HostsPage() {
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(host.id);
const msg = await remove(host.groupId);
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
},
});
}, [modal, t, remove, messageApi]);
const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => {
await setEnable(host.id, next);
await setEnable(host.groupId, next);
}, [setEnable]);
const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => {
const group = inboundHostsInOrder(hosts, host.inboundId);
const idx = group.findIndex((h) => h.id === host.id);
const sorted = sortHosts(hosts);
const idx = sorted.findIndex((h) => h.groupId === host.groupId);
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
if (idx < 0 || swapWith < 0 || swapWith >= group.length) return;
const ids = group.map((h) => h.id);
[ids[idx], ids[swapWith]] = [ids[swapWith], ids[idx]];
await reorder(ids);
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);
}, [hosts, reorder]);
const onBulkEnable = useCallback(async (enable: boolean) => {
if (selectedIds.length === 0) return;
const msg = await bulkSetEnable(selectedIds, enable);
if (msg?.success) setSelectedIds([]);
}, [selectedIds, bulkSetEnable]);
if (selectedGroupIds.length === 0) return;
const msg = await bulkSetEnable(selectedGroupIds, enable);
if (msg?.success) setSelectedGroupIds([]);
}, [selectedGroupIds, bulkSetEnable]);
const onBulkDelete = useCallback(() => {
if (selectedIds.length === 0) return;
if (selectedGroupIds.length === 0) return;
modal.confirm({
title: t('pages.hosts.bulkDeleteConfirm', { count: selectedIds.length }),
title: t('pages.hosts.bulkDeleteConfirm', { count: selectedGroupIds.length }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkDel(selectedIds);
const msg = await bulkDel(selectedGroupIds);
if (msg?.success) {
messageApi.success(t('pages.hosts.toasts.delete'));
setSelectedIds([]);
setSelectedGroupIds([]);
}
},
});
}, [selectedIds, modal, t, bulkDel, messageApi]);
}, [selectedGroupIds, modal, t, bulkDel, messageApi]);
const summary = useMemo(() => {
const total = hosts.length;
@@ -179,8 +167,8 @@ export default function HostsPage() {
inboundOptions={inboundOptions}
loading={loading}
isMobile={isMobile}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
selectedGroupIds={selectedGroupIds}
onSelectionChange={setSelectedGroupIds}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
@@ -201,6 +189,7 @@ export default function HostsPage() {
mode={formMode}
host={formHost}
inboundOptions={inboundOptions}
existingHosts={hosts}
save={onSave}
onOpenChange={setFormOpen}
/>