diff --git a/database/db.go b/database/db.go index 10e729c3..114440ae 100644 --- a/database/db.go +++ b/database/db.go @@ -68,6 +68,7 @@ func initModels() error { &model.ApiToken{}, &model.ClientRecord{}, &model.ClientInbound{}, + &model.ClientGroup{}, &model.InboundFallback{}, } for _, mdl := range models { diff --git a/database/model/model.go b/database/model/model.go index 6c4230ad..411b74c2 100644 --- a/database/model/model.go +++ b/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 { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c4919ac9..2576917f 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2805,6 +2805,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": [ diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index e1fe0980..ab74ec40 100644 --- a/frontend/src/api/queryKeys.ts +++ b/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, diff --git a/frontend/src/components/AppSidebar.css b/frontend/src/components/AppSidebar.css index 39a595c4..ac1de456 100644 --- a/frontend/src/components/AppSidebar.css +++ b/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; diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index 49b6426c..85f93f0f 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -9,6 +9,7 @@ import { ClusterOutlined, CloseOutlined, DashboardOutlined, + GithubOutlined, HeartOutlined, ImportOutlined, LogoutOutlined, @@ -17,6 +18,7 @@ import { MoonOutlined, SettingOutlined, SunOutlined, + TagsOutlined, TeamOutlined, ToolOutlined, } from '@ant-design/icons'; @@ -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' | 'inbound' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs'; +type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs'; const iconByName: Record = { dashboard: DashboardOutlined, 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 ( + + + {!collapsed && {label}} + + ); +} + function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: { id: string; isDark: boolean; @@ -103,6 +125,7 @@ export default function AppSidebar() { { key: '/', icon: 'dashboard', title: t('menu.dashboard') }, { 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() {
{collapsed ? '3X' : '3X-UI'} - {!collapsed && panelVersion && ( - v{panelVersion} - )}
{!collapsed && (
@@ -204,6 +224,9 @@ export default function AppSidebar() { items={toMenuItems(utilItems)} onClick={onMenuClick} /> +
+ +
3X-UI - {panelVersion && v{panelVersion}}
@@ -259,6 +281,9 @@ export default function AppSidebar() { items={toMenuItems(utilItems)} onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }} /> +
+ +
{!drawerOpen && ( diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 3e559fca..73535c6b 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -55,6 +55,7 @@ export interface ClientQueryParams { autoRenew?: 'on' | 'off' | ''; hasTgId?: 'yes' | 'no' | ''; hasComment?: 'yes' | 'no' | ''; + group?: string; } const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; @@ -79,6 +80,7 @@ function buildQS(p: ClientQueryParams): string { 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(); } @@ -130,6 +132,7 @@ export function useClients() { && (prev.autoRenew ?? '') === (next.autoRenew ?? '') && (prev.hasTgId ?? '') === (next.hasTgId ?? '') && (prev.hasComment ?? '') === (next.hasComment ?? '') + && (prev.group ?? '') === (next.group ?? '') ) return prev; return next; }); @@ -169,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; @@ -230,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), @@ -322,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); return attachMut.mutateAsync({ email, inboundIds }); @@ -407,6 +421,7 @@ export function useClients() { total, filtered, summary, + allGroups, hydrate, query, setQuery, @@ -427,6 +442,7 @@ export function useClients() { remove, bulkDelete, bulkAdjust, + bulkAssignGroup, attach, detach, resetTraffic, diff --git a/frontend/src/hooks/usePageTitle.ts b/frontend/src/hooks/usePageTitle.ts index 4f70a286..af06952b 100644 --- a/frontend/src/hooks/usePageTitle.ts +++ b/frontend/src/hooks/usePageTitle.ts @@ -6,6 +6,7 @@ const TITLE_KEYS: Record = { '/': 'menu.dashboard', '/inbounds': 'menu.inbounds', '/clients': 'menu.clients', + '/groups': 'menu.groups', '/nodes': 'menu.nodes', '/settings': 'menu.settings', '/xray': 'menu.xray', diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index b0827a48..284c8034 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -535,6 +535,56 @@ export const sections: readonly Section[] = [ body: '[\n {\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7]\n },\n {\n "client": {\n "email": "bob@example.com",\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": "alice@example.com", "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', diff --git a/frontend/src/pages/clients/BulkAssignGroupModal.tsx b/frontend/src/pages/clients/BulkAssignGroupModal.tsx new file mode 100644 index 00000000..b1389b50 --- /dev/null +++ b/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} + onOpenChange(false)} + onOk={submit} + destroyOnHidden + > +
+ + ({ value: g }))} + onChange={(v) => setValue(v ?? '')} + filterOption={(input, option) => + String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase()) + } + allowClear + style={{ width: '100%' }} + autoFocus + /> + +
+
+ + ); +} diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 59d05940..76f27187 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, message } from 'antd'; +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,6 +37,7 @@ function emptyForm(): FormState { emailPostfix: '', quantity: 1, subId: '', + group: '', comment: '', flow: '', limitIp: 0, @@ -50,6 +52,7 @@ export default function ClientBulkAddModal({ open, inbounds, ipLimitEnable = false, + groups = [], onOpenChange, onSaved, }: ClientBulkAddModalProps) { @@ -157,6 +160,7 @@ export default function ClientBulkAddModal({ expiryTime: form.expiryTime, reset: Number(form.reset) || 0, limitIp: Number(form.limitIp) || 0, + group: form.group, comment: form.comment, enable: true, }, @@ -263,6 +267,20 @@ export default function ClientBulkAddModal({ + + ({ value: g }))} + onChange={(v) => update('group', v ?? '')} + filterOption={(input, option) => + String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase()) + } + allowClear + style={{ width: '100%' }} + /> + + update('comment', e.target.value)} /> diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 619094d0..c2f83de3 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/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, @@ -61,6 +62,7 @@ interface ClientFormModalProps { attachedIds?: number[]; ipLimitEnable?: boolean; tgBotEnable?: boolean; + groups?: string[]; save: ( payload: Record | SaveCreatePayload, meta: SaveMetaEdit | SaveMetaCreate, @@ -83,6 +85,7 @@ interface FormState { reset: number; limitIp: number; tgId: number; + group: string; comment: string; enable: boolean; inboundIds: number[]; @@ -104,6 +107,7 @@ function emptyForm(): FormState { reset: 0, limitIp: 0, tgId: 0, + group: '', comment: '', enable: true, inboundIds: [], @@ -128,6 +132,7 @@ export default function ClientFormModal({ attachedIds = [], ipLimitEnable = false, tgBotEnable = false, + groups = [], save, onOpenChange, }: ClientFormModalProps) { @@ -163,6 +168,7 @@ export default function ClientFormModal({ 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] : [], @@ -287,6 +293,7 @@ export default function ClientFormModal({ reset: form.reset, limitIp: form.limitIp, tgId: form.tgId, + group: form.group, comment: form.comment, enable: form.enable, inboundIds: form.inboundIds, @@ -507,6 +514,21 @@ export default function ClientFormModal({ update('comment', e.target.value)} /> + + + ({ value: g }))} + onChange={(v) => update('group', v ?? '')} + filterOption={(input, option) => + String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase()) + } + allowClear + style={{ width: '100%' }} + /> + + diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index 195d539f..8eaba3fa 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/frontend/src/pages/clients/ClientsPage.css @@ -74,6 +74,7 @@ align-items: center; gap: 8px; flex-wrap: wrap; + padding: 6px 0; } .email-cell { diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 3f97a9d2..fbc8200e 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -31,6 +31,7 @@ import { EditOutlined, FilterOutlined, InfoCircleOutlined, + LinkOutlined, MoreOutlined, PlusOutlined, QrcodeOutlined, @@ -38,6 +39,7 @@ import { RetweetOutlined, SearchOutlined, SortAscendingOutlined, + TagsOutlined, TeamOutlined, UsergroupAddOutlined, } from '@ant-design/icons'; @@ -58,6 +60,8 @@ 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'; @@ -97,6 +101,7 @@ function readFilterState(): PersistedFilterState { 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 { @@ -140,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, @@ -165,6 +171,8 @@ export default function ClientsPage() { const [qrClient, setQrClient] = useState(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([]); const initial = readFilterState(); @@ -210,6 +218,7 @@ export default function ClientsPage() { autoRenew: filters.autoRenew || undefined, hasTgId: filters.hasTgId || undefined, hasComment: filters.hasComment || undefined, + group: filters.groups.join(',') || undefined, sort: sortColumn || undefined, order: sortOrder || undefined, }); @@ -236,6 +245,12 @@ export default function ClientsPage() { return [...values].sort(); }, [inbounds]); + const groupOptions = useMemo(() => { + const values = new Set(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) { @@ -562,6 +577,29 @@ export default function ClientsPage() {
), }, + { + title: t('pages.clients.group'), + key: 'group', + width: 130, + render: (_v, record) => { + if (!record.group) return ; + const isActive = filters.groups.includes(record.group); + return ( + { + e.stopPropagation(); + if (!isActive) { + setFilters({ ...filters, groups: [...filters.groups, record.group!] }); + } + }} + > + {record.group} + + ); + }, + }, { title: t('pages.clients.attachedInbounds'), key: 'inboundIds', @@ -627,7 +665,7 @@ export default function ClientsPage() { ), }, // eslint-disable-next-line react-hooks/exhaustive-deps - ], [t, togglingEmail, clientBucket, isOnline, inboundsById]); + ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters]); const tablePagination = { current: currentPage, @@ -740,28 +778,56 @@ export default function ClientsPage() { hoverable title={
- - {selectedRowKeys.length > 0 && ( <> - - + + )} - - + , + label: t('pages.clients.bulk'), + onClick: () => setBulkAddOpen(true), + }, + { + key: 'resetAll', + icon: , + label: t('pages.clients.resetAllTraffics'), + onClick: onResetAllTraffics, + }, + { + key: 'delDepleted', + icon: , + label: t('pages.clients.delDepleted'), + danger: true, + onClick: onDelDepleted, + }, + ], + }} + > + +
} > @@ -838,6 +904,16 @@ export default function ClientsPage() { {inboundLabel(id)} ))} + {filters.groups.map((g) => ( + setFilters({ ...filters, groups: filters.groups.filter((x) => x !== g) })} + > + {t('pages.clients.group')}: {g} + + ))} {(filters.expiryFrom || filters.expiryTo) && ( clearOneFilter('expiryFrom')}> {t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'} @@ -1008,6 +1084,7 @@ export default function ClientsPage() { inbounds={inbounds} ipLimitEnable={ipLimitEnable} tgBotEnable={tgBotEnable} + groups={allGroups} save={onSave} onOpenChange={setFormOpen} /> @@ -1035,6 +1112,7 @@ export default function ClientsPage() { open={bulkAddOpen} inbounds={inbounds} ipLimitEnable={ipLimitEnable} + groups={allGroups} onOpenChange={setBulkAddOpen} onSaved={() => setBulkAddOpen(false)} /> @@ -1054,6 +1132,31 @@ export default function ClientsPage() { }} /> + + + + + { + const msg = await bulkAssignGroup([...selectedRowKeys], group); + if (msg?.success) { + setSelectedRowKeys([]); + return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 }; + } + return null; + }} + /> + diff --git a/frontend/src/pages/clients/FilterDrawer.tsx b/frontend/src/pages/clients/FilterDrawer.tsx index 4f7cdfed..0d20cb97 100644 --- a/frontend/src/pages/clients/FilterDrawer.tsx +++ b/frontend/src/pages/clients/FilterDrawer.tsx @@ -27,6 +27,7 @@ interface FilterDrawerProps { onChange: (next: ClientFilters) => void; inbounds: InboundOption[]; protocols: string[]; + groups: string[]; } const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const; @@ -38,6 +39,7 @@ export default function FilterDrawer({ onChange, inbounds, protocols, + groups, }: FilterDrawerProps) { const { t } = useTranslation(); @@ -60,6 +62,11 @@ export default function FilterDrawer({ [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, @@ -126,6 +133,21 @@ export default function FilterDrawer({ /> + + setCreateName(e.target.value)} + onPressEnter={confirmCreate} + placeholder={t('pages.clients.groupPlaceholder')} + autoFocus + /> + + + + + setRenameOpen(false)} + onOk={confirmRename} + destroyOnHidden + > +
+ + setRenameValue(e.target.value)} + onPressEnter={confirmRename} + placeholder={t('pages.clients.groupPlaceholder')} + autoFocus + /> + +
+
+ + + + + + + { + 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; + }} + /> + + + + ); +} diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index d72180ac..7c2afd47 100644 --- a/frontend/src/routes.tsx +++ b/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() }, { path: 'inbounds', element: withSuspense() }, { path: 'clients', element: withSuspense() }, + { path: 'groups', element: withSuspense() }, { path: 'nodes', element: withSuspense() }, { path: 'settings', element: withSuspense() }, { path: 'xray', element: withSuspense() }, diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index d5e528a8..c8fc254c 100644 --- a/frontend/src/schemas/client.ts +++ b/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(), @@ -111,6 +120,7 @@ export const ClientFormSchema = z.object({ 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()), @@ -137,6 +147,7 @@ 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), @@ -158,3 +169,4 @@ export type BulkCreateResult = z.infer; export type ClientBulkAddFormValues = z.infer; export type ClientBulkAdjustFormValues = z.infer; export type ClientFormValues = z.infer; +export type GroupSummary = z.infer; diff --git a/frontend/src/styles/page-cards.css b/frontend/src/styles/page-cards.css index c2aa5d9e..e35d851e 100644 --- a/frontend/src/styles/page-cards.css +++ b/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; } diff --git a/frontend/src/styles/page-shell.css b/frontend/src/styles/page-shell.css index 5713322e..ce504c05 100644 --- a/frontend/src/styles/page-shell.css +++ b/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; } } diff --git a/web/controller/client.go b/web/controller/client.go index 6ea3d327..9aa87c36 100644 --- a/web/controller/client.go +++ b/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() +} diff --git a/web/service/client.go b/web/service/client.go index 19283f4b..34f5c84c 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -237,6 +237,7 @@ 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) { @@ -863,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"` @@ -894,6 +896,7 @@ type ClientPageParams struct { 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 @@ -908,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 @@ -1017,6 +1021,9 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin if !clientMatchesHasComment(c, params.HasComment) { continue } + if !clientMatchesAnyGroup(c, params.Group) { + continue + } filtered = append(filtered, c) } @@ -1038,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, @@ -1045,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), @@ -1096,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, @@ -1261,6 +1590,26 @@ func clientMatchesHasComment(c ClientWithAttachments, mode string) bool { 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 { if bucket == "" { return true diff --git a/web/translation/en-US.json b/web/translation/en-US.json index cf04d4a8..a726ed79 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -102,6 +102,7 @@ "dashboard": "Overview", "inbounds": "Inbounds", "clients": "Clients", + "groups": "Groups", "nodes": "Nodes", "settings": "Panel Settings", "xray": "Xray Configs", @@ -482,6 +483,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", @@ -509,6 +513,21 @@ "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", @@ -543,6 +562,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",