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.