mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-17 08:40:59 +00:00
feat(clients): add top-level Clients tab and CRUD API
Adds /panel/api/clients endpoints (list, get, add, update, del, attach, detach) backed by ClientService methods that orchestrate the per-inbound Add/Update/Del flows so a single client row is created once and attached to many inbounds in one operation. The frontend gains a dedicated Clients page (frontend/clients.html + src/pages/clients/) with an AntD table, multi-inbound attach modal, and full CRUD. Axios interceptor learns to honour Content-Type: application/json so the JSON endpoints work alongside the legacy form-encoded ones. The legacy per-inbound client modal stays untouched in this PR — both flows now write to the same source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
||||
inbounds := api.Group("/inbounds")
|
||||
a.inboundController = NewInboundController(inbounds)
|
||||
|
||||
clients := api.Group("/clients")
|
||||
NewClientController(clients)
|
||||
|
||||
// Server API
|
||||
server := api.Group("/server")
|
||||
a.serverController = NewServerController(server)
|
||||
|
||||
@@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||
basePath = "/panel/api"
|
||||
case "inbound.go":
|
||||
basePath = "/panel/api/inbounds"
|
||||
case "client.go":
|
||||
basePath = "/panel/api/clients"
|
||||
case "server.go":
|
||||
basePath = "/panel/api/server"
|
||||
case "node.go":
|
||||
@@ -127,6 +129,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||
// 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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ClientController struct {
|
||||
clientService service.ClientService
|
||||
inboundService service.InboundService
|
||||
xrayService service.XrayService
|
||||
}
|
||||
|
||||
func NewClientController(g *gin.RouterGroup) *ClientController {
|
||||
a := &ClientController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.POST("/add", a.create)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.delete)
|
||||
g.POST("/:id/attach", a.attach)
|
||||
g.POST("/:id/detach", a.detach)
|
||||
}
|
||||
|
||||
func (a *ClientController) list(c *gin.Context) {
|
||||
rows, err := a.clientService.List()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
rec, err := a.clientService.GetByID(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) create(c *gin.Context) {
|
||||
var payload service.ClientCreatePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
var updated model.Client
|
||||
if err := c.ShouldBindJSON(&updated); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Update(&a.inboundService, id, updated)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) delete(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
keepTraffic := c.Query("keepTraffic") == "1"
|
||||
needRestart, err := a.clientService.Delete(&a.inboundService, id, keepTraffic)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
type attachDetachBody struct {
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (a *ClientController) attach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Attach(&a.inboundService, id, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) detach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Detach(&a.inboundService, id, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
g.GET("/", a.index)
|
||||
g.GET("/inbounds", a.inbounds)
|
||||
g.GET("/clients", a.clients)
|
||||
g.GET("/nodes", a.nodes)
|
||||
g.GET("/settings", a.settings)
|
||||
g.GET("/xray", a.xraySettings)
|
||||
@@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
|
||||
serveDistPage(c, "inbounds.html")
|
||||
}
|
||||
|
||||
func (a *XUIController) clients(c *gin.Context) {
|
||||
serveDistPage(c, "clients.html")
|
||||
}
|
||||
|
||||
// nodes renders the multi-panel nodes management page.
|
||||
func (a *XUIController) nodes(c *gin.Context) {
|
||||
serveDistPage(c, "nodes.html")
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClientWithAttachments struct {
|
||||
model.ClientRecord
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
||||
}
|
||||
|
||||
func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
|
||||
if rec == nil {
|
||||
return ""
|
||||
}
|
||||
switch p {
|
||||
case model.Trojan:
|
||||
return rec.Password
|
||||
case model.Shadowsocks:
|
||||
return rec.Email
|
||||
case model.Hysteria, model.Hysteria2:
|
||||
return rec.Auth
|
||||
default:
|
||||
return rec.UUID
|
||||
}
|
||||
}
|
||||
|
||||
type ClientService struct{}
|
||||
|
||||
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
||||
@@ -141,3 +168,347 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
||||
row := &model.ClientRecord{}
|
||||
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
|
||||
var ids []int
|
||||
err := database.GetDB().Table("client_inbounds").
|
||||
Where("client_id = ?", id).
|
||||
Order("inbound_id ASC").
|
||||
Pluck("inbound_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) List() ([]ClientWithAttachments, error) {
|
||||
db := database.GetDB()
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []ClientWithAttachments{}, nil
|
||||
}
|
||||
|
||||
clientIds := make([]int, 0, len(rows))
|
||||
emails := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
clientIds = append(clientIds, rows[i].Id)
|
||||
if rows[i].Email != "" {
|
||||
emails = append(emails, rows[i].Email)
|
||||
}
|
||||
}
|
||||
|
||||
var links []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments := make(map[int][]int, len(rows))
|
||||
for _, l := range links {
|
||||
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
||||
}
|
||||
|
||||
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
||||
if len(emails) > 0 {
|
||||
var stats []xray.ClientTraffic
|
||||
if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range stats {
|
||||
trafficByEmail[stats[i].Email] = &stats[i]
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]ClientWithAttachments, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, ClientWithAttachments{
|
||||
ClientRecord: rows[i],
|
||||
InboundIds: attachments[rows[i].Id],
|
||||
Traffic: trafficByEmail[rows[i].Email],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type ClientCreatePayload struct {
|
||||
Client model.Client `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
||||
if payload == nil {
|
||||
return false, common.NewError("empty payload")
|
||||
}
|
||||
client := payload.Client
|
||||
if strings.TrimSpace(client.Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
if len(payload.InboundIds) == 0 {
|
||||
return false, common.NewError("at least one inbound is required")
|
||||
}
|
||||
|
||||
if client.SubID == "" {
|
||||
client.SubID = uuid.NewString()
|
||||
}
|
||||
if !client.Enable {
|
||||
client.Enable = true
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if client.CreatedAt == 0 {
|
||||
client.CreatedAt = now
|
||||
}
|
||||
client.UpdatedAt = now
|
||||
|
||||
existing := &model.ClientRecord{}
|
||||
err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, err
|
||||
}
|
||||
emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
|
||||
if emailTaken {
|
||||
if existing.SubID == "" || existing.SubID != client.SubID {
|
||||
return false, common.NewError("email already in use:", client.Email)
|
||||
}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range payload.InboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
if err := s.fillProtocolDefaults(&client, inbound.Protocol); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol) error {
|
||||
switch p {
|
||||
case model.VMESS, model.VLESS:
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.NewString()
|
||||
}
|
||||
case model.Trojan, model.Shadowsocks:
|
||||
if c.Password == "" {
|
||||
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
case model.Hysteria, model.Hysteria2:
|
||||
if c.Auth == "" {
|
||||
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(updated.Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
if updated.SubID == "" {
|
||||
updated.SubID = existing.SubID
|
||||
}
|
||||
if updated.SubID == "" {
|
||||
updated.SubID = uuid.NewString()
|
||||
}
|
||||
updated.UpdatedAt = time.Now().UnixMilli()
|
||||
if updated.CreatedAt == 0 {
|
||||
updated.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
oldKey := clientKeyForProtocol(inbound.Protocol, existing)
|
||||
if oldKey == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.fillProtocolDefaults(&updated, inbound.Protocol); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, upErr := inboundSvc.UpdateInboundClient(&model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
}, oldKey)
|
||||
if upErr != nil {
|
||||
return needRestart, upErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
key := clientKeyForProtocol(inbound.Protocol, existing)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
|
||||
if delErr != nil {
|
||||
return needRestart, delErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
currentIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
have := make(map[int]struct{}, len(currentIds))
|
||||
for _, x := range currentIds {
|
||||
have[x] = struct{}{}
|
||||
}
|
||||
|
||||
clientWire := existing.ToClient()
|
||||
clientWire.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
if _, attached := have[ibId]; attached {
|
||||
continue
|
||||
}
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
copyClient := *clientWire
|
||||
if err := s.fillProtocolDefaults(©Client, inbound.Protocol); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
currentIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
have := make(map[int]struct{}, len(currentIds))
|
||||
for _, x := range currentIds {
|
||||
have[x] = struct{}{}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
if _, attached := have[ibId]; !attached {
|
||||
continue
|
||||
}
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
key := clientKeyForProtocol(inbound.Protocol, existing)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
|
||||
if delErr != nil {
|
||||
return needRestart, delErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"ultraDark": "Ultra Dark",
|
||||
"dashboard": "Overview",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"nodes": "Nodes",
|
||||
"settings": "Panel Settings",
|
||||
"xray": "Xray Configs",
|
||||
@@ -397,6 +398,19 @@
|
||||
"renew": "Auto Renew",
|
||||
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
|
||||
},
|
||||
"clients": {
|
||||
"title": "Clients",
|
||||
"addTitle": "Add Client",
|
||||
"editTitle": "Edit Client",
|
||||
"attachedInbounds": "Attached inbounds",
|
||||
"selectInbound": "Select one or more inbounds",
|
||||
"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.",
|
||||
"toasts": {
|
||||
"deleted": "Client deleted"
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Nodes",
|
||||
"addNode": "Add Node",
|
||||
|
||||
Reference in New Issue
Block a user