refactor(clients): coherent group management — rename, split, extract

This bundles a set of group-related improvements that built up across
one session and only make sense together.

Terminology / API surface:
- Rename "assign group" → "add to group" everywhere: i18n keys,
  callback names (bulkAddToGroup), component + file names
  (BulkAddToGroupModal, AddClientsToGroupModal), Go controller/struct
  names (bulkAddToGroup, AddToGroup), OpenAPI summaries. Nothing keeps
  the word "assign" anymore.
- Move group routes under /panel/api/clients/groups/* (was
  /bulkAssignGroup at the clients root).
- Split add and remove into two endpoints: /groups/bulkAdd now rejects
  empty group; new /groups/bulkRemove clears the label for the given
  emails. The old "submit empty to clear" UX is gone — Ungroup is its
  own action.

UI affordances on Clients page:
- Promote Group + Ungroup to visible bar buttons next to Attach +
  Detach. Group reuses BulkAddToGroupModal; Ungroup pops a danger
  confirm and calls bulkRemoveFromGroup.
- Custom UngroupIcon (TagsOutlined with a diagonal strike) for the
  Ungroup button so the pairing reads at a glance.
- Hide the Group column when no clients have a group label yet —
  removes a column of em-dashes on fresh installs.

UI on Groups page:
- New per-row Add clients… / Remove clients… actions backed by
  GroupAddClientsModal and GroupRemoveClientsModal: rich client picker
  (email / comment / current group / enable) with search and
  preserveSelectedRowKeys, mirroring the inbounds Attach modal UX.

Controller split:
- Move all /groups/* routes, handlers, and request bodies out of
  web/controller/client.go into a dedicated web/controller/group.go
  (GroupController with leaner clientService + xrayService
  dependencies). URLs are byte-identical because the new controller
  registers on the same parent gin.RouterGroup; api_docs_test.go gets
  a group.go → /panel/api/clients basePath entry so its route
  extraction keeps working.

Invalidation dedup:
- Removing a client from a group on the Groups page used to refetch
  /clients/groups and /clients/onlines three times: once from the
  mutation's onSuccess, once from a redundant invalidate() in the
  page's onSubmit, once from the WebSocket invalidate broadcast that
  the backend fires after every mutation. The manual invalidate() is
  gone, and a small invalidationTracker module lets websocketBridge
  skip WS-driven invalidates that arrive within 1.5s of a local
  invalidate — bringing the refetch count down to one. The WS path
  still works for changes made by another tab or user.
This commit is contained in:
MHSanaei
2026-05-28 12:59:20 +02:00
parent bf1b488a63
commit 530e338c66
20 changed files with 764 additions and 174 deletions
@@ -3,13 +3,13 @@ import { lazy, useEffect, useMemo, useState } from 'react';
import { HttpUtil } from '@/utils';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
const BulkAssignGroupModal = lazy(() => import('@/pages/clients/BulkAssignGroupModal'));
const BulkAddToGroupModal = lazy(() => import('@/pages/clients/BulkAddToGroupModal'));
interface AssignClientsGroupModalProps {
interface AddClientsToGroupModalProps {
open: boolean;
source: DBInbound | null;
onClose: () => void;
onAssigned?: () => void;
onAdded?: () => void;
}
function readClientEmails(settings: unknown): string[] {
@@ -18,12 +18,12 @@ function readClientEmails(settings: unknown): string[] {
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
}
export default function AssignClientsGroupModal({
export default function AddClientsToGroupModal({
open,
source,
onClose,
onAssigned,
}: AssignClientsGroupModalProps) {
onAdded,
}: AddClientsToGroupModalProps) {
const [groups, setGroups] = useState<string[]>([]);
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
@@ -41,19 +41,19 @@ export default function AssignClientsGroupModal({
}, [open]);
return (
<BulkAssignGroupModal
<BulkAddToGroupModal
open={open}
count={emails.length}
groups={groups}
onOpenChange={(o) => { if (!o) onClose(); }}
onSubmit={async (group) => {
const msg = await HttpUtil.post(
'/panel/api/clients/bulkAssignGroup',
'/panel/api/clients/groups/bulkAdd',
{ emails, group },
{ headers: { 'Content-Type': 'application/json' } },
);
if (!msg?.success) return null;
onAssigned?.();
onAdded?.();
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}}
/>
+1 -1
View File
@@ -263,7 +263,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r
if (isInboundMultiUser(record) && hasClients) {
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
items.push({ key: 'assignGroup', icon: <TagsOutlined />, label: t('pages.inbounds.assignClientsGroup') });
items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
}
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
+6 -6
View File
@@ -40,7 +40,7 @@ const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
const QrCodeModal = lazy(() => import('./QrCodeModal'));
const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
const DetachClientsModal = lazy(() => import('./DetachClientsModal'));
const AssignClientsGroupModal = lazy(() => import('./AssignClientsGroupModal'));
const AddClientsToGroupModal = lazy(() => import('./AddClientsToGroupModal'));
type RowAction =
| 'edit'
@@ -54,7 +54,7 @@ type RowAction =
| 'delAllClients'
| 'attachClients'
| 'detachClients'
| 'assignGroup'
| 'addToGroup'
| 'clone';
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -452,7 +452,7 @@ export default function InboundsPage() {
// Actions that touch per-client secrets (uuid, password, flow, ...) need
// the full payload that the slim list view does not ship. Hydrate first
// and then operate on the rehydrated record.
const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'assignGroup'];
const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'addToGroup'];
let target = dbInbound;
if (hydratingKeys.includes(key)) {
const hydrated = await hydrateInbound(dbInbound.id);
@@ -497,7 +497,7 @@ export default function InboundsPage() {
setDetachSource(target);
setDetachOpen(true);
break;
case 'assignGroup':
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
break;
@@ -631,10 +631,10 @@ export default function InboundsPage() {
/>
</LazyMount>
<LazyMount when={groupOpen}>
<AssignClientsGroupModal
<AddClientsToGroupModal
open={groupOpen}
onClose={() => setGroupOpen(false)}
onAssigned={refresh}
onAdded={refresh}
source={groupSource}
/>
</LazyMount>