P8(2): inbound node reassignment (move) with backend re-homing
An inbound's 部署到 node can now be changed after creation; the row, clients, client_traffics and subscription output stay untouched (same inbound id throughout). Contract: the update payload's nodeId counts only when the key was explicitly sent — the controller probes the raw JSON body and sets Inbound.NodeIDProvided; the wire adapter now always emits nodeId (null = local) so clearing the picker is an explicit move-to-local, while callers that omit nodeId keep the stored assignment (legacy behaviour). UpdateInbound resolves the target before the port-conflict check (so conflicts are scoped to the target node), rejects moves to a missing node, re-derives auto tags under the target node's prefix, validates the tag against the target node's namespace, and marks BOTH nodes dirty: the source node's next reconcile sweeps the old tag (no silent ghost — the flag persists until a reconcile succeeds, so an offline side just defers), the target's pushes the inbound. Agent-bound nodes converge via GetXrayConfigForAgent on the agent's next config poll. A local side has no reconcile loop, so it is applied directly post-commit (Del/Add on the local runtime, restart fallback). Frontend: the deploy-to Select is no longer disabled in edit mode (protocol stays locked); candidate rules unchanged (online nodes only). Tests: panel->panel move with reconcile sweep/push assertions, auto-tag prefix regeneration, agent-config flip in both directions, omitted-nodeId legacy contract, same-node no-op, missing-node rejection, node->local move.
This commit is contained in:
@@ -77,7 +77,7 @@ export interface WireInboundPayload {
|
||||
sniffing: string;
|
||||
tag: string;
|
||||
clientStats?: unknown;
|
||||
nodeId?: number;
|
||||
nodeId?: number | null;
|
||||
shareAddrStrategy: ShareAddrStrategy;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
@@ -384,6 +384,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||
subSortIndex: values.subSortIndex,
|
||||
disableFlow: values.disableFlow,
|
||||
};
|
||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||
// nodeId is always emitted (null = local panel): the update endpoint
|
||||
// distinguishes an explicit null — "move the inbound back to local" —
|
||||
// from an omitted key, which keeps the stored assignment untouched.
|
||||
payload.nodeId = values.nodeId ?? null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -618,7 +618,6 @@ export default function InboundFormModal({
|
||||
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
|
||||
<Select
|
||||
showSearch
|
||||
disabled={mode === 'edit'}
|
||||
placeholder={t('pages.inbounds.localPanel')}
|
||||
allowClear
|
||||
options={selectableNodes.map((n) => ({
|
||||
|
||||
@@ -228,10 +228,11 @@ describe('formValuesToWirePayload', () => {
|
||||
expect(payload.sniffing).toBe('');
|
||||
});
|
||||
|
||||
it('omits nodeId when null', () => {
|
||||
it('emits nodeId as explicit null when unset', () => {
|
||||
const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
|
||||
const payload = formValuesToWirePayload(values);
|
||||
expect('nodeId' in payload).toBe(false);
|
||||
expect('nodeId' in payload).toBe(true);
|
||||
expect(payload.nodeId).toBeNull();
|
||||
});
|
||||
|
||||
it('includes nodeId when set', () => {
|
||||
|
||||
Reference in New Issue
Block a user