Prechádzať zdrojové kódy

feat: allow selecting inbounds synchronized from nodes (#5178)

* feat: select node inbounds for synchronization

Allow node owners to import either all remote inbounds or an explicit tag-based selection. Add remote inbound discovery, persistence, snapshot filtering, API documentation, tests, and localized UI labels.

* fix

* fix: scope node reconcile and orphan sweep to selected inbound tags

In 'selected' sync mode unselected inbounds never enter the panel DB, so
ReconcileNode treated them as undesired and deleted them from the node the
first time it went config-dirty. Reconcile now only sweeps remote tags that
are part of the selection; everything else on the node is unmanaged.

Panel-created or renamed inbounds on a selected-mode node also vanished:
their tag was outside the selection, so the next traffic pull filtered them
out of the snapshot and the orphan sweep silently dropped the central row.
AddInbound/UpdateInbound now allow the tag on the node before committing.

---------

Co-authored-by: Sanaei <[email protected]>
animesha3 16 hodín pred
rodič
commit
554d85c2f7
32 zmenil súbory, kde vykonal 741 pridanie a 16 odobranie
  1. 78 0
      frontend/public/openapi.json
  2. 9 0
      frontend/src/api/queries/useNodeMutations.ts
  3. 4 0
      frontend/src/generated/examples.ts
  4. 15 0
      frontend/src/generated/schemas.ts
  5. 2 0
      frontend/src/generated/types.ts
  6. 2 0
      frontend/src/generated/zod.ts
  7. 7 0
      frontend/src/pages/api-docs/endpoints.ts
  8. 71 0
      frontend/src/pages/nodes/NodeFormModal.tsx
  9. 2 1
      frontend/src/pages/nodes/NodesPage.tsx
  10. 4 0
      frontend/src/schemas/node.ts
  11. 14 12
      internal/database/model/model.go
  12. 13 0
      internal/web/controller/node.go
  13. 2 1
      internal/web/job/node_traffic_sync_job.go
  14. 19 0
      internal/web/runtime/remote.go
  15. 16 0
      internal/web/service/inbound.go
  16. 19 2
      internal/web/service/inbound_node.go
  17. 197 0
      internal/web/service/inbound_node_reconcile_test.go
  18. 85 0
      internal/web/service/node.go
  19. 52 0
      internal/web/service/node_test.go
  20. 10 0
      internal/web/translation/ar-EG.json
  21. 10 0
      internal/web/translation/en-US.json
  22. 10 0
      internal/web/translation/es-ES.json
  23. 10 0
      internal/web/translation/fa-IR.json
  24. 10 0
      internal/web/translation/id-ID.json
  25. 10 0
      internal/web/translation/ja-JP.json
  26. 10 0
      internal/web/translation/pt-BR.json
  27. 10 0
      internal/web/translation/ru-RU.json
  28. 10 0
      internal/web/translation/tr-TR.json
  29. 10 0
      internal/web/translation/uk-UA.json
  30. 10 0
      internal/web/translation/vi-VN.json
  31. 10 0
      internal/web/translation/zh-CN.json
  32. 10 0
      internal/web/translation/zh-TW.json

+ 78 - 0
frontend/public/openapi.json

@@ -1572,6 +1572,19 @@
             "example": 5,
             "type": "integer"
           },
+          "inboundSyncMode": {
+            "enum": [
+              "all",
+              "selected"
+            ],
+            "type": "string"
+          },
+          "inboundTags": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
           "lastError": {
             "type": "string"
           },
@@ -1675,6 +1688,8 @@
           "guid",
           "id",
           "inboundCount",
+          "inboundSyncMode",
+          "inboundTags",
           "lastError",
           "lastHeartbeat",
           "latencyMs",
@@ -6011,6 +6026,10 @@
                       "guid": "",
                       "id": 1,
                       "inboundCount": 5,
+                      "inboundSyncMode": "all",
+                      "inboundTags": [
+                        ""
+                      ],
                       "lastError": "",
                       "lastHeartbeat": 1700000000,
                       "latencyMs": 42,
@@ -6451,6 +6470,65 @@
         }
       }
     },
