20 Commity 3c5e9fa774 ... 3046d96145

Autor SHA1 Wiadomość Data
  MHSanaei 3046d96145 refactor(inbound-tag): add short protocol segment, rename tcpudp suffix 2 godzin temu
  MHSanaei 7ade9d9a1f refactor(inbound-tag): node-prefixed + transport-suffixed canonical shape 2 godzin temu
  MHSanaei d347605233 fix(remote-traffic): handle tag collisions + readable warning format 3 godzin temu
  MHSanaei 76043fe306 docs(api): document POST /panel/api/inbounds/:id/delAllClients 3 godzin temu
  MHSanaei be5425cbed refactor(sparkline): move min/max readout to a corner badge 3 godzin temu
  MHSanaei e23599cb18 feat(inbounds): row action to delete all clients of an inbound 3 godzin temu
  MHSanaei 93eda06878 feat(clients,groups): client groups + sub-links export + dedicated groups page 4 godzin temu
  MHSanaei 7680e27d1d feat(clients): toolbar sort selector + preserve updated_at on unchanged rows 6 godzin temu
  MHSanaei 6286bb8676 chore(ui): polish empty states + sidebar icon + i18n page titles 6 godzin temu
  MHSanaei 2bba1d21d2 refactor(metrics-modal): mark min/max on chart + improve grid contrast 6 godzin temu
  MHSanaei f1e433e839 feat(clients,inbound): Auto Renew in Bulk Add + cleaner inbound wire payload 8 godzin temu
  MHSanaei 43288e6686 refactor(forms): modernize random buttons in client + outbound modals 8 godzin temu
  MHSanaei 9d2a4f217e feat(inbound-form): salamander auto-seed for Hysteria + modernize random buttons 8 godzin temu
  MHSanaei 222e000b3b feat(inbound-form): seed FinalMask with mkcp-original when KCP is selected 8 godzin temu
  MHSanaei 0296b2abd0 docs(port-conflict): refresh stale comments after the refactor 9 godzin temu
  MHSanaei 980511bcad feat(port-conflict): include offending inbound + L4 in the error, cover quic and tunnel.allowedNetwork 9 godzin temu
  MHSanaei 96a5c73e02 refactor(inbounds): cleaner network tags and cover Mixed/Tunnel + client form select polish 9 godzin temu
  MHSanaei 3675f88caf feat(clients): advanced filter drawer with multi-select state/protocol/inbound + expiry/usage ranges + auto-renew/tg/comment 9 godzin temu
  MHSanaei 5eb80eca8e fix(clients): avoid duplicate ClientRecord when email is changed on edit 10 godzin temu
  MHSanaei 313d041db3 feat(clients): restore Auto Renew field in client form 10 godzin temu
58 zmienionych plików z 4607 dodań i 1171 usunięć
  1. 1 0
      database/db.go
  2. 19 0
      database/model/model.go
  3. 392 0
      frontend/public/openapi.json
  4. 1 0
      frontend/src/api/queryKeys.ts
  5. 41 18
      frontend/src/components/AppSidebar.css
  6. 33 8
      frontend/src/components/AppSidebar.tsx
  7. 13 2
      frontend/src/components/FinalMaskForm.tsx
  8. 30 0
      frontend/src/components/Sparkline.css
  9. 161 57
      frontend/src/components/Sparkline.tsx
  10. 41 3
      frontend/src/hooks/useClients.ts
  11. 14 10
      frontend/src/hooks/usePageTitle.ts
  12. 20 3
      frontend/src/lib/xray/inbound-form-adapter.ts
  13. 4 0
      frontend/src/models/dbinbound.ts
  14. 59 0
      frontend/src/pages/api-docs/endpoints.ts
  15. 83 0
      frontend/src/pages/clients/BulkAssignGroupModal.tsx
  16. 43 10
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  17. 69 31
      frontend/src/pages/clients/ClientFormModal.tsx
  18. 17 2
      frontend/src/pages/clients/ClientsPage.css
  19. 432 246
      frontend/src/pages/clients/ClientsPage.tsx
  20. 244 0
      frontend/src/pages/clients/FilterDrawer.tsx
  21. 193 0
      frontend/src/pages/clients/SubLinksModal.tsx
  22. 39 0
      frontend/src/pages/clients/filters.ts
  23. 528 0
      frontend/src/pages/groups/GroupsPage.tsx
  24. 535 514
      frontend/src/pages/inbounds/InboundFormModal.tsx
  25. 6 2
      frontend/src/pages/inbounds/InboundList.css
  26. 112 17
      frontend/src/pages/inbounds/InboundList.tsx
  27. 20 1
      frontend/src/pages/inbounds/InboundsPage.tsx
  28. 7 9
      frontend/src/pages/index/SystemHistoryModal.css
  29. 45 10
      frontend/src/pages/index/SystemHistoryModal.tsx
  30. 0 4
      frontend/src/pages/index/XrayMetricsModal.css
  31. 46 10
      frontend/src/pages/index/XrayMetricsModal.tsx
  32. 6 2
      frontend/src/pages/nodes/NodeList.css
  33. 13 1
      frontend/src/pages/nodes/NodeList.tsx
  34. 37 40
      frontend/src/pages/xray/OutboundFormModal.tsx
  35. 2 0
      frontend/src/routes.tsx
  36. 14 0
      frontend/src/schemas/client.ts
  37. 7 0
      frontend/src/styles/page-cards.css
  38. 14 4
      frontend/src/styles/page-shell.css
  39. 0 6
      frontend/src/styles/utils.css
  40. 127 0
      web/controller/client.go
  41. 36 19
      web/controller/inbound.go
  42. 577 22
      web/service/client.go
  43. 84 15
      web/service/inbound.go
  44. 144 60
      web/service/port_conflict.go
  45. 192 43
      web/service/port_conflict_test.go
  46. 3 0
      web/translation/ar-EG.json
  47. 70 2
      web/translation/en-US.json
  48. 3 0
      web/translation/es-ES.json
  49. 3 0
      web/translation/fa-IR.json
  50. 3 0
      web/translation/id-ID.json
  51. 3 0
      web/translation/ja-JP.json
  52. 3 0
      web/translation/pt-BR.json
  53. 3 0
      web/translation/ru-RU.json
  54. 3 0
      web/translation/tr-TR.json
  55. 3 0
      web/translation/uk-UA.json
  56. 3 0
      web/translation/vi-VN.json
  57. 3 0
      web/translation/zh-CN.json
  58. 3 0
      web/translation/zh-TW.json

+ 1 - 0
database/db.go

