mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-29 16:39:35 +00:00
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.
166 lines
4.6 KiB
Go
166 lines
4.6 KiB
Go
package controller
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type routeDef struct {
|
|
Method string
|
|
Path string
|
|
}
|
|
|
|
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
|
|
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
|
|
|
|
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
|
|
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
|
|
|
|
// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
|
|
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
|
|
// placeholders (paths starting with /{...}) are skipped because they aren't
|
|
// registered on the main Gin engine.
|
|
func buildDocSet(t *testing.T) map[string]bool {
|
|
t.Helper()
|
|
controllerDir, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Fatalf("failed to get current dir: %v", err)
|
|
}
|
|
endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
|
|
data, err := os.ReadFile(endpointsPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
|
|
}
|
|
docSet := make(map[string]bool)
|
|
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
|
|
method, path := m[1], m[2]
|
|
if method == "WS" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
|
|
continue
|
|
}
|
|
docSet[method+" "+path] = true
|
|
}
|
|
if len(docSet) == 0 {
|
|
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
|
|
}
|
|
return docSet
|
|
}
|
|
|
|
func TestAPIRoutesDocumented(t *testing.T) {
|
|
docSet := buildDocSet(t)
|
|
|
|
controllerDir, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Fatalf("failed to get current dir: %v", err)
|
|
}
|
|
|
|
var allRoutes []routeDef
|
|
|
|
entries, err := os.ReadDir(controllerDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to read controller dir: %v", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
|
|
if err != nil {
|
|
t.Fatalf("failed to read %s: %v", entry.Name(), err)
|
|
}
|
|
src := string(data)
|
|
|
|
// Determine the base path for this file based on its initRouter patterns
|
|
basePath := ""
|
|
switch entry.Name() {
|
|
case "index.go":
|
|
basePath = ""
|
|
case "xui.go":
|
|
basePath = "/panel"
|
|
case "api.go":
|
|
basePath = "/panel/api"
|
|
case "inbound.go":
|
|
basePath = "/panel/api/inbounds"
|
|
case "client.go":
|
|
basePath = "/panel/api/clients"
|
|
case "group.go":
|
|
basePath = "/panel/api/clients"
|
|
case "server.go":
|
|
basePath = "/panel/api/server"
|
|
case "node.go":
|
|
basePath = "/panel/api/nodes"
|
|
case "setting.go":
|
|
basePath = "/panel/setting"
|
|
case "xray_setting.go":
|
|
basePath = "/panel/xray"
|
|
case "custom_geo.go":
|
|
basePath = "/panel/api/custom-geo"
|
|
case "websocket.go":
|
|
basePath = ""
|
|
}
|
|
|
|
// Find all route registrations
|
|
matches := routePattern.FindAllStringSubmatch(src, -1)
|
|
for _, m := range matches {
|
|
method := m[2]
|
|
path := strings.TrimSpace(m[3])
|
|
if basePath == "" {
|
|
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
|
|
} else {
|
|
fullPath := basePath + path
|
|
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
|
|
}
|
|
}
|
|
}
|
|
|
|
// The WebSocket route /ws is registered in web/web.go (not a controller file)
|
|
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
|
|
|
|
missingFromDocs := 0
|
|
foundInDoc := 0
|
|
sourceSet := make(map[string]bool)
|
|
|
|
for _, r := range allRoutes {
|
|
key := r.Method + " " + r.Path
|
|
// Skip SPA page routes (these are UI pages, not API endpoints)
|
|
spaPages := map[string]bool{
|
|
"/": true, "/panel/": true, "/panel/inbounds": true,
|
|
"/panel/clients": true,
|
|
"/panel/nodes": true, "/panel/settings": true,
|
|
"/panel/xray": true, "/panel/api-docs": true,
|
|
}
|
|
if spaPages[r.Path] {
|
|
continue
|
|
}
|
|
// Skip /panel/csrf-token (documented under auth as /csrf-token)
|
|
if r.Path == "/panel/csrf-token" {
|
|
continue
|
|
}
|
|
// Skip Chrome DevTools route
|
|
if strings.Contains(r.Path, ".well-known") {
|
|
continue
|
|
}
|
|
|
|
sourceSet[key] = true
|
|
if docSet[key] {
|
|
foundInDoc++
|
|
} else {
|
|
missingFromDocs++
|
|
t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
|
|
}
|
|
}
|
|
|
|
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
|
|
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
|
|
|
|
if missingFromDocs > 0 {
|
|
t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
|
|
}
|
|
}
|