+    "/panel/api/nodes/inbounds": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Use unsaved node connection details to list the remote inbounds available for selective import.",
+        "operationId": "post_panel_api_nodes_inbounds",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "name": "de-fra-1",
+                "scheme": "https",
+                "address": "node1.example.com",
+                "port": 2053,
+                "basePath": "/",
+                "apiToken": "abcdef..."
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "tag": "inbound-443",
+                      "remark": "VLESS",
+                      "protocol": "vless",
+                      "port": 443
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/probe/{id}": {
       "post": {
         "tags": [

+ 9 - 0
frontend/src/api/queries/useNodeMutations.ts

@@ -15,6 +15,13 @@ export interface NodeUpdateResult {
   error?: string;
 }
 
+export interface RemoteInboundOption {
+  tag: string;
+  remark?: string;
+  protocol?: string;
+  port?: number;
+}
+
 export function useNodeMutations() {
   const queryClient = useQueryClient();
   const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
@@ -72,5 +79,7 @@ export function useNodeMutations() {
     },
     fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
       HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
+    fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
+      HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
   };
 }

+ 4 - 0
frontend/src/generated/examples.ts

@@ -342,6 +342,10 @@ export const EXAMPLES: Record<string, unknown> = {
     "guid": "",
     "id": 1,
     "inboundCount": 5,
+    "inboundSyncMode": "all",
+    "inboundTags": [
+      ""
+    ],
     "lastError": "",
     "lastHeartbeat": 1700000000,
     "latencyMs": 42,

+ 15 - 0
frontend/src/generated/schemas.ts

@@ -1546,6 +1546,19 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 5,
         "type": "integer"
       },
+      "inboundSyncMode": {
+        "enum": [
+          "all",
+          "selected"
+        ],
+        "type": "string"
+      },
+      "inboundTags": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
       "lastError": {
         "type": "string"
       },
@@ -1649,6 +1662,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "guid",
       "id",
       "inboundCount",
+      "inboundSyncMode",
+      "inboundTags",
       "lastError",
       "lastHeartbeat",
       "latencyMs",

+ 2 - 0
frontend/src/generated/types.ts

@@ -348,6 +348,8 @@ export interface Node {
   guid: string;
   id: number;
   inboundCount: number;
+  inboundSyncMode: string;
+  inboundTags: string[];
   lastError: string;
   lastHeartbeat: number;
   latencyMs: number;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -374,6 +374,8 @@ export const NodeSchema = z.object({
   guid: z.string(),
   id: z.number().int(),
   inboundCount: z.number().int(),
+  inboundSyncMode: z.enum(['all', 'selected']),
+  inboundTags: z.array(z.string()),
   lastError: z.string(),
   lastHeartbeat: z.number().int(),
   latencyMs: z.number().int(),

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -843,6 +843,13 @@ export const sections: readonly Section[] = [
         body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/"\n}',
         response: '{\n  "success": true,\n  "obj": "k3b1...base64-sha256...="\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/inbounds',
+        summary: 'Use unsaved node connection details to list the remote inbounds available for selective import.',
+        body: '{\n  "name": "de-fra-1",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+        response: '{\n  "success": true,\n  "obj": [\n    { "tag": "inbound-443", "remark": "VLESS", "protocol": "vless", "port": 443 }\n  ]\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/nodes/probe/:id',

+ 71 - 0
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -14,6 +14,7 @@ import {
   message,
 } from 'antd';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
 import type { Msg } from '@/utils';
 import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
 import { antdRule } from '@/utils/zodForm';
@@ -27,6 +28,7 @@ interface NodeFormModalProps {
   node: NodeRecord | null;
   testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
   fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
+  fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
   save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
   onOpenChange: (open: boolean) => void;
 }
@@ -45,6 +47,8 @@ function defaultValues(): NodeFormValues {
     allowPrivateAddress: false,
     tlsVerifyMode: 'verify',
     pinnedCertSha256: '',
+    inboundSyncMode: 'all',
+    inboundTags: [],
   };
 }
 
@@ -54,6 +58,7 @@ export default function NodeFormModal({
   node,
   testConnection,
   fetchFingerprint,
+  fetchInbounds,
   save,
   onOpenChange,
 }: NodeFormModalProps) {
@@ -64,9 +69,12 @@ export default function NodeFormModal({
   const [submitting, setSubmitting] = useState(false);
   const [testing, setTesting] = useState(false);
   const [fetchingPin, setFetchingPin] = useState(false);
+  const [fetchingInbounds, setFetchingInbounds] = useState(false);
+  const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
   const [testResult, setTestResult] = useState<ProbeResult | null>(null);
   const scheme = Form.useWatch('scheme', form) ?? 'https';
   const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
+  const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
 
   useEffect(() => {
     if (!open) return;
@@ -82,6 +90,7 @@ export default function NodeFormModal({
     if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
     form.resetFields();
     form.setFieldsValue(next);
+    setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
     setTestResult(null);
   }, [open, mode, node, form]);
 
@@ -104,6 +113,8 @@ export default function NodeFormModal({
       allowPrivateAddress: values.allowPrivateAddress,
       tlsVerifyMode: values.tlsVerifyMode,
       pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
+      inboundSyncMode: values.inboundSyncMode,
+      inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
     };
   }
 
@@ -149,6 +160,26 @@ export default function NodeFormModal({
     }
   }
 
+  async function onFetchInbounds() {
+    try {
+      await form.validateFields(['name', 'address', 'port', 'apiToken']);
+    } catch {
+      return;
+    }
+    setFetchingInbounds(true);
+    try {
+      const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
+      if (msg?.success && Array.isArray(msg.obj)) {
+        setInboundOptions(msg.obj);
+        messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
+      } else {
+        messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
+      }
+    } finally {
+      setFetchingInbounds(false);
+    }
+  }
+
   async function onFinish(values: NodeFormValues) {
     const result = NodeFormSchema.safeParse(values);
     if (!result.success) {
@@ -323,6 +354,46 @@ export default function NodeFormModal({
             <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
           </Form.Item>
 
+          <Form.Item
+            label={t('pages.nodes.inboundSyncMode')}
+            name="inboundSyncMode"
+            extra={t('pages.nodes.inboundSyncModeHint')}
+          >
+            <Select
+              options={[
+                { value: 'all', label: t('pages.nodes.allInbounds') },
+                { value: 'selected', label: t('pages.nodes.selectedInbounds') },
+              ]}
+            />
+          </Form.Item>
+
+          {inboundSyncMode === 'selected' && (
+            <Form.Item
+              label={t('pages.nodes.inboundTags')}
+              name="inboundTags"
+              extra={t('pages.nodes.inboundTagsHint')}
+            >
+              <Select
+                mode="multiple"
+                allowClear
+                loading={fetchingInbounds}
+                placeholder={t('pages.nodes.inboundTagsPlaceholder')}
+                popupRender={(menu) => (
+                  <>
+                    <Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
+                      {t('pages.nodes.loadInbounds')}
+                    </Button>
+                    {menu}
+                  </>
+                )}
+                options={inboundOptions.map((inbound) => ({
+                  value: inbound.tag,
+                  label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
+                }))}
+              />
+            </Form.Item>
+          )}
+
           <div className="test-row">
             <Button type="default" loading={testing} onClick={onTest}>
               {t('pages.nodes.testConnection')}

+ 2 - 1
frontend/src/pages/nodes/NodesPage.tsx

@@ -30,7 +30,7 @@ export default function NodesPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
   const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
-  const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
+  const { create, update, remove, setEnable, testConnection, fetchFingerprint, fetchInbounds, probe, updatePanels } = useNodeMutations();
 
   const { data: latestVersion = '' } = useQuery({
     queryKey: ['server', 'panelUpdateInfo'],
@@ -235,6 +235,7 @@ export default function NodesPage() {
           node={formNode}
           testConnection={testConnection}
           fetchFingerprint={fetchFingerprint}
+          fetchInbounds={fetchInbounds}
           save={onSave}
           onOpenChange={setFormOpen}
         />

+ 4 - 0
frontend/src/schemas/node.ts

@@ -31,6 +31,8 @@ export const NodeRecordSchema = z.object({
   allowPrivateAddress: z.boolean().optional(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
   pinnedCertSha256: z.string().optional(),
+  inboundSyncMode: z.enum(['all', 'selected']).optional(),
+  inboundTags: z.array(z.string()).optional(),
   // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
   // whether it's a read-only transitive sub-node surfaced from a downstream node.
   guid: z.string().optional(),
@@ -63,6 +65,8 @@ export const NodeFormSchema = z.object({
   allowPrivateAddress: z.boolean(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
   pinnedCertSha256: z.string().optional().default(''),
+  inboundSyncMode: z.enum(['all', 'selected']),
+  inboundTags: z.array(z.string()),
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 14 - 12
internal/database/model/model.go

@@ -447,18 +447,20 @@ type Setting struct {
 // endpoint over HTTP using the per-node ApiToken to populate the runtime
 // status fields below.
 type Node struct {
-	Id                  int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
-	Name                string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
-	Remark              string `json:"remark" form:"remark"`
-	Scheme              string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
-	Address             string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
-	Port                int    `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
-	BasePath            string `json:"basePath" form:"basePath" example:"/"`
-	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
-	Enable              bool   `json:"enable" form:"enable" gorm:"default:true" example:"true"`
-	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
-	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
-	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
+	Id                  int      `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
+	Name                string   `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
+	Remark              string   `json:"remark" form:"remark"`
+	Scheme              string   `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
+	Address             string   `json:"address" form:"address" validate:"required" example:"node1.example.com"`
+	Port                int      `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
+	BasePath            string   `json:"basePath" form:"basePath" example:"/"`
+	ApiToken            string   `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
+	Enable              bool     `json:"enable" form:"enable" gorm:"default:true" example:"true"`
+	AllowPrivateAddress bool     `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
+	TlsVerifyMode       string   `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
+	PinnedCertSha256    string   `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
+	InboundSyncMode     string   `json:"inboundSyncMode" form:"inboundSyncMode" gorm:"column:inbound_sync_mode;default:all" validate:"omitempty,oneof=all selected"`
+	InboundTags         []string `json:"inboundTags" form:"inboundTags" gorm:"serializer:json;column:inbound_tags"`
 
 	// Guid is the remote panel's stable self-identifier (its panelGuid),
 	// learned from each heartbeat. It is the globally stable node identity used

+ 13 - 0
internal/web/controller/node.go

@@ -37,6 +37,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/test", a.test)
 	g.POST("/certFingerprint", a.certFingerprint)
+	g.POST("/inbounds", a.inbounds)
 	g.POST("/probe/:id", a.probe)
 	g.POST("/updatePanel", a.updatePanel)
 	g.GET("/history/:id/:metric/:bucket", a.history)
@@ -160,6 +161,18 @@ func (a *NodeController) setEnable(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
 }
 
+func (a *NodeController) inbounds(c *gin.Context) {
+	n := &model.Node{}
+	if err := c.ShouldBind(n); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
+		return
+	}
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
+	defer cancel()
+	options, err := a.nodeService.GetRemoteInboundOptions(ctx, n)
+	jsonObj(c, options, err)
+}
+
 func (a *NodeController) test(c *gin.Context) {
 	n := &model.Node{}
 	if err := c.ShouldBind(n); err != nil {

+ 2 - 1
internal/web/job/node_traffic_sync_job.go

@@ -239,7 +239,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 
 	if n.ConfigDirty {
 		reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
-		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
+		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
 		reconcileCancel()
 		if reconcileErr != nil {
 			logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
@@ -260,6 +260,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		j.inboundService.ClearNodeOnlineClients(n.Id)
 		return
 	}
+	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	if err != nil {

+ 19 - 0
internal/web/runtime/remote.go

@@ -45,6 +45,13 @@ type Remote struct {
 	remoteIDByTag map[string]int
 }
 
+type RemoteInboundOption struct {
+	Tag      string         `json:"tag"`
+	Remark   string         `json:"remark"`
+	Protocol model.Protocol `json:"protocol"`
+	Port     int            `json:"port"`
+}
+
 func NewRemote(n *model.Node) *Remote {
 	return &Remote{
 		node:          n,
@@ -205,6 +212,18 @@ func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
 	return tags, nil
 }
 
+func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, error) {
+	env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
+	if err != nil {
+		return nil, err
+	}
+	var list []RemoteInboundOption
+	if err := json.Unmarshal(env.Obj, &list); err != nil {
+		return nil, fmt.Errorf("decode inbound list: %w", err)
+	}
+	return list, nil
+}
+
 func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
 	env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
 	if err != nil {

+ 16 - 0
internal/web/service/inbound.go

@@ -559,6 +559,14 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, err
 	}
 
+	// Before the deferred commit, so a node in "selected" sync mode cannot
+	// sweep the new central row in the gap before its tag is allowed.
+	if inbound.NodeID != nil {
+		if aErr := (&NodeService{}).EnsureInboundTagAllowed(*inbound.NodeID, inbound.Tag); aErr != nil {
+			logger.Warning("allow inbound tag on node failed:", aErr)
+		}
+	}
+
 	needRestart := false
 	if inbound.Enable {
 		rt, push, dirty, perr := s.nodePushPlan(inbound)
@@ -949,6 +957,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		}
 	}
 
+	// A rename must allow the new tag before the deferred commit, or a node in
+	// "selected" sync mode would sweep the renamed central row on the next pull.
+	if oldInbound.NodeID != nil {
+		if aErr := (&NodeService{}).EnsureInboundTagAllowed(*oldInbound.NodeID, oldInbound.Tag); aErr != nil {
+			logger.Warning("allow inbound tag on node failed:", aErr)
+		}
+	}
+
 	if err = tx.Save(oldInbound).Error; err != nil {
 		return inbound, false, err
 	}

+ 19 - 2
internal/web/service/inbound_node.go

@@ -76,10 +76,11 @@ func (s *InboundService) AnyNodePending(inboundIds []int) bool {
 	return false
 }
 
-func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, nodeID int) error {
-	if rt == nil || nodeID <= 0 {
+func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, n *model.Node) error {
+	if rt == nil || n == nil || n.Id <= 0 {
 		return nil
 	}
+	nodeID := n.Id
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
@@ -104,10 +105,26 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 			return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
 		}
 	}
+	// In "selected" sync mode the panel only manages the selected tags: the
+	// rest were never imported, so their absence from the local DB must not
+	// delete them from the node. Only a selected tag missing locally (the
+	// panel deleted it while the node was unreachable) may be swept.
+	var selected map[string]struct{}
+	if n.InboundSyncMode == "selected" {
+		selected = make(map[string]struct{}, len(n.InboundTags))
+		for _, tag := range n.InboundTags {
+			selected[tag] = struct{}{}
+		}
+	}
 	for _, tag := range remoteTags {
 		if _, want := desiredTags[tag]; want {
 			continue
 		}
+		if selected != nil {
+			if _, managed := selected[tag]; !managed {
+				continue
+			}
+		}
 		if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
 			return fmt.Errorf("reconcile delete %q: %w", tag, err)
 		}

+ 197 - 0
internal/web/service/inbound_node_reconcile_test.go

@@ -0,0 +1,197 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+// fakeNodePanel serves just enough of the node API for ReconcileNode: the
+// inbound list plus update/del endpoints, recording which remote ids get
+// deleted.
+func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) {
+	t.Helper()
+	var mu sync.Mutex
+	var deleted []int
+	writeOK := func(w http.ResponseWriter, obj any) {
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
+	}
+	mux := http.NewServeMux()
+	mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
+		type row struct {
+			Id  int    `json:"id"`
+			Tag string `json:"tag"`
+		}
+		rows := make([]row, 0, len(tagToID))
+		for tag, id := range tagToID {
+			rows = append(rows, row{Id: id, Tag: tag})
+		}
+		writeOK(w, rows)
+	})
+	mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) {
+		writeOK(w, nil)
+	})
+	mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
+		id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
+		if err != nil {
+			http.Error(w, "bad id", http.StatusBadRequest)
+			return
+		}
+		mu.Lock()
+		deleted = append(deleted, id)
+		mu.Unlock()
+		writeOK(w, nil)
+	})
+	ts := httptest.NewServer(mux)
+	t.Cleanup(ts.Close)
+	return ts, func() []int {
+		mu.Lock()
+		defer mu.Unlock()
+		out := append([]int(nil), deleted...)
+		sort.Ints(out)
+		return out
+	}
+}
+
+func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node {
+	t.Helper()
+	u, err := url.Parse(ts.URL)
+	if err != nil {
+		t.Fatalf("parse test server URL: %v", err)
+	}
+	port, err := strconv.Atoi(u.Port())
+	if err != nil {
+		t.Fatalf("parse test server port: %v", err)
+	}
+	n := &model.Node{
+		Name:                name,
+		Scheme:              "http",
+		Address:             u.Hostname(),
+		Port:                port,
+		BasePath:            "/",
+		ApiToken:            "tok",
+		Enable:              true,
+		AllowPrivateAddress: true,
+		Status:              "online",
+		InboundSyncMode:     mode,
+		InboundTags:         tags,
+	}
+	if err := database.GetDB().Create(n).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	return n
+}
+
+// In "selected" sync mode the panel never imports the unselected inbounds, so
+// reconcile must not treat their absence from the local DB as a deletion: only
+// a *selected* tag missing locally may be swept from the node.
+func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
+	setupConflictDB(t)
+
+	ts, deletedIDs := fakeNodePanel(t, map[string]int{
+		"keep":          1,
+		"selected-gone": 2,
+		"unmanaged":     3,
+	})
+	node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
+	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+
+	svc := InboundService{}
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+		t.Fatalf("ReconcileNode: %v", err)
+	}
+
+	got := deletedIDs()
+	if len(got) != 1 || got[0] != 2 {
+		t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
+	}
+}
+
+// "all" mode keeps the original anti-entropy contract: every remote inbound
+// missing from the local DB is deleted on the node.
+func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
+	setupConflictDB(t)
+
+	ts, deletedIDs := fakeNodePanel(t, map[string]int{
+		"keep":   1,
+		"gone-a": 2,
+		"gone-b": 3,
+	})
+	node := reconcileTestNode(t, ts, "all-node", "all", nil)
+	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+
+	svc := InboundService{}
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+		t.Fatalf("ReconcileNode: %v", err)
+	}
+
+	got := deletedIDs()
+	if len(got) != 2 || got[0] != 2 || got[1] != 3 {
+		t.Fatalf("deleted remote ids = %v, want [2 3]", got)
+	}
+}
+
+func TestEnsureInboundTagAllowed(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	svc := NodeService{}
+
+	selected := &model.Node{
+		Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
+		InboundSyncMode: "selected", InboundTags: []string{"a"},
+	}
+	if err := db.Create(selected).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
+		t.Fatalf("EnsureInboundTagAllowed add: %v", err)
+	}
+	var got model.Node
+	if err := db.First(&got, selected.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
+		t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
+	}
+
+	if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
+		t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
+	}
+	if err := db.First(&got, selected.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if len(got.InboundTags) != 2 {
+		t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
+	}
+
+	all := &model.Node{
+		Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
+		InboundSyncMode: "all",
+	}
+	if err := db.Create(all).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
+		t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
+	}
+	var gotAll model.Node
+	if err := db.First(&gotAll, all.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if len(gotAll.InboundTags) != 0 {
+		t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
+	}
+}

+ 85 - 0
internal/web/service/node.go

@@ -347,6 +347,25 @@ func (s *NodeService) normalize(n *model.Node) error {
 		n.TlsVerifyMode = "verify"
 	}
 	n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
+	if n.InboundSyncMode != "selected" {
+		n.InboundSyncMode = "all"
+		n.InboundTags = nil
+	} else {
+		seen := make(map[string]struct{}, len(n.InboundTags))
+		tags := make([]string, 0, len(n.InboundTags))
+		for _, tag := range n.InboundTags {
+			tag = strings.TrimSpace(tag)
+			if tag == "" {
+				continue
+			}
+			if _, ok := seen[tag]; ok {
+				continue
+			}
+			seen[tag] = struct{}{}
+			tags = append(tags, tag)
+		}
+		n.InboundTags = tags
+	}
 	if n.TlsVerifyMode == "pin" {
 		if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
 			return common.NewError(err.Error())
@@ -368,6 +387,10 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 	if err := s.normalize(in); err != nil {
 		return err
 	}
+	inboundTagsJSON, err := json.Marshal(in.InboundTags)
+	if err != nil {
+		return err
+	}
 	db := database.GetDB()
 	existing := &model.Node{}
 	if err := db.Where("id = ?", id).First(existing).Error; err != nil {
@@ -385,6 +408,8 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"allow_private_address": in.AllowPrivateAddress,
 		"tls_verify_mode":       in.TlsVerifyMode,
 		"pinned_cert_sha256":    in.PinnedCertSha256,
+		"inbound_sync_mode":     in.InboundSyncMode,
+		"inbound_tags":          string(inboundTagsJSON),
 	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
@@ -395,6 +420,66 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 	return nil
 }
 
+func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
+	if err := s.normalize(n); err != nil {
+		return nil, err
+	}
+	return runtime.NewRemote(n).ListInboundOptions(ctx)
+}
+
+// EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
+// selection when the node syncs in "selected" mode. Without it, the next
+// traffic sync would filter the tag out of the snapshot and the orphan sweep
+// would silently delete the central row the panel just created or renamed.
+// Tags are only ever added (never removed): on a rename the node may keep
+// reporting the old tag until the remote update lands, and a leftover entry
+// that matches nothing is harmless.
+func (s *NodeService) EnsureInboundTagAllowed(nodeID int, tag string) error {
+	tag = strings.TrimSpace(tag)
+	if nodeID <= 0 || tag == "" {
+		return nil
+	}
+	db := database.GetDB()
+	node := &model.Node{}
+	if err := db.Where("id = ?", nodeID).First(node).Error; err != nil {
+		return err
+	}
+	if node.InboundSyncMode != "selected" {
+		return nil
+	}
+	for _, t := range node.InboundTags {
+		if t == tag {
+			return nil
+		}
+	}
+	buf, err := json.Marshal(append(node.InboundTags, tag))
+	if err != nil {
+		return err
+	}
+	return db.Model(model.Node{}).Where("id = ?", nodeID).
+		Updates(map[string]any{"inbound_tags": string(buf)}).Error
+}
+
+func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
+	if n == nil || snap == nil || n.InboundSyncMode != "selected" {
+		return
+	}
+	allowed := make(map[string]struct{}, len(n.InboundTags))
+	for _, tag := range n.InboundTags {
+		allowed[tag] = struct{}{}
+	}
+	filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
+	for _, inbound := range snap.Inbounds {
+		if inbound == nil {
+			continue
+		}
+		if _, ok := allowed[inbound.Tag]; ok {
+			filtered = append(filtered, inbound)
+		}
+	}
+	snap.Inbounds = filtered
+}
+
 func (s *NodeService) Delete(id int) error {
 	db := database.GetDB()
 	if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {

+ 52 - 0
internal/web/service/node_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 
 func TestNormalizeBasePath(t *testing.T) {
@@ -160,3 +161,54 @@ func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
 		t.Fatalf("Scheme = %q, want https", n.Scheme)
 	}
 }
+
+func TestNodeService_NormalizeInboundSelection(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{
+		Name:            "n",
+		Address:         "example.com",
+		Port:            443,
+		InboundSyncMode: "selected",
+		InboundTags:     []string{" alpha ", "", "beta", "alpha"},
+	}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.InboundSyncMode != "selected" {
+		t.Fatalf("InboundSyncMode = %q, want selected", n.InboundSyncMode)
+	}
+	if len(n.InboundTags) != 2 || n.InboundTags[0] != "alpha" || n.InboundTags[1] != "beta" {
+		t.Fatalf("InboundTags = %#v, want [alpha beta]", n.InboundTags)
+	}
+}
+
+func TestFilterNodeSnapshot(t *testing.T) {
+	snapshot := func() *runtime.TrafficSnapshot {
+		return &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
+			{Tag: "alpha"},
+			{Tag: "beta"},
+			{Tag: "gamma"},
+		}}
+	}
+
+	all := snapshot()
+	FilterNodeSnapshot(&model.Node{InboundSyncMode: "all"}, all)
+	if len(all.Inbounds) != 3 {
+		t.Fatalf("all mode kept %d inbounds, want 3", len(all.Inbounds))
+	}
+
+	selected := snapshot()
+	FilterNodeSnapshot(&model.Node{
+		InboundSyncMode: "selected",
+		InboundTags:     []string{"beta"},
+	}, selected)
+	if len(selected.Inbounds) != 1 || selected.Inbounds[0].Tag != "beta" {
+		t.Fatalf("selected mode produced %#v, want only beta", selected.Inbounds)
+	}
+
+	none := snapshot()
+	FilterNodeSnapshot(&model.Node{InboundSyncMode: "selected"}, none)
+	if len(none.Inbounds) != 0 {
+		t.Fatalf("empty selection kept %d inbounds, want 0", len(none.Inbounds))
+	}
+}

+ 10 - 0
internal/web/translation/ar-EG.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
       "allowPrivateAddress": "السماح بالعنوان الخاص",
       "allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
+      "inboundSyncMode": "استيراد الاتصالات الواردة",
+      "inboundSyncModeHint": "اختر الاتصالات الواردة التي سيتم استيرادها من هذه العقدة. تستورد العقد الحالية جميع الاتصالات افتراضيًا.",
+      "allInbounds": "جميع الاتصالات الواردة",
+      "selectedInbounds": "الاتصالات الواردة المحددة",
+      "inboundTags": "الاتصالات الواردة",
+      "inboundTagsHint": "تتم المطابقة حسب وسم الاتصال الوارد. القائمة الفارغة لا تستورد أي اتصال.",
+      "inboundTagsPlaceholder": "حمّل الاتصالات الواردة وحددها",
+      "loadInbounds": "تحميل الاتصالات الواردة من العقدة",
+      "inboundsLoaded": "تم تحميل {{count}} اتصال وارد",
+      "inboundsLoadFailed": "فشل تحميل الاتصالات الواردة",
       "enable": "مفعل",
       "status": "الحالة",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/en-US.json

@@ -861,6 +861,16 @@
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
       "allowPrivateAddress": "Allow private address",
       "allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
+      "inboundSyncMode": "Inbound import",
+      "inboundSyncModeHint": "Choose which inbounds are imported from this node. Existing nodes default to all inbounds.",
+      "allInbounds": "All inbounds",
+      "selectedInbounds": "Selected inbounds",
+      "inboundTags": "Inbounds",
+      "inboundTagsHint": "Selection is matched by the inbound tag. An empty selection imports none.",
+      "inboundTagsPlaceholder": "Load and select inbounds",
+      "loadInbounds": "Load inbounds from node",
+      "inboundsLoaded": "Loaded {{count}} inbounds",
+      "inboundsLoadFailed": "Failed to load inbounds",
       "enable": "Enabled",
       "status": "Status",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/es-ES.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
       "allowPrivateAddress": "Permitir dirección privada",
       "allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
+      "inboundSyncMode": "Importación de inbounds",
+      "inboundSyncModeHint": "Elige qué inbounds importar desde este nodo. Los nodos existentes importan todos de forma predeterminada.",
+      "allInbounds": "Todos los inbounds",
+      "selectedInbounds": "Inbounds seleccionados",
+      "inboundTags": "Inbounds",
+      "inboundTagsHint": "La selección se compara por la etiqueta del inbound. Una selección vacía no importa ninguno.",
+      "inboundTagsPlaceholder": "Carga y selecciona inbounds",
+      "loadInbounds": "Cargar inbounds desde el nodo",
+      "inboundsLoaded": "Se cargaron {{count}} inbounds",
+      "inboundsLoadFailed": "No se pudieron cargar los inbounds",
       "enable": "Habilitado",
       "status": "Estado",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/fa-IR.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
       "allowPrivateAddress": "اجازه آدرس خصوصی",
       "allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
+      "inboundSyncMode": "وارد کردن اینباندها",
+      "inboundSyncModeHint": "اینباندهای قابل وارد کردن از این نود را انتخاب کنید. نودهای موجود به‌طور پیش‌فرض همه را وارد می‌کنند.",
+      "allInbounds": "همه اینباندها",
+      "selectedInbounds": "اینباندهای انتخاب‌شده",
+      "inboundTags": "اینباندها",
+      "inboundTagsHint": "انتخاب بر اساس تگ اینباند تطبیق داده می‌شود. انتخاب خالی چیزی وارد نمی‌کند.",
+      "inboundTagsPlaceholder": "اینباندها را بارگیری و انتخاب کنید",
+      "loadInbounds": "بارگیری اینباندها از نود",
+      "inboundsLoaded": "{{count}} اینباند بارگیری شد",
+      "inboundsLoadFailed": "بارگیری اینباندها ناموفق بود",
       "enable": "فعال",
       "status": "وضعیت",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/id-ID.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
       "allowPrivateAddress": "Izinkan alamat pribadi",
       "allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
+      "inboundSyncMode": "Impor inbound",
+      "inboundSyncModeHint": "Pilih inbound yang diimpor dari node ini. Node yang sudah ada mengimpor semua inbound secara default.",
+      "allInbounds": "Semua inbound",
+      "selectedInbounds": "Inbound terpilih",
+      "inboundTags": "Inbound",
+      "inboundTagsHint": "Pilihan dicocokkan berdasarkan tag inbound. Pilihan kosong tidak mengimpor apa pun.",
+      "inboundTagsPlaceholder": "Muat dan pilih inbound",
+      "loadInbounds": "Muat inbound dari node",
+      "inboundsLoaded": "{{count}} inbound dimuat",
+      "inboundsLoadFailed": "Gagal memuat inbound",
       "enable": "Aktif",
       "status": "Status",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/ja-JP.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
       "allowPrivateAddress": "プライベートアドレスを許可",
       "allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
+      "inboundSyncMode": "インバウンドのインポート",
+      "inboundSyncModeHint": "このノードからインポートするインバウンドを選択します。既存のノードは既定ですべてをインポートします。",
+      "allInbounds": "すべてのインバウンド",
+      "selectedInbounds": "選択したインバウンド",
+      "inboundTags": "インバウンド",
+      "inboundTagsHint": "インバウンドタグで照合します。何も選択しない場合はインポートされません。",
+      "inboundTagsPlaceholder": "インバウンドを読み込んで選択",
+      "loadInbounds": "ノードからインバウンドを読み込む",
+      "inboundsLoaded": "{{count}}件のインバウンドを読み込みました",
+      "inboundsLoadFailed": "インバウンドを読み込めませんでした",
       "enable": "有効",
       "status": "ステータス",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/pt-BR.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
       "allowPrivateAddress": "Permitir endereço privado",
       "allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
+      "inboundSyncMode": "Importação de inbounds",
+      "inboundSyncModeHint": "Escolha quais inbounds importar deste nó. Nós existentes importam todos por padrão.",
+      "allInbounds": "Todos os inbounds",
+      "selectedInbounds": "Inbounds selecionados",
+      "inboundTags": "Inbounds",
+      "inboundTagsHint": "A seleção é comparada pela tag do inbound. Uma seleção vazia não importa nenhum.",
+      "inboundTagsPlaceholder": "Carregue e selecione inbounds",
+      "loadInbounds": "Carregar inbounds do nó",
+      "inboundsLoaded": "{{count}} inbounds carregados",
+      "inboundsLoadFailed": "Falha ao carregar inbounds",
       "enable": "Ativado",
       "status": "Status",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/ru-RU.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
       "allowPrivateAddress": "Разрешить частный адрес",
       "allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
+      "inboundSyncMode": "Импорт инбаундов",
+      "inboundSyncModeHint": "Выберите, какие инбаунды импортировать с этой ноды. Для существующих нод по умолчанию импортируются все.",
+      "allInbounds": "Все инбаунды",
+      "selectedInbounds": "Выбранные инбаунды",
+      "inboundTags": "Инбаунды",
+      "inboundTagsHint": "Выбор сопоставляется по тегу инбаунда. Пустой список не импортирует ничего.",
+      "inboundTagsPlaceholder": "Загрузите и выберите инбаунды",
+      "loadInbounds": "Загрузить инбаунды с ноды",
+      "inboundsLoaded": "Загружено инбаундов: {{count}}",
+      "inboundsLoadFailed": "Не удалось загрузить инбаунды",
       "enable": "Включён",
       "status": "Статус",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/tr-TR.json

@@ -861,6 +861,16 @@
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
       "allowPrivateAddress": "Özel Adrese İzin Ver",
       "allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştirin.",
+      "inboundSyncMode": "Inbound içe aktarma",
+      "inboundSyncModeHint": "Bu düğümden içe aktarılacak inbound'ları seçin. Mevcut düğümler varsayılan olarak tümünü içe aktarır.",
+      "allInbounds": "Tüm inbound'lar",
+      "selectedInbounds": "Seçili inbound'lar",
+      "inboundTags": "Inbound'lar",
+      "inboundTagsHint": "Seçim inbound etiketiyle eşleştirilir. Boş seçim hiçbir şeyi içe aktarmaz.",
+      "inboundTagsPlaceholder": "Inbound'ları yükleyip seçin",
+      "loadInbounds": "Inbound'ları düğümden yükle",
+      "inboundsLoaded": "{{count}} inbound yüklendi",
+      "inboundsLoadFailed": "Inbound'lar yüklenemedi",
       "enable": "Etkin",
       "status": "Durum",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/uk-UA.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
       "allowPrivateAddress": "Дозволити приватну адресу",
       "allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
+      "inboundSyncMode": "Імпорт інбаундів",
+      "inboundSyncModeHint": "Виберіть інбаунди для імпорту з цього вузла. Для наявних вузлів типово імпортуються всі.",
+      "allInbounds": "Усі інбаунди",
+      "selectedInbounds": "Вибрані інбаунди",
+      "inboundTags": "Інбаунди",
+      "inboundTagsHint": "Вибір зіставляється за тегом інбаунду. Порожній список нічого не імпортує.",
+      "inboundTagsPlaceholder": "Завантажте та виберіть інбаунди",
+      "loadInbounds": "Завантажити інбаунди з вузла",
+      "inboundsLoaded": "Завантажено інбаундів: {{count}}",
+      "inboundsLoadFailed": "Не вдалося завантажити інбаунди",
       "enable": "Увімкнено",
       "status": "Статус",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/vi-VN.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
       "allowPrivateAddress": "Cho phép địa chỉ riêng",
       "allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
+      "inboundSyncMode": "Nhập inbound",
+      "inboundSyncModeHint": "Chọn các inbound được nhập từ nút này. Các nút hiện có mặc định nhập tất cả.",
+      "allInbounds": "Tất cả inbound",
+      "selectedInbounds": "Inbound đã chọn",
+      "inboundTags": "Inbound",
+      "inboundTagsHint": "Lựa chọn được đối chiếu theo tag inbound. Lựa chọn trống sẽ không nhập gì.",
+      "inboundTagsPlaceholder": "Tải và chọn inbound",
+      "loadInbounds": "Tải inbound từ nút",
+      "inboundsLoaded": "Đã tải {{count}} inbound",
+      "inboundsLoadFailed": "Không thể tải inbound",
       "enable": "Kích hoạt",
       "status": "Trạng thái",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/zh-CN.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "allowPrivateAddress": "允许私有地址",
       "allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
+      "inboundSyncMode": "入站导入",
+      "inboundSyncModeHint": "选择要从此节点导入的入站。现有节点默认导入全部入站。",
+      "allInbounds": "全部入站",
+      "selectedInbounds": "选定的入站",
+      "inboundTags": "入站",
+      "inboundTagsHint": "按入站标签匹配。空选择不会导入任何入站。",
+      "inboundTagsPlaceholder": "加载并选择入站",
+      "loadInbounds": "从节点加载入站",
+      "inboundsLoaded": "已加载 {{count}} 个入站",
+      "inboundsLoadFailed": "加载入站失败",
       "enable": "已启用",
       "status": "状态",
       "cpu": "CPU",

+ 10 - 0
internal/web/translation/zh-TW.json

@@ -860,6 +860,16 @@
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "allowPrivateAddress": "允許私有地址",
       "allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
+      "inboundSyncMode": "入站匯入",
+      "inboundSyncModeHint": "選擇要從此節點匯入的入站。現有節點預設匯入所有入站。",
+      "allInbounds": "所有入站",
+      "selectedInbounds": "選取的入站",
+      "inboundTags": "入站",
+      "inboundTagsHint": "依入站標籤比對。空白選取不會匯入任何入站。",
+      "inboundTagsPlaceholder": "載入並選取入站",
+      "loadInbounds": "從節點載入入站",
+      "inboundsLoaded": "已載入 {{count}} 個入站",
+      "inboundsLoadFailed": "載入入站失敗",
       "enable": "已啟用",
       "status": "狀態",
       "cpu": "CPU",