@@ -68,6 +68,7 @@ func initModels() error {
 		&model.ApiToken{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientGroup{},
 		&model.InboundFallback{},
 	}
 	for _, mdl := range models {

+ 19 - 0
database/model/model.go

@@ -371,6 +371,7 @@ type Client struct {
 	Enable     bool           `json:"enable" form:"enable"`         // Whether the client is enabled
 	TgID       int64          `json:"tgId" form:"tgId"`             // Telegram user ID for notifications
 	SubID      string         `json:"subId" form:"subId"`           // Subscription identifier
+	Group      string         `json:"group,omitempty" form:"group"` // Logical grouping label
 	Comment    string         `json:"comment" form:"comment"`       // Client comment
 	Reset      int            `json:"reset" form:"reset"`           // Reset period in days
 	CreatedAt  int64          `json:"created_at,omitempty"`         // Creation timestamp
@@ -392,6 +393,7 @@ type ClientRecord struct {
 	ExpiryTime int64  `json:"expiryTime" gorm:"column:expiry_time"`
 	Enable     bool   `json:"enable" gorm:"default:true"`
 	TgID       int64  `json:"tgId" gorm:"column:tg_id"`
+	Group      string `json:"group" gorm:"column:group_name;default:''"`
 	Comment    string `json:"comment"`
 	Reset      int    `json:"reset" gorm:"default:0"`
 	CreatedAt  int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
@@ -400,6 +402,15 @@ type ClientRecord struct {
 
 func (ClientRecord) TableName() string { return "clients" }
 
+type ClientGroup struct {
+	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Name      string `json:"name" gorm:"uniqueIndex;not null"`
+	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	UpdatedAt int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
+}
+
+func (ClientGroup) TableName() string { return "client_groups" }
+
 // MarshalJSON emits the reverse column as a nested JSON object rather than an
 // escaped JSON-text string, matching the same convention Inbound uses for its
 // JSON-text columns. Empty storage renders as null.
@@ -472,6 +483,7 @@ func (c *Client) ToRecord() *ClientRecord {
 		ExpiryTime: c.ExpiryTime,
 		Enable:     c.Enable,
 		TgID:       c.TgID,
+		Group:      c.Group,
 		Comment:    c.Comment,
 		Reset:      c.Reset,
 		CreatedAt:  c.CreatedAt,
@@ -499,6 +511,7 @@ func (r *ClientRecord) ToClient() *Client {
 		ExpiryTime: r.ExpiryTime,
 		Enable:     r.Enable,
 		TgID:       r.TgID,
+		Group:      r.Group,
 		Comment:    r.Comment,
 		Reset:      r.Reset,
 		CreatedAt:  r.CreatedAt,
@@ -623,6 +636,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.Comment = incoming.Comment
 		}
 	}
+	if existing.Group != incoming.Group && incoming.Group != "" {
+		if incomingNewer || existing.Group == "" {
+			keep("group", existing.Group, incoming.Group, incoming.Group)
+			existing.Group = incoming.Group
+		}
+	}
 	if existing.Enable != incoming.Enable {
 		if incoming.Enable {
 			if !existing.Enable {

+ 392 - 0
frontend/public/openapi.json

@@ -751,6 +751,53 @@
         }
       }
     },
+    "/panel/api/inbounds/{id}/delAllClients": {
+      "post": {
+        "tags": [
+          "Inbounds"
+        ],
+        "summary": "Remove every client attached to a single inbound while keeping the inbound itself. Collects emails from settings.clients[] and feeds them into the optimized bulk-delete path (runtime user removal + traffic-row cleanup + SyncInbound). Destructive and cannot be undone.",
+        "operationId": "post_panel_api_inbounds_id_delAllClients",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Inbound ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "deleted": 12
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/inbounds/resetAllTraffics": {
       "post": {
         "tags": [
@@ -2805,6 +2852,351 @@
         }
       }
     },
+    "/panel/api/clients/bulkAssignGroup": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Assign the given group label to many clients in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound's settings JSON in a single transaction. Pass an empty group to clear the label. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group.",
+        "operationId": "post_panel_api_clients_bulkAssignGroup",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ],
+                "group": "customer-a"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "affected": 2
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/bulkResetTraffic": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
+        "operationId": "post_panel_api_clients_bulkResetTraffic",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "affected": 2
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/groups": {
+      "get": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "List all client groups with their member counts. Merges persisted groups (rows in client_groups, including empty placeholders) with the distinct group_name values currently set on clients. Sorted alphabetically (case-insensitive).",
+        "operationId": "get_panel_api_clients_groups",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "name": "customer-a",
+                      "clientCount": 5
+                    },
+                    {
+                      "name": "internal",
+                      "clientCount": 0
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/groups/{name}/emails": {
+      "get": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Return just the email list of clients that currently belong to the given group. Useful for fanning a single bulk action over an entire group without round-tripping the full client list.",
+        "operationId": "get_panel_api_clients_groups_name_emails",
+        "parameters": [
+          {
+            "name": "name",
+            "in": "path",
+            "required": true,
+            "description": "Group name (URL-encoded).",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    "alice",
+                    "bob",
+                    "carol"
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/groups/create": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is assigned to it. Errors if a group with the same name already exists.",
+        "operationId": "post_panel_api_clients_groups_create",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "name": "customer-a"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "name": "customer-a"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/groups/rename": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Rename a group. The new name is applied to the client_groups row AND propagated to every matching client (both clients.group_name and the client entry inside every owning inbound's settings JSON) in a single transaction. Returns the number of clients whose label was updated.",
+        "operationId": "post_panel_api_clients_groups_rename",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "oldName": "customer-a",
+                "newName": "tier-1"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "affected": 5
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/groups/delete": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Remove a group. Deletes the client_groups row and clears the group label from every matching client (both clients.group_name and the inbound settings JSON). The clients themselves are NOT deleted — use /bulkDel after filtering by group for that. Returns the count of clients whose label was cleared.",
+        "operationId": "post_panel_api_clients_groups_delete",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "name": "customer-a"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "affected": 5
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/resetTraffic/{email}": {
       "post": {
         "tags": [

+ 1 - 0
frontend/src/api/queryKeys.ts

@@ -21,6 +21,7 @@ export const keys = {
     list: (params: unknown) => ['clients', 'list', params] as const,
     onlines: () => ['clients', 'onlines'] as const,
     lastOnline: () => ['clients', 'lastOnline'] as const,
+    groups: () => ['clients', 'groups'] as const,
   },
   xray: {
     root: () => ['xray'] as const,

+ 41 - 18
frontend/src/components/AppSidebar.css

@@ -18,7 +18,7 @@
   align-items: center;
   justify-content: space-between;
   gap: 8px;
-  padding: 14px 14px;
+  padding: 14px 16px 14px 24px;
   border-bottom: 1px solid var(--ant-color-border-secondary);
   user-select: none;
 }
@@ -32,29 +32,12 @@
 
 .brand-block {
   display: inline-flex;
-  flex-direction: column;
   align-items: center;
   min-width: 0;
   line-height: 1.1;
 }
 
-.brand-text {
-  display: block;
-}
-
-.brand-version {
-  display: block;
-  width: 100%;
-  text-align: center;
-  font-size: 10px;
-  font-weight: 500;
-  letter-spacing: 0;
-  opacity: 0.6;
-  margin-top: 2px;
-}
-
 .sider-brand-collapsed .brand-block {
-  align-items: center;
   flex: 0 0 auto;
 }
 
@@ -206,6 +189,46 @@
   border-top: 1px solid var(--ant-color-border-secondary);
 }
 
+.sider-footer {
+  flex: 0 0 auto;
+  padding: 8px 8px 12px;
+}
+
+.sider-version {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 10px;
+  width: 100%;
+  padding: 8px 16px;
+  color: var(--ant-color-text-secondary);
+  font-size: 13px;
+  font-weight: 500;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  text-decoration: none;
+  transition: color 0.2s;
+}
+
+.sider-version .anticon {
+  font-size: 16px;
+}
+
+.sider-version:hover,
+.sider-version:focus-visible {
+  color: var(--ant-color-primary);
+  outline: none;
+}
+
+.sider-version.is-collapsed {
+  justify-content: center;
+  padding: 8px 0;
+}
+
+.drawer-footer {
+  flex: 0 0 auto;
+  padding: 8px 8px 12px;
+}
+
 @media (max-width: 768px) {
   .drawer-handle {
     display: inline-flex;

+ 33 - 8
frontend/src/components/AppSidebar.tsx

@@ -9,16 +9,18 @@ import {
   ClusterOutlined,
   CloseOutlined,
   DashboardOutlined,
+  GithubOutlined,
   HeartOutlined,
+  ImportOutlined,
   LogoutOutlined,
   MenuOutlined,
   MoonFilled,
   MoonOutlined,
   SettingOutlined,
   SunOutlined,
+  TagsOutlined,
   TeamOutlined,
   ToolOutlined,
-  UserOutlined,
 } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
@@ -27,14 +29,16 @@ import './AppSidebar.css';
 
 const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
 const DONATE_URL = 'https://donate.sanaei.dev/';
+const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
 
-type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
+type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
 
 const iconByName: Record<IconName, ComponentType> = {
   dashboard: DashboardOutlined,
-  user: UserOutlined,
+  inbound: ImportOutlined,
   team: TeamOutlined,
+  groups: TagsOutlined,
   setting: SettingOutlined,
   tool: ToolOutlined,
   cluster: ClusterOutlined,
@@ -65,6 +69,24 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
   );
 }
 
+function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
+  if (!version) return null;
+  const label = `v${version}`;
+  return (
+    <a
+      href={REPO_URL}
+      target="_blank"
+      rel="noopener noreferrer"
+      className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
+      aria-label={`GitHub ${label}`}
+      title={label}
+    >
+      <GithubOutlined />
+      {!collapsed && <span className="sider-version-text">{label}</span>}
+    </a>
+  );
+}
+
 function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
   id: string;
   isDark: boolean;
@@ -101,8 +123,9 @@ export default function AppSidebar() {
 
   const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
     { key: '/', icon: 'dashboard', title: t('menu.dashboard') },
-    { key: '/inbounds', icon: 'user', title: t('menu.inbounds') },
+    { key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
     { key: '/clients', icon: 'team', title: t('menu.clients') },
+    { key: '/groups', icon: 'groups', title: t('menu.groups') },
     { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
     { key: '/settings', icon: 'setting', title: t('menu.settings') },
     { key: '/xray', icon: 'tool', title: t('menu.xray') },
@@ -171,9 +194,6 @@ export default function AppSidebar() {
         <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
           <div className="brand-block">
             <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
-            {!collapsed && panelVersion && (
-              <span className="brand-version">v{panelVersion}</span>
-            )}
           </div>
           {!collapsed && (
             <div className="brand-actions">
@@ -204,6 +224,9 @@ export default function AppSidebar() {
           items={toMenuItems(utilItems)}
           onClick={onMenuClick}
         />
+        <div className="sider-footer">
+          <VersionBadge version={panelVersion} collapsed={collapsed} />
+        </div>
       </Layout.Sider>
 
       <Drawer
@@ -222,7 +245,6 @@ export default function AppSidebar() {
         <div className="drawer-header">
           <div className="brand-block">
             <span className="drawer-brand">3X-UI</span>
-            {panelVersion && <span className="brand-version">v{panelVersion}</span>}
           </div>
           <div className="drawer-header-actions">
             <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -259,6 +281,9 @@ export default function AppSidebar() {
           items={toMenuItems(utilItems)}
           onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
         />
+        <div className="drawer-footer">
+          <VersionBadge version={panelVersion} />
+        </div>
       </Drawer>
 
       {!drawerOpen && (

+ 13 - 2
frontend/src/components/FinalMaskForm.tsx

@@ -424,8 +424,19 @@ function UdpMaskItem({
           const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
           if (type === 'mkcp-aes128gcm' || type === 'salamander') {
             return (
-              <Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
-                <Input placeholder="Obfuscation password" />
+              <Form.Item label="Password">
+                <Space.Compact block>
+                  <Form.Item name={[fieldName, 'settings', 'password']} noStyle>
+                    <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
+                  </Form.Item>
+                  <Button
+                    icon={<ReloadOutlined />}
+                    onClick={() => form.setFieldValue(
+                      [...absolutePath, 'settings', 'password'],
+                      RandomUtil.randomLowerAndNum(16),
+                    )}
+                  />
+                </Space.Compact>
               </Form.Item>
             );
           }

+ 30 - 0
frontend/src/components/Sparkline.css

@@ -2,3 +2,33 @@
   display: block;
   width: 100%;
 }
+
+.sparkline-container {
+  position: relative;
+  width: 100%;
+}
+
+.sparkline-extrema {
+  position: absolute;
+  top: 2px;
+  right: 8px;
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+  padding: 2px 8px;
+  background: color-mix(in srgb, var(--ant-color-bg-elevated) 88%, transparent);
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 16px;
+  pointer-events: none;
+  z-index: 1;
+}
+
+.sparkline-extrema .extrema-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  white-space: nowrap;
+}

+ 161 - 57
frontend/src/components/Sparkline.tsx

@@ -3,6 +3,8 @@ import {
   Area,
   AreaChart,
   CartesianGrid,
+  ReferenceDot,
+  ReferenceLine,
   ResponsiveContainer,
   Tooltip,
   XAxis,
@@ -10,6 +12,23 @@ import {
 } from 'recharts';
 import './Sparkline.css';
 
+export interface SparklineReferenceLine {
+  y: number;
+  label?: string;
+  color?: string;
+  dash?: string;
+}
+
+export interface SparklineExtrema {
+  show?: boolean;
+  formatter?: (v: number) => string;
+  minColor?: string;
+  maxColor?: string;
+}
+
+const DEFAULT_MIN_COLOR = '#52c41a';
+const DEFAULT_MAX_COLOR = '#fa541c';
+
 interface SparklineProps {
   data: number[];
   labels?: (string | number)[];
@@ -29,6 +48,9 @@ interface SparklineProps {
   valueMax?: number | null;
   yFormatter?: (v: number) => string;
   tooltipFormatter?: ((v: number) => string) | null;
+  tooltipLabelFormatter?: ((label: string) => string) | null;
+  referenceLines?: SparklineReferenceLine[];
+  extrema?: SparklineExtrema;
 }
 
 interface ChartPoint {
@@ -56,6 +78,9 @@ export default function Sparkline({
   valueMax = 100,
   yFormatter = (v: number) => `${Math.round(v)}%`,
   tooltipFormatter = null,
+  tooltipLabelFormatter = null,
+  referenceLines,
+  extrema,
 }: SparklineProps) {
   const reactId = useId();
   const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
@@ -103,64 +128,143 @@ export default function Sparkline({
 
   const fmtTooltip = tooltipFormatter ?? yFormatter;
 
+  const extremaPoints = useMemo(() => {
+    if (!extrema?.show || points.length < 2) return null;
+    let minIdx = 0;
+    let maxIdx = 0;
+    for (let i = 1; i < points.length; i++) {
+      if (points[i].value < points[minIdx].value) minIdx = i;
+      if (points[i].value > points[maxIdx].value) maxIdx = i;
+    }
+    if (minIdx === maxIdx) return null;
+    return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx };
+  }, [points, extrema?.show]);
+
+  const fmtExtrema = extrema?.formatter ?? yFormatter;
+  const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
+  const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
+
   return (
-    <ResponsiveContainer width="100%" height={height} className="sparkline-svg">
-      <AreaChart data={points} margin={{ top: 6, right: 6, bottom: showAxes ? 14 : 4, left: showAxes ? 4 : 4 }}>
-        <defs>
-          <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
-            <stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
-            <stop offset="100%" stopColor={stroke} stopOpacity={0} />
-          </linearGradient>
-        </defs>
-        {showGrid && (
-          <CartesianGrid stroke="var(--ant-color-border-secondary)" strokeDasharray="2 4" vertical={false} />
-        )}
-        <XAxis
-          dataKey="label"
-          hide={!showAxes}
-          tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
-          axisLine={false}
-          tickLine={false}
-          interval={0}
-          ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
-        />
-        <YAxis
-          domain={yDomain}
-          hide={!showAxes}
-          tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
-          axisLine={false}
-          tickLine={false}
-          tickFormatter={yFormatter}
-          ticks={yTicks}
-          width={48}
-        />
-        {showTooltip && (
-          <Tooltip
-            cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
-            contentStyle={{
-              background: 'var(--ant-color-bg-elevated)',
-              border: '1px solid var(--ant-color-border-secondary)',
-              borderRadius: 4,
-              fontSize: 12,
-              padding: '4px 8px',
-            }}
-            labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 2 }}
-            itemStyle={{ color: 'var(--ant-color-text)', padding: 0 }}
-            formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
-            separator=""
+    <div className="sparkline-container">
+      {extremaPoints && (
+        <div className="sparkline-extrema" aria-hidden="true">
+          <span className="extrema-item" style={{ color: maxColor }}>
+            ▲ {fmtExtrema(extremaPoints.max.value)}
+          </span>
+          <span className="extrema-item" style={{ color: minColor }}>
+            ▼ {fmtExtrema(extremaPoints.min.value)}
+          </span>
+        </div>
+      )}
+      <ResponsiveContainer width="100%" height={height} className="sparkline-svg">
+        <AreaChart
+          data={points}
+          margin={{
+            top: showAxes ? 14 : 6,
+            right: showAxes ? 12 : 6,
+            bottom: showAxes ? 26 : 4,
+            left: 4,
+          }}
+        >
+          <defs>
+            <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
+              <stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
+              <stop offset="100%" stopColor={stroke} stopOpacity={0} />
+            </linearGradient>
+          </defs>
+          {showGrid && (
+            <CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
+          )}
+          <XAxis
+            dataKey="label"
+            hide={!showAxes}
+            tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
+            axisLine={false}
+            tickLine={false}
+            tickMargin={14}
+            interval={0}
+            ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
+          />
+          <YAxis
+            domain={yDomain}
+            hide={!showAxes}
+            tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
+            axisLine={false}
+            tickLine={false}
+            tickMargin={8}
+            tickFormatter={yFormatter}
+            ticks={yTicks}
+            width={56}
+          />
+          {showTooltip && (
+            <Tooltip
+              cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
+              contentStyle={{
+                background: 'var(--ant-color-bg-elevated)',
+                border: '1px solid var(--ant-color-border-secondary)',
+                borderRadius: 6,
+                fontSize: 12,
+                padding: '6px 10px',
+                boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
+              }}
+              labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
+              itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
+              formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
+              labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
+              separator=""
+            />
+          )}
+          {referenceLines?.map((rl, idx) => (
+            <ReferenceLine
+              key={`ref-${idx}-${rl.y}`}
+              y={rl.y}
+              stroke={rl.color || stroke}
+              strokeDasharray={rl.dash || '5 4'}
+              strokeWidth={1.4}
+              label={rl.label ? {
+                value: rl.label,
+                position: 'insideTopRight',
+                fill: rl.color || stroke,
+                fontSize: 10,
+                fontWeight: 600,
+              } : undefined}
+              ifOverflow="extendDomain"
+            />
+          ))}
+          {extremaPoints && (
+            <>
+              <ReferenceDot
+                x={extremaPoints.max.label}
+                y={extremaPoints.max.value}
+                r={4.5}
+                fill={maxColor}
+                stroke="var(--ant-color-bg-elevated)"
+                strokeWidth={2}
+                ifOverflow="extendDomain"
+              />
+              <ReferenceDot
+                x={extremaPoints.min.label}
+                y={extremaPoints.min.value}
+                r={4.5}
+                fill={minColor}
+                stroke="var(--ant-color-bg-elevated)"
+                strokeWidth={2}
+                ifOverflow="extendDomain"
+              />
+            </>
+          )}
+          <Area
+            type="monotone"
+            dataKey="value"
+            stroke={stroke}
+            strokeWidth={strokeWidth}
+            fill={`url(#${gradId})`}
+            dot={false}
+            activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
+            isAnimationActive={false}
           />
-        )}
-        <Area
-          type="monotone"
-          dataKey="value"
-          stroke={stroke}
-          strokeWidth={strokeWidth}
-          fill={`url(#${gradId})`}
-          dot={false}
-          activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
-          isAnimationActive={false}
-        />
-      </AreaChart>
-    </ResponsiveContainer>
+        </AreaChart>
+      </ResponsiveContainer>
+    </div>
   );
 }

+ 41 - 3
frontend/src/hooks/useClients.ts

@@ -42,11 +42,20 @@ export interface ClientQueryParams {
   page: number;
   pageSize: number;
   search?: string;
+  // CSV strings — frontend joins arrays on ',', backend splits the same way.
   filter?: string;
   protocol?: string;
-  inbound?: number;
+  inbound?: string;
   sort?: string;
   order?: 'ascend' | 'descend';
+  expiryFrom?: number;
+  expiryTo?: number;
+  usageFrom?: number;
+  usageTo?: number;
+  autoRenew?: 'on' | 'off' | '';
+  hasTgId?: 'yes' | 'no' | '';
+  hasComment?: 'yes' | 'no' | '';
+  group?: string;
 }
 
 const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
@@ -61,9 +70,17 @@ function buildQS(p: ClientQueryParams): string {
   if (p.search) sp.set('search', p.search);
   if (p.filter) sp.set('filter', p.filter);
   if (p.protocol) sp.set('protocol', p.protocol);
-  if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
+  if (p.inbound) sp.set('inbound', p.inbound);
   if (p.sort) sp.set('sort', p.sort);
   if (p.order) sp.set('order', p.order);
+  if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom));
+  if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo));
+  if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom));
+  if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo));
+  if (p.autoRenew) sp.set('autoRenew', p.autoRenew);
+  if (p.hasTgId) sp.set('hasTgId', p.hasTgId);
+  if (p.hasComment) sp.set('hasComment', p.hasComment);
+  if (p.group) sp.set('group', p.group);
   return sp.toString();
 }
 
@@ -105,9 +122,17 @@ export function useClients() {
         && (prev.search ?? '') === (next.search ?? '')
         && (prev.filter ?? '') === (next.filter ?? '')
         && (prev.protocol ?? '') === (next.protocol ?? '')
-        && (prev.inbound ?? 0) === (next.inbound ?? 0)
+        && (prev.inbound ?? '') === (next.inbound ?? '')
         && (prev.sort ?? '') === (next.sort ?? '')
         && (prev.order ?? '') === (next.order ?? '')
+        && (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
+        && (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
+        && (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
+        && (prev.usageTo ?? 0) === (next.usageTo ?? 0)
+        && (prev.autoRenew ?? '') === (next.autoRenew ?? '')
+        && (prev.hasTgId ?? '') === (next.hasTgId ?? '')
+        && (prev.hasComment ?? '') === (next.hasComment ?? '')
+        && (prev.group ?? '') === (next.group ?? '')
       ) return prev;
       return next;
     });
@@ -147,6 +172,7 @@ export function useClients() {
   const total = listQuery.data?.total ?? 0;
   const filtered = listQuery.data?.filtered ?? 0;
   const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
+  const allGroups = listQuery.data?.groups ?? [];
   const fetched = listQuery.data !== undefined;
   const loading = listQuery.isFetching;
 
@@ -208,6 +234,12 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
+  const bulkAssignGroupMut = useMutation({
+    mutationFn: (body: { emails: string[]; group: string }) =>
+      HttpUtil.post('/panel/api/clients/bulkAssignGroup', body, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
   const updateMut = useMutation({
     mutationFn: ({ email, client }: { email: string; client: unknown }) =>
       HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
@@ -300,6 +332,10 @@ export function useClients() {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
   }, [bulkAdjustMut]);
+  const bulkAssignGroup = useCallback((emails: string[], group: string) => {
+    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+    return bulkAssignGroupMut.mutateAsync({ emails, group });
+  }, [bulkAssignGroupMut]);
   const attach = useCallback((email: string, inboundIds: number[]) => {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return attachMut.mutateAsync({ email, inboundIds });
@@ -385,6 +421,7 @@ export function useClients() {
     total,
     filtered,
     summary,
+    allGroups,
     hydrate,
     query,
     setQuery,
@@ -405,6 +442,7 @@ export function useClients() {
     remove,
     bulkDelete,
     bulkAdjust,
+    bulkAssignGroup,
     attach,
     detach,
     resetTraffic,

+ 14 - 10
frontend/src/hooks/usePageTitle.ts

@@ -1,22 +1,26 @@
 import { useEffect } from 'react';
 import { useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
 
-const TITLES: Record<string, string> = {
-  '/': 'Overview',
-  '/inbounds': 'Inbounds',
-  '/clients': 'Clients',
-  '/nodes': 'Nodes',
-  '/settings': 'Settings',
-  '/xray': 'Xray Config',
-  '/api-docs': 'API Docs',
+const TITLE_KEYS: Record<string, string> = {
+  '/': 'menu.dashboard',
+  '/inbounds': 'menu.inbounds',
+  '/clients': 'menu.clients',
+  '/groups': 'menu.groups',
+  '/nodes': 'menu.nodes',
+  '/settings': 'menu.settings',
+  '/xray': 'menu.xray',
+  '/api-docs': 'menu.apiDocs',
 };
 
 export function usePageTitle() {
   const { pathname } = useLocation();
+  const { t } = useTranslation();
 
   useEffect(() => {
-    const title = TITLES[pathname] || '3X-UI';
+    const key = TITLE_KEYS[pathname];
+    const title = key ? t(key) : '3X-UI';
     const host = window.location.hostname;
     document.title = host ? `${host} - ${title}` : title;
-  }, [pathname]);
+  }, [pathname, t]);
 }

+ 20 - 3
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -227,15 +227,32 @@ export function dropLegacyOptionalEmpties(
   const fb = settings.fallbacks;
   if (Array.isArray(fb) && fb.length === 0) delete settings.fallbacks;
 
-  // StreamSettings emits `finalmask` only when at least one transport
-  // mask exists (legacy `hasFinalMask`). Otherwise drop the whole block.
   if (stream) {
+    // StreamSettings emits `finalmask` only when at least one transport
+    // mask exists (legacy `hasFinalMask`). Drop the whole block when all
+    // sub-fields are empty; otherwise drop only the empty sub-arrays so
+    // the wire payload doesn't carry a stray `"tcp": []` next to a
+    // populated UDP mask list (and vice versa).
     const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown } | undefined;
     if (fm && typeof fm === 'object') {
       const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
       const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
       const hasQuic = fm.quicParams != null;
-      if (!hasTcp && !hasUdp && !hasQuic) delete stream.finalmask;
+      if (!hasTcp && !hasUdp && !hasQuic) {
+        delete stream.finalmask;
+      } else {
+        if (!hasTcp) delete fm.tcp;
+        if (!hasUdp) delete fm.udp;
+      }
+    }
+
+    // Hysteria's per-client auth lives in settings.clients[*].auth; the
+    // streamSettings.hysteriaSettings.auth slot is a holdover from older
+    // hysteria builds and serves no purpose on the inbound side, so an
+    // empty value shouldn't ride along in the JSON payload.
+    const hs = stream.hysteriaSettings as { auth?: string } | undefined;
+    if (hs && typeof hs === 'object' && (hs.auth === '' || hs.auth == null)) {
+      delete hs.auth;
     }
   }
 }

+ 4 - 0
frontend/src/models/dbinbound.ts

@@ -155,6 +155,10 @@ export class DBInbound {
         return this.protocol === Protocols.HYSTERIA;
     }
 
+    get isTunnel() {
+        return this.protocol === Protocols.TUNNEL;
+    }
+
     get address(): string {
         let address = location.hostname;
         if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {

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

@@ -174,6 +174,15 @@ export const sections: readonly Section[] = [
           { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
         ],
       },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/:id/delAllClients',
+        summary: 'Remove every client attached to a single inbound while keeping the inbound itself. Collects emails from settings.clients[] and feeds them into the optimized bulk-delete path (runtime user removal + traffic-row cleanup + SyncInbound). Destructive and cannot be undone.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 12\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/resetAllTraffics',
@@ -535,6 +544,56 @@ export const sections: readonly Section[] = [
         body: '[\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7]\n  },\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7, 9]\n  }\n]',
         response: '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use" }\n    ]\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkAssignGroup',
+        summary: 'Assign the given group label to many clients in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound\'s settings JSON in a single transaction. Pass an empty group to clear the label. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group.',
+        body: '{\n  "emails": ["alice", "bob"],\n  "group": "customer-a"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "affected": 2\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkResetTraffic',
+        summary: 'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.',
+        body: '{\n  "emails": ["alice", "bob"]\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "affected": 2\n  }\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/groups',
+        summary: 'List all client groups with their member counts. Merges persisted groups (rows in client_groups, including empty placeholders) with the distinct group_name values currently set on clients. Sorted alphabetically (case-insensitive).',
+        response: '{\n  "success": true,\n  "obj": [\n    { "name": "customer-a", "clientCount": 5 },\n    { "name": "internal", "clientCount": 0 }\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/groups/:name/emails',
+        summary: 'Return just the email list of clients that currently belong to the given group. Useful for fanning a single bulk action over an entire group without round-tripping the full client list.',
+        params: [
+          { name: 'name', in: 'path', type: 'string', desc: 'Group name (URL-encoded).' },
+        ],
+        response: '{\n  "success": true,\n  "obj": ["alice", "bob", "carol"]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/groups/create',
+        summary: 'Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is assigned to it. Errors if a group with the same name already exists.',
+        body: '{\n  "name": "customer-a"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "name": "customer-a"\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/groups/rename',
+        summary: 'Rename a group. The new name is applied to the client_groups row AND propagated to every matching client (both clients.group_name and the client entry inside every owning inbound\'s settings JSON) in a single transaction. Returns the number of clients whose label was updated.',
+        body: '{\n  "oldName": "customer-a",\n  "newName": "tier-1"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "affected": 5\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/groups/delete',
+        summary: 'Remove a group. Deletes the client_groups row and clears the group label from every matching client (both clients.group_name and the inbound settings JSON). The clients themselves are NOT deleted — use /bulkDel after filtering by group for that. Returns the count of clients whose label was cleared.',
+        body: '{\n  "name": "customer-a"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "affected": 5\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/resetTraffic/:email',

+ 83 - 0
frontend/src/pages/clients/BulkAssignGroupModal.tsx

@@ -0,0 +1,83 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { AutoComplete, Form, Modal, message } from 'antd';
+
+interface BulkAssignGroupModalProps {
+  open: boolean;
+  count: number;
+  groups: string[];
+  onOpenChange: (open: boolean) => void;
+  onSubmit: (group: string) => Promise<{ affected?: number } | null>;
+}
+
+export default function BulkAssignGroupModal({
+  open,
+  count,
+  groups,
+  onOpenChange,
+  onSubmit,
+}: BulkAssignGroupModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [value, setValue] = useState('');
+  const [submitting, setSubmitting] = useState(false);
+
+  useEffect(() => {
+    if (open) setValue('');
+  }, [open]);
+
+  async function submit() {
+    const next = value.trim();
+    setSubmitting(true);
+    try {
+      const result = await onSubmit(next);
+      if (result) {
+        const affected = result.affected ?? 0;
+        if (next === '') {
+          messageApi.success(t('pages.clients.assignGroupClearedToast', { count: affected }));
+        } else {
+          messageApi.success(t('pages.clients.assignGroupAssignedToast', { count: affected, group: next }));
+        }
+        onOpenChange(false);
+      }
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={t('pages.clients.assignGroupTitle', { count })}
+        okText={t('save')}
+        cancelText={t('cancel')}
+        confirmLoading={submitting}
+        onCancel={() => onOpenChange(false)}
+        onOk={submit}
+        destroyOnHidden
+      >
+        <Form layout="vertical">
+          <Form.Item
+            label={t('pages.clients.group')}
+            tooltip={t('pages.clients.assignGroupTooltip')}
+          >
+            <AutoComplete
+              value={value}
+              placeholder={t('pages.clients.assignGroupPlaceholder')}
+              options={groups.map((g) => ({ value: g }))}
+              onChange={(v) => setValue(v ?? '')}
+              filterOption={(input, option) =>
+                String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
+              }
+              allowClear
+              style={{ width: '100%' }}
+              autoFocus
+            />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+}

+ 43 - 10
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
-import { SyncOutlined } from '@ant-design/icons';
+import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, message } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
@@ -21,6 +21,7 @@ interface ClientBulkAddModalProps {
   open: boolean;
   inbounds: InboundOption[];
   ipLimitEnable?: boolean;
+  groups?: string[];
   onOpenChange: (open: boolean) => void;
   onSaved?: () => void;
 }
@@ -36,11 +37,13 @@ function emptyForm(): FormState {
     emailPostfix: '',
     quantity: 1,
     subId: '',
+    group: '',
     comment: '',
     flow: '',
     limitIp: 0,
     totalGB: 0,
     expiryTime: 0,
+    reset: 0,
     inboundIds: [],
   };
 }
@@ -49,6 +52,7 @@ export default function ClientBulkAddModal({
   open,
   inbounds,
   ipLimitEnable = false,
+  groups = [],
   onOpenChange,
   onSaved,
 }: ClientBulkAddModalProps) {
@@ -154,7 +158,9 @@ export default function ClientBulkAddModal({
           flow: showFlow ? (form.flow || '') : '',
           totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
           expiryTime: form.expiryTime,
+          reset: Number(form.reset) || 0,
           limitIp: Number(form.limitIp) || 0,
+          group: form.group,
           comment: form.comment,
           enable: true,
         },
@@ -247,16 +253,32 @@ export default function ClientBulkAddModal({
             </Form.Item>
           )}
 
-          <Form.Item label={
-            <>
-              {t('subscription.title')}
-              <SyncOutlined
-                className="random-icon"
+          <Form.Item label={t('pages.clients.subId')}>
+            <Space.Compact style={{ display: 'flex' }}>
+              <Input
+                value={form.subId}
+                onChange={(e) => update('subId', e.target.value)}
+                style={{ flex: 1 }}
+              />
+              <Button
+                icon={<ReloadOutlined />}
                 onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
               />
-            </>
-          }>
-            <Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
+            </Space.Compact>
+          </Form.Item>
+
+          <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
+            <AutoComplete
+              value={form.group}
+              placeholder={t('pages.clients.groupPlaceholder')}
+              options={groups.map((g) => ({ value: g }))}
+              onChange={(v) => update('group', v ?? '')}
+              filterOption={(input, option) =>
+                String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
+              }
+              allowClear
+              style={{ width: '100%' }}
+            />
           </Form.Item>
 
           <Form.Item label={t('comment')}>
@@ -310,6 +332,17 @@ export default function ClientBulkAddModal({
               />
             </Form.Item>
           )}
+
+          <Form.Item
+            label={t('pages.clients.renew')}
+            tooltip={t('pages.clients.renewDesc')}
+          >
+            <InputNumber
+              value={form.reset}
+              min={0}
+              onChange={(v) => update('reset', Number(v) || 0)}
+            />
+          </Form.Item>
         </Form>
       </Modal>
     </>

+ 69 - 31
frontend/src/pages/clients/ClientFormModal.tsx

@@ -1,6 +1,7 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
+  AutoComplete,
   Button,
   Col,
   Form,
@@ -14,6 +15,7 @@ import {
   Tag,
   message,
 } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 
@@ -60,6 +62,7 @@ interface ClientFormModalProps {
   attachedIds?: number[];
   ipLimitEnable?: boolean;
   tgBotEnable?: boolean;
+  groups?: string[];
   save: (
     payload: Record<string, unknown> | SaveCreatePayload,
     meta: SaveMetaEdit | SaveMetaCreate,
@@ -79,8 +82,10 @@ interface FormState {
   expiryDate: Dayjs | null;
   delayedStart: boolean;
   delayedDays: number;
+  reset: number;
   limitIp: number;
   tgId: number;
+  group: string;
   comment: string;
   enable: boolean;
   inboundIds: number[];
@@ -99,8 +104,10 @@ function emptyForm(): FormState {
     expiryDate: null,
     delayedStart: false,
     delayedDays: 0,
+    reset: 0,
     limitIp: 0,
     tgId: 0,
+    group: '',
     comment: '',
     enable: true,
     inboundIds: [],
@@ -125,6 +132,7 @@ export default function ClientFormModal({
   attachedIds = [],
   ipLimitEnable = false,
   tgBotEnable = false,
+  groups = [],
   save,
   onOpenChange,
 }: ClientFormModalProps) {
@@ -157,8 +165,10 @@ export default function ClientFormModal({
         flow: client.flow || '',
         reverseTag: client.reverse?.tag || '',
         totalGB: bytesToGB(client.totalGB || 0),
+        reset: Number(client.reset) || 0,
         limitIp: client.limitIp || 0,
         tgId: Number(client.tgId) || 0,
+        group: client.group || '',
         comment: client.comment || '',
         enable: !!client.enable,
         inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
@@ -280,8 +290,10 @@ export default function ClientFormModal({
       totalGB: form.totalGB,
       delayedStart: form.delayedStart,
       delayedDays: form.delayedDays,
+      reset: form.reset,
       limitIp: form.limitIp,
       tgId: form.tgId,
+      group: form.group,
       comment: form.comment,
       enable: form.enable,
       inboundIds: form.inboundIds,
@@ -303,6 +315,7 @@ export default function ClientFormModal({
       flow: showFlow ? (form.flow || '') : '',
       totalGB: gbToBytes(form.totalGB),
       expiryTime,
+      reset: Number(form.reset) || 0,
       limitIp: Number(form.limitIp) || 0,
       tgId: Number(form.tgId) || 0,
       comment: form.comment,
@@ -364,7 +377,7 @@ export default function ClientFormModal({
                     style={{ flex: 1 }}
                     onChange={(e) => update('email', e.target.value)}
                   />
-                  <Button onClick={() => update('email', RandomUtil.randomLowerAndNum(12))}>↻</Button>
+                  <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
                 </Space.Compact>
               </Form.Item>
             </Col>
@@ -372,7 +385,7 @@ export default function ClientFormModal({
               <Form.Item label={t('pages.clients.subId')}>
                 <Space.Compact style={{ display: 'flex' }}>
                   <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
-                  <Button onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}>↻</Button>
+                  <Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
                 </Space.Compact>
               </Form.Item>
             </Col>
@@ -383,7 +396,7 @@ export default function ClientFormModal({
               <Form.Item label={t('pages.clients.hysteriaAuth')}>
                 <Space.Compact style={{ display: 'flex' }}>
                   <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
-                  <Button onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))}>↻</Button>
+                  <Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
                 </Space.Compact>
               </Form.Item>
             </Col>
@@ -391,7 +404,7 @@ export default function ClientFormModal({
               <Form.Item label={t('pages.clients.password')}>
                 <Space.Compact style={{ display: 'flex' }}>
                   <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
-                  <Button onClick={() => update('password', RandomUtil.randomLowerAndNum(16))}>↻</Button>
+                  <Button icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} />
                 </Space.Compact>
               </Form.Item>
             </Col>
@@ -402,7 +415,7 @@ export default function ClientFormModal({
               <Form.Item label={t('pages.clients.uuid')}>
                 <Space.Compact style={{ display: 'flex' }}>
                   <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
-                  <Button onClick={() => update('uuid', RandomUtil.randomUUID())}>↻</Button>
+                  <Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
                 </Space.Compact>
               </Form.Item>
             </Col>
@@ -452,32 +465,39 @@ export default function ClientFormModal({
             </Col>
           </Row>
 
-          {(showFlow || showReverseTag) && (
-            <Row gutter={16}>
-              {showFlow && (
-                <Col xs={24} md={12}>
-                  <Form.Item label={t('pages.clients.flow')}>
-                    <Select
-                      value={form.flow}
-                      onChange={(v) => update('flow', v)}
-                      options={[
-                        { value: '', label: t('none') },
-                        ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
-                      ]}
-                    />
-                  </Form.Item>
-                </Col>
-              )}
-              {showReverseTag && (
-                <Col xs={24} md={12}>
-                  <Form.Item label={t('pages.clients.reverseTag')}>
-                    <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
-                      onChange={(e) => update('reverseTag', e.target.value)} />
-                  </Form.Item>
-                </Col>
-              )}
-            </Row>
-          )}
+          <Row gutter={16}>
+            <Col xs={24} md={12}>
+              <Form.Item
+                label={t('pages.clients.renew')}
+                tooltip={t('pages.clients.renewDesc')}
+              >
+                <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
+                  onChange={(v) => update('reset', Number(v) || 0)} />
+              </Form.Item>
+            </Col>
+            {showReverseTag && (
+              <Col xs={24} md={12}>
+                <Form.Item label={t('pages.clients.reverseTag')}>
+                  <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
+                    onChange={(e) => update('reverseTag', e.target.value)} />
+                </Form.Item>
+              </Col>
+            )}
+            {showFlow && (
+              <Col xs={24} md={12}>
+                <Form.Item label={t('pages.clients.flow')}>
+                  <Select
+                    value={form.flow}
+                    onChange={(v) => update('flow', v)}
+                    options={[
+                      { value: '', label: t('none') },
+                      ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                    ]}
+                  />
+                </Form.Item>
+              </Col>
+            )}
+          </Row>
 
           <Row gutter={16}>
             {tgBotEnable && (
@@ -494,6 +514,21 @@ export default function ClientFormModal({
                 <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
               </Form.Item>
             </Col>
+            <Col xs={24} md={12}>
+              <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
+                <AutoComplete
+                  value={form.group}
+                  placeholder={t('pages.clients.groupPlaceholder')}
+                  options={groups.map((g) => ({ value: g }))}
+                  onChange={(v) => update('group', v ?? '')}
+                  filterOption={(input, option) =>
+                    String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
+                  }
+                  allowClear
+                  style={{ width: '100%' }}
+                />
+              </Form.Item>
+            </Col>
           </Row>
 
           <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
@@ -503,6 +538,9 @@ export default function ClientFormModal({
               onChange={(v) => update('inboundIds', v)}
               options={inboundOptions}
               placeholder={t('pages.clients.selectInbound')}
+              maxTagCount="responsive"
+              placement="topLeft"
+              listHeight={220}
               showSearch={{
                 filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
               }}

+ 17 - 2
frontend/src/pages/clients/ClientsPage.css

@@ -33,6 +33,20 @@
   flex: 0 0 auto;
 }
 
+.filter-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin: 0 0 12px;
+  padding: 6px 8px;
+  background: var(--ant-color-fill-quaternary);
+  border-radius: 8px;
+}
+
+.filter-chips .ant-tag {
+  margin: 0;
+}
+
 .dot {
   display: inline-block;
   width: 8px;
@@ -60,6 +74,7 @@
   align-items: center;
   gap: 8px;
   flex-wrap: wrap;
+  padding: 6px 0;
 }
 
 .email-cell {
@@ -164,7 +179,7 @@
 .card-empty {
   text-align: center;
   padding: 40px 16px;
-  opacity: 0.55;
+  color: var(--ant-color-text-secondary);
   display: flex;
   flex-direction: column;
   align-items: center;
@@ -174,5 +189,5 @@
 .clients-empty {
   padding: 32px 0;
   text-align: center;
-  opacity: 0.55;
+  color: var(--ant-color-text-secondary);
 }

+ 432 - 246
frontend/src/pages/clients/ClientsPage.tsx

@@ -13,7 +13,6 @@ import {
   Modal,
   Pagination,
   Popover,
-  Radio,
   Row,
   Select,
   Space,
@@ -32,14 +31,16 @@ import {
   EditOutlined,
   FilterOutlined,
   InfoCircleOutlined,
+  LinkOutlined,
   MoreOutlined,
   PlusOutlined,
   QrcodeOutlined,
   RestOutlined,
   RetweetOutlined,
   SearchOutlined,
+  SortAscendingOutlined,
+  TagsOutlined,
   TeamOutlined,
-  UserOutlined,
   UsergroupAddOutlined,
 } from '@ant-design/icons';
 
@@ -58,18 +59,20 @@ const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
 const ClientQrModal = lazy(() => import('./ClientQrModal'));
 const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
 const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
+const FilterDrawer = lazy(() => import('./FilterDrawer'));
+const SubLinksModal = lazy(() => import('./SubLinksModal'));
+const BulkAssignGroupModal = lazy(() => import('./BulkAssignGroupModal'));
+import { emptyFilters, activeFilterCount } from './filters';
+import type { ClientFilters } from './filters';
 import './ClientsPage.css';
 
 const FILTER_STATE_KEY = 'clientsFilterState';
 
 type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
 
-interface FilterState {
-  enableFilter: boolean;
+interface PersistedFilterState {
   searchKey: string;
-  filterBy: string;
-  protocolFilter?: string;
-  inboundFilter?: number;
+  filters: ClientFilters;
 }
 
 const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -86,22 +89,50 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
 };
 const INBOUND_CHIP_LIMIT = 1;
 
-function readFilterState(): FilterState {
+function readFilterState(): PersistedFilterState {
   try {
     const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
-    const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
+    const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
     return {
-      enableFilter: !!raw.enableFilter,
-      searchKey: raw.searchKey || '',
-      filterBy: raw.filterBy || '',
-      protocolFilter: raw.protocolFilter,
-      inboundFilter: inb,
+      searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
+      filters: {
+        ...emptyFilters(),
+        ...fromRaw,
+        buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
+        protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
+        inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
+        groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
+      },
     };
   } catch {
-    return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
+    return { searchKey: '', filters: emptyFilters() };
   }
 }
 
+function gbToBytes(gb: number | undefined): number {
+  if (!gb || gb <= 0) return 0;
+  return Math.round(gb * 1024 * 1024 * 1024);
+}
+
+const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
+  { value: 'createdAt:ascend',    column: 'createdAt',  order: 'ascend',   labelKey: 'pages.clients.sortOldest' },
+  { value: 'createdAt:descend',   column: 'createdAt',  order: 'descend',  labelKey: 'pages.clients.sortNewest' },
+  { value: 'updatedAt:descend',   column: 'updatedAt',  order: 'descend',  labelKey: 'pages.clients.sortRecentlyUpdated' },
+  { value: 'lastOnline:descend',  column: 'lastOnline', order: 'descend',  labelKey: 'pages.clients.sortRecentlyOnline' },
+  { value: 'email:ascend',        column: 'email',      order: 'ascend',   labelKey: 'pages.clients.sortEmailAZ' },
+  { value: 'email:descend',       column: 'email',      order: 'descend',  labelKey: 'pages.clients.sortEmailZA' },
+  { value: 'traffic:descend',     column: 'traffic',    order: 'descend',  labelKey: 'pages.clients.sortMostTraffic' },
+  { value: 'remaining:descend',   column: 'remaining',  order: 'descend',  labelKey: 'pages.clients.sortHighestRemaining' },
+  { value: 'expiryTime:ascend',   column: 'expiryTime', order: 'ascend',   labelKey: 'pages.clients.sortExpiringSoonest' },
+];
+
+const DEFAULT_SORT = SORT_OPTIONS[0];
+
+function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string {
+  if (!column || !order) return DEFAULT_SORT.value;
+  return `${column}:${order}`;
+}
+
 export default function ClientsPage() {
   const { t } = useTranslation();
   const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -114,10 +145,11 @@ export default function ClientsPage() {
   const {
     clients, filtered,
     summary: serverSummary,
+    allGroups,
     setQuery,
     inbounds, onlines, loading, fetched, subSettings,
     ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
-    create, update, remove, bulkDelete, bulkAdjust, attach, detach,
+    create, update, remove, bulkDelete, bulkAdjust, bulkAssignGroup, attach, detach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     hydrate,
@@ -139,17 +171,17 @@ export default function ClientsPage() {
   const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
   const [bulkAddOpen, setBulkAddOpen] = useState(false);
   const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
+  const [subLinksOpen, setSubLinksOpen] = useState(false);
+  const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
   const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
 
   const initial = readFilterState();
-  const [enableFilter, setEnableFilter] = useState(initial.enableFilter);
   const [searchKey, setSearchKey] = useState(initial.searchKey);
-  const [filterBy, setFilterBy] = useState(initial.filterBy);
-  const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
-  const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
+  const [filters, setFilters] = useState<ClientFilters>(initial.filters);
+  const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
 
-  const [sortColumn, setSortColumn] = useState<string | null>(null);
-  const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
+  const [sortColumn, setSortColumn] = useState<string | null>(DEFAULT_SORT.column);
+  const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order);
   const [currentPage, setCurrentPage] = useState(1);
   const [tablePageSize, setTablePageSize] = useState(25);
   // debouncedSearch lags behind the input so we don't spam the server on every
@@ -157,10 +189,8 @@ export default function ClientsPage() {
   const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
 
   useEffect(() => {
-    localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
-      enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
-    }));
-  }, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
+    localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
+  }, [searchKey, filters]);
 
   useEffect(() => {
     const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -171,20 +201,30 @@ export default function ClientsPage() {
     // Reset to page 1 whenever a filter or sort changes — otherwise an empty
     // result set on a high page number leaves the user staring at "no clients".
     setCurrentPage(1);
-  }, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
+  }, [debouncedSearch, filters, sortColumn, sortOrder]);
 
   useEffect(() => {
     setQuery({
       page: currentPage,
       pageSize: tablePageSize,
-      search: enableFilter ? '' : debouncedSearch,
-      filter: enableFilter ? (filterBy || '') : '',
-      protocol: protocolFilter || '',
-      inbound: inboundFilter,
+      search: debouncedSearch,
+      filter: filters.buckets.join(','),
+      protocol: filters.protocols.join(','),
+      inbound: filters.inboundIds.join(','),
+      expiryFrom: filters.expiryFrom,
+      expiryTo: filters.expiryTo,
+      usageFrom: gbToBytes(filters.usageFromGB),
+      usageTo: gbToBytes(filters.usageToGB),
+      autoRenew: filters.autoRenew || undefined,
+      hasTgId: filters.hasTgId || undefined,
+      hasComment: filters.hasComment || undefined,
+      group: filters.groups.join(',') || undefined,
       sort: sortColumn || undefined,
       order: sortOrder || undefined,
     });
-  }, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
+  }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
+
+  const activeCount = activeFilterCount(filters);
 
   useEffect(() => {
     if (pageSize > 0) {
@@ -205,6 +245,12 @@ export default function ClientsPage() {
     return [...values].sort();
   }, [inbounds]);
 
+  const groupOptions = useMemo(() => {
+    const values = new Set<string>(allGroups);
+    for (const g of filters.groups) values.add(g);
+    return [...values].sort((a, b) => a.localeCompare(b));
+  }, [allGroups, filters.groups]);
+
   const isOnline = useCallback((email: string) => !!email && onlineSet.has(email), [onlineSet]);
 
   function inboundLabel(id: number) {
@@ -464,151 +510,162 @@ export default function ClientsPage() {
     return classes.join(' ');
   }, [isDark, isUltra]);
 
-  const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag, _filters, sorter) => {
+  const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
     if (pag?.current) setCurrentPage(pag.current);
     if (pag?.pageSize) setTablePageSize(pag.pageSize);
-    const s = Array.isArray(sorter) ? sorter[0] : sorter;
-    setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
-    setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
   };
 
-  const columns = useMemo<ColumnsType<ClientRecord>>(() => {
-    function sortableCol<T extends ColumnsType<ClientRecord>[number]>(col: T, key: string): T {
-      return {
-        ...col,
-        sorter: true,
-        showSorterTooltip: false,
-        sortOrder: sortColumn === key ? sortOrder : null,
-        sortDirections: ['ascend', 'descend'],
-      };
-    }
-    return [
-      {
-        title: t('pages.clients.actions'),
-        key: 'actions',
-        width: 200,
-        render: (_v, record) => (
-          <Space size={4}>
-            <Tooltip title={t('pages.clients.qrCode')}>
-              <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
-            </Tooltip>
-            <Tooltip title={t('pages.clients.moreInformation')}>
-              <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
-            </Tooltip>
-            <Tooltip title={t('pages.inbounds.resetTraffic')}>
-              <Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
-            </Tooltip>
-            <Tooltip title={t('edit')}>
-              <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
-            </Tooltip>
-            <Tooltip title={t('delete')}>
-              <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
-            </Tooltip>
-          </Space>
-        ),
+  const columns = useMemo<ColumnsType<ClientRecord>>(() => [
+    {
+      title: t('pages.clients.actions'),
+      key: 'actions',
+      width: 200,
+      render: (_v, record) => (
+        <Space size={4}>
+          <Tooltip title={t('pages.clients.qrCode')}>
+            <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
+          </Tooltip>
+          <Tooltip title={t('pages.clients.moreInformation')}>
+            <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
+          </Tooltip>
+          <Tooltip title={t('pages.inbounds.resetTraffic')}>
+            <Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
+          </Tooltip>
+          <Tooltip title={t('edit')}>
+            <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
+          </Tooltip>
+          <Tooltip title={t('delete')}>
+            <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
+          </Tooltip>
+        </Space>
+      ),
+    },
+    {
+      title: t('pages.clients.enabled'),
+      key: 'enable',
+      width: 80,
+      render: (_v, record) => (
+        <Switch
+          checked={!!record.enable}
+          size="small"
+          loading={togglingEmail === record.email}
+          onChange={(next) => onToggleEnable(record, next)}
+        />
+      ),
+    },
+    {
+      title: t('pages.clients.online'),
+      key: 'online',
+      width: 90,
+      render: (_v, record) => {
+        const bucket = clientBucket(record);
+        if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
+        if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
+        if (!record.enable) return <Tag>{t('disabled')}</Tag>;
+        if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
+        return <Tag>{t('pages.clients.offline')}</Tag>;
       },
-      sortableCol({
-        title: t('pages.clients.enabled'), key: 'enable', width: 80,
-        render: (_v, record) => (
-          <Switch
-            checked={!!record.enable}
-            size="small"
-            loading={togglingEmail === record.email}
-            onChange={(next) => onToggleEnable(record, next)}
-          />
-        ),
-      }, 'enable'),
-      {
-        title: t('pages.clients.online'),
-        key: 'online',
-        width: 90,
-        render: (_v, record) => {
-          const bucket = clientBucket(record);
-          if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
-          if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
-          if (!record.enable) return <Tag>{t('disabled')}</Tag>;
-          if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
-          return <Tag>{t('pages.clients.offline')}</Tag>;
-        },
+    },
+    {
+      title: t('pages.clients.client'),
+      key: 'email',
+      render: (_v, record) => (
+        <div className="email-cell">
+          <span className="email">{record.email}</span>
+          {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
+          {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
+        </div>
+      ),
+    },
+    {
+      title: t('pages.clients.group'),
+      key: 'group',
+      width: 130,
+      render: (_v, record) => {
+        if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
+        const isActive = filters.groups.includes(record.group);
+        return (
+          <Tag
+            color="geekblue"
+            style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }}
+            onClick={(e) => {
+              e.stopPropagation();
+              if (!isActive) {
+                setFilters({ ...filters, groups: [...filters.groups, record.group!] });
+              }
+            }}
+          >
+            {record.group}
+          </Tag>
+        );
       },
-      sortableCol({
-        title: t('pages.clients.client'),
-        key: 'email',
-        render: (_v, record) => (
-          <div className="email-cell">
-            <span className="email">{record.email}</span>
-            {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
-            {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
-          </div>
-        ),
-      }, 'email'),
-      sortableCol({
-        title: t('pages.clients.attachedInbounds'),
-        key: 'inboundIds',
-        width: 170,
-        render: (_v, record) => {
-          const ids = record.inboundIds || [];
-          if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
-          const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
-          const overflow = ids.slice(INBOUND_CHIP_LIMIT);
-          const chip = (id: number, compact: boolean) => {
-            const ib = inboundsById[id];
-            const proto = (ib?.protocol || '').toLowerCase();
-            const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-            const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
-            return (
-              <Tooltip key={id} title={inboundLabel(id)}>
-                <Tag color={color} style={{ margin: 2 }}>
-                  {compact ? compactLabel : inboundLabel(id)}
-                </Tag>
-              </Tooltip>
-            );
-          };
+    },
+    {
+      title: t('pages.clients.attachedInbounds'),
+      key: 'inboundIds',
+      width: 170,
+      render: (_v, record) => {
+        const ids = record.inboundIds || [];
+        if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
+        const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
+        const overflow = ids.slice(INBOUND_CHIP_LIMIT);
+        const chip = (id: number, compact: boolean) => {
+          const ib = inboundsById[id];
+          const proto = (ib?.protocol || '').toLowerCase();
+          const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
+          const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
           return (
-            <>
-              {visible.map((id) => chip(id, true))}
-              {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((id) => chip(id, false))}
-                    </div>
-                  }
-                >
-                  <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
-                    +{overflow.length}
-                  </Tag>
-                </Popover>
-              )}
-            </>
+            <Tooltip key={id} title={inboundLabel(id)}>
+              <Tag color={color} style={{ margin: 2 }}>
+                {compact ? compactLabel : inboundLabel(id)}
+              </Tag>
+            </Tooltip>
           );
-        },
-      }, 'inboundIds'),
-      sortableCol({
-        title: t('pages.clients.traffic'),
-        key: 'traffic',
-        render: (_v, record) => trafficLabel(record),
-      }, 'traffic'),
-      sortableCol({
-        title: t('pages.clients.remaining'),
-        key: 'remaining',
-        width: 130,
-        render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
-      }, 'remaining'),
-      sortableCol({
-        title: t('pages.clients.duration'),
-        key: 'expiryTime',
-        render: (_v, record) => (
-          <Tooltip title={expiryLabel(record)}>
-            <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
-          </Tooltip>
-        ),
-      }, 'expiryTime'),
-    ];
+        };
+        return (
+          <>
+            {visible.map((id) => chip(id, true))}
+            {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((id) => chip(id, false))}
+                  </div>
+                }
+              >
+                <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+                  +{overflow.length}
+                </Tag>
+              </Popover>
+            )}
+          </>
+        );
+      },
+    },
+    {
+      title: t('pages.clients.traffic'),
+      key: 'traffic',
+      render: (_v, record) => trafficLabel(record),
+    },
+    {
+      title: t('pages.clients.remaining'),
+      key: 'remaining',
+      width: 130,
+      render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
+    },
+    {
+      title: t('pages.clients.duration'),
+      key: 'expiryTime',
+      render: (_v, record) => (
+        <Tooltip title={expiryLabel(record)}>
+          <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
+        </Tooltip>
+      ),
+    },
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline, inboundsById]);
+  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters]);
 
   const tablePagination = {
     current: currentPage,
@@ -640,10 +697,16 @@ export default function ClientsPage() {
   const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
   const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
 
-  function onToggleFilter(checked: boolean) {
-    setEnableFilter(checked);
-    if (checked) setSearchKey('');
-    else setFilterBy('');
+  function clearOneFilter<K extends keyof ClientFilters>(key: K) {
+    if (key === 'expiryFrom' || key === 'expiryTo') {
+      setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
+      return;
+    }
+    if (key === 'usageFromGB' || key === 'usageToGB') {
+      setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined });
+      return;
+    }
+    setFilters({ ...filters, [key]: emptyFilters()[key] });
   }
 
   return (
@@ -715,98 +778,172 @@ export default function ClientsPage() {
                       hoverable
                       title={
                         <div className="card-toolbar">
-                          <Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
+                          <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
                             {!isMobile && t('pages.clients.addClients')}
                           </Button>
-                          <Button size="small" icon={<UsergroupAddOutlined />} onClick={() => setBulkAddOpen(true)}>
-                            {!isMobile && t('pages.clients.bulk')}
-                          </Button>
                           {selectedRowKeys.length > 0 && (
                             <>
-                              <Button size="small" icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
+                              <Button icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
                                 {t('pages.clients.adjustSelected', { count: selectedRowKeys.length })}
                               </Button>
-                              <Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
+                              <Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
+                                {t('pages.clients.assignGroupSelected', { count: selectedRowKeys.length })}
+                              </Button>
+                              <Button icon={<LinkOutlined />} onClick={() => setSubLinksOpen(true)}>
+                                {t('pages.clients.subLinksSelected', { count: selectedRowKeys.length })}
+                              </Button>
+                              <Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>
                                 {t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
                               </Button>
                             </>
                           )}
-                          <Button size="small" icon={<RetweetOutlined />} onClick={onResetAllTraffics}>
-                            {!isMobile && t('pages.clients.resetAllTraffics')}
-                          </Button>
-                          <Button size="small" danger icon={<RestOutlined />} onClick={onDelDepleted}>
-                            {!isMobile && t('pages.clients.delDepleted')}
-                          </Button>
+                          <Dropdown
+                            trigger={['click']}
+                            placement="bottomRight"
+                            menu={{
+                              items: [
+                                {
+                                  key: 'bulk',
+                                  icon: <UsergroupAddOutlined />,
+                                  label: t('pages.clients.bulk'),
+                                  onClick: () => setBulkAddOpen(true),
+                                },
+                                {
+                                  key: 'resetAll',
+                                  icon: <RetweetOutlined />,
+                                  label: t('pages.clients.resetAllTraffics'),
+                                  onClick: onResetAllTraffics,
+                                },
+                                {
+                                  key: 'delDepleted',
+                                  icon: <RestOutlined />,
+                                  label: t('pages.clients.delDepleted'),
+                                  danger: true,
+                                  onClick: onDelDepleted,
+                                },
+                              ],
+                            }}
+                          >
+                            <Button icon={<MoreOutlined />}>
+                              {!isMobile && t('more')}
+                            </Button>
+                          </Dropdown>
                         </div>
                       }
                     >
                       <div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
-                        <Switch
-                          checked={enableFilter}
-                          onChange={onToggleFilter}
-                          checkedChildren={<SearchOutlined />}
-                          unCheckedChildren={<FilterOutlined />}
-                        />
-                        {!enableFilter && (
-                          <Input
-                            value={searchKey}
-                            onChange={(e) => setSearchKey(e.target.value)}
-                            placeholder={t('search')}
-                            autoFocus
-                            size={isMobile ? 'small' : 'middle'}
-                            style={{ maxWidth: 300 }}
-                          />
-                        )}
-                        {enableFilter && (
-                          <Radio.Group
-                            value={filterBy}
-                            onChange={(e) => setFilterBy(e.target.value)}
-                            optionType="button"
-                            buttonStyle="solid"
-                            size={isMobile ? 'small' : 'middle'}
-                          >
-                            <Radio.Button value="">{t('none')}</Radio.Button>
-                            <Radio.Button value="active">{t('subscription.active')}</Radio.Button>
-                            <Radio.Button value="deactive">{t('disabled')}</Radio.Button>
-                            <Radio.Button value="depleted">{t('depleted')}</Radio.Button>
-                            <Radio.Button value="expiring">{t('depletingSoon')}</Radio.Button>
-                            <Radio.Button value="online">{t('online')}</Radio.Button>
-                          </Radio.Group>
-                        )}
-                        <Select
-                          value={protocolFilter}
-                          onChange={(v) => {
-                            setProtocolFilter(v);
-                            if (v && inboundFilter) {
-                              const ib = inbounds.find((x) => x.id === inboundFilter);
-                              if (!ib || ib.protocol !== v) setInboundFilter(undefined);
-                            }
-                          }}
+                        <Input
+                          value={searchKey}
+                          onChange={(e) => setSearchKey(e.target.value)}
+                          placeholder={t('pages.clients.searchPlaceholder')}
                           allowClear
-                          placeholder={t('pages.inbounds.protocol')}
+                          prefix={<SearchOutlined />}
                           size={isMobile ? 'small' : 'middle'}
-                          style={{ width: 150 }}
-                          options={protocolOptions.map((p) => ({ value: p, label: p }))}
+                          style={{ maxWidth: 320 }}
                         />
+                        <Badge count={activeCount} size="small" offset={[-4, 4]}>
+                          <Button
+                            icon={<FilterOutlined />}
+                            size={isMobile ? 'small' : 'middle'}
+                            onClick={() => setFilterDrawerOpen(true)}
+                            type={activeCount > 0 ? 'primary' : 'default'}
+                          >
+                            {!isMobile && t('filter')}
+                          </Button>
+                        </Badge>
                         <Select
-                          value={inboundFilter}
-                          onChange={(v) => setInboundFilter(v)}
-                          allowClear
-                          showSearch={{ optionFilterProp: 'label' }}
-                          placeholder={t('inbounds')}
+                          value={sortValueFor(sortColumn, sortOrder)}
                           size={isMobile ? 'small' : 'middle'}
-                          style={{ minWidth: 160, maxWidth: 240 }}
-                          options={inbounds
-                            .filter((ib) => !protocolFilter || ib.protocol === protocolFilter)
-                            .map((ib) => ({
-                              value: ib.id,
-                              label: ib.remark
-                                ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
-                                : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
-                            }))}
+                          suffixIcon={<SortAscendingOutlined />}
+                          style={{ minWidth: isMobile ? 130 : 200 }}
+                          onChange={(value) => {
+                            const opt = SORT_OPTIONS.find((o) => o.value === value);
+                            setSortColumn(opt?.column ?? null);
+                            setSortOrder(opt?.order ?? null);
+                          }}
+                          options={SORT_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
                         />
+                        {activeCount > 0 && (
+                          <Button
+                            size={isMobile ? 'small' : 'middle'}
+                            onClick={() => setFilters(emptyFilters())}
+                          >
+                            {t('pages.clients.clearAllFilters')}
+                          </Button>
+                        )}
                       </div>
 
+                      {activeCount > 0 && (
+                        <div className="filter-chips">
+                          {filters.buckets.map((b) => (
+                            <Tag
+                              key={`b-${b}`}
+                              closable
+                              onClose={() => setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })}
+                            >
+                              {bucketChipLabel(b, t)}
+                            </Tag>
+                          ))}
+                          {filters.protocols.map((p) => (
+                            <Tag
+                              key={`p-${p}`}
+                              closable
+                              color="blue"
+                              onClose={() => setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })}
+                            >
+                              {p}
+                            </Tag>
+                          ))}
+                          {filters.inboundIds.map((id) => (
+                            <Tag
+                              key={`i-${id}`}
+                              closable
+                              color="cyan"
+                              onClose={() => setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })}
+                            >
+                              {inboundLabel(id)}
+                            </Tag>
+                          ))}
+                          {filters.groups.map((g) => (
+                            <Tag
+                              key={`g-${g}`}
+                              closable
+                              color="geekblue"
+                              onClose={() => setFilters({ ...filters, groups: filters.groups.filter((x) => x !== g) })}
+                            >
+                              {t('pages.clients.group')}: {g}
+                            </Tag>
+                          ))}
+                          {(filters.expiryFrom || filters.expiryTo) && (
+                            <Tag closable color="purple" onClose={() => clearOneFilter('expiryFrom')}>
+                              {t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'}
+                              {' → '}
+                              {filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'}
+                            </Tag>
+                          )}
+                          {(filters.usageFromGB || filters.usageToGB) && (
+                            <Tag closable color="orange" onClose={() => clearOneFilter('usageFromGB')}>
+                              {t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `–${filters.usageToGB}` : '+'} GB
+                            </Tag>
+                          )}
+                          {filters.autoRenew && (
+                            <Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
+                              {t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
+                            </Tag>
+                          )}
+                          {filters.hasTgId && (
+                            <Tag closable onClose={() => clearOneFilter('hasTgId')}>
+                              {t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
+                            </Tag>
+                          )}
+                          {filters.hasComment && (
+                            <Tag closable onClose={() => clearOneFilter('hasComment')}>
+                              {t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
+                            </Tag>
+                          )}
+                        </div>
+                      )}
+
                       {!isMobile ? (
                         <Table<ClientRecord>
                           columns={columns}
@@ -821,8 +958,8 @@ export default function ClientsPage() {
                           locale={{
                             emptyText: (
                               <div className="clients-empty">
-                                <UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
-                                <div>{t('pages.clients.empty')}</div>
+                                <TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+                                <div>{t('noData')}</div>
                               </div>
                             ),
                           }}
@@ -846,8 +983,8 @@ export default function ClientsPage() {
                             )}
                             {filteredClients.length === 0 && (
                               <div className="card-empty">
-                                <UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
-                                <div>{t('pages.clients.empty')}</div>
+                                <TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
+                                <div>{t('noData')}</div>
                               </div>
                             )}
                             {filteredClients.length > 0 && (
@@ -947,6 +1084,7 @@ export default function ClientsPage() {
             inbounds={inbounds}
             ipLimitEnable={ipLimitEnable}
             tgBotEnable={tgBotEnable}
+            groups={allGroups}
             save={onSave}
             onOpenChange={setFormOpen}
           />
@@ -974,6 +1112,7 @@ export default function ClientsPage() {
             open={bulkAddOpen}
             inbounds={inbounds}
             ipLimitEnable={ipLimitEnable}
+            groups={allGroups}
             onOpenChange={setBulkAddOpen}
             onSaved={() => setBulkAddOpen(false)}
           />
@@ -993,7 +1132,54 @@ export default function ClientsPage() {
             }}
           />
         </LazyMount>
+        <LazyMount when={subLinksOpen}>
+          <SubLinksModal
+            open={subLinksOpen}
+            emails={selectedRowKeys}
+            clients={clients}
+            subSettings={subSettings}
+            onOpenChange={setSubLinksOpen}
+          />
+        </LazyMount>
+        <LazyMount when={bulkGroupOpen}>
+          <BulkAssignGroupModal
+            open={bulkGroupOpen}
+            count={selectedRowKeys.length}
+            groups={allGroups}
+            onOpenChange={setBulkGroupOpen}
+            onSubmit={async (group) => {
+              const msg = await bulkAssignGroup([...selectedRowKeys], group);
+              if (msg?.success) {
+                setSelectedRowKeys([]);
+                return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
+              }
+              return null;
+            }}
+          />
+        </LazyMount>
+        <LazyMount when={filterDrawerOpen}>
+          <FilterDrawer
+            open={filterDrawerOpen}
+            onOpenChange={setFilterDrawerOpen}
+            filters={filters}
+            onChange={setFilters}
+            inbounds={inbounds}
+            protocols={protocolOptions}
+            groups={groupOptions}
+          />
+        </LazyMount>
       </Layout>
     </ConfigProvider>
   );
 }
+
+function bucketChipLabel(b: string, t: (k: string) => string): string {
+  switch (b) {
+    case 'active': return t('subscription.active');
+    case 'expiring': return t('depletingSoon');
+    case 'depleted': return t('depleted');
+    case 'deactive': return t('disabled');
+    case 'online': return t('online');
+    default: return b;
+  }
+}

+ 244 - 0
frontend/src/pages/clients/FilterDrawer.tsx

@@ -0,0 +1,244 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Button,
+  Checkbox,
+  Col,
+  DatePicker,
+  Drawer,
+  Form,
+  InputNumber,
+  Radio,
+  Row,
+  Select,
+  Space,
+  Typography,
+} from 'antd';
+import dayjs from 'dayjs';
+import type { Dayjs } from 'dayjs';
+
+import type { InboundOption } from '@/hooks/useClients';
+import { emptyFilters, type ClientFilters } from './filters';
+
+interface FilterDrawerProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  filters: ClientFilters;
+  onChange: (next: ClientFilters) => void;
+  inbounds: InboundOption[];
+  protocols: string[];
+  groups: string[];
+}
+
+const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const;
+
+export default function FilterDrawer({
+  open,
+  onOpenChange,
+  filters,
+  onChange,
+  inbounds,
+  protocols,
+  groups,
+}: FilterDrawerProps) {
+  const { t } = useTranslation();
+
+  function patch<K extends keyof ClientFilters>(key: K, value: ClientFilters[K]) {
+    onChange({ ...filters, [key]: value });
+  }
+
+  const inboundOptions = useMemo(
+    () => inbounds.map((ib) => ({
+      value: ib.id,
+      label: ib.remark
+        ? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
+        : `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
+    })),
+    [inbounds],
+  );
+
+  const protocolOptions = useMemo(
+    () => protocols.map((p) => ({ value: p, label: p })),
+    [protocols],
+  );
+
+  const groupOptions = useMemo(
+    () => groups.map((g) => ({ value: g, label: g })),
+    [groups],
+  );
+
+  const dateRange: [Dayjs | null, Dayjs | null] = [
+    filters.expiryFrom ? dayjs(filters.expiryFrom) : null,
+    filters.expiryTo ? dayjs(filters.expiryTo) : null,
+  ];
+
+  return (
+    <Drawer
+      title={t('pages.clients.filterTitle')}
+      open={open}
+      onClose={() => onOpenChange(false)}
+      width={420}
+      destroyOnHidden
+      footer={
+        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+          <Button onClick={() => onChange(emptyFilters())} danger>
+            {t('pages.clients.clearAllFilters')}
+          </Button>
+          <Button type="primary" onClick={() => onOpenChange(false)}>
+            {t('done')}
+          </Button>
+        </div>
+      }
+    >
+      <Form layout="vertical">
+        <Form.Item label={<Typography.Text strong>{t('status')}</Typography.Text>}>
+          <Checkbox.Group
+            value={filters.buckets}
+            onChange={(v) => patch('buckets', v as string[])}
+          >
+            <Space direction="vertical">
+              {BUCKET_KEYS.map((k) => (
+                <Checkbox key={k} value={k}>
+                  {bucketLabel(k, t)}
+                </Checkbox>
+              ))}
+            </Space>
+          </Checkbox.Group>
+        </Form.Item>
+
+        <Form.Item label={t('pages.inbounds.protocol')}>
+          <Select
+            mode="multiple"
+            value={filters.protocols}
+            onChange={(v) => patch('protocols', v as string[])}
+            options={protocolOptions}
+            placeholder={t('pages.inbounds.protocol')}
+            maxTagCount="responsive"
+            allowClear
+          />
+        </Form.Item>
+
+        <Form.Item label={t('inbounds')}>
+          <Select
+            mode="multiple"
+            value={filters.inboundIds}
+            onChange={(v) => patch('inboundIds', v as number[])}
+            options={inboundOptions}
+            placeholder={t('inbounds')}
+            maxTagCount="responsive"
+            allowClear
+            showSearch
+            optionFilterProp="label"
+            listHeight={220}
+          />
+        </Form.Item>
+
+        <Form.Item label={t('pages.clients.group')}>
+          <Select
+            mode="multiple"
+            value={filters.groups}
+            onChange={(v) => patch('groups', v as string[])}
+            options={groupOptions}
+            placeholder={t('pages.clients.groupPlaceholder')}
+            maxTagCount="responsive"
+            allowClear
+            showSearch
+            optionFilterProp="label"
+            listHeight={220}
+          />
+        </Form.Item>
+
+        <Form.Item label={t('pages.clients.expiryTime')}>
+          <DatePicker.RangePicker
+            value={dateRange}
+            onChange={(range) => {
+              const from = range?.[0]?.startOf('day').valueOf();
+              const to = range?.[1]?.endOf('day').valueOf();
+              onChange({ ...filters, expiryFrom: from || undefined, expiryTo: to || undefined });
+            }}
+            style={{ width: '100%' }}
+            allowEmpty={[true, true]}
+          />
+        </Form.Item>
+
+        <Form.Item label={`${t('pages.clients.traffic')} (GB)`}>
+          <Row gutter={8}>
+            <Col span={12}>
+              <InputNumber
+                value={filters.usageFromGB}
+                min={0}
+                step={1}
+                placeholder={t('from')}
+                style={{ width: '100%' }}
+                onChange={(v) => patch('usageFromGB', typeof v === 'number' ? v : undefined)}
+              />
+            </Col>
+            <Col span={12}>
+              <InputNumber
+                value={filters.usageToGB}
+                min={0}
+                step={1}
+                placeholder={t('to')}
+                style={{ width: '100%' }}
+                onChange={(v) => patch('usageToGB', typeof v === 'number' ? v : undefined)}
+              />
+            </Col>
+          </Row>
+        </Form.Item>
+
+        <Form.Item label={t('pages.clients.renew')}>
+          <Radio.Group
+            value={filters.autoRenew}
+            onChange={(e) => patch('autoRenew', e.target.value)}
+            optionType="button"
+            buttonStyle="solid"
+            options={[
+              { value: '', label: t('all') },
+              { value: 'on', label: t('enabled') },
+              { value: 'off', label: t('disabled') },
+            ]}
+          />
+        </Form.Item>
+
+        <Form.Item label={t('pages.clients.telegramId')}>
+          <Radio.Group
+            value={filters.hasTgId}
+            onChange={(e) => patch('hasTgId', e.target.value)}
+            optionType="button"
+            buttonStyle="solid"
+            options={[
+              { value: '', label: t('all') },
+              { value: 'yes', label: t('pages.clients.has') },
+              { value: 'no', label: t('pages.clients.hasNot') },
+            ]}
+          />
+        </Form.Item>
+
+        <Form.Item label={t('pages.clients.comment')}>
+          <Radio.Group
+            value={filters.hasComment}
+            onChange={(e) => patch('hasComment', e.target.value)}
+            optionType="button"
+            buttonStyle="solid"
+            options={[
+              { value: '', label: t('all') },
+              { value: 'yes', label: t('pages.clients.has') },
+              { value: 'no', label: t('pages.clients.hasNot') },
+            ]}
+          />
+        </Form.Item>
+      </Form>
+    </Drawer>
+  );
+}
+
+function bucketLabel(key: string, t: (k: string) => string): string {
+  switch (key) {
+    case 'active': return t('subscription.active');
+    case 'expiring': return t('depletingSoon');
+    case 'depleted': return t('depleted');
+    case 'deactive': return t('disabled');
+    case 'online': return t('online');
+    default: return key;
+  }
+}

+ 193 - 0
frontend/src/pages/clients/SubLinksModal.tsx

@@ -0,0 +1,193 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Modal, Table, Tooltip, Typography, message } from 'antd';
+import type { TableColumnType } from 'antd';
+import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
+
+import type { ClientRecord } from '@/hooks/useClients';
+
+interface SubSettings {
+  enable: boolean;
+  subURI: string;
+  subJsonURI: string;
+  subJsonEnable: boolean;
+}
+
+interface SubLinksModalProps {
+  open: boolean;
+  emails: string[];
+  clients: ClientRecord[];
+  subSettings?: SubSettings;
+  onOpenChange: (open: boolean) => void;
+}
+
+interface Row {
+  key: string;
+  email: string;
+  subId: string;
+  link: string;
+  jsonLink: string;
+}
+
+export default function SubLinksModal({
+  open,
+  emails,
+  clients,
+  subSettings,
+  onOpenChange,
+}: SubLinksModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+
+  const enabled = !!subSettings?.enable && !!subSettings?.subURI;
+  const jsonEnabled = !!subSettings?.subJsonEnable && !!subSettings?.subJsonURI;
+
+  const rows = useMemo<Row[]>(() => {
+    if (!enabled) return [];
+    const byEmail = new Map(clients.map((c) => [c.email, c]));
+    const out: Row[] = [];
+    for (const email of emails) {
+      const c = byEmail.get(email);
+      if (!c?.subId) continue;
+      out.push({
+        key: email,
+        email,
+        subId: c.subId,
+        link: subSettings!.subURI + c.subId,
+        jsonLink: jsonEnabled ? subSettings!.subJsonURI + c.subId : '',
+      });
+    }
+    return out;
+  }, [emails, clients, enabled, jsonEnabled, subSettings]);
+
+  const allText = useMemo(
+    () => rows.map((r) => (jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`)).join('\n'),
+    [rows, jsonEnabled],
+  );
+
+  async function copy(text: string, label?: string) {
+    try {
+      await navigator.clipboard.writeText(text);
+      messageApi.success(label || t('copied'));
+    } catch {
+      messageApi.error(t('somethingWentWrong'));
+    }
+  }
+
+  function download() {
+    const blob = new Blob([allText], { type: 'text/plain;charset=utf-8' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+    a.href = url;
+    a.download = `sub-links-${stamp}.txt`;
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+    URL.revokeObjectURL(url);
+  }
+
+  const columns: TableColumnType<Row>[] = [
+    {
+      title: t('pages.clients.client'),
+      dataIndex: 'email',
+      key: 'email',
+      width: 180,
+      ellipsis: true,
+    },
+    {
+      title: t('pages.clients.subLinkColumn'),
+      dataIndex: 'link',
+      key: 'link',
+      ellipsis: true,
+      render: (link: string) => (
+        <Tooltip title={link} placement="topLeft">
+          <Typography.Text copyable={false} ellipsis>{link}</Typography.Text>
+        </Tooltip>
+      ),
+    },
+    {
+      title: '',
+      key: 'actions',
+      width: 64,
+      render: (_v, row) => (
+        <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
+      ),
+    },
+  ];
+
+  if (jsonEnabled) {
+    columns.splice(2, 0, {
+      title: t('pages.clients.subJsonLinkColumn'),
+      dataIndex: 'jsonLink',
+      key: 'jsonLink',
+      ellipsis: true,
+      render: (link: string) => (
+        <Tooltip title={link} placement="topLeft">
+          <Typography.Text copyable={false} ellipsis>{link}</Typography.Text>
+        </Tooltip>
+      ),
+    });
+  }
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={t('pages.clients.subLinksTitle', { count: rows.length })}
+        width={780}
+        footer={
+          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+            <Button onClick={() => onOpenChange(false)}>{t('close')}</Button>
+            <div style={{ display: 'flex', gap: 8 }}>
+              <Button
+                icon={<CopyOutlined />}
+                disabled={rows.length === 0}
+                onClick={() => copy(allText, t('pages.clients.subLinksCopiedAll', { count: rows.length }))}
+              >
+                {t('pages.clients.subLinksCopyAll')}
+              </Button>
+              <Button
+                type="primary"
+                icon={<DownloadOutlined />}
+                disabled={rows.length === 0}
+                onClick={download}
+              >
+                {t('download')}
+              </Button>
+            </div>
+          </div>
+        }
+        onCancel={() => onOpenChange(false)}
+      >
+        {!enabled && (
+          <Alert
+            type="warning"
+            showIcon
+            message={t('pages.clients.subLinksDisabled')}
+            description={t('pages.clients.subLinksDisabledHint')}
+            style={{ marginBottom: 12 }}
+          />
+        )}
+        {enabled && rows.length === 0 && (
+          <Alert
+            type="info"
+            showIcon
+            message={t('pages.clients.subLinksEmpty')}
+            style={{ marginBottom: 12 }}
+          />
+        )}
+        {rows.length > 0 && (
+          <Table<Row>
+            dataSource={rows}
+            columns={columns}
+            size="small"
+            pagination={false}
+            scroll={{ y: 360 }}
+          />
+        )}
+      </Modal>
+    </>
+  );
+}

+ 39 - 0
frontend/src/pages/clients/filters.ts

@@ -0,0 +1,39 @@
+export interface ClientFilters {
+  buckets: string[];
+  protocols: string[];
+  inboundIds: number[];
+  groups: string[];
+  expiryFrom?: number;
+  expiryTo?: number;
+  usageFromGB?: number;
+  usageToGB?: number;
+  autoRenew: '' | 'on' | 'off';
+  hasTgId: '' | 'yes' | 'no';
+  hasComment: '' | 'yes' | 'no';
+}
+
+export function emptyFilters(): ClientFilters {
+  return {
+    buckets: [],
+    protocols: [],
+    inboundIds: [],
+    groups: [],
+    autoRenew: '',
+    hasTgId: '',
+    hasComment: '',
+  };
+}
+
+export function activeFilterCount(f: ClientFilters): number {
+  let n = 0;
+  if (f.buckets.length) n++;
+  if (f.protocols.length) n++;
+  if (f.inboundIds.length) n++;
+  if (f.groups.length) n++;
+  if (f.expiryFrom || f.expiryTo) n++;
+  if (f.usageFromGB || f.usageToGB) n++;
+  if (f.autoRenew) n++;
+  if (f.hasTgId) n++;
+  if (f.hasComment) n++;
+  return n;
+}

+ 528 - 0
frontend/src/pages/groups/GroupsPage.tsx

@@ -0,0 +1,528 @@
+import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Button,
+  Card,
+  Col,
+  ConfigProvider,
+  Dropdown,
+  Form,
+  Input,
+  Layout,
+  Modal,
+  Row,
+  Space,
+  Spin,
+  Statistic,
+  Table,
+  Tag,
+  Tooltip,
+  message,
+} from 'antd';
+import type { MenuProps, TableColumnsType } from 'antd';
+import {
+  ClockCircleOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  LinkOutlined,
+  MoreOutlined,
+  PlusOutlined,
+  RetweetOutlined,
+  TagsOutlined,
+  TeamOutlined,
+} from '@ant-design/icons';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { useTheme } from '@/hooks/useTheme';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { usePageTitle } from '@/hooks/usePageTitle';
+import { useClients } from '@/hooks/useClients';
+import { HttpUtil } from '@/utils';
+import { setMessageInstance } from '@/utils/messageBus';
+import AppSidebar from '@/components/AppSidebar';
+import LazyMount from '@/components/LazyMount';
+import { keys } from '@/api/queryKeys';
+import { GroupSummaryListSchema, type GroupSummary } from '@/schemas/client';
+import { parseMsg } from '@/utils/zodValidate';
+
+const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
+const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
+
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
+
+async function fetchGroups(): Promise<GroupSummary[]> {
+  const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups');
+  const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups');
+  return validated.obj ?? [];
+}
+
+async function fetchEmailsForGroup(name: string): Promise<string[]> {
+  const msg = await HttpUtil.get<string[]>(
+    `/panel/api/clients/groups/${encodeURIComponent(name)}/emails`,
+    undefined,
+    { silent: true },
+  );
+  if (!msg?.success || !Array.isArray(msg.obj)) return [];
+  return msg.obj;
+}
+
+export default function GroupsPage() {
+  usePageTitle();
+  const { t } = useTranslation();
+  const { isDark, isUltra, antdThemeConfig } = useTheme();
+  const { isMobile } = useMediaQuery();
+  const [modal, modalContextHolder] = Modal.useModal();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
+  const queryClient = useQueryClient();
+
+  const { clients, subSettings, bulkAdjust, bulkDelete } = useClients();
+
+  const groupsQuery = useQuery({
+    queryKey: keys.clients.groups(),
+    queryFn: fetchGroups,
+  });
+  const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
+  const loading = groupsQuery.isFetching;
+  const fetched = groupsQuery.data !== undefined;
+
+  const invalidate = useCallback(() => {
+    queryClient.invalidateQueries({ queryKey: keys.clients.root() });
+  }, [queryClient]);
+
+  const createMut = useMutation({
+    mutationFn: (body: { name: string }) =>
+      HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const renameMut = useMutation({
+    mutationFn: (body: { oldName: string; newName: string }) =>
+      HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const deleteMut = useMutation({
+    mutationFn: (body: { name: string }) =>
+      HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const bulkResetMut = useMutation({
+    mutationFn: (body: { emails: string[] }) =>
+      HttpUtil.post('/panel/api/clients/bulkResetTraffic', body, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+  });
+
+  const [createOpen, setCreateOpen] = useState(false);
+  const [createName, setCreateName] = useState('');
+
+  const [renameOpen, setRenameOpen] = useState(false);
+  const [renameTarget, setRenameTarget] = useState<GroupSummary | null>(null);
+  const [renameValue, setRenameValue] = useState('');
+
+  const [subLinksOpen, setSubLinksOpen] = useState(false);
+  const [adjustOpen, setAdjustOpen] = useState(false);
+  const [groupEmails, setGroupEmails] = useState<string[]>([]);
+  const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
+
+  const totalGroups = groups.length;
+  const totalClients = useMemo(
+    () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
+    [groups],
+  );
+  const emptyGroups = useMemo(
+    () => groups.filter((g) => (g.clientCount || 0) === 0).length,
+    [groups],
+  );
+
+  function openCreate() {
+    setCreateName('');
+    setCreateOpen(true);
+  }
+
+  async function confirmCreate() {
+    const name = createName.trim();
+    if (!name) return;
+    if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) {
+      messageApi.error(t('pages.groups.renameCollision', { name }));
+      return;
+    }
+    const msg = await createMut.mutateAsync({ name });
+    if (msg?.success) {
+      messageApi.success(t('pages.groups.createSuccess', { name }));
+      setCreateOpen(false);
+    }
+  }
+
+  function openRename(g: GroupSummary) {
+    setRenameTarget(g);
+    setRenameValue(g.name);
+    setRenameOpen(true);
+  }
+
+  async function confirmRename() {
+    if (!renameTarget) return;
+    const next = renameValue.trim();
+    if (!next || next === renameTarget.name) {
+      setRenameOpen(false);
+      return;
+    }
+    if (groups.some((g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name)) {
+      messageApi.error(t('pages.groups.renameCollision', { name: next }));
+      return;
+    }
+    const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next });
+    if (msg?.success) {
+      const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
+      messageApi.success(t('pages.groups.renameSuccess', { count: affected }));
+      setRenameOpen(false);
+    }
+  }
+
+  function onDelete(g: GroupSummary) {
+    modal.confirm({
+      title: t('pages.groups.deleteConfirmTitle', { name: g.name }),
+      content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await deleteMut.mutateAsync({ name: g.name });
+        if (msg?.success) {
+          const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
+          messageApi.success(t('pages.groups.deleteSuccess', { count: affected }));
+        }
+      },
+    });
+  }
+
+  async function openSubLinksFor(g: GroupSummary) {
+    if (!g.clientCount) {
+      messageApi.info(t('pages.groups.emptyForAction'));
+      return;
+    }
+    const emails = await fetchEmailsForGroup(g.name);
+    if (emails.length === 0) {
+      messageApi.info(t('pages.groups.emptyForAction'));
+      return;
+    }
+    setGroupForAction(g);
+    setGroupEmails(emails);
+    setSubLinksOpen(true);
+  }
+
+  async function openAdjustFor(g: GroupSummary) {
+    if (!g.clientCount) {
+      messageApi.info(t('pages.groups.emptyForAction'));
+      return;
+    }
+    const emails = await fetchEmailsForGroup(g.name);
+    if (emails.length === 0) {
+      messageApi.info(t('pages.groups.emptyForAction'));
+      return;
+    }
+    setGroupForAction(g);
+    setGroupEmails(emails);
+    setAdjustOpen(true);
+  }
+
+  function onDeleteClients(g: GroupSummary) {
+    if (!g.clientCount) {
+      messageApi.info(t('pages.groups.emptyForAction'));
+      return;
+    }
+    modal.confirm({
+      title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }),
+      content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const emails = await fetchEmailsForGroup(g.name);
+        if (emails.length === 0) return;
+        const msg = await bulkDelete(emails);
+        if (msg?.success) {
+          const ok = msg.obj?.deleted ?? 0;
+          const skipped = msg.obj?.skipped ?? [];
+          const failed = skipped.length;
+          if (failed === 0) {
+            messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
+          } else {
+            const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
+            messageApi.warning(firstError
+              ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
+              : t('pages.groups.deleteClientsMixed', { ok, failed }));
+          }
+        }
+      },
+    });
+  }
+
+  function onResetTraffic(g: GroupSummary) {
+    if (!g.clientCount) {
+      messageApi.info(t('pages.groups.emptyForAction'));
+      return;
+    }
+    modal.confirm({
+      title: t('pages.groups.resetConfirmTitle', { name: g.name }),
+      content: t('pages.groups.resetConfirmContent', { count: g.clientCount }),
+      okText: t('reset'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const emails = await fetchEmailsForGroup(g.name);
+        if (emails.length === 0) return;
+        const msg = await bulkResetMut.mutateAsync({ emails });
+        if (msg?.success) {
+          const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
+          messageApi.success(t('pages.groups.resetSuccess', { count: affected }));
+        }
+      },
+    });
+  }
+
+  function rowActions(row: GroupSummary): MenuProps['items'] {
+    return [
+      {
+        key: 'subLinks',
+        icon: <LinkOutlined />,
+        label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
+        disabled: !row.clientCount,
+        onClick: () => openSubLinksFor(row),
+      },
+      {
+        key: 'adjust',
+        icon: <ClockCircleOutlined />,
+        label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
+        disabled: !row.clientCount,
+        onClick: () => openAdjustFor(row),
+      },
+      {
+        key: 'reset',
+        icon: <RetweetOutlined />,
+        label: t('pages.groups.resetTraffic'),
+        disabled: !row.clientCount,
+        onClick: () => onResetTraffic(row),
+      },
+      { type: 'divider' },
+      {
+        key: 'rename',
+        icon: <EditOutlined />,
+        label: t('pages.groups.rename'),
+        onClick: () => openRename(row),
+      },
+      {
+        key: 'deleteClients',
+        icon: <DeleteOutlined />,
+        label: t('pages.groups.deleteClients'),
+        danger: true,
+        disabled: !row.clientCount,
+        onClick: () => onDeleteClients(row),
+      },
+      {
+        key: 'delete',
+        icon: <DeleteOutlined />,
+        label: t('pages.groups.deleteGroupOnly'),
+        danger: true,
+        onClick: () => onDelete(row),
+      },
+    ];
+  }
+
+  const columns: TableColumnsType<GroupSummary> = [
+    {
+      title: t('pages.clients.actions'),
+      key: 'actions',
+      width: 90,
+      render: (_v, row) => (
+        <Space size={4}>
+          <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
+            <Button size="small" type="text" icon={<MoreOutlined />} />
+          </Dropdown>
+          <Tooltip title={t('pages.groups.rename')}>
+            <Button size="small" type="text" icon={<EditOutlined />} onClick={() => openRename(row)} />
+          </Tooltip>
+        </Space>
+      ),
+    },
+    {
+      title: t('pages.groups.name'),
+      dataIndex: 'name',
+      key: 'name',
+      render: (name: string) => <Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>{name}</Tag>,
+    },
+    {
+      title: t('pages.groups.clientCount'),
+      dataIndex: 'clientCount',
+      key: 'clientCount',
+      width: 180,
+      render: (count: number) => <span>{count || 0}</span>,
+    },
+  ];
+
+  const pageClass = useMemo(() => {
+    const classes = ['groups-page'];
+    if (isDark) classes.push('is-dark');
+    if (isUltra) classes.push('is-ultra');
+    return classes.join(' ');
+  }, [isDark, isUltra]);
+
+  return (
+    <ConfigProvider theme={antdThemeConfig}>
+      {messageContextHolder}
+      {modalContextHolder}
+      <Layout className={pageClass}>
+        <AppSidebar />
+        <Layout className="content-shell">
+          <Layout.Content id="content-layout" className="content-area">
+            <Spin spinning={!fetched} delay={200} description="Loading…" size="large">
+              {!fetched ? (
+                <div className="loading-spacer" />
+              ) : (
+                <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
+                  <Col span={24}>
+                    <Card size="small" hoverable className="summary-card">
+                      <Row gutter={[16, isMobile ? 16 : 12]}>
+                        <Col xs={12} sm={8} md={6}>
+                          <Statistic
+                            title={t('pages.groups.totalGroups')}
+                            value={String(totalGroups)}
+                            prefix={<TagsOutlined />}
+                          />
+                        </Col>
+                        <Col xs={12} sm={8} md={6}>
+                          <Statistic
+                            title={t('pages.groups.totalGroupedClients')}
+                            value={String(totalClients)}
+                            prefix={<TeamOutlined />}
+                          />
+                        </Col>
+                        <Col xs={12} sm={8} md={6}>
+                          <Statistic
+                            title={t('pages.groups.emptyGroups')}
+                            value={String(emptyGroups)}
+                          />
+                        </Col>
+                      </Row>
+                    </Card>
+                  </Col>
+
+                  <Col span={24}>
+                    <Card
+                      size="small"
+                      hoverable
+                      title={
+                        <div className="card-toolbar">
+                          <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
+                            {!isMobile && t('pages.groups.addGroup')}
+                          </Button>
+                        </div>
+                      }
+                    >
+                      <Table<GroupSummary>
+                        dataSource={groups}
+                        columns={columns}
+                        rowKey="name"
+                        size="small"
+                        pagination={false}
+                        loading={loading}
+                        locale={{
+                          emptyText: (
+                            <div className="card-empty">
+                              <TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+                              <div>{t('noData')}</div>
+                            </div>
+                          ),
+                        }}
+                      />
+                    </Card>
+                  </Col>
+                </Row>
+              )}
+            </Spin>
+          </Layout.Content>
+        </Layout>
+
+        <Modal
+          open={createOpen}
+          title={t('pages.groups.addGroup')}
+          okText={t('create')}
+          cancelText={t('cancel')}
+          confirmLoading={createMut.isPending}
+          onCancel={() => setCreateOpen(false)}
+          onOk={confirmCreate}
+          destroyOnHidden
+        >
+          <Form layout="vertical">
+            <Form.Item label={t('pages.groups.name')}>
+              <Input
+                value={createName}
+                onChange={(e) => setCreateName(e.target.value)}
+                onPressEnter={confirmCreate}
+                placeholder={t('pages.clients.groupPlaceholder')}
+                autoFocus
+              />
+            </Form.Item>
+          </Form>
+        </Modal>
+
+        <Modal
+          open={renameOpen}
+          title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
+          okText={t('save')}
+          cancelText={t('cancel')}
+          confirmLoading={renameMut.isPending}
+          onCancel={() => setRenameOpen(false)}
+          onOk={confirmRename}
+          destroyOnHidden
+        >
+          <Form layout="vertical">
+            <Form.Item label={t('pages.groups.name')}>
+              <Input
+                value={renameValue}
+                onChange={(e) => setRenameValue(e.target.value)}
+                onPressEnter={confirmRename}
+                placeholder={t('pages.clients.groupPlaceholder')}
+                autoFocus
+              />
+            </Form.Item>
+          </Form>
+        </Modal>
+
+        <LazyMount when={subLinksOpen}>
+          <SubLinksModal
+            open={subLinksOpen}
+            emails={groupEmails}
+            clients={clients}
+            subSettings={subSettings}
+            onOpenChange={setSubLinksOpen}
+          />
+        </LazyMount>
+
+        <LazyMount when={adjustOpen}>
+          <ClientBulkAdjustModal
+            open={adjustOpen}
+            count={groupEmails.length}
+            onOpenChange={setAdjustOpen}
+            onSubmit={async (addDays, addBytes) => {
+              const msg = await bulkAdjust(groupEmails, addDays, addBytes);
+              if (msg?.success) {
+                const obj = msg.obj ?? { adjusted: 0 };
+                messageApi.success(
+                  t('pages.groups.adjustSuccess', {
+                    count: obj.adjusted ?? 0,
+                    name: groupForAction?.name ?? '',
+                  }),
+                );
+                return obj;
+              }
+              return null;
+            }}
+          />
+        </LazyMount>
+      </Layout>
+    </ConfigProvider>
+  );
+}

Plik diff jest za duży
+ 535 - 514
frontend/src/pages/inbounds/InboundFormModal.tsx


+ 6 - 2
frontend/src/pages/inbounds/InboundList.css

@@ -132,8 +132,12 @@
 
 .card-empty {
   text-align: center;
-  opacity: 0.4;
-  padding: 20px 0;
+  color: var(--ant-color-text-secondary);
+  padding: 24px 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
 }
 
 @media (max-width: 768px) {

+ 112 - 17
frontend/src/pages/inbounds/InboundList.tsx

@@ -28,6 +28,7 @@ import {
   BlockOutlined,
   DeleteOutlined,
   InfoCircleOutlined,
+  UsergroupDeleteOutlined,
 } from '@ant-design/icons';
 
 import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
@@ -53,8 +54,58 @@ function readStreamHints(streamSettings: unknown): StreamHints {
   };
 }
 
-function readSettings(settings: unknown): { method?: string } {
-  return coerceInboundJsonField(settings) as { method?: string };
+// Display label for a network value. All known transports render in
+// upper-case for visual consistency with the TCP/UDP/TLS/Reality tags
+// already shown alongside; compound names (`httpupgrade`, `splithttp`,
+// `xhttp`) get a tiny touch of casing so they don't read as one word.
+function networkLabel(network: string): string {
+  const n = (network || '').toLowerCase();
+  if (!n) return 'TCP';
+  switch (n) {
+    case 'httpupgrade': return 'HTTPUpgrade';
+    case 'splithttp': return 'SplitHTTP';
+    case 'xhttp': return 'XHTTP';
+  }
+  return n.toUpperCase();
+}
+
+// Returns the underlying L4 protocol for transports whose name isn't
+// already TCP/UDP. `kcp` and `quic` both ride on UDP; everything else
+// (`ws`, `grpc`, `http`, `httpupgrade`, `xhttp`) is TCP-based and gets
+// no extra tag (the transport name implies TCP).
+function networkL4(network: string): 'UDP' | '' {
+  const n = (network || '').toLowerCase();
+  if (n === 'kcp' || n === 'quic') return 'UDP';
+  return '';
+}
+
+// Shadowsocks settings.network ("tcp" / "udp" / "tcp,udp") and Tunnel
+// settings.allowedNetwork (same shape, different field name) both carry
+// the L4 transport list independent of streamSettings. Returns a
+// comma-separated label.
+function commaNetworkLabel(raw: string): string {
+  const parts = (raw || 'tcp').toLowerCase().split(',').map((p) => p.trim()).filter(Boolean);
+  if (parts.length === 0) return 'TCP';
+  return parts.map(networkLabel).join(',');
+}
+
+function shadowsocksNetworkLabel(settings: unknown): string {
+  return commaNetworkLabel(readSettings(settings).network || '');
+}
+
+function tunnelNetworkLabel(settings: unknown): string {
+  return commaNetworkLabel(readSettings(settings).allowedNetwork || '');
+}
+
+// Mixed (socks+http combo) is always TCP at L4; settings.udp=true adds
+// UDP-associate support on the same port (SOCKS5 UDP).
+function mixedNetworkLabel(settings: unknown): string {
+  const st = coerceInboundJsonField(settings) as { udp?: boolean };
+  return st.udp ? 'TCP,UDP' : 'TCP';
+}
+
+function readSettings(settings: unknown): { method?: string; network?: string; allowedNetwork?: string } {
+  return coerceInboundJsonField(settings) as { method?: string; network?: string; allowedNetwork?: string };
 }
 
 function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
@@ -80,6 +131,7 @@ type ProtocolFlags = {
   isMixed?: boolean;
   isHTTP?: boolean;
   isWireguard?: boolean;
+  isTunnel?: boolean;
 };
 
 interface DBInboundRecord extends ProtocolFlags {
@@ -116,6 +168,7 @@ export type RowAction =
   | 'clipboard'
   | 'delete'
   | 'resetTraffic'
+  | 'delAllClients'
   | 'clone';
 
 export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -177,11 +230,12 @@ function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
 interface RowActionsMenuProps {
   record: DBInboundRecord;
   subEnable: boolean;
+  hasClients: boolean;
   onClick: (key: RowAction) => void;
   isMobile?: boolean;
 }
 
-function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean }): MenuProps['items'] {
+function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean; hasClients?: boolean }): MenuProps['items'] {
   const items: MenuProps['items'] = [];
   if (isMobile) {
     items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
@@ -204,11 +258,14 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInb
   items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
   items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
   items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
+  if (isInboundMultiUser(record) && hasClients) {
+    items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
+  }
   items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
   return items;
 }
 
-function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) {
+function RowActionsCell({ record, subEnable, hasClients, onClick }: RowActionsMenuProps) {
   const { t } = useTranslation();
   return (
     <div className="action-buttons">
@@ -216,7 +273,7 @@ function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) {
       <Dropdown
         trigger={['click']}
         menu={{
-          items: buildRowActionsMenu({ record, subEnable, t }),
+          items: buildRowActionsMenu({ record, subEnable, t, hasClients }),
           onClick: ({ key }) => onClick(key as RowAction),
         }}
       >
@@ -299,6 +356,7 @@ export default function InboundList({
           <RowActionsCell
             record={record}
             subEnable={subEnable}
+            hasClients={(clientCount[record.id]?.clients || 0) > 0}
             onClick={(key) => onRowAction({ key, dbInbound: record })}
           />
         ),
@@ -368,13 +426,21 @@ export default function InboundList({
         ...sorterFor('protocol'),
         render: (_, record) => {
           const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
-          if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) {
+          if (record.isWireguard || record.isHysteria) {
+            tags.push(<Tag key="n" color="green">UDP</Tag>);
+          } else if (record.isSS) {
             const stream = readStreamHints(record.streamSettings);
-            tags.push(
-              <Tag key="n" color="green">
-                {record.isHysteria ? 'UDP' : stream.network}
-              </Tag>,
-            );
+            tags.push(<Tag key="n" color="green">{shadowsocksNetworkLabel(record.settings)}</Tag>);
+            if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
+          } else if (record.isTunnel) {
+            tags.push(<Tag key="n" color="green">{tunnelNetworkLabel(record.settings)}</Tag>);
+          } else if (record.isMixed) {
+            tags.push(<Tag key="n" color="green">{mixedNetworkLabel(record.settings)}</Tag>);
+          } else if (record.isVMess || record.isVLess || record.isTrojan) {
+            const stream = readStreamHints(record.streamSettings);
+            tags.push(<Tag key="n" color="green">{networkLabel(stream.network)}</Tag>);
+            const l4 = networkL4(stream.network);
+            if (l4) tags.push(<Tag key="l4" color="green">{l4}</Tag>);
             if (stream.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
             if (stream.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
           }
@@ -541,7 +607,10 @@ export default function InboundList({
         {isMobile ? (
           <div className="inbound-cards">
             {sortedInbounds.length === 0 ? (
-              <div className="card-empty">—</div>
+              <div className="card-empty">
+                <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
+                <div>{t('noData')}</div>
+              </div>
             ) : (
               sortedInbounds.map((record) => (
                 <div key={record.id} className="inbound-card">
@@ -561,7 +630,7 @@ export default function InboundList({
                         trigger={['click']}
                         placement="bottomRight"
                         menu={{
-                          items: buildRowActionsMenu({ record, subEnable, t, isMobile: true }),
+                          items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
                           onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
                         }}
                       >
@@ -582,6 +651,14 @@ export default function InboundList({
             scroll={{ x: 1000 }}
             style={{ marginTop: 10 }}
             size="small"
+            locale={{
+              emptyText: (
+                <div className="card-empty">
+                  <ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+                  <div>{t('noData')}</div>
+                </div>
+              ),
+            }}
             onChange={(_p, _f, sorter) => {
               const single = Array.isArray(sorter) ? sorter[0] : sorter;
               const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
@@ -606,13 +683,31 @@ export default function InboundList({
             <div className="stat-row">
               <span className="stat-label">{t('pages.inbounds.protocol')}</span>
               <Tag color="purple">{statsRecord.protocol}</Tag>
-              {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (() => {
+              {(statsRecord.isWireguard || statsRecord.isHysteria) && (
+                <Tag color="green">UDP</Tag>
+              )}
+              {statsRecord.isSS && (() => {
+                const stream = readStreamHints(statsRecord.streamSettings);
+                return (
+                  <>
+                    <Tag color="green">{shadowsocksNetworkLabel(statsRecord.settings)}</Tag>
+                    {stream.isTls && <Tag color="blue">TLS</Tag>}
+                  </>
+                );
+              })()}
+              {statsRecord.isTunnel && (
+                <Tag color="green">{tunnelNetworkLabel(statsRecord.settings)}</Tag>
+              )}
+              {statsRecord.isMixed && (
+                <Tag color="green">{mixedNetworkLabel(statsRecord.settings)}</Tag>
+              )}
+              {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan) && (() => {
                 const stream = readStreamHints(statsRecord.streamSettings);
+                const l4 = networkL4(stream.network);
                 return (
                   <>
-                    <Tag color="green">
-                      {statsRecord.isHysteria ? 'UDP' : stream.network}
-                    </Tag>
+                    <Tag color="green">{networkLabel(stream.network)}</Tag>
+                    {l4 && <Tag color="green">{l4}</Tag>}
                     {stream.isTls && <Tag color="blue">TLS</Tag>}
                     {stream.isReality && <Tag color="blue">Reality</Tag>}
                   </>

+ 20 - 1
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -48,6 +48,7 @@ type RowAction =
   | 'clipboard'
   | 'delete'
   | 'resetTraffic'
+  | 'delAllClients'
   | 'clone';
 
 type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -355,6 +356,21 @@ export default function InboundsPage() {
     });
   }, [modal, refresh, t]);
 
+  const confirmDelAllClients = useCallback((dbInbound: DBInbound) => {
+    const count = clientCount[dbInbound.id]?.clients || 0;
+    modal.confirm({
+      title: t('pages.inbounds.delAllClientsConfirmTitle', { remark: dbInbound.remark, count }),
+      content: t('pages.inbounds.delAllClientsConfirmContent'),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delAllClients`);
+        if (msg?.success) await refresh();
+      },
+    });
+  }, [modal, refresh, t, clientCount]);
+
   const confirmClone = useCallback((dbInbound: DBInbound) => {
     modal.confirm({
       title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
@@ -456,13 +472,16 @@ export default function InboundsPage() {
       case 'resetTraffic':
         confirmResetTraffic(target);
         break;
+      case 'delAllClients':
+        confirmDelAllClients(target);
+        break;
       case 'clone':
         confirmClone(target);
         break;
       default:
         messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
     }
-  }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]);
+  }, [hydrateInbound, openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmDelAllClients, confirmClone, messageApi]);
 
   return (
     <ConfigProvider theme={antdThemeConfig}>

+ 7 - 9
frontend/src/pages/index/SystemHistoryModal.css

@@ -1,6 +1,12 @@
+.metric-modal-title {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px;
+}
+
 .bucket-select {
   width: 80px;
-  margin-left: 10px;
 }
 
 .history-tabs {
@@ -15,11 +21,3 @@
   border: 1px solid var(--ant-color-border-secondary);
   box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
 }
-
-.cpu-chart-meta {
-  margin-bottom: 12px;
-  font-size: 11.5px;
-  opacity: 0.65;
-  letter-spacing: 0.3px;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-}

+ 45 - 10
frontend/src/pages/index/SystemHistoryModal.tsx

@@ -47,6 +47,22 @@ function unitFormatter(unit: string, activeKey: string): (v: number) => string {
   };
 }
 
+function formatFullTimestamp(unixSec: number): string {
+  const d = new Date(unixSec * 1000);
+  const today = new Date();
+  const sameDay = d.getFullYear() === today.getFullYear()
+    && d.getMonth() === today.getMonth()
+    && d.getDate() === today.getDate();
+  const hh = String(d.getHours()).padStart(2, '0');
+  const mm = String(d.getMinutes()).padStart(2, '0');
+  const ss = String(d.getSeconds()).padStart(2, '0');
+  const time = `${hh}:${mm}:${ss}`;
+  if (sameDay) return time;
+  const MM = String(d.getMonth() + 1).padStart(2, '0');
+  const DD = String(d.getDate()).padStart(2, '0');
+  return `${MM}-${DD} ${time}`;
+}
+
 export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
@@ -54,6 +70,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
   const [bucket, setBucket] = useState(2);
   const [points, setPoints] = useState<number[]>([]);
   const [labels, setLabels] = useState<string[]>([]);
+  const [timestamps, setTimestamps] = useState<number[]>([]);
 
   const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
   const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
@@ -62,6 +79,22 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
     [activeMetric, activeKey],
   );
 
+  const tsLookup = useMemo(() => {
+    const m = new Map<string, number>();
+    for (let i = 0; i < labels.length; i++) {
+      m.set(labels[i], timestamps[i]);
+    }
+    return m;
+  }, [labels, timestamps]);
+
+  const tooltipLabelFormatter = useCallback(
+    (label: string) => {
+      const ts = tsLookup.get(label);
+      return ts ? formatFullTimestamp(ts) : label;
+    },
+    [tsLookup],
+  );
+
   const fetchBucket = useCallback(async () => {
     if (!activeMetric) return;
     try {
@@ -70,6 +103,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
       if (msg?.success && Array.isArray(msg.obj)) {
         const vals: number[] = [];
         const labs: string[] = [];
+        const tss: number[] = [];
         for (const p of msg.obj) {
           const d = new Date(p.t * 1000);
           const hh = String(d.getHours()).padStart(2, '0');
@@ -77,24 +111,26 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
           const ss = String(d.getSeconds()).padStart(2, '0');
           labs.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
           vals.push(Number(p.v) || 0);
+          tss.push(Number(p.t) || 0);
         }
         setLabels(labs);
         setPoints(vals);
+        setTimestamps(tss);
       } else {
         setLabels([]);
         setPoints([]);
+        setTimestamps([]);
       }
     } catch (e) {
       console.error('Failed to fetch history bucket', e);
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
     }
   }, [activeMetric, bucket]);
 
   useEffect(() => {
-    if (open) {
-      setActiveKey('cpu');
-    }
+    if (open) setActiveKey('cpu');
   }, [open]);
 
   useEffect(() => {
@@ -108,8 +144,8 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
       width={isMobile ? '95vw' : 900}
       onCancel={onClose}
       title={
-        <>
-          {t('pages.index.systemHistoryTitle')}
+        <div className="metric-modal-title">
+          <span>{t('pages.index.systemHistoryTitle')}</span>
           <Select
             value={bucket}
             size="small"
@@ -124,7 +160,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
               { value: 300, label: '5h' },
             ]}
           />
-        </>
+        </div>
       }
     >
       <Tabs
@@ -136,13 +172,10 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
       />
 
       <div className="cpu-chart-wrap">
-        <div className="cpu-chart-meta">
-          Timeframe: {bucket} sec per point (total {points.length} points)
-        </div>
         <Sparkline
           data={points}
           labels={labels}
-          height={220}
+          height={260}
           stroke={strokeColor}
           strokeWidth={2.2}
           showGrid
@@ -155,6 +188,8 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
           valueMin={0}
           valueMax={activeMetric?.valueMax ?? null}
           yFormatter={yFormatter}
+          tooltipLabelFormatter={tooltipLabelFormatter}
+          extrema={{ show: true, formatter: yFormatter }}
         />
       </div>
     </Modal>

+ 0 - 4
frontend/src/pages/index/XrayMetricsModal.css

@@ -63,7 +63,3 @@
   .obs-dot.is-alive { animation: none; }
 }
 
-.listen-tag {
-  opacity: 0.7;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-}

+ 46 - 10
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -70,6 +70,22 @@ function fmtTimestamp(unixSec: number): string {
   return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
 }
 
+function formatFullTimestamp(unixSec: number): string {
+  const d = new Date(unixSec * 1000);
+  const today = new Date();
+  const sameDay = d.getFullYear() === today.getFullYear()
+    && d.getMonth() === today.getMonth()
+    && d.getDate() === today.getDate();
+  const hh = String(d.getHours()).padStart(2, '0');
+  const mm = String(d.getMinutes()).padStart(2, '0');
+  const ss = String(d.getSeconds()).padStart(2, '0');
+  const time = `${hh}:${mm}:${ss}`;
+  if (sameDay) return time;
+  const MM = String(d.getMonth() + 1).padStart(2, '0');
+  const DD = String(d.getDate()).padStart(2, '0');
+  return `${MM}-${DD} ${time}`;
+}
+
 export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
@@ -77,6 +93,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
   const [bucket, setBucket] = useState(2);
   const [points, setPoints] = useState<number[]>([]);
   const [labels, setLabels] = useState<string[]>([]);
+  const [timestamps, setTimestamps] = useState<number[]>([]);
   const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
   const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
   const [obsActiveTag, setObsActiveTag] = useState('');
@@ -90,10 +107,27 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null;
 
+  const tsLookup = useMemo(() => {
+    const m = new Map<string, number>();
+    for (let i = 0; i < labels.length; i++) {
+      m.set(labels[i], timestamps[i]);
+    }
+    return m;
+  }, [labels, timestamps]);
+
+  const tooltipLabelFormatter = useCallback(
+    (label: string) => {
+      const ts = tsLookup.get(label);
+      return ts ? formatFullTimestamp(ts) : label;
+    },
+    [tsLookup],
+  );
+
   const applyHistory = useCallback((msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => {
     if (msg?.success && Array.isArray(msg.obj)) {
       const vals: number[] = [];
       const labs: string[] = [];
+      const tss: number[] = [];
       for (const p of msg.obj) {
         const d = new Date(p.t * 1000);
         const hh = String(d.getHours()).padStart(2, '0');
@@ -101,12 +135,15 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
         const ss = String(d.getSeconds()).padStart(2, '0');
         labs.push(currentBucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
         vals.push(Number(p.v) || 0);
+        tss.push(Number(p.t) || 0);
       }
       setLabels(labs);
       setPoints(vals);
+      setTimestamps(tss);
     } else {
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
     }
   }, []);
 
@@ -148,6 +185,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       console.error('Failed to fetch xray metrics bucket', e);
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
     }
   }, [activeMetric, bucket, applyHistory]);
 
@@ -155,6 +193,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
     if (!obsActiveTag) {
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
       return;
     }
     try {
@@ -165,6 +204,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       console.error('Failed to fetch observatory bucket', e);
       setLabels([]);
       setPoints([]);
+      setTimestamps([]);
     }
   }, [obsActiveTag, bucket, applyHistory]);
 
@@ -225,8 +265,8 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       width={isMobile ? '95vw' : 900}
       onCancel={onClose}
       title={
-        <>
-          {t('pages.index.xrayMetricsTitle')}
+        <div className="metric-modal-title">
+          <span>{t('pages.index.xrayMetricsTitle')}</span>
           <Select
             value={bucket}
             size="small"
@@ -241,7 +281,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
               { value: 300, label: '5h' },
             ]}
           />
-        </>
+        </div>
       }
     >
       {!state.enabled && (
@@ -313,16 +353,10 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
       )}
 
       <div className="cpu-chart-wrap">
-        <div className="cpu-chart-meta">
-          Timeframe: {bucket} sec per point (total {points.length} points)
-          {state.enabled && state.listen && (
-            <span className="listen-tag"> · {state.listen}</span>
-          )}
-        </div>
         <Sparkline
           data={points}
           labels={labels}
-          height={220}
+          height={260}
           stroke={strokeColor}
           strokeWidth={2.2}
           showGrid
@@ -335,6 +369,8 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
           valueMin={0}
           valueMax={null}
           yFormatter={yFormatter}
+          tooltipLabelFormatter={tooltipLabelFormatter}
+          extrema={{ show: true, formatter: yFormatter }}
         />
       </div>
     </Modal>

+ 6 - 2
frontend/src/pages/nodes/NodeList.css

@@ -135,6 +135,10 @@
 
 .card-empty {
   text-align: center;
-  opacity: 0.4;
-  padding: 20px 0;
+  color: var(--ant-color-text-secondary);
+  padding: 24px 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
 }

+ 13 - 1
frontend/src/pages/nodes/NodeList.tsx

@@ -15,6 +15,7 @@ import {
 import type { BadgeProps } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import {
+  ClusterOutlined,
   DeleteOutlined,
   EditOutlined,
   ExclamationCircleOutlined,
@@ -279,7 +280,10 @@ export default function NodeList({
         <>
           <div className="node-cards">
             {dataSource.length === 0 ? (
-              <div className="card-empty">—</div>
+              <div className="card-empty">
+                <ClusterOutlined style={{ fontSize: 28, opacity: 0.5 }} />
+                <div>{t('noData')}</div>
+              </div>
             ) : (
               dataSource.map((record) => (
                 <div key={record.id} className="node-card">
@@ -435,6 +439,14 @@ export default function NodeList({
           scroll={{ x: 'max-content' }}
           size="middle"
           rowKey="id"
+          locale={{
+            emptyText: (
+              <div className="card-empty">
+                <ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+                <div>{t('noData')}</div>
+              </div>
+            ),
+          }}
           expandable={{
             expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
           }}

+ 37 - 40
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -13,7 +13,7 @@ import {
   Tabs,
   message,
 } from 'antd';
-import { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';
+import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 
 import FinalMaskForm from '@/components/FinalMaskForm';
 import HeaderMapEditor from '@/components/HeaderMapEditor';
@@ -98,7 +98,7 @@ function isMuxAllowed(protocol: string, flow: string, network: string): boolean
 }
 
 const NETWORK_OPTIONS: { value: string; label: string }[] = [
-  { value: 'tcp', label: 'TCP (RAW)' },
+  { value: 'tcp', label: 'RAW' },
   { value: 'kcp', label: 'mKCP' },
   { value: 'ws', label: 'WebSocket' },
   { value: 'grpc', label: 'gRPC' },
@@ -684,11 +684,11 @@ export default function OutboundFormModal({
                                         ['settings', 'fragment'],
                                         checked
                                           ? {
-                                              packets: 'tlshello',
-                                              length: '100-200',
-                                              interval: '10-20',
-                                              maxSplit: '300-400',
-                                            }
+                                            packets: 'tlshello',
+                                            length: '100-200',
+                                            interval: '10-20',
+                                            maxSplit: '300-400',
+                                          }
                                           : { packets: '', length: '', interval: '', maxSplit: '' },
                                       );
                                     }}
@@ -977,23 +977,20 @@ export default function OutboundFormModal({
                         <Form.Item label={t('pages.inbounds.address')} name={['settings', 'address']}>
                           <Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
                         </Form.Item>
-                        <Form.Item
-                          label={
-                            <>
-                              {t('pages.inbounds.privatekey')}
-                              <SyncOutlined
-                                className="random-icon"
-                                onClick={() => {
-                                  const pair = Wireguard.generateKeypair();
-                                  form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
-                                  form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
-                                }}
-                              />
-                            </>
-                          }
-                          name={['settings', 'secretKey']}
-                        >
-                          <Input />
+                        <Form.Item label={t('pages.inbounds.privatekey')}>
+                          <Space.Compact block>
+                            <Form.Item name={['settings', 'secretKey']} noStyle>
+                              <Input style={{ width: 'calc(100% - 32px)' }} />
+                            </Form.Item>
+                            <Button
+                              icon={<ReloadOutlined />}
+                              onClick={() => {
+                                const pair = Wireguard.generateKeypair();
+                                form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
+                                form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
+                              }}
+                            />
+                          </Space.Compact>
                         </Form.Item>
                         <Form.Item label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
                           <Input disabled />
@@ -1143,20 +1140,20 @@ export default function OutboundFormModal({
                                           ['streamSettings', 'tcpSettings', 'header'],
                                           checked
                                             ? {
-                                                type: 'http',
-                                                request: {
-                                                  version: '1.1',
-                                                  method: 'GET',
-                                                  path: ['/'],
-                                                  headers: {},
-                                                },
-                                                response: {
-                                                  version: '1.1',
-                                                  status: '200',
-                                                  reason: 'OK',
-                                                  headers: {},
-                                                },
-                                              }
+                                              type: 'http',
+                                              request: {
+                                                version: '1.1',
+                                                method: 'GET',
+                                                path: ['/'],
+                                                headers: {},
+                                              },
+                                              response: {
+                                                version: '1.1',
+                                                status: '200',
+                                                reason: 'OK',
+                                                headers: {},
+                                              },
+                                            }
                                             : { type: 'none' },
                                         )
                                       }
@@ -1771,8 +1768,8 @@ export default function OutboundFormModal({
                               normalize={(v: unknown) =>
                                 Array.isArray(v)
                                   ? v
-                                      .map((x) => Number(x))
-                                      .filter((n) => Number.isInteger(n) && n > 0)
+                                    .map((x) => Number(x))
+                                    .filter((n) => Number.isInteger(n) && n > 0)
                                   : []
                               }
                             >

+ 2 - 0
frontend/src/routes.tsx

@@ -6,6 +6,7 @@ import PanelLayout from '@/layouts/PanelLayout';
 const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
 const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
 const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
+const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
 const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
 const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
 const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
@@ -23,6 +24,7 @@ const routes: RouteObject[] = [
       { index: true, element: withSuspense(<IndexPage />) },
       { path: 'inbounds', element: withSuspense(<InboundsPage />) },
       { path: 'clients', element: withSuspense(<ClientsPage />) },
+      { path: 'groups', element: withSuspense(<GroupsPage />) },
       { path: 'nodes', element: withSuspense(<NodesPage />) },
       { path: 'settings', element: withSuspense(<SettingsPage />) },
       { path: 'xray', element: withSuspense(<XrayPage />) },

+ 14 - 0
frontend/src/schemas/client.ts

@@ -25,6 +25,7 @@ export const ClientRecordSchema = z.object({
   expiryTime: z.number().optional(),
   limitIp: z.number().optional(),
   tgId: z.union([z.number(), z.string()]).optional(),
+  group: z.string().optional(),
   comment: z.string().optional(),
   enable: z.boolean().optional(),
   reset: z.number().optional(),
@@ -63,6 +64,7 @@ export const ClientPageResponseSchema = z.object({
   page: z.number(),
   pageSize: z.number(),
   summary: ClientsSummarySchema.nullable().optional(),
+  groups: nullableStringArray.optional(),
 });
 
 export const ClientHydrateSchema = z.object({
@@ -97,6 +99,13 @@ export const DelDepletedResultSchema = z.object({
 
 export const OnlinesSchema = nullableStringArray;
 
+export const GroupSummarySchema = z.object({
+  name: z.string(),
+  clientCount: z.number(),
+});
+
+export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
+
 export const ClientFormSchema = z.object({
   email: z.string().trim().min(1, 'pages.clients.email'),
   subId: z.string(),
@@ -108,8 +117,10 @@ export const ClientFormSchema = z.object({
   totalGB: z.number().min(0),
   delayedStart: z.boolean(),
   delayedDays: z.number().int().min(0),
+  reset: z.number().int().min(0),
   limitIp: z.number().int().min(0),
   tgId: z.number().int().min(0),
+  group: z.string(),
   comment: z.string(),
   enable: z.boolean(),
   inboundIds: z.array(z.number()),
@@ -136,11 +147,13 @@ export const ClientBulkAddFormSchema = z.object({
   emailPostfix: z.string(),
   quantity: z.number().int().min(1).max(100),
   subId: z.string(),
+  group: z.string(),
   comment: z.string(),
   flow: z.string(),
   limitIp: z.number().int().min(0),
   totalGB: z.number().min(0),
   expiryTime: z.number(),
+  reset: z.number().int().min(0),
   inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
 });
 
@@ -156,3 +169,4 @@ export type BulkCreateResult = z.infer<typeof BulkCreateResultSchema>;
 export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
 export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
 export type ClientFormValues = z.infer<typeof ClientFormSchema>;
+export type GroupSummary = z.infer<typeof GroupSummarySchema>;

+ 7 - 0
frontend/src/styles/page-cards.css

@@ -4,6 +4,7 @@
 .xray-page .ant-card,
 .settings-page .ant-card,
 .nodes-page .ant-card,
+.groups-page .ant-card,
 .api-docs-page .ant-card {
   border-radius: 12px;
   box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
@@ -16,6 +17,7 @@
 .xray-page.is-dark .ant-card,
 .settings-page.is-dark .ant-card,
 .nodes-page.is-dark .ant-card,
+.groups-page.is-dark .ant-card,
 .api-docs-page.is-dark .ant-card {
   box-shadow:
     0 1px 2px rgba(0, 0, 0, 0.4),
@@ -28,6 +30,7 @@
 .xray-page.is-dark.is-ultra .ant-card,
 .settings-page.is-dark.is-ultra .ant-card,
 .nodes-page.is-dark.is-ultra .ant-card,
+.groups-page.is-dark.is-ultra .ant-card,
 .api-docs-page.is-dark.is-ultra .ant-card {
   box-shadow:
     0 1px 2px rgba(0, 0, 0, 0.6),
@@ -40,6 +43,7 @@
 .xray-page .ant-card.ant-card-hoverable:hover,
 .settings-page .ant-card.ant-card-hoverable:hover,
 .nodes-page .ant-card.ant-card-hoverable:hover,
+.groups-page .ant-card.ant-card-hoverable:hover,
 .api-docs-page .ant-card.ant-card-hoverable:hover {
   transform: translateY(-2px);
   box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
@@ -51,6 +55,7 @@
 .xray-page.is-dark .ant-card.ant-card-hoverable:hover,
 .settings-page.is-dark .ant-card.ant-card-hoverable:hover,
 .nodes-page.is-dark .ant-card.ant-card-hoverable:hover,
+.groups-page.is-dark .ant-card.ant-card-hoverable:hover,
 .api-docs-page.is-dark .ant-card.ant-card-hoverable:hover {
   box-shadow:
     0 8px 24px rgba(0, 0, 0, 0.5),
@@ -63,6 +68,7 @@
 .xray-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
 .settings-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
 .nodes-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
+.groups-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
 .api-docs-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover {
   box-shadow:
     0 8px 24px rgba(0, 0, 0, 0.75),
@@ -75,6 +81,7 @@
 .xray-page .ant-card .ant-card-actions,
 .settings-page .ant-card .ant-card-actions,
 .nodes-page .ant-card .ant-card-actions,
+.groups-page .ant-card .ant-card-actions,
 .api-docs-page .ant-card .ant-card-actions {
   background: transparent;
 }

+ 14 - 4
frontend/src/styles/page-shell.css

@@ -4,6 +4,7 @@
 .xray-page,
 .settings-page,
 .nodes-page,
+.groups-page,
 .api-docs-page {
   --bg-page: #e6e8ec;
   --bg-card: #ffffff;
@@ -17,6 +18,7 @@
 .xray-page.is-dark,
 .settings-page.is-dark,
 .nodes-page.is-dark,
+.groups-page.is-dark,
 .api-docs-page.is-dark {
   --bg-page: #1a1b1f;
   --bg-card: #23252b;
@@ -28,6 +30,7 @@
 .xray-page.is-dark.is-ultra,
 .settings-page.is-dark.is-ultra,
 .nodes-page.is-dark.is-ultra,
+.groups-page.is-dark.is-ultra,
 .api-docs-page.is-dark.is-ultra {
   --bg-page: #000;
   --bg-card: #101013;
@@ -45,6 +48,8 @@
 .settings-page .ant-layout-content,
 .nodes-page .ant-layout,
 .nodes-page .ant-layout-content,
+.groups-page .ant-layout,
+.groups-page .ant-layout-content,
 .api-docs-page .ant-layout,
 .api-docs-page .ant-layout-content {
   background: transparent;
@@ -56,6 +61,7 @@
 .xray-page .content-shell,
 .settings-page .content-shell,
 .nodes-page .content-shell,
+.groups-page .content-shell,
 .api-docs-page .content-shell {
   background: transparent;
 }
@@ -65,14 +71,16 @@
 .inbounds-page .content-area,
 .xray-page .content-area,
 .settings-page .content-area,
-.nodes-page .content-area {
+.nodes-page .content-area,
+.groups-page .content-area {
   padding: 24px;
 }
 
 @media (max-width: 768px) {
   .clients-page .content-area,
   .inbounds-page .content-area,
-  .nodes-page .content-area {
+  .nodes-page .content-area,
+  .groups-page .content-area {
     padding: 8px;
   }
 }
@@ -130,14 +138,16 @@
 
 .clients-page .summary-card,
 .inbounds-page .summary-card,
-.nodes-page .summary-card {
+.nodes-page .summary-card,
+.groups-page .summary-card {
   padding: 16px;
 }
 
 @media (max-width: 768px) {
   .clients-page .summary-card,
   .inbounds-page .summary-card,
-  .nodes-page .summary-card {
+  .nodes-page .summary-card,
+  .groups-page .summary-card {
     padding: 8px;
   }
 }

+ 0 - 6
frontend/src/styles/utils.css

@@ -16,12 +16,6 @@
 
 .zero-margin { margin: 0; }
 
-.random-icon {
-  margin-left: 4px;
-  cursor: pointer;
-  color: var(--ant-color-primary);
-}
-
 .danger-icon {
   margin-left: 8px;
   cursor: pointer;

+ 127 - 0
web/controller/client.go

@@ -47,12 +47,20 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/bulkAdjust", a.bulkAdjust)
 	g.POST("/bulkDel", a.bulkDelete)
 	g.POST("/bulkCreate", a.bulkCreate)
+	g.POST("/bulkAssignGroup", a.bulkAssignGroup)
 	g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
 	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
 	g.POST("/ips/:email", a.getIps)
 	g.POST("/clearIps/:email", a.clearIps)
 	g.POST("/onlines", a.onlines)
 	g.POST("/lastOnline", a.lastOnline)
+
+	g.GET("/groups", a.listGroups)
+	g.GET("/groups/:name/emails", a.groupEmails)
+	g.POST("/groups/create", a.createGroup)
+	g.POST("/groups/rename", a.renameGroup)
+	g.POST("/groups/delete", a.deleteGroup)
+	g.POST("/bulkResetTraffic", a.bulkResetTraffic)
 }
 
 func (a *ClientController) list(c *gin.Context) {
@@ -210,6 +218,27 @@ type bulkDeleteRequest struct {
 	KeepTraffic bool     `json:"keepTraffic"`
 }
 
+type bulkAssignGroupRequest struct {
+	Emails []string `json:"emails"`
+	Group  string   `json:"group"`
+}
+
+func (a *ClientController) bulkAssignGroup(c *gin.Context) {
+	var req bulkAssignGroupRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	affected, err := a.clientService.AssignGroup(req.Emails, req.Group)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	a.xrayService.SetToNeedRestart()
+	notifyClientsChanged()
+}
+
 func (a *ClientController) bulkDelete(c *gin.Context) {
 	var req bulkDeleteRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
@@ -393,3 +422,101 @@ func (a *ClientController) detach(c *gin.Context) {
 	}
 	notifyClientsChanged()
 }
+
+func (a *ClientController) listGroups(c *gin.Context) {
+	rows, err := a.clientService.ListGroups()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, rows, nil)
+}
+
+func (a *ClientController) groupEmails(c *gin.Context) {
+	name := c.Param("name")
+	emails, err := a.clientService.EmailsByGroup(name)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, emails, nil)
+}
+
+type bulkResetRequest struct {
+	Emails []string `json:"emails"`
+}
+
+func (a *ClientController) bulkResetTraffic(c *gin.Context) {
+	var req bulkResetRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	affected, err := a.clientService.BulkResetTraffic(&a.inboundService, req.Emails)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	a.xrayService.SetToNeedRestart()
+	notifyClientsChanged()
+}
+
+type groupCreateBody struct {
+	Name string `json:"name"`
+}
+
+func (a *ClientController) createGroup(c *gin.Context) {
+	var body groupCreateBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.clientService.CreateGroup(body.Name); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"name": body.Name}, nil)
+	notifyClientsChanged()
+}
+
+type groupRenameBody struct {
+	OldName string `json:"oldName"`
+	NewName string `json:"newName"`
+}
+
+func (a *ClientController) renameGroup(c *gin.Context) {
+	var body groupRenameBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	a.xrayService.SetToNeedRestart()
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	notifyClientsChanged()
+}
+
+type groupDeleteBody struct {
+	Name string `json:"name"`
+}
+
+func (a *ClientController) deleteGroup(c *gin.Context) {
+	var body groupDeleteBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	affected, err := a.clientService.DeleteGroup(body.Name)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	a.xrayService.SetToNeedRestart()
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	notifyClientsChanged()
+}

+ 36 - 19
web/controller/inbound.go

@@ -2,7 +2,6 @@ package controller
 
 import (
 	"encoding/json"
-	"fmt"
 	"net"
 	"strconv"
 	"strings"
@@ -19,6 +18,7 @@ import (
 // InboundController handles HTTP requests related to Xray inbounds management.
 type InboundController struct {
 	inboundService  service.InboundService
+	clientService   service.ClientService
 	xrayService     service.XrayService
 	fallbackService service.FallbackService
 }
@@ -72,6 +72,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.POST("/update/:id", a.updateInbound)
 	g.POST("/setEnable/:id", a.setInboundEnable)
 	g.POST("/:id/resetTraffic", a.resetInboundTraffic)
+	g.POST("/:id/delAllClients", a.delAllInboundClients)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/import", a.importInbound)
 	g.POST("/:id/fallbacks", a.setFallbacks)
@@ -143,17 +144,6 @@ func (a *InboundController) addInbound(c *gin.Context) {
 	if inbound.NodeID != nil && *inbound.NodeID == 0 {
 		inbound.NodeID = nil
 	}
-	// When the central panel deploys an inbound to a remote node, it sends
-	// the Tag pre-computed (so both DBs agree on the identifier). Local
-	// UI submits don't include a Tag — we compute one from listen+port
-	// using the original collision-avoiding scheme.
-	if inbound.Tag == "" {
-		if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
-			inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
-		} else {
-			inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
-		}
-	}
 
 	inbound, needRestart, err := a.inboundService.AddInbound(inbound)
 	if err != nil {
@@ -276,6 +266,40 @@ func (a *InboundController) resetInboundTraffic(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
 }
 
+// delAllInboundClients removes every client attached to a specific inbound
+// while keeping the inbound itself. Internally collects the current email
+// list from settings.clients[] and feeds it into ClientService.BulkDelete,
+// which handles per-inbound JSON rewriting, runtime user removal, traffic
+// row cleanup, and the SyncInbound mapping pass in one optimized cycle.
+func (a *InboundController) delAllInboundClients(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	emails, err := a.inboundService.EmailsByInbound(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if len(emails) == 0 {
+		jsonObj(c, service.BulkDeleteResult{}, nil)
+		return
+	}
+	result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, emails, false)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	user := session.GetLoginUser(c)
+	a.broadcastInboundsUpdate(user.Id)
+	notifyClientsChanged()
+}
+
 // resetAllTraffics resets all traffic counters across all inbounds.
 func (a *InboundController) resetAllTraffics(c *gin.Context) {
 	err := a.inboundService.ResetAllTraffics()
@@ -302,13 +326,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
 	if inbound.NodeID != nil && *inbound.NodeID == 0 {
 		inbound.NodeID = nil
 	}
-	if inbound.Tag == "" {
-		if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
-			inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
-		} else {
-			inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
-		}
-	}
 
 	for index := range inbound.ClientStats {
 		inbound.ClientStats[index].Id = 0

+ 577 - 22
web/service/client.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"slices"
 	"sort"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -236,17 +237,25 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 			row.ExpiryTime = incoming.ExpiryTime
 			row.Enable = incoming.Enable
 			row.TgID = incoming.TgID
+			row.Group = incoming.Group
 			row.Comment = incoming.Comment
 			row.Reset = incoming.Reset
 			if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
 				row.CreatedAt = incoming.CreatedAt
 			}
-			if incoming.UpdatedAt > row.UpdatedAt {
-				row.UpdatedAt = incoming.UpdatedAt
+			preservedUpdatedAt := row.UpdatedAt
+			if incoming.UpdatedAt > preservedUpdatedAt {
+				preservedUpdatedAt = incoming.UpdatedAt
 			}
+			row.UpdatedAt = preservedUpdatedAt
 			if err := tx.Save(row).Error; err != nil {
 				return err
 			}
+			if err := tx.Model(&model.ClientRecord{}).
+				Where("id = ?", row.Id).
+				UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
+				return err
+			}
 		}
 
 		link := model.ClientInbound{
@@ -595,6 +604,27 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		updated.CreatedAt = existing.CreatedAt
 	}
 
+	// Rename the ClientRecord row up front when the email changes. SyncInbound
+	// (invoked from UpdateInboundClient below) looks up by email — without
+	// renaming first it would treat the new email as a brand-new client,
+	// insert a duplicate ClientRecord, and leave the original orphaned.
+	if updated.Email != existing.Email {
+		var collisionCount int64
+		if err := database.GetDB().Model(&model.ClientRecord{}).
+			Where("email = ? AND id <> ?", updated.Email, id).
+			Count(&collisionCount).Error; err != nil {
+			return false, err
+		}
+		if collisionCount > 0 {
+			return false, common.NewError("Duplicate email:", updated.Email)
+		}
+		if err := database.GetDB().Model(&model.ClientRecord{}).
+			Where("id = ?", id).
+			Update("email", updated.Email).Error; err != nil {
+			return false, err
+		}
+	}
+
 	needRestart := false
 	for _, ibId := range inboundIds {
 		inbound, getErr := inboundSvc.GetInbound(ibId)
@@ -626,7 +656,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 
 	if err := database.GetDB().Model(&model.ClientRecord{}).
 		Where("id = ?", id).
-		Update("updated_at", updated.UpdatedAt).Error; err != nil {
+		UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
 		return needRestart, err
 	}
 	return needRestart, nil
@@ -834,6 +864,7 @@ type ClientSlim struct {
 	ExpiryTime int64               `json:"expiryTime"`
 	LimitIP    int                 `json:"limitIp"`
 	Reset      int                 `json:"reset"`
+	Group      string              `json:"group,omitempty"`
 	Comment    string              `json:"comment,omitempty"`
 	InboundIds []int               `json:"inboundIds"`
 	Traffic    *xray.ClientTraffic `json:"traffic,omitempty"`
@@ -843,15 +874,29 @@ type ClientSlim struct {
 
 // ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
 // All fields are optional — the empty value means "no filter" / defaults.
+//
+// Filter / Protocol / Inbound accept either a single value or a comma-separated
+// list; matching is OR within a field and AND across fields. The numeric range
+// fields treat 0 as "unset" on the lower bound and 0 (or negative) as
+// "unbounded" on the upper bound.
 type ClientPageParams struct {
 	Page     int    `form:"page"`
 	PageSize int    `form:"pageSize"`
 	Search   string `form:"search"`
 	Filter   string `form:"filter"`
 	Protocol string `form:"protocol"`
-	Inbound  int    `form:"inbound"`
+	Inbound  string `form:"inbound"`
 	Sort     string `form:"sort"`
 	Order    string `form:"order"`
+
+	ExpiryFrom int64  `form:"expiryFrom"`
+	ExpiryTo   int64  `form:"expiryTo"`
+	UsageFrom  int64  `form:"usageFrom"`
+	UsageTo    int64  `form:"usageTo"`
+	AutoRenew  string `form:"autoRenew"`
+	HasTgID    string `form:"hasTgId"`
+	HasComment string `form:"hasComment"`
+	Group      string `form:"group"`
 }
 
 // ClientPageResponse is the shape returned by ListPaged. `Total` is the
@@ -866,6 +911,7 @@ type ClientPageResponse struct {
 	Page     int            `json:"page"`
 	PageSize int            `json:"pageSize"`
 	Summary  ClientsSummary `json:"summary"`
+	Groups   []string       `json:"groups"`
 }
 
 // ClientsSummary collects per-bucket counts plus the matching email lists so
@@ -910,8 +956,12 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		page = 1
 	}
 
+	protocols := parseCSVStrings(params.Protocol)
+	inboundIDs := parseCSVInts(params.Inbound)
+	buckets := parseCSVStrings(params.Filter)
+
 	var protocolByInbound map[int]string
-	if params.Protocol != "" {
+	if len(protocols) > 0 {
 		inbounds, err := inboundSvc.GetAllInbounds()
 		if err == nil {
 			protocolByInbound = make(map[int]string, len(inbounds))
@@ -947,13 +997,31 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		if needle != "" && !clientMatchesSearch(c, needle) {
 			continue
 		}
-		if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
+		if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
+			continue
+		}
+		if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
+			continue
+		}
+		if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
 			continue
 		}
-		if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) {
+		if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
 			continue
 		}
-		if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
+		if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
+			continue
+		}
+		if !clientMatchesAutoRenew(c, params.AutoRenew) {
+			continue
+		}
+		if !clientMatchesHasTgID(c, params.HasTgID) {
+			continue
+		}
+		if !clientMatchesHasComment(c, params.HasComment) {
+			continue
+		}
+		if !clientMatchesAnyGroup(c, params.Group) {
 			continue
 		}
 		filtered = append(filtered, c)
@@ -977,6 +1045,15 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		items = append(items, toClientSlim(c))
 	}
 
+	groupRows, gErr := s.ListGroups()
+	if gErr != nil {
+		return nil, gErr
+	}
+	groups := make([]string, 0, len(groupRows))
+	for _, g := range groupRows {
+		groups = append(groups, g.Name)
+	}
+
 	return &ClientPageResponse{
 		Items:    items,
 		Total:    total,
@@ -984,9 +1061,321 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		Page:     page,
 		PageSize: pageSize,
 		Summary:  summary,
+		Groups:   groups,
 	}, nil
 }
 
+type GroupSummary struct {
+	Name        string `json:"name"`
+	ClientCount int    `json:"clientCount"`
+}
+
+func (s *ClientService) ListGroups() ([]GroupSummary, error) {
+	db := database.GetDB()
+	var derived []GroupSummary
+	if err := db.Model(&model.ClientRecord{}).
+		Select("group_name AS name, COUNT(*) AS client_count").
+		Where("group_name <> ''").
+		Group("group_name").
+		Scan(&derived).Error; err != nil {
+		return nil, err
+	}
+	var stored []model.ClientGroup
+	if err := db.Find(&stored).Error; err != nil {
+		return nil, err
+	}
+	merged := make(map[string]int, len(derived)+len(stored))
+	for _, g := range stored {
+		merged[g.Name] = 0
+	}
+	for _, g := range derived {
+		merged[g.Name] = g.ClientCount
+	}
+	out := make([]GroupSummary, 0, len(merged))
+	for name, count := range merged {
+		out = append(out, GroupSummary{Name: name, ClientCount: count})
+	}
+	sort.Slice(out, func(i, j int) bool {
+		return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
+	})
+	return out, nil
+}
+
+func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return []string{}, nil
+	}
+	db := database.GetDB()
+	var emails []string
+	if err := db.Model(&model.ClientRecord{}).
+		Where("group_name = ?", name).
+		Order("email ASC").
+		Pluck("email", &emails).Error; err != nil {
+		return nil, err
+	}
+	if emails == nil {
+		emails = []string{}
+	}
+	return emails, nil
+}
+
+func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []string) (int, error) {
+	if len(emails) == 0 {
+		return 0, nil
+	}
+	count := 0
+	for _, email := range emails {
+		if _, err := s.ResetTrafficByEmail(inboundSvc, email); err != nil {
+			return count, err
+		}
+		count++
+	}
+	return count, nil
+}
+
+func (s *ClientService) CreateGroup(name string) error {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return common.NewError("group name is required")
+	}
+	db := database.GetDB()
+	var count int64
+	if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil {
+		return err
+	}
+	if count > 0 {
+		return common.NewError("group already exists")
+	}
+	return db.Create(&model.ClientGroup{Name: name}).Error
+}
+
+func (s *ClientService) RenameGroup(oldName, newName string) (int, error) {
+	oldName = strings.TrimSpace(oldName)
+	newName = strings.TrimSpace(newName)
+	if oldName == "" {
+		return 0, common.NewError("old group name is required")
+	}
+	if newName == "" {
+		return 0, common.NewError("new group name is required")
+	}
+	if oldName == newName {
+		return 0, nil
+	}
+	return s.replaceGroupValue(oldName, newName)
+}
+
+func (s *ClientService) DeleteGroup(name string) (int, error) {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return 0, common.NewError("group name is required")
+	}
+	return s.replaceGroupValue(name, "")
+}
+
+func (s *ClientService) AssignGroup(emails []string, group string) (int, error) {
+	group = strings.TrimSpace(group)
+	if len(emails) == 0 {
+		return 0, nil
+	}
+	db := database.GetDB()
+
+	if group != "" {
+		var exists int64
+		if err := db.Model(&model.ClientGroup{}).Where("name = ?", group).Count(&exists).Error; err != nil {
+			return 0, err
+		}
+		if exists == 0 {
+			var derived int64
+			if err := db.Model(&model.ClientRecord{}).Where("group_name = ?", group).Count(&derived).Error; err != nil {
+				return 0, err
+			}
+			if derived == 0 {
+				if err := db.Create(&model.ClientGroup{Name: group}).Error; err != nil {
+					return 0, err
+				}
+			}
+		}
+	}
+
+	var records []model.ClientRecord
+	if err := db.Where("email IN ?", emails).Find(&records).Error; err != nil {
+		return 0, err
+	}
+	if len(records) == 0 {
+		return 0, nil
+	}
+	affectedEmails := make([]string, 0, len(records))
+	for _, r := range records {
+		affectedEmails = append(affectedEmails, r.Email)
+	}
+
+	tx := db.Begin()
+	if err := tx.Model(&model.ClientRecord{}).
+		Where("email IN ?", affectedEmails).
+		UpdateColumn("group_name", group).Error; err != nil {
+		tx.Rollback()
+		return 0, err
+	}
+
+	var inboundIDs []int
+	if err := tx.Table("client_inbounds").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email IN ?", affectedEmails).
+		Distinct("client_inbounds.inbound_id").
+		Pluck("inbound_id", &inboundIDs).Error; err != nil {
+		tx.Rollback()
+		return 0, err
+	}
+
+	emailSet := make(map[string]struct{}, len(affectedEmails))
+	for _, e := range affectedEmails {
+		emailSet[e] = struct{}{}
+	}
+
+	for _, ibID := range inboundIDs {
+		var ib model.Inbound
+		if err := tx.First(&ib, ibID).Error; err != nil {
+			tx.Rollback()
+			return 0, err
+		}
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
+			continue
+		}
+		clients, ok := settings["clients"].([]any)
+		if !ok {
+			continue
+		}
+		modified := false
+		for i := range clients {
+			cm, ok := clients[i].(map[string]any)
+			if !ok {
+				continue
+			}
+			email, _ := cm["email"].(string)
+			if _, hit := emailSet[email]; !hit {
+				continue
+			}
+			if group == "" {
+				delete(cm, "group")
+			} else {
+				cm["group"] = group
+			}
+			clients[i] = cm
+			modified = true
+		}
+		if modified {
+			settings["clients"] = clients
+			newSettings, err := json.Marshal(settings)
+			if err != nil {
+				continue
+			}
+			ib.Settings = string(newSettings)
+			if err := tx.Save(&ib).Error; err != nil {
+				tx.Rollback()
+				return 0, err
+			}
+		}
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		return 0, err
+	}
+	return len(records), nil
+}
+
+func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) {
+	db := database.GetDB()
+	if newName == "" {
+		if err := db.Where("name = ?", oldName).Delete(&model.ClientGroup{}).Error; err != nil {
+			return 0, err
+		}
+	} else {
+		if err := db.Model(&model.ClientGroup{}).Where("name = ?", oldName).Update("name", newName).Error; err != nil {
+			return 0, err
+		}
+	}
+	var records []model.ClientRecord
+	if err := db.Where("group_name = ?", oldName).Find(&records).Error; err != nil {
+		return 0, err
+	}
+	if len(records) == 0 {
+		return 0, nil
+	}
+	affectedEmails := make([]string, 0, len(records))
+	for _, r := range records {
+		affectedEmails = append(affectedEmails, r.Email)
+	}
+
+	tx := db.Begin()
+	if err := tx.Model(&model.ClientRecord{}).
+		Where("group_name = ?", oldName).
+		UpdateColumn("group_name", newName).Error; err != nil {
+		tx.Rollback()
+		return 0, err
+	}
+
+	var inboundIDs []int
+	if err := tx.Table("client_inbounds").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email IN ?", affectedEmails).
+		Distinct("client_inbounds.inbound_id").
+		Pluck("inbound_id", &inboundIDs).Error; err != nil {
+		tx.Rollback()
+		return 0, err
+	}
+
+	for _, ibID := range inboundIDs {
+		var ib model.Inbound
+		if err := tx.First(&ib, ibID).Error; err != nil {
+			tx.Rollback()
+			return 0, err
+		}
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
+			continue
+		}
+		clients, ok := settings["clients"].([]any)
+		if !ok {
+			continue
+		}
+		modified := false
+		for i := range clients {
+			cm, ok := clients[i].(map[string]any)
+			if !ok {
+				continue
+			}
+			if g, ok := cm["group"].(string); ok && g == oldName {
+				if newName == "" {
+					delete(cm, "group")
+				} else {
+					cm["group"] = newName
+				}
+				clients[i] = cm
+				modified = true
+			}
+		}
+		if modified {
+			settings["clients"] = clients
+			newSettings, err := json.Marshal(settings)
+			if err != nil {
+				continue
+			}
+			ib.Settings = string(newSettings)
+			if err := tx.Save(&ib).Error; err != nil {
+				tx.Rollback()
+				return 0, err
+			}
+		}
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		return 0, err
+	}
+	return len(records), nil
+}
+
 func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
 	s := ClientsSummary{
 		Total:    len(all),
@@ -1035,6 +1424,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim {
 		ExpiryTime: c.ExpiryTime,
 		LimitIP:    c.LimitIP,
 		Reset:      c.Reset,
+		Group:      c.Group,
 		Comment:    c.Comment,
 		InboundIds: c.InboundIds,
 		Traffic:    c.Traffic,
@@ -1047,35 +1437,177 @@ func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
 	if needle == "" {
 		return true
 	}
-	if strings.Contains(strings.ToLower(c.Email), needle) {
-		return true
+	candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
+	for _, v := range candidates {
+		if v != "" && strings.Contains(strings.ToLower(v), needle) {
+			return true
+		}
 	}
-	if strings.Contains(strings.ToLower(c.SubID), needle) {
-		return true
+	return false
+}
+
+// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
+// and drops blanks. Returns nil when the input has no usable entries — the
+// caller can then skip the predicate entirely.
+func parseCSVStrings(raw string) []string {
+	if raw == "" {
+		return nil
 	}
-	if strings.Contains(strings.ToLower(c.Comment), needle) {
-		return true
+	parts := strings.Split(raw, ",")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		s := strings.ToLower(strings.TrimSpace(p))
+		if s != "" {
+			out = append(out, s)
+		}
 	}
-	return false
+	if len(out) == 0 {
+		return nil
+	}
+	return out
 }
 
-func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound map[int]string) bool {
-	if protocol == "" {
-		return true
+// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
+// non-positive entries are silently dropped.
+func parseCSVInts(raw string) []int {
+	if raw == "" {
+		return nil
+	}
+	parts := strings.Split(raw, ",")
+	out := make([]int, 0, len(parts))
+	for _, p := range parts {
+		s := strings.TrimSpace(p)
+		if s == "" {
+			continue
+		}
+		if n, err := strconv.Atoi(s); err == nil && n > 0 {
+			out = append(out, n)
+		}
+	}
+	if len(out) == 0 {
+		return nil
 	}
+	return out
+}
+
+func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
 	for _, id := range c.InboundIds {
-		if byInbound[id] == protocol {
+		p := byInbound[id]
+		if p == "" {
+			continue
+		}
+		if slices.Contains(protocols, strings.ToLower(p)) {
 			return true
 		}
 	}
 	return false
 }
 
-func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool {
-	if inboundId <= 0 {
+func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
+	for _, id := range c.InboundIds {
+		if slices.Contains(inboundIds, id) {
+			return true
+		}
+	}
+	return false
+}
+
+func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
+	for _, b := range buckets {
+		if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
+			return true
+		}
+	}
+	return false
+}
+
+func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
+	if fromMs <= 0 && toMs <= 0 {
 		return true
 	}
-	return slices.Contains(c.InboundIds, inboundId)
+	// expiryTime of 0 means "never expires"; treat it as outside any bounded
+	// range so users filtering by date see only clients with concrete expiries.
+	if c.ExpiryTime == 0 {
+		return false
+	}
+	// Negative expiry is the "delayed start" sentinel; same treatment as never.
+	if c.ExpiryTime < 0 {
+		return false
+	}
+	if fromMs > 0 && c.ExpiryTime < fromMs {
+		return false
+	}
+	if toMs > 0 && c.ExpiryTime > toMs {
+		return false
+	}
+	return true
+}
+
+func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
+	if fromBytes <= 0 && toBytes <= 0 {
+		return true
+	}
+	used := int64(0)
+	if c.Traffic != nil {
+		used = c.Traffic.Up + c.Traffic.Down
+	}
+	if fromBytes > 0 && used < fromBytes {
+		return false
+	}
+	if toBytes > 0 && used > toBytes {
+		return false
+	}
+	return true
+}
+
+func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "on":
+		return c.Reset > 0
+	case "off":
+		return c.Reset <= 0
+	}
+	return true
+}
+
+func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "yes":
+		return c.TgID != 0
+	case "no":
+		return c.TgID == 0
+	}
+	return true
+}
+
+func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "yes":
+		return strings.TrimSpace(c.Comment) != ""
+	case "no":
+		return strings.TrimSpace(c.Comment) == ""
+	}
+	return true
+}
+
+func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
+	groups := parseCSVStrings(csv)
+	if len(groups) == 0 {
+		return true
+	}
+	current := strings.TrimSpace(c.Group)
+	for _, g := range groups {
+		if g == "" {
+			if current == "" {
+				return true
+			}
+			continue
+		}
+		if strings.EqualFold(g, current) {
+			return true
+		}
+	}
+	return false
 }
 
 func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
@@ -1167,6 +1699,29 @@ func sortClients(rows []ClientWithAttachments, sortKey, order string) {
 				eb = b.ExpiryTime
 			}
 			return ea < eb
+		case "createdAt":
+			if a.CreatedAt == b.CreatedAt {
+				return a.Id < b.Id
+			}
+			return a.CreatedAt < b.CreatedAt
+		case "updatedAt":
+			if a.UpdatedAt == b.UpdatedAt {
+				return a.Id < b.Id
+			}
+			return a.UpdatedAt < b.UpdatedAt
+		case "lastOnline":
+			la := int64(0)
+			if a.Traffic != nil {
+				la = a.Traffic.LastOnline
+			}
+			lb := int64(0)
+			if b.Traffic != nil {
+				lb = b.Traffic.LastOnline
+			}
+			if la == lb {
+				return a.Id < b.Id
+			}
+			return la < lb
 		}
 		return false
 	}

+ 84 - 15
web/service/inbound.go

@@ -5,10 +5,12 @@ package service
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"sort"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/google/uuid"
@@ -23,6 +25,8 @@ import (
 	"gorm.io/gorm/clause"
 )
 
+var reportedRemoteTagConflict sync.Map
+
 type InboundService struct {
 	xrayApi         xray.XrayAPI
 	clientService   ClientService
@@ -467,12 +471,12 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 
-	exist, err := s.checkPortConflict(inbound, 0)
+	conflict, err := s.checkPortConflict(inbound, 0)
 	if err != nil {
 		return inbound, false, err
 	}
-	if exist {
-		return inbound, false, common.NewError("Port already exists:", inbound.Port)
+	if conflict != nil {
+		return inbound, false, common.NewError(conflict.String())
 	}
 
 	inbound.Tag, err = s.resolveInboundTag(inbound, 0)
@@ -735,12 +739,12 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 
-	exist, err := s.checkPortConflict(inbound, inbound.Id)
+	conflict, err := s.checkPortConflict(inbound, inbound.Id)
 	if err != nil {
 		return inbound, false, err
 	}
-	if exist {
-		return inbound, false, common.NewError("Port already exists:", inbound.Port)
+	if conflict != nil {
+		return inbound, false, common.NewError(conflict.String())
 	}
 
 	oldInbound, err := s.GetInbound(inbound.Id)
@@ -1254,9 +1258,15 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		Find(&central).Error; err != nil {
 		return false, err
 	}
-	tagToCentral := make(map[string]*model.Inbound, len(central))
+	// Index under both stored tag and the prefix-stripped form so a snap's
+	// bare tag resolves whether or not we rewrote it with n<id>- at create.
+	tagToCentral := make(map[string]*model.Inbound, len(central)*2)
+	prefix := nodeTagPrefix(&nodeID)
 	for i := range central {
 		tagToCentral[central[i].Tag] = &central[i]
+		if prefix != "" && strings.HasPrefix(central[i].Tag, prefix) {
+			tagToCentral[strings.TrimPrefix(central[i].Tag, prefix)] = &central[i]
+		}
 	}
 
 	var centralClientStats []xray.ClientTraffic
@@ -1313,10 +1323,44 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 		c, ok := tagToCentral[snapIb.Tag]
 		if !ok {
+			// Try snap.Tag first; on collision fall back to the n<id>-
+			// prefixed form so local+node can both own the same port.
+			pickFreeTag := func() (string, error) {
+				candidates := []string{snapIb.Tag}
+				if prefix != "" && !strings.HasPrefix(snapIb.Tag, prefix) {
+					candidates = append(candidates, prefix+snapIb.Tag)
+				}
+				for _, t := range candidates {
+					var owner model.Inbound
+					err := tx.Where("tag = ?", t).First(&owner).Error
+					if errors.Is(err, gorm.ErrRecordNotFound) {
+						return t, nil
+					}
+					if err != nil {
+						return "", err
+					}
+				}
+				return "", nil
+			}
+			chosenTag, err := pickFreeTag()
+			if err != nil {
+				logger.Warningf("setRemoteTraffic: check tag %q failed: %v", snapIb.Tag, err)
+				continue
+			}
+			if chosenTag == "" {
+				key := fmt.Sprintf("%d:%s", nodeID, snapIb.Tag)
+				if _, seen := reportedRemoteTagConflict.LoadOrStore(key, struct{}{}); !seen {
+					logger.Warningf(
+						"setRemoteTraffic: tag %q from node %d collides with an existing inbound even after the n%d- prefix — skipping (rename one side to remove the duplicate)",
+						snapIb.Tag, nodeID, nodeID,
+					)
+				}
+				continue
+			}
 			newIb := model.Inbound{
 				UserId:         defaultUserId,
 				NodeID:         &nodeID,
-				Tag:            snapIb.Tag,
+				Tag:            chosenTag,
 				Listen:         snapIb.Listen,
 				Port:           snapIb.Port,
 				Protocol:       snapIb.Protocol,
@@ -1332,10 +1376,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				Down:           snapIb.Down,
 			}
 			if err := tx.Create(&newIb).Error; err != nil {
-				logger.Warning("setRemoteTraffic: create central inbound for tag", snapIb.Tag, "failed:", err)
+				logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)
 				continue
 			}
 			tagToCentral[snapIb.Tag] = &newIb
+			if newIb.Tag != snapIb.Tag {
+				tagToCentral[newIb.Tag] = &newIb
+			}
 			structuralChange = true
 			continue
 		}
@@ -1508,7 +1555,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 		clients, gcErr := s.GetClients(snapIb)
 		if gcErr != nil {
-			logger.Warning("setRemoteTraffic: parse clients for tag", snapIb.Tag, "failed:", gcErr)
+			logger.Warningf("setRemoteTraffic: parse clients for tag %q failed: %v", snapIb.Tag, gcErr)
 			continue
 		}
 		csEnableByEmail := make(map[string]bool, len(snapIb.ClientStats))
@@ -1526,7 +1573,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			filtered = append(filtered, clients[i])
 		}
 		if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
-			logger.Warning("setRemoteTraffic: sync clients for tag", snapIb.Tag, "failed:", err)
+			logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
 		}
 	}
 
@@ -1557,10 +1604,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				continue
 			}
 			if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
-				logger.Warning("setRemoteTraffic: delete ClientRecord", email, "failed:", err)
+				logger.Warningf("setRemoteTraffic: delete ClientRecord %q failed: %v", email, err)
 			}
 			if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
-				logger.Warning("setRemoteTraffic: delete ClientTraffic", email, "failed:", err)
+				logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
 			}
 			structuralChange = true
 		}
@@ -1745,11 +1792,12 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 							break
 						}
 					}
-					// Backfill created_at and updated_at
 					if _, ok := c["created_at"]; !ok {
 						c["created_at"] = time.Now().Unix() * 1000
 					}
-					c["updated_at"] = time.Now().Unix() * 1000
+					if _, ok := c["updated_at"]; !ok {
+						c["updated_at"] = time.Now().Unix() * 1000
+					}
 					newClients = append(newClients, any(c))
 				}
 				settings["clients"] = newClients
@@ -2414,6 +2462,27 @@ func (s *InboundService) ResetInboundTraffic(id int) error {
 	})
 }
 
+// EmailsByInbound returns the list of client emails currently configured on
+// an inbound's settings.clients[]. Used by the "delete all clients" flow on
+// the inbounds page, which then feeds the list into ClientService.BulkDelete.
+func (s *InboundService) EmailsByInbound(inboundId int) ([]string, error) {
+	inbound, err := s.GetInbound(inboundId)
+	if err != nil {
+		return nil, err
+	}
+	clients, err := s.GetClients(inbound)
+	if err != nil {
+		return nil, err
+	}
+	emails := make([]string, 0, len(clients))
+	for _, c := range clients {
+		if e := strings.TrimSpace(c.Email); e != "" {
+			emails = append(emails, e)
+		}
+	}
+	return emails, nil
+}
+
 func (s *InboundService) DelDepletedClients(id int) (err error) {
 	db := database.GetDB()
 	tx := db.Begin()

+ 144 - 60
web/service/port_conflict.go

@@ -20,17 +20,17 @@ const (
 	transportUDP
 )
 
-// conflicts is true when the two masks share any L4 transport.
-func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 }
-
 // inboundTransports returns the L4 transports the given inbound listens on.
 // always returns at least one bit (falls back to tcp on parse errors), so
-// the validator never gets looser than the old port-only check.
+// no parse failure can silently let a real socket collision through.
 //
 // the rules:
 //   - hysteria, wireguard: udp regardless of streamSettings
-//   - streamSettings.network=kcp: udp
-//   - shadowsocks: whatever settings.network says ("tcp" / "udp" / "tcp,udp")
+//   - streamSettings.network=kcp or quic: udp (both ride on udp at L4)
+//   - shadowsocks: settings.network ("tcp" / "udp" / "tcp,udp"), overrides
+//     the streamSettings-derived bit when present
+//   - tunnel (xray dokodemo-door): same shape via settings.allowedNetwork
+//     (3x-ui's wrapper renames the field)
 //   - mixed (socks/http combo): tcp + udp when settings.udp is true
 //   - everything else: tcp
 func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
@@ -42,7 +42,7 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 
 	var bits transportBits
 
-	// peek at streamSettings.network to spot udp transports like kcp.
+	// peek at streamSettings.network to spot udp-based transports.
 	// parse errors are non-fatal: missing or weird streamSettings just
 	// keeps the default tcp bit below.
 	network := ""
@@ -54,23 +54,31 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 			}
 		}
 	}
-	if network == "kcp" {
+	switch network {
+	case "kcp", "quic":
 		bits |= transportUDP
-	} else {
+	default:
 		bits |= transportTCP
 	}
 
-	// some protocols also listen on udp on the same port via their own
-	// settings json. parse and merge.
+	// a few protocols carry their L4 choice in settings instead of (or in
+	// addition to) streamSettings: SS / Tunnel via a CSV field that wins
+	// outright, Mixed via an additive udp boolean.
 	if settings != "" {
 		var st map[string]any
 		if json.Unmarshal([]byte(settings), &st) == nil {
 			switch protocol {
-			case model.Shadowsocks:
-				// shadowsocks settings.network controls both tcp and udp,
-				// independently of streamSettings. the field takes "tcp",
-				// "udp", or "tcp,udp". if it's set, it wins outright.
-				if n, ok := st["network"].(string); ok && n != "" {
+			case model.Shadowsocks, model.Tunnel:
+				// shadowsocks exposes settings.network, tunnel exposes
+				// settings.allowedNetwork (3x-ui's wrapper around xray's
+				// dokodemo-door). both carry "tcp" / "udp" / "tcp,udp"
+				// and, when present, win outright over the streamSettings-
+				// derived default; absent/empty keeps the inferred bit (tcp).
+				key := "network"
+				if protocol == model.Tunnel {
+					key = "allowedNetwork"
+				}
+				if n, ok := st[key].(string); ok && n != "" {
 					bits = 0
 					for part := range strings.SplitSeq(n, ",") {
 						switch strings.TrimSpace(part) {
@@ -113,19 +121,57 @@ func isAnyListen(s string) bool {
 	return s == "" || s == "0.0.0.0" || s == "::" || s == "::0"
 }
 
-// checkPortConflict reports whether adding/updating an inbound on
-// (listen, port) would clash with an existing inbound. unlike the old
-// port-only check, this one understands that tcp/443 and udp/443 are
-// independent sockets in linux and may coexist on the same address.
+// portConflictDetail describes the existing inbound that an add/update
+// would collide with. it carries enough context for the API layer to
+// render a user-actionable error ("port 443 (tcp) already used by
+// inbound 'my-vless' (#7) on *") instead of the historical opaque
+// "Port exists". Transports holds only the bits the two inbounds
+// actually share, not the existing inbound's full transport mask.
+type portConflictDetail struct {
+	InboundID  int
+	Remark     string
+	Tag        string
+	Listen     string
+	Port       int
+	Transports transportBits
+}
+
+// String renders the detail as a single-line, user-facing summary.
+func (d *portConflictDetail) String() string {
+	name := d.Remark
+	if name == "" {
+		name = d.Tag
+	}
+	if name == "" {
+		name = fmt.Sprintf("#%d", d.InboundID)
+	} else {
+		name = fmt.Sprintf("'%s' (#%d)", name, d.InboundID)
+	}
+	listen := d.Listen
+	if isAnyListen(listen) {
+		listen = "*"
+	}
+	return fmt.Sprintf("port %d (%s) already used by inbound %s on %s",
+		d.Port, transportTagSuffix(d.Transports), name, listen)
+}
+
+// checkPortConflict reports the existing inbound (if any) that adding
+// or updating an inbound on (listen, port) would clash with. nil result
+// means no conflict.
+//
+// the check understands that tcp/443 and udp/443 are independent
+// sockets in linux and may coexist on the same address (see
+// inboundTransports for the per-protocol L4 mapping).
 //
 // node scope: inbounds with different NodeID run on different physical
 // machines (local panel xray vs a remote node, or two remote nodes),
 // so their sockets can't collide. only candidates with the same NodeID
 // participate in the listen/transport overlap check.
 //
-// the listen-overlap rule (specific addr conflicts with any-addr on the
-// same port, both directions) is preserved from the previous check.
-func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) {
+// listen overlap: a specific listen address conflicts with any-address
+// on the same port (both directions), otherwise only identical specific
+// addresses overlap.
+func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
 	db := database.GetDB()
 
 	var candidates []*model.Inbound
@@ -134,7 +180,7 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
 		q = q.Where("id != ?", ignoreId)
 	}
 	if err := q.Find(&candidates).Error; err != nil {
-		return false, err
+		return nil, err
 	}
 
 	newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
@@ -145,11 +191,21 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
 		if !listenOverlaps(c.Listen, inbound.Listen) {
 			continue
 		}
-		if inboundTransports(c.Protocol, c.StreamSettings, c.Settings).conflicts(newBits) {
-			return true, nil
+		existingBits := inboundTransports(c.Protocol, c.StreamSettings, c.Settings)
+		shared := existingBits & newBits
+		if shared == 0 {
+			continue
 		}
+		return &portConflictDetail{
+			InboundID:  c.Id,
+			Remark:     c.Remark,
+			Tag:        c.Tag,
+			Listen:     c.Listen,
+			Port:       c.Port,
+			Transports: shared,
+		}, nil
 	}
-	return false, nil
+	return nil, nil
 }
 
 // sameNode reports whether two NodeID pointers refer to the same xray
@@ -167,20 +223,16 @@ func sameNode(a, b *int) bool {
 	return *a == *b
 }
 
-// baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
-// shape. kept exactly so existing routing rules that reference these tags
-// keep working after the upgrade.
+// baseInboundTag is the "in-<port>" / "in-<listen>:<port>" core used
+// by composeInboundTag and as a probe shape in setRemoteTrafficLocked
+// for node-side xray imports that pre-date the canonical naming.
 func baseInboundTag(listen string, port int) string {
 	if isAnyListen(listen) {
-		return fmt.Sprintf("inbound-%v", port)
+		return fmt.Sprintf("in-%v", port)
 	}
-	return fmt.Sprintf("inbound-%v:%v", listen, port)
+	return fmt.Sprintf("in-%v:%v", listen, port)
 }
 
-// transportTagSuffix turns a transport mask into a short, stable string
-// for tag disambiguation. only used when the base "inbound-<port>" is
-// already taken on a coexisting transport (e.g. tcp inbound already lives
-// on 443 and we're now adding a udp one).
 func transportTagSuffix(b transportBits) string {
 	switch b {
 	case transportTCP:
@@ -188,34 +240,69 @@ func transportTagSuffix(b transportBits) string {
 	case transportUDP:
 		return "udp"
 	case transportTCP | transportUDP:
-		return "mixed"
+		return "tcpudp"
 	}
 	return "any"
 }
 
-// generateInboundTag picks a tag for the inbound that doesn't collide with
-// any existing row. for the common single-inbound-per-port case the tag
-// stays exactly as before ("inbound-443"), so user routing rules don't
-// silently change shape on upgrade. only when a same-port neighbour
-// already owns the base tag (now possible because tcp/443 and udp/443 can
-// coexist after the transport-aware port check) does this append a
-// transport suffix like "inbound-443-udp".
-//
-// ignoreId is the inbound's own id during update so it doesn't see itself
-// as a collision; pass 0 on add.
-func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
-	base := baseInboundTag(inbound.Listen, inbound.Port)
-	exists, err := s.tagExists(base, ignoreId)
-	if err != nil {
-		return "", err
+// nodeTagPrefix scopes a tag to one remote node so the same listen+port
+// can live on the central panel and on a node without bumping the global
+// UNIQUE(inbounds.tag) constraint. nil → "" (local panel).
+func nodeTagPrefix(nodeID *int) string {
+	if nodeID == nil {
+		return ""
 	}
-	if !exists {
-		return base, nil
+	return fmt.Sprintf("n%d-", *nodeID)
+}
+
+// protocolShortName collapses the full protocol identifier into a 2–4
+// char tag-friendly token (shadowsocks → ss, wireguard → wg, …). Falls
+// back to the raw identifier for anything not in the table so future
+// protocols don't need a code change just to get a tag.
+func protocolShortName(p model.Protocol) string {
+	switch p {
+	case model.VMESS:
+		return "vm"
+	case model.VLESS:
+		return "vl"
+	case model.Trojan:
+		return "tr"
+	case model.Shadowsocks:
+		return "ss"
+	case model.Mixed:
+		return "mx"
+	case model.WireGuard:
+		return "wg"
+	case model.Hysteria:
+		return "hy"
+	case model.Tunnel:
+		return "tn"
+	case model.HTTP:
+		return "http"
+	}
+	if p == "" {
+		return "any"
 	}
+	return string(p)
+}
 
-	suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings))
-	candidate := base + "-" + suffix
-	exists, err = s.tagExists(candidate, ignoreId)
+// composeInboundTag returns the canonical
+// "[n<id>-]inbound-[<listen>:]<port>-<protocol>-<network>" shape used
+// for every newly created inbound. The protocol + network segments
+// disambiguate tcp/443 and udp/443 sharing a listener; the node prefix
+// lets the same port live on local + node.
+func composeInboundTag(listen string, port int, protocol model.Protocol, nodeID *int, bits transportBits) string {
+	return nodeTagPrefix(nodeID) + baseInboundTag(listen, port) + "-" + protocolShortName(protocol) + "-" + transportTagSuffix(bits)
+}
+
+// generateInboundTag returns a free tag in the canonical shape. ignoreId
+// is the inbound's own id on update so it doesn't see itself as taken;
+// pass 0 on add. Numeric suffix fallback is defensive — the port check
+// should have already blocked an exact-collision insert.
+func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
+	bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
+	candidate := composeInboundTag(inbound.Listen, inbound.Port, inbound.Protocol, inbound.NodeID, bits)
+	exists, err := s.tagExists(candidate, ignoreId)
 	if err != nil {
 		return "", err
 	}
@@ -223,9 +310,6 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
 		return candidate, nil
 	}
 
-	// the transport-aware port check should have already blocked this
-	// path, but guard anyway so a unique-constraint failure doesn't reach
-	// the user as an opaque sqlite error.
 	for i := 2; i < 100; i++ {
 		c := fmt.Sprintf("%s-%d", candidate, i)
 		exists, err = s.tagExists(c, ignoreId)

+ 192 - 43
web/service/port_conflict_test.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"path/filepath"
+	"strings"
 	"sync"
 	"testing"
 
@@ -137,7 +138,7 @@ func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) {
 	if err != nil {
 		t.Fatalf("checkPortConflict: %v", err)
 	}
-	if exist {
+	if exist != nil {
 		t.Fatalf("vless/tcp and hysteria2/udp on the same port must be allowed to coexist")
 	}
 }
@@ -159,7 +160,7 @@ func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) {
 	if err != nil {
 		t.Fatalf("checkPortConflict: %v", err)
 	}
-	if !exist {
+	if exist == nil {
 		t.Fatalf("two tcp inbounds on the same port must still conflict")
 	}
 }
@@ -181,7 +182,7 @@ func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) {
 	if err != nil {
 		t.Fatalf("checkPortConflict: %v", err)
 	}
-	if !exist {
+	if exist == nil {
 		t.Fatalf("two udp inbounds on the same port must conflict")
 	}
 }
@@ -201,7 +202,7 @@ func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
 		Protocol:       model.VLESS,
 		StreamSettings: `{"network":"tcp"}`,
 	}
-	if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || !exist {
+	if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || exist == nil {
 		t.Fatalf("tcp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
 	}
 
@@ -211,7 +212,7 @@ func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
 		Port:     443,
 		Protocol: model.Hysteria,
 	}
-	if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || !exist {
+	if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || exist == nil {
 		t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
 	}
 }
@@ -229,7 +230,7 @@ func TestCheckPortConflict_DifferentPortNeverConflicts(t *testing.T) {
 		Protocol:       model.VLESS,
 		StreamSettings: `{"network":"tcp"}`,
 	}
-	if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
+	if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
 		t.Fatalf("different port must not conflict; exist=%v err=%v", exist, err)
 	}
 }
@@ -251,7 +252,7 @@ func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
 		Protocol:       model.VLESS,
 		StreamSettings: `{"network":"tcp"}`,
 	}
-	if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
+	if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
 		t.Fatalf("different specific listen must not conflict; exist=%v err=%v", exist, err)
 	}
 
@@ -263,18 +264,16 @@ func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
 		Protocol:       model.VLESS,
 		StreamSettings: `{"network":"tcp"}`,
 	}
-	if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || !exist {
+	if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || exist == nil {
 		t.Fatalf("any-addr on same port+transport must conflict with specific; exist=%v err=%v", exist, err)
 	}
 }
 
-// when the base "inbound-<port>" tag is already taken on a coexisting
-// transport, generateInboundTag must disambiguate with a transport
-// suffix so the unique-tag DB constraint stays satisfied.
+// even with a stale legacy tag owning "in-443", a new UDP-side
+// inbound gets a fully qualified canonical tag and does not collide.
 func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) {
 	setupConflictDB(t)
-	// existing tcp inbound owns "inbound-443".
-	seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+	seedInboundConflict(t, "in-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
 
 	svc := &InboundService{}
 	udp := &model.Inbound{
@@ -286,13 +285,13 @@ func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) {
 	if err != nil {
 		t.Fatalf("generateInboundTag: %v", err)
 	}
-	if got != "inbound-443-udp" {
-		t.Fatalf("expected disambiguated tag inbound-443-udp, got %q", got)
+	if got != "in-443-hy-udp" {
+		t.Fatalf("expected in-443-hy-udp, got %q", got)
 	}
 }
 
-// when the port is free, the historical "inbound-<port>" shape is kept
-// so existing routing rules don't change shape on upgrade.
+// when the port is free, the canonical tag carries protocol + transport
+// so tcp/8443 and udp/8443 get distinct tags out of the box.
 func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
 	setupConflictDB(t)
 
@@ -306,19 +305,19 @@ func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
 	if err != nil {
 		t.Fatalf("generateInboundTag: %v", err)
 	}
-	if got != "inbound-8443" {
-		t.Fatalf("expected inbound-8443, got %q", got)
+	if got != "in-8443-vl-tcp" {
+		t.Fatalf("expected in-8443-vl-tcp, got %q", got)
 	}
 }
 
-// updating an inbound on its own port must not flag its own tag as
-// taken, that's what ignoreId is for.
+// updating an inbound on its own port must not flag its own tag as taken;
+// that's what ignoreId is for.
 func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
 	setupConflictDB(t)
-	seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+	seedInboundConflict(t, "in-443-vl-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
 
 	var existing model.Inbound
-	if err := database.GetDB().Where("tag = ?", "inbound-443").First(&existing).Error; err != nil {
+	if err := database.GetDB().Where("tag = ?", "in-443-vl-tcp").First(&existing).Error; err != nil {
 		t.Fatalf("read seeded row: %v", err)
 	}
 
@@ -327,16 +326,15 @@ func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
 	if err != nil {
 		t.Fatalf("generateInboundTag: %v", err)
 	}
-	if got != "inbound-443" {
+	if got != "in-443-vl-tcp" {
 		t.Fatalf("self-update must keep base tag, got %q", got)
 	}
 }
 
-// specific listen address gets the listen-prefixed shape and same
-// disambiguation rules.
+// specific listen address gets the listen-prefixed shape and same suffix.
 func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
 	setupConflictDB(t)
-	seedInboundConflict(t, "inbound-1.2.3.4:443", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+	seedInboundConflict(t, "in-1.2.3.4:443", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
 
 	svc := &InboundService{}
 	udp := &model.Inbound{
@@ -348,8 +346,8 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
 	if err != nil {
 		t.Fatalf("generateInboundTag: %v", err)
 	}
-	if got != "inbound-1.2.3.4:443-udp" {
-		t.Fatalf("expected inbound-1.2.3.4:443-udp, got %q", got)
+	if got != "in-1.2.3.4:443-hy-udp" {
+		t.Fatalf("expected in-1.2.3.4:443-hy-udp, got %q", got)
 	}
 }
 
@@ -386,8 +384,8 @@ func TestCheckPortConflict_NodeScope(t *testing.T) {
 			if err != nil {
 				t.Fatalf("checkPortConflict: %v", err)
 			}
-			if got != c.want {
-				t.Fatalf("got conflict=%v, want %v", got, c.want)
+			if (got != nil) != c.want {
+				t.Fatalf("got conflict=%v, want %v", got != nil, c.want)
 			}
 		})
 	}
@@ -401,12 +399,12 @@ func TestCheckPortConflict_NodeScope(t *testing.T) {
 // panels diverged, causing a UNIQUE constraint failure on sync.
 func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
 	setupConflictDB(t)
-	seedInboundConflictNode(t, "inbound-5000", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
-	seedInboundConflictNode(t, "inbound-5000-udp", "0.0.0.0", 5000, model.Hysteria, ``, ``, nil)
+	seedInboundConflictNode(t, "in-5000-vl-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
+	seedInboundConflictNode(t, "in-5000-hy-udp", "0.0.0.0", 5000, model.Hysteria, ``, ``, nil)
 
 	svc := &InboundService{}
 	pushed := &model.Inbound{
-		Tag:            "inbound-5000-tcp",
+		Tag:            "custom-pushed-tag",
 		Listen:         "0.0.0.0",
 		Port:           5000,
 		Protocol:       model.VLESS,
@@ -417,14 +415,14 @@ func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
 	if err != nil {
 		t.Fatalf("resolveInboundTag: %v", err)
 	}
-	if got != "inbound-5000-tcp" {
+	if got != "custom-pushed-tag" {
 		t.Fatalf("caller tag must be preserved when free, got %q", got)
 	}
 }
 
 // when the caller leaves Tag empty (the local UI path) resolveInboundTag
-// falls back to generateInboundTag, which keeps the historical
-// "inbound-<port>" shape so existing routing rules don't change.
+// falls back to generateInboundTag, which emits the canonical
+// "in-<port>-<transport>" shape.
 func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
 	setupConflictDB(t)
 
@@ -438,8 +436,8 @@ func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
 	if err != nil {
 		t.Fatalf("resolveInboundTag: %v", err)
 	}
-	if got != "inbound-8443" {
-		t.Fatalf("expected generated inbound-8443, got %q", got)
+	if got != "in-8443-vl-tcp" {
+		t.Fatalf("expected generated in-8443-vl-tcp, got %q", got)
 	}
 }
 
@@ -450,11 +448,11 @@ func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
 // tag that the central will pick up via the AddInbound response.
 func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
 	setupConflictDB(t)
-	seedInboundConflictNode(t, "inbound-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
+	seedInboundConflictNode(t, "in-5000-vl-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
 
 	svc := &InboundService{}
 	pushed := &model.Inbound{
-		Tag:            "inbound-5000-tcp",
+		Tag:            "in-5000-vl-tcp",
 		Listen:         "0.0.0.0",
 		Port:           5000,
 		Protocol:       model.Hysteria,
@@ -465,11 +463,56 @@ func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
 	if err != nil {
 		t.Fatalf("resolveInboundTag: %v", err)
 	}
-	if got == "inbound-5000-tcp" {
+	if got == "in-5000-vl-tcp" {
 		t.Fatalf("colliding caller tag must be replaced, but resolver kept %q", got)
 	}
 }
 
+// inbounds bound to a remote node get the canonical tag prefixed with
+// "n<id>-" so the same listen+port+transport can live on the central
+// panel and on the node simultaneously without bumping the global
+// UNIQUE(inbounds.tag) constraint.
+func TestGenerateInboundTag_NodePrefix(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	in := &model.Inbound{
+		Listen:   "0.0.0.0",
+		Port:     443,
+		Protocol: model.VLESS,
+		NodeID:   intPtr(1),
+	}
+	got, err := svc.generateInboundTag(in, 0)
+	if err != nil {
+		t.Fatalf("generateInboundTag: %v", err)
+	}
+	if got != "n1-in-443-vl-tcp" {
+		t.Fatalf("expected n1-in-443-vl-tcp, got %q", got)
+	}
+}
+
+// a node-prefixed inbound shouldn't collide with a same-port local one:
+// the prefix scopes the tag to that specific node.
+func TestGenerateInboundTag_NodePrefixedDoesNotCollideWithLocal(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "in-443-vl-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+	svc := &InboundService{}
+	in := &model.Inbound{
+		Listen:   "0.0.0.0",
+		Port:     443,
+		Protocol: model.VLESS,
+		NodeID:   intPtr(1),
+	}
+	got, err := svc.generateInboundTag(in, 0)
+	if err != nil {
+		t.Fatalf("generateInboundTag: %v", err)
+	}
+	if got != "n1-in-443-vl-tcp" {
+		t.Fatalf("expected n1-in-443-vl-tcp, got %q", got)
+	}
+}
+
 // updating an inbound must not see itself as a conflict, that's what
 // ignoreId is for.
 func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
@@ -482,7 +525,113 @@ func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
 	}
 
 	svc := &InboundService{}
-	if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist {
+	if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist != nil {
 		t.Fatalf("self-update must not be flagged as conflict; exist=%v err=%v", exist, err)
 	}
 }
+
+// streamSettings.network=quic rides on UDP at L4, so a QUIC inbound must
+// conflict with a UDP-only neighbour (hysteria) on the same port but not
+// with a TCP-only one. covers the gap left by the original kcp-only check.
+func TestCheckPortConflict_QUICTreatedAsUDP(t *testing.T) {
+	quic := &model.Inbound{
+		Tag:            "vless-quic-443",
+		Listen:         "0.0.0.0",
+		Port:           443,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"quic"}`,
+	}
+
+	t.Run("conflicts with hysteria/udp", func(t *testing.T) {
+		setupConflictDB(t)
+		seedInboundConflict(t, "hyst-443", "0.0.0.0", 443, model.Hysteria, ``, ``)
+		svc := &InboundService{}
+		if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist == nil {
+			t.Fatalf("quic on same port as hysteria must conflict; exist=%v err=%v", exist, err)
+		}
+	})
+
+	t.Run("coexists with vless/tcp", func(t *testing.T) {
+		setupConflictDB(t)
+		seedInboundConflict(t, "vless-tcp-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
+		svc := &InboundService{}
+		if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist != nil {
+			t.Fatalf("quic and tcp on same port must coexist; exist=%v err=%v", exist, err)
+		}
+	})
+}
+
+// tunnel (dokodemo-door) carries its L4 transport list in
+// settings.allowedNetwork, not settings.network. verify the predicate
+// picks the right field for each protocol.
+func TestCheckPortConflict_TunnelAllowedNetwork(t *testing.T) {
+	setupConflictDB(t)
+	seedInboundConflict(t, "tunnel-udp-443", "0.0.0.0", 443, model.Tunnel, ``, `{"allowedNetwork":"udp"}`)
+
+	svc := &InboundService{}
+
+	// tcp inbound on same port should coexist with udp-only tunnel.
+	tcpNeighbour := &model.Inbound{
+		Tag:            "vless-443",
+		Listen:         "0.0.0.0",
+		Port:           443,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+	}
+	if exist, err := svc.checkPortConflict(tcpNeighbour, 0); err != nil || exist != nil {
+		t.Fatalf("tunnel/udp and vless/tcp on same port must coexist; exist=%v err=%v", exist, err)
+	}
+
+	// udp neighbour (hysteria) on same port must conflict.
+	udpNeighbour := &model.Inbound{
+		Tag:      "hyst-443",
+		Listen:   "0.0.0.0",
+		Port:     443,
+		Protocol: model.Hysteria,
+	}
+	if exist, err := svc.checkPortConflict(udpNeighbour, 0); err != nil || exist == nil {
+		t.Fatalf("tunnel/udp and hysteria on same port must conflict; exist=%v err=%v", exist, err)
+	}
+}
+
+// the rich conflict detail surfaced to the user must name the offending
+// inbound (by remark when available) and the shared L4 transport(s).
+func TestCheckPortConflict_DetailMessage(t *testing.T) {
+	setupConflictDB(t)
+	seeded := &model.Inbound{
+		Tag:            "vless-443",
+		Remark:         "my-vless",
+		Enable:         true,
+		Listen:         "0.0.0.0",
+		Port:           443,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp"}`,
+		Settings:       `{}`,
+	}
+	if err := database.GetDB().Create(seeded).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+
+	svc := &InboundService{}
+	candidate := &model.Inbound{
+		Tag:            "trojan-443",
+		Listen:         "0.0.0.0",
+		Port:           443,
+		Protocol:       model.Trojan,
+		StreamSettings: `{"network":"ws"}`,
+	}
+	got, err := svc.checkPortConflict(candidate, 0)
+	if err != nil || got == nil {
+		t.Fatalf("expected conflict, got=%v err=%v", got, err)
+	}
+	msg := got.String()
+	if !strings.Contains(msg, "my-vless") {
+		t.Fatalf("message should mention the conflicting inbound's remark; got %q", msg)
+	}
+	if !strings.Contains(msg, "tcp") {
+		t.Fatalf("message should mention the shared L4 transport; got %q", msg)
+	}
+	if !strings.Contains(msg, "443") {
+		t.Fatalf("message should mention the port; got %q", msg)
+	}
+}

+ 3 - 0
web/translation/ar-EG.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "يعيد عدادات الإرسال/الاستقبال لهذا الإدخال إلى 0.",
       "cloneConfirmTitle": "نسخ الإدخال \"{remark}\"؟",
       "cloneConfirmContent": "ينشئ نسخة بمنفذ جديد وقائمة عملاء فارغة.",
+      "delAllClients": "حذف جميع العملاء",
+      "delAllClientsConfirmTitle": "حذف جميع العملاء البالغ عددهم {count} من \"{remark}\"؟",
+      "delAllClientsConfirmContent": "يزيل كل عميل من هذا الإدخال ويحذف سجلات حركة المرور الخاصة بهم. يتم الاحتفاظ بالإدخال نفسه. لا يمكن التراجع عن هذا.",
       "exportLinksTitle": "تصدير روابط الإدخال",
       "exportSubsTitle": "تصدير روابط الاشتراك",
       "exportAllLinksTitle": "تصدير كل روابط الإدخالات",

+ 70 - 2
web/translation/en-US.json

@@ -18,6 +18,10 @@
   "protocol": "Protocol",
   "search": "Search",
   "filter": "Filter",
+  "all": "All",
+  "from": "From",
+  "to": "To",
+  "done": "Done",
   "loading": "Loading...",
   "refresh": "Refresh",
   "clear": "Clear",
@@ -34,7 +38,7 @@
   "edit": "Edit",
   "delete": "Delete",
   "reset": "Reset",
-  "noData": "No data.",
+  "noData": "Nothing here yet",
   "copySuccess": "Copied successfully",
   "sure": "Sure",
   "encryption": "Encryption",
@@ -98,6 +102,7 @@
     "dashboard": "Overview",
     "inbounds": "Inbounds",
     "clients": "Clients",
+    "groups": "Groups",
     "nodes": "Nodes",
     "settings": "Panel Settings",
     "xray": "Xray Configs",
@@ -290,6 +295,9 @@
       "resetConfirmContent": "Resets up/down counters to 0 for this inbound.",
       "cloneConfirmTitle": "Clone inbound \"{remark}\"?",
       "cloneConfirmContent": "Creates a copy with a new port and an empty client list.",
+      "delAllClients": "Delete All Clients",
+      "delAllClientsConfirmTitle": "Delete all {count} clients from \"{remark}\"?",
+      "delAllClientsConfirmContent": "This removes every client from this inbound and drops their traffic records. The inbound itself is kept. This cannot be undone.",
       "exportLinksTitle": "Export inbound links",
       "exportSubsTitle": "Export subscription links",
       "exportAllLinksTitle": "Export all inbound links",
@@ -454,6 +462,20 @@
       "days": "Day(s)",
       "renew": "Auto Renew",
       "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
+      "searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
+      "filterTitle": "Filter clients",
+      "clearAllFilters": "Clear all",
+      "sortOldest": "Oldest first",
+      "sortNewest": "Newest first",
+      "sortRecentlyUpdated": "Recently updated",
+      "sortRecentlyOnline": "Recently online",
+      "sortEmailAZ": "Email A→Z",
+      "sortEmailZA": "Email Z→A",
+      "sortMostTraffic": "Most traffic",
+      "sortHighestRemaining": "Highest remaining",
+      "sortExpiringSoonest": "Expiring soonest",
+      "has": "Has",
+      "hasNot": "Doesn't have",
       "title": "Clients",
       "actions": "Actions",
       "totalGB": "Total Sent/Received (GB)",
@@ -464,6 +486,9 @@
       "subId": "Subscription ID",
       "online": "Online",
       "email": "Email",
+      "group": "Group",
+      "groupDesc": "Logical label used to bucket related clients (e.g. team, customer, region). Filterable from the toolbar.",
+      "groupPlaceholder": "e.g. customer-a",
       "comment": "Comment",
       "traffic": "Traffic",
       "offline": "Offline",
@@ -487,11 +512,25 @@
       "resetAllTraffics": "Reset all client traffic",
       "resetAllTrafficsTitle": "Reset all client traffic?",
       "resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.",
-      "empty": "No clients yet — add one to get started.",
       "deleteConfirmTitle": "Delete client {email}?",
       "deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
       "deleteSelected": "Delete ({count})",
       "adjustSelected": "Adjust ({count})",
+      "subLinksSelected": "Sub links ({count})",
+      "assignGroupSelected": "Group ({count})",
+      "assignGroupTitle": "Assign group to {count} client(s)",
+      "assignGroupTooltip": "Pick an existing group or type a new name. Leave blank to clear the group on the selected clients.",
+      "assignGroupPlaceholder": "Group name (leave blank to clear)",
+      "assignGroupAssignedToast": "Assigned {count} client(s) to {group}",
+      "assignGroupClearedToast": "Cleared group from {count} client(s)",
+      "subLinksTitle": "Sub links ({count})",
+      "subLinkColumn": "Subscription URL",
+      "subJsonLinkColumn": "Subscription JSON URL",
+      "subLinksCopyAll": "Copy all",
+      "subLinksCopiedAll": "Copied {count} link(s)",
+      "subLinksEmpty": "None of the selected clients have a subscription ID.",
+      "subLinksDisabled": "Subscription service is disabled.",
+      "subLinksDisabledHint": "Enable subscription in Panel Settings → Subscription to generate links.",
       "bulkDeleteConfirmTitle": "Delete {count} clients?",
       "bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
       "bulkAdjustTitle": "Adjust {count} clients",
@@ -526,6 +565,35 @@
         "delDepleted": "{count} depleted clients deleted"
       }
     },
+    "groups": {
+      "title": "Groups",
+      "name": "Name",
+      "clientCount": "Clients in group",
+      "totalGroups": "Total groups",
+      "totalGroupedClients": "Clients with a group",
+      "emptyGroups": "Empty groups",
+      "addGroup": "Add Group",
+      "createSuccess": "Group \"{name}\" created.",
+      "rename": "Rename",
+      "renameTitle": "Rename {name}",
+      "renameCollision": "A group named \"{name}\" already exists.",
+      "renameSuccess": "Renamed group on {count} client(s).",
+      "deleteConfirmTitle": "Delete group {name}?",
+      "deleteConfirmContent": "This removes the group and clears its label from {count} client(s). The clients themselves are not deleted.",
+      "deleteSuccess": "Cleared group from {count} client(s).",
+      "resetTraffic": "Reset traffic",
+      "resetConfirmTitle": "Reset traffic for group {name}?",
+      "resetConfirmContent": "This zeros up/down for all {count} client(s) in this group.",
+      "resetSuccess": "Reset traffic for {count} client(s).",
+      "adjustSuccess": "Adjusted {count} client(s) in {name}.",
+      "emptyForAction": "This group has no clients yet.",
+      "deleteGroupOnly": "Delete group (keep clients)",
+      "deleteClients": "Delete clients in group",
+      "deleteClientsConfirmTitle": "Delete all clients in {name}?",
+      "deleteClientsConfirmContent": "This permanently removes {count} client(s) along with their traffic records. The group label is cleared too. This cannot be undone.",
+      "deleteClientsSuccess": "Deleted {count} client(s).",
+      "deleteClientsMixed": "{ok} deleted, {failed} skipped"
+    },
     "nodes": {
       "title": "Nodes",
       "addNode": "Add Node",

+ 3 - 0
web/translation/es-ES.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "Restablece los contadores de subida/bajada a 0 para este inbound.",
       "cloneConfirmTitle": "¿Clonar el inbound \"{remark}\"?",
       "cloneConfirmContent": "Crea una copia con un puerto nuevo y una lista de clientes vacía.",
+      "delAllClients": "Eliminar todos los clientes",
+      "delAllClientsConfirmTitle": "¿Eliminar los {count} clientes de \"{remark}\"?",
+      "delAllClientsConfirmContent": "Elimina todos los clientes de este inbound y sus registros de tráfico. El inbound se mantiene. Esto no se puede deshacer.",
       "exportLinksTitle": "Exportar enlaces del inbound",
       "exportSubsTitle": "Exportar enlaces de suscripción",
       "exportAllLinksTitle": "Exportar todos los enlaces de inbound",

+ 3 - 0
web/translation/fa-IR.json

@@ -290,6 +290,9 @@
       "resetConfirmContent": "شمارنده‌های ارسال/دریافت این اینباند به صفر برمی‌گردد.",
       "cloneConfirmTitle": "اینباند «{remark}» کپی شود؟",
       "cloneConfirmContent": "یک نسخه با پورت جدید و لیست کلاینت خالی ساخته می‌شود.",
+      "delAllClients": "حذف همه کلاینت‌ها",
+      "delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
+      "delAllClientsConfirmContent": "تمام کلاینت‌های این اینباند به همراه رکوردهای ترافیک‌شان حذف می‌شوند. خود اینباند باقی می‌ماند. این عمل غیرقابل بازگشت است.",
       "exportLinksTitle": "خروجی لینک‌های اینباند",
       "exportSubsTitle": "خروجی لینک‌های ساب",
       "exportAllLinksTitle": "خروجی لینک‌های همه اینباندها",

+ 3 - 0
web/translation/id-ID.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "Mengatur ulang counter unggah/unduh ke 0 untuk inbound ini.",
       "cloneConfirmTitle": "Klon inbound \"{remark}\"?",
       "cloneConfirmContent": "Membuat salinan dengan port baru dan daftar klien kosong.",
+      "delAllClients": "Hapus Semua Klien",
+      "delAllClientsConfirmTitle": "Hapus semua {count} klien dari \"{remark}\"?",
+      "delAllClientsConfirmContent": "Menghapus setiap klien dari inbound ini dan menghapus catatan trafiknya. Inbound itu sendiri dipertahankan. Tindakan ini tidak dapat dibatalkan.",
       "exportLinksTitle": "Ekspor tautan inbound",
       "exportSubsTitle": "Ekspor tautan langganan",
       "exportAllLinksTitle": "Ekspor semua tautan inbound",

+ 3 - 0
web/translation/ja-JP.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "このインバウンドの送受信カウンタを 0 にリセットします。",
       "cloneConfirmTitle": "インバウンド「{remark}」を複製しますか?",
       "cloneConfirmContent": "新しいポートと空のクライアント一覧でコピーを作成します。",
+      "delAllClients": "すべてのクライアントを削除",
+      "delAllClientsConfirmTitle": "「{remark}」から {count} 件のクライアントをすべて削除しますか?",
+      "delAllClientsConfirmContent": "このインバウンドからすべてのクライアントを削除し、トラフィックレコードも破棄します。インバウンド自体は保持されます。この操作は取り消せません。",
       "exportLinksTitle": "インバウンドリンクのエクスポート",
       "exportSubsTitle": "サブスクリプションリンクのエクスポート",
       "exportAllLinksTitle": "全インバウンドリンクのエクスポート",

+ 3 - 0
web/translation/pt-BR.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "Zera os contadores de envio/recebimento para este inbound.",
       "cloneConfirmTitle": "Clonar o inbound \"{remark}\"?",
       "cloneConfirmContent": "Cria uma cópia com uma nova porta e lista de clientes vazia.",
+      "delAllClients": "Excluir todos os clientes",
+      "delAllClientsConfirmTitle": "Excluir todos os {count} clientes de \"{remark}\"?",
+      "delAllClientsConfirmContent": "Remove todos os clientes deste inbound e descarta seus registros de tráfego. O inbound em si é mantido. Esta ação não pode ser desfeita.",
       "exportLinksTitle": "Exportar links do inbound",
       "exportSubsTitle": "Exportar links de assinatura",
       "exportAllLinksTitle": "Exportar todos os links de inbound",

+ 3 - 0
web/translation/ru-RU.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "Сбрасывает счётчики отправки/получения этого подключения до 0.",
       "cloneConfirmTitle": "Клонировать подключение \"{remark}\"?",
       "cloneConfirmContent": "Создаёт копию с новым портом и пустым списком клиентов.",
+      "delAllClients": "Удалить всех клиентов",
+      "delAllClientsConfirmTitle": "Удалить всех {count} клиентов из \"{remark}\"?",
+      "delAllClientsConfirmContent": "Удаляет всех клиентов этого подключения и сбрасывает их записи трафика. Само подключение сохраняется. Это действие нельзя отменить.",
       "exportLinksTitle": "Экспортировать ссылки подключения",
       "exportSubsTitle": "Экспортировать ссылки подписки",
       "exportAllLinksTitle": "Экспортировать все ссылки подключений",

+ 3 - 0
web/translation/tr-TR.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "Bu inbound için gönderme/alma sayaçlarını 0'a sıfırlar.",
       "cloneConfirmTitle": "\"{remark}\" inbound klonlansın mı?",
       "cloneConfirmContent": "Yeni bir port ve boş istemci listesiyle bir kopya oluşturur.",
+      "delAllClients": "Tüm istemcileri sil",
+      "delAllClientsConfirmTitle": "\"{remark}\" içindeki {count} istemcinin tamamı silinsin mi?",
+      "delAllClientsConfirmContent": "Bu inbound'a ait tüm istemcileri ve trafik kayıtlarını siler. Inbound'un kendisi korunur. Bu işlem geri alınamaz.",
       "exportLinksTitle": "Inbound bağlantılarını dışa aktar",
       "exportSubsTitle": "Abonelik bağlantılarını dışa aktar",
       "exportAllLinksTitle": "Tüm inbound bağlantılarını dışa aktar",

+ 3 - 0
web/translation/uk-UA.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "Скидає лічильники відправки/отримання цього вхідного до 0.",
       "cloneConfirmTitle": "Клонувати вхідні \"{remark}\"?",
       "cloneConfirmContent": "Створює копію з новим портом і порожнім списком клієнтів.",
+      "delAllClients": "Видалити всіх клієнтів",
+      "delAllClientsConfirmTitle": "Видалити всіх {count} клієнтів із \"{remark}\"?",
+      "delAllClientsConfirmContent": "Видаляє всіх клієнтів цього вхідного й скидає їхні записи трафіку. Сам вхідний зберігається. Цю дію не можна скасувати.",
       "exportLinksTitle": "Експортувати посилання вхідних",
       "exportSubsTitle": "Експортувати посилання підписок",
       "exportAllLinksTitle": "Експортувати всі посилання вхідних",

+ 3 - 0
web/translation/vi-VN.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "Đặt lại bộ đếm lên/xuống về 0 cho inbound này.",
       "cloneConfirmTitle": "Sao chép inbound \"{remark}\"?",
       "cloneConfirmContent": "Tạo bản sao với cổng mới và danh sách khách hàng trống.",
+      "delAllClients": "Xóa tất cả khách hàng",
+      "delAllClientsConfirmTitle": "Xóa toàn bộ {count} khách hàng khỏi \"{remark}\"?",
+      "delAllClientsConfirmContent": "Xóa mọi khách hàng khỏi inbound này và hủy bản ghi lưu lượng của họ. Bản thân inbound vẫn được giữ lại. Hành động này không thể hoàn tác.",
       "exportLinksTitle": "Xuất liên kết inbound",
       "exportSubsTitle": "Xuất liên kết đăng ký",
       "exportAllLinksTitle": "Xuất tất cả liên kết inbound",

+ 3 - 0
web/translation/zh-CN.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "将此入站的上/下行计数器清零。",
       "cloneConfirmTitle": "克隆入站 \"{remark}\"?",
       "cloneConfirmContent": "使用新端口和空客户端列表创建副本。",
+      "delAllClients": "删除所有客户端",
+      "delAllClientsConfirmTitle": "从 \"{remark}\" 中删除全部 {count} 个客户端?",
+      "delAllClientsConfirmContent": "从此入站中移除每个客户端并丢弃其流量记录。入站本身将保留。此操作无法撤销。",
       "exportLinksTitle": "导出入站链接",
       "exportSubsTitle": "导出订阅链接",
       "exportAllLinksTitle": "导出所有入站链接",

+ 3 - 0
web/translation/zh-TW.json

@@ -289,6 +289,9 @@
       "resetConfirmContent": "將此入站的上/下行計數器歸零。",
       "cloneConfirmTitle": "複製入站「{remark}」?",
       "cloneConfirmContent": "使用新連接埠和空客戶端清單建立副本。",
+      "delAllClients": "刪除所有客戶端",
+      "delAllClientsConfirmTitle": "從「{remark}」中刪除全部 {count} 個客戶端?",
+      "delAllClientsConfirmContent": "從此入站中移除每個客戶端並捨棄其流量記錄。入站本身將保留。此操作無法復原。",
       "exportLinksTitle": "匯出入站連結",
       "exportSubsTitle": "匯出訂閱連結",
       "exportAllLinksTitle": "匯出所有入站連結",

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików