mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-28 16:55:00 +03:00
Merge remote-tracking branch 'upstream/main' into sync-3.6.0
# Conflicts: # .github/workflows/claude-bot.yml # .github/workflows/release.yml # DockerInit.sh # frontend/package-lock.json # frontend/package.json # frontend/src/hooks/useClients.ts # frontend/src/layouts/AppSidebar.tsx # frontend/src/main.tsx # internal/config/version # internal/database/model/model.go # internal/web/service/client_wireguard.go # internal/web/service/inbound.go
This commit is contained in:
@@ -78,6 +78,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
api.Use(middleware.ConfigEnvelopeMiddleware())
|
||||
api.Use(middleware.CSRFMiddleware())
|
||||
|
||||
api.GET("/openapi.json", ServeOpenAPISpec)
|
||||
|
||||
// Inbounds API
|
||||
inbounds := api.Group("/inbounds")
|
||||
a.inboundController = NewInboundController(inbounds)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
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 "spa.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 "host.go":
|
||||
basePath = "/panel/api/hosts"
|
||||
case "setting.go":
|
||||
basePath = "/panel/api/setting"
|
||||
case "xray_setting.go":
|
||||
basePath = "/panel/api/xray"
|
||||
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/groups": true,
|
||||
"/panel/nodes": true, "/panel/settings": true,
|
||||
"/panel/xray": true, "/panel/outbound": true,
|
||||
"/panel/routing": 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)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/list/paged", a.listPaged)
|
||||
g.GET("/get/:email", a.get)
|
||||
g.GET("/get/tgId/:tgId", a.getByTgId)
|
||||
g.GET("/traffic/:email", a.getTrafficByEmail)
|
||||
g.GET("/subLinks/:subId", a.getSubLinks)
|
||||
g.GET("/links/:email", a.getClientLinks)
|
||||
@@ -105,6 +106,32 @@ func (a *ClientController) listPaged(c *gin.Context) {
|
||||
jsonObj(c, resp, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, error) {
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.Flow = flow
|
||||
var usedTraffic int64
|
||||
if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
|
||||
usedTraffic = t.Up + t.Down
|
||||
}
|
||||
return gin.H{
|
||||
"client": rec,
|
||||
"inboundIds": inboundIds,
|
||||
"externalLinks": externalLinks,
|
||||
"usedTraffic": usedTraffic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
rec, err := a.clientService.GetRecordByEmail(nil, email)
|
||||
@@ -112,30 +139,36 @@ func (a *ClientController) get(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
|
||||
payload, err := a.buildClientPayload(rec)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
|
||||
jsonObj(c, payload, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getByTgId(c *gin.Context) {
|
||||
tgIdStr := c.Param("tgId")
|
||||
tgId, err := strconv.ParseInt(tgIdStr, 10, 64)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
|
||||
records, err := a.clientService.GetRecordsByTgID(tgId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
rec.Flow = flow
|
||||
// Consumed bytes (up+down, including cross-node global overlay) so API
|
||||
// consumers can pair usage with the client's totalGB quota (#4973).
|
||||
// Best-effort: a traffic lookup failure must not break the client fetch.
|
||||
var usedTraffic int64
|
||||
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
|
||||
usedTraffic = t.Up + t.Down
|
||||
results := make([]gin.H, 0, len(records))
|
||||
for _, rec := range records {
|
||||
payload, err := a.buildClientPayload(rec)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
results = append(results, payload)
|
||||
}
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
|
||||
jsonObj(c, results, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) create(c *gin.Context) {
|
||||
|
||||
@@ -163,7 +163,5 @@ func (a *IndexController) csrfToken(c *gin.Context) {
|
||||
// getTwoFactorEnable retrieves the current status of two-factor authentication.
|
||||
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
|
||||
status, err := a.settingService.GetTwoFactorEnable()
|
||||
if err == nil {
|
||||
jsonObj(c, status, nil)
|
||||
}
|
||||
jsonObj(c, status, err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
|
||||
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||
for i := 0; i < loginLimitMaxRecords+100; i++ {
|
||||
for i := range loginLimitMaxRecords + 100 {
|
||||
limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestLoginLimiterEvictionSparesActiveBlocks(t *testing.T) {
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
limiter.mu.Lock()
|
||||
for i := 0; i < loginLimitMaxRecords-1; i++ {
|
||||
for i := range loginLimitMaxRecords - 1 {
|
||||
limiter.attempts["victim-"+strconv.Itoa(i)] = &loginLimitRecord{blockedUntil: now.Add(10 * time.Minute)}
|
||||
}
|
||||
limiter.attempts["filler"] = &loginLimitRecord{failures: []time.Time{now}}
|
||||
|
||||
@@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
g.POST("/all", a.getAllSetting)
|
||||
g.POST("/defaultSettings", a.getDefaultSettings)
|
||||
g.POST("/factoryDefaults", a.getFactoryDefaults)
|
||||
g.POST("/update", a.updateSetting)
|
||||
g.POST("/validateRegex", a.validateRegex)
|
||||
g.POST("/updateUser", a.updateUser)
|
||||
@@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
|
||||
jsonObj(c, result, nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) getFactoryDefaults(c *gin.Context) {
|
||||
jsonObj(c, a.settingService.GetFactoryDefaults(), nil)
|
||||
}
|
||||
|
||||
// updateSetting updates all settings with the provided data.
|
||||
func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
form, ok := middleware.BindAndValidate[updateSettingForm](c)
|
||||
|
||||
@@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool {
|
||||
}
|
||||
|
||||
func trustedProxyCIDRs() (trusted string) {
|
||||
trusted = "127.0.0.1/32,::1/128"
|
||||
trusted = service.DefaultTrustedProxyCIDRs
|
||||
defer func() {
|
||||
_ = recover()
|
||||
}()
|
||||
|
||||
@@ -28,11 +28,12 @@ type AllSetting struct {
|
||||
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
|
||||
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"`
|
||||
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
|
||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
|
||||
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
|
||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
||||
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
|
||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package job
|
||||
|
||||
import "testing"
|
||||
|
||||
// A bind that succeeds with an unusable search returns (empty, nil); the only
|
||||
// guard was `err != nil`, so that answer detached the entire inbound.
|
||||
func TestAutoDeleteAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
previous int64
|
||||
fetched int
|
||||
want bool
|
||||
}{
|
||||
{"empty fetch on a fresh job is refused", 0, 0, false},
|
||||
{"empty fetch after a healthy sync is refused", 500, 0, false},
|
||||
{"full fetch on a fresh job is allowed", 0, 500, true},
|
||||
{"steady fetch is allowed", 500, 500, true},
|
||||
{"growth is allowed", 500, 900, true},
|
||||
{"shrink above the retention floor is allowed", 500, 250, true},
|
||||
{"shrink below the retention floor is refused", 500, 249, false},
|
||||
{"collapse to a single user is refused", 500, 1, false},
|
||||
{"single user with no history is allowed", 0, 1, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
j := NewLdapSyncJob()
|
||||
j.lastFlagCount.Store(c.previous)
|
||||
if got := j.autoDeleteSafeForFetch(c.fetched); got != c.want {
|
||||
t.Fatalf("autoDeleteSafeForFetch(previous=%d, fetched=%d) = %v, want %v",
|
||||
c.previous, c.fetched, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package job
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -12,11 +13,16 @@ import (
|
||||
|
||||
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
|
||||
|
||||
// Share of the previous successful fetch a new one must still return to be
|
||||
// trusted: a sudden collapse means a broken directory far more often than churn.
|
||||
const ldapAutoDeleteMinRetainPercent = 50
|
||||
|
||||
type LdapSyncJob struct {
|
||||
settingService service.SettingService
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
xrayService service.XrayService
|
||||
lastFlagCount atomic.Int64
|
||||
}
|
||||
|
||||
// --- Helper functions for mustGet ---
|
||||
@@ -77,7 +83,7 @@ func (j *LdapSyncJob) Run() {
|
||||
UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
|
||||
UserAttr: mustGetString(j.settingService.GetLdapUserAttr),
|
||||
FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
|
||||
TruthyVals: splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)),
|
||||
TruthyVals: truthyValuesOrDefault(mustGetString(j.settingService.GetLdapTruthyValues)),
|
||||
Invert: mustGetBool(j.settingService.GetLdapInvertFlag),
|
||||
}
|
||||
|
||||
@@ -157,7 +163,7 @@ func (j *LdapSyncJob) Run() {
|
||||
|
||||
// --- Auto delete clients not in LDAP ---
|
||||
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
|
||||
if autoDelete {
|
||||
if autoDelete && j.autoDeleteSafeForFetch(len(flags)) {
|
||||
ldapEmailSet := map[string]struct{}{}
|
||||
for e := range flags {
|
||||
ldapEmailSet[e] = struct{}{}
|
||||
@@ -166,11 +172,35 @@ func (j *LdapSyncJob) Run() {
|
||||
j.deleteClientsNotInLDAP(tag, ldapEmailSet)
|
||||
}
|
||||
}
|
||||
j.lastFlagCount.Store(int64(len(flags)))
|
||||
}
|
||||
|
||||
// FetchVlessFlags returns (empty, nil) when the bind succeeds but the search
|
||||
// yields nothing — a renamed OU, a lost read grant — which is not "all gone".
|
||||
func (j *LdapSyncJob) autoDeleteSafeForFetch(fetched int) bool {
|
||||
if fetched == 0 {
|
||||
logger.Warning("LDAP auto-delete skipped: directory returned no usable users")
|
||||
return false
|
||||
}
|
||||
previous := j.lastFlagCount.Load()
|
||||
if previous > 0 && int64(fetched)*100 < previous*ldapAutoDeleteMinRetainPercent {
|
||||
logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous successful sync saw %d (below %d%% retention)",
|
||||
fetched, previous, ldapAutoDeleteMinRetainPercent)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truthyValuesOrDefault(s string) []string {
|
||||
if vals := splitCsv(s); len(vals) > 0 {
|
||||
return vals
|
||||
}
|
||||
return DefaultTruthyValues
|
||||
}
|
||||
|
||||
func splitCsv(s string) []string {
|
||||
if s == "" {
|
||||
return DefaultTruthyValues
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
@@ -63,7 +63,7 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
if !websocket.HasClients() {
|
||||
return
|
||||
}
|
||||
updated, err := j.nodeService.GetNodeTree()
|
||||
updated, err := j.nodeService.GetNodeTreeView()
|
||||
if err != nil {
|
||||
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
@@ -13,15 +15,25 @@ type PeriodicTrafficResetJob struct {
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
period Period
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
// NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
|
||||
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
|
||||
func NewPeriodicTrafficResetJob(period Period, location *time.Location) *PeriodicTrafficResetJob {
|
||||
return &PeriodicTrafficResetJob{
|
||||
period: period,
|
||||
period: period,
|
||||
location: location,
|
||||
}
|
||||
}
|
||||
|
||||
func monthlyResetDue(resetDay int, now time.Time) bool {
|
||||
if resetDay < 1 {
|
||||
resetDay = 1
|
||||
}
|
||||
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
return now.Day() == min(resetDay, lastDay)
|
||||
}
|
||||
|
||||
// Run resets traffic statistics for all inbounds that match the configured reset period.
|
||||
func (j *PeriodicTrafficResetJob) Run() {
|
||||
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
|
||||
@@ -30,13 +42,22 @@ func (j *PeriodicTrafficResetJob) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
if j.period == "monthly" {
|
||||
now := time.Now().In(j.location)
|
||||
due := inbounds[:0]
|
||||
for _, inbound := range inbounds {
|
||||
if monthlyResetDue(inbound.TrafficResetDay, now) {
|
||||
due = append(due, inbound)
|
||||
}
|
||||
}
|
||||
inbounds = due
|
||||
}
|
||||
if len(inbounds) == 0 {
|
||||
return
|
||||
}
|
||||
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
|
||||
|
||||
resetCount := 0
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
|
||||
if resetInboundErr != nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMonthlyResetDue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resetDay int
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{"legacy default on first", 0, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), true},
|
||||
{"configured day", 15, time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), true},
|
||||
{"before configured day", 15, time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC), false},
|
||||
{"month end", 31, time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), true},
|
||||
{"short month fallback", 31, time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), true},
|
||||
{"leap year fallback", 31, time.Date(2028, time.February, 29, 0, 0, 0, 0, time.UTC), true},
|
||||
{"not before short month end", 31, time.Date(2028, time.February, 28, 0, 0, 0, 0, time.UTC), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := monthlyResetDue(tc.resetDay, tc.now); got != tc.want {
|
||||
t.Fatalf("monthlyResetDue(%d, %s) = %v, want %v", tc.resetDay, tc.now.Format(time.DateOnly), got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// ReapSyncOrphansJob hard-deletes the clients the node-snapshot sweep only
|
||||
// soft-orphaned, once enough clean merges have agreed they are really gone.
|
||||
type ReapSyncOrphansJob struct {
|
||||
clientService service.ClientService
|
||||
}
|
||||
|
||||
func NewReapSyncOrphansJob() *ReapSyncOrphansJob {
|
||||
return new(ReapSyncOrphansJob)
|
||||
}
|
||||
|
||||
func (j *ReapSyncOrphansJob) Run() {
|
||||
reaped, err := j.clientService.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
logger.Warning("reap sync orphans failed:", err)
|
||||
return
|
||||
}
|
||||
if reaped > 0 {
|
||||
logger.Infof("reap sync orphans: removed %d client(s)", reaped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func stallingListener(t *testing.T) (addr string, release func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
<-done
|
||||
_ = c.Close()
|
||||
}(conn)
|
||||
go func(c net.Conn) { _, _ = io.Copy(io.Discard, c) }(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
_ = ln.Close()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalInformClient_StalledReceiverTimesOut(t *testing.T) {
|
||||
addr, release := stallingListener(t)
|
||||
defer release()
|
||||
|
||||
request := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(request)
|
||||
request.Header.SetMethod("POST")
|
||||
request.Header.SetContentType("application/json; charset=UTF-8")
|
||||
request.SetBody([]byte(`{"clientTraffics":[],"inboundTraffics":[]}`))
|
||||
request.SetRequestURI("http://" + addr + "/inform")
|
||||
request.Header.SetConnectionClose()
|
||||
|
||||
response := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseResponse(response)
|
||||
|
||||
start := time.Now()
|
||||
err := externalInformClient.DoTimeout(request, response, externalInformTimeout)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("a receiver that never answers must produce an error, not a completed request")
|
||||
}
|
||||
if !errors.Is(err, fasthttp.ErrTimeout) {
|
||||
t.Errorf("want a timeout error, got %v", err)
|
||||
}
|
||||
if elapsed > externalInformTimeout+2*time.Second {
|
||||
t.Errorf("call took %v, must be bounded by %v", elapsed, externalInformTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalInformClient_HasDeadlines(t *testing.T) {
|
||||
if externalInformClient.ReadTimeout <= 0 || externalInformClient.WriteTimeout <= 0 {
|
||||
t.Fatal("the traffic-notify client must carry read and write deadlines")
|
||||
}
|
||||
if externalInformTimeout >= 5*time.Second {
|
||||
t.Errorf("the call budget (%v) must stay under the 5s traffic poll cadence", externalInformTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInformSkippedWhenNothingToReport(t *testing.T) {
|
||||
j := &XrayTrafficJob{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
j.informTrafficToExternalAPI(nil, nil)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("an empty payload must return before touching settings or the network")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
@@ -28,6 +29,40 @@ type XrayTrafficJob struct {
|
||||
// refetch for the rest.
|
||||
const clientStatsSnapshotMaxClients = 5000
|
||||
|
||||
// splitMovedClientTraffics keeps the rows that actually moved bytes this poll,
|
||||
// alongside the active-email list and set derived from the same pass.
|
||||
//
|
||||
// Xray reports a row for every known email whether or not it transferred
|
||||
// anything, so on a large panel nearly every delta is zero. The database writes
|
||||
// and the external-API inform consume the full slice before this point; the
|
||||
// WebSocket frame only feeds the dashboard's live speed column, where an absent
|
||||
// row and a zero row render identically. Broadcasting just the movers keeps that
|
||||
// frame from growing with the client count — at 5k clients it was carrying about
|
||||
// a megabyte of zeros every five seconds.
|
||||
func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) {
|
||||
moved := make([]*xray.ClientTraffic, 0, len(clientTraffics))
|
||||
emails := make([]string, 0, len(clientTraffics))
|
||||
active := make(map[string]bool, len(clientTraffics))
|
||||
for _, ct := range clientTraffics {
|
||||
if ct == nil || ct.Up+ct.Down <= 0 {
|
||||
continue
|
||||
}
|
||||
moved = append(moved, ct)
|
||||
emails = append(emails, ct.Email)
|
||||
active[ct.Email] = true
|
||||
}
|
||||
return moved, emails, active
|
||||
}
|
||||
|
||||
const externalInformTimeout = 3 * time.Second
|
||||
|
||||
var externalInformClient = &fasthttp.Client{
|
||||
ReadTimeout: externalInformTimeout,
|
||||
WriteTimeout: externalInformTimeout,
|
||||
MaxIdleConnDuration: time.Minute,
|
||||
MaxConnsPerHost: 4,
|
||||
}
|
||||
|
||||
// NewXrayTrafficJob creates a new traffic collection job instance.
|
||||
func NewXrayTrafficJob() *XrayTrafficJob {
|
||||
return new(XrayTrafficJob)
|
||||
@@ -78,14 +113,7 @@ func (j *XrayTrafficJob) Run() {
|
||||
// than the shared last_online column, which remote-node syncs also bump
|
||||
// and would otherwise make a client active only on a remote node appear
|
||||
// online on local inbounds.
|
||||
activeEmails := make([]string, 0, len(clientTraffics))
|
||||
deltaActive := make(map[string]bool, len(clientTraffics))
|
||||
for _, ct := range clientTraffics {
|
||||
if ct != nil && ct.Up+ct.Down > 0 {
|
||||
activeEmails = append(activeEmails, ct.Email)
|
||||
deltaActive[ct.Email] = true
|
||||
}
|
||||
}
|
||||
movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics)
|
||||
// When the core supports the online-stats API, union in connection-based
|
||||
// onlines. Neither signal alone covers everything: an idle-but-connected
|
||||
// client moves no bytes between polls (the delta heuristic's blind spot),
|
||||
@@ -169,7 +197,7 @@ func (j *XrayTrafficJob) Run() {
|
||||
}
|
||||
websocket.BroadcastTraffic(map[string]any{
|
||||
"traffics": traffics,
|
||||
"clientTraffics": clientTraffics,
|
||||
"clientTraffics": movedTraffics,
|
||||
"onlineClients": onlineClients,
|
||||
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
|
||||
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
|
||||
@@ -197,6 +225,9 @@ func (j *XrayTrafficJob) Run() {
|
||||
}
|
||||
|
||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||
if len(inboundTraffics) == 0 && len(clientTraffics) == 0 {
|
||||
return
|
||||
}
|
||||
informURL, err := j.settingService.GetExternalTrafficInformURI()
|
||||
if err != nil {
|
||||
logger.Warning("get ExternalTrafficInformURI failed:", err)
|
||||
@@ -218,9 +249,10 @@ func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traf
|
||||
request.Header.SetContentType("application/json; charset=UTF-8")
|
||||
request.SetBody(requestBody)
|
||||
request.SetRequestURI(informURL)
|
||||
request.Header.SetConnectionClose()
|
||||
response := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseResponse(response)
|
||||
if err := fasthttp.Do(request, response); err != nil {
|
||||
if err := externalInformClient.DoTimeout(request, response, externalInformTimeout); err != nil {
|
||||
logger.Warning("POST ExternalTrafficInformURI failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func TestSplitMovedClientTraffics(t *testing.T) {
|
||||
rows := []*xray.ClientTraffic{
|
||||
{Email: "idle@x", Up: 0, Down: 0},
|
||||
{Email: "up@x", Up: 1024, Down: 0},
|
||||
{Email: "down@x", Up: 0, Down: 2048},
|
||||
nil,
|
||||
{Email: "both@x", Up: 512, Down: 512},
|
||||
{Email: "alsoidle@x", Up: 0, Down: 0},
|
||||
}
|
||||
|
||||
moved, emails, active := splitMovedClientTraffics(rows)
|
||||
|
||||
t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) {
|
||||
got := make([]string, 0, len(moved))
|
||||
for _, ct := range moved {
|
||||
got = append(got, ct.Email)
|
||||
}
|
||||
want := []string{"up@x", "down@x", "both@x"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("moved = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) {
|
||||
want := []string{"up@x", "down@x", "both@x"}
|
||||
if !slices.Equal(emails, want) {
|
||||
t.Fatalf("activeEmails = %v, want %v", emails, want)
|
||||
}
|
||||
if len(active) != len(want) {
|
||||
t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want))
|
||||
}
|
||||
for _, e := range want {
|
||||
if !active[e] {
|
||||
t.Fatalf("deltaActive missing %q", e)
|
||||
}
|
||||
}
|
||||
if active["idle@x"] {
|
||||
t.Fatal("an idle client must not count as active")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) {
|
||||
moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{
|
||||
{Email: "a@x"}, {Email: "b@x"},
|
||||
})
|
||||
if len(moved) != 0 || len(emails) != 0 || len(active) != 0 {
|
||||
t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
)
|
||||
|
||||
/*
|
||||
frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
|
||||
API route omitted there silently vanishes from the generated OpenAPI docs,
|
||||
and an entry for a removed route documents an endpoint that 404s. This test
|
||||
constructs the real router and diffs it against the registry both ways.
|
||||
|
||||
Scope: everything under /panel/api/ plus the session-auth surface the
|
||||
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
|
||||
/ws). SPA page routes are UI, not API, and stay out; registry paths that
|
||||
start with "/{" describe the standalone subscription server, which this
|
||||
engine does not serve.
|
||||
*/
|
||||
|
||||
var contractExtraRoutes = map[string]bool{
|
||||
"POST /login": true,
|
||||
"POST /logout": true,
|
||||
"GET /csrf-token": true,
|
||||
"POST /getTwoFactorEnable": true,
|
||||
"GET /ws": true,
|
||||
}
|
||||
|
||||
func inContractScope(method, path string) bool {
|
||||
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
|
||||
}
|
||||
|
||||
func registeredContractRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
previous := global.GetWebServer()
|
||||
s := NewServer()
|
||||
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
|
||||
global.SetWebServer(s)
|
||||
t.Cleanup(func() {
|
||||
s.cancel()
|
||||
global.SetWebServer(previous)
|
||||
})
|
||||
|
||||
engine, err := s.initRouter()
|
||||
if err != nil {
|
||||
t.Fatalf("init router: %v", err)
|
||||
}
|
||||
routes := make(map[string]bool)
|
||||
for _, r := range engine.Routes() {
|
||||
routes[r.Method+" "+r.Path] = true
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
t.Fatal("no routes registered; router construction is broken")
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func documentedContractRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
|
||||
if err != nil {
|
||||
t.Fatalf("read endpoints.ts: %v", err)
|
||||
}
|
||||
text := string(source)
|
||||
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`)
|
||||
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
|
||||
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
|
||||
if declared := strings.Count(text, "method: '"); len(methods) != declared {
|
||||
t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
|
||||
}
|
||||
docs := make(map[string]bool)
|
||||
for i, m := range methods {
|
||||
segmentEnd := len(text)
|
||||
if i+1 < len(methods) {
|
||||
segmentEnd = methods[i+1][0]
|
||||
}
|
||||
pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
|
||||
if pathMatch == nil {
|
||||
t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
|
||||
}
|
||||
method := text[m[2]:m[3]]
|
||||
if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
|
||||
continue
|
||||
}
|
||||
docs[method+" "+pathMatch[1]] = true
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func TestRouteRegistryContract(t *testing.T) {
|
||||
registered := registeredContractRoutes(t)
|
||||
documented := documentedContractRoutes(t)
|
||||
|
||||
t.Run("every API route is documented", func(t *testing.T) {
|
||||
var missing []string
|
||||
for route := range registered {
|
||||
fields := strings.Fields(route)
|
||||
if inContractScope(fields[0], fields[1]) && !documented[route] {
|
||||
missing = append(missing, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
for _, route := range missing {
|
||||
t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every documented route is registered", func(t *testing.T) {
|
||||
var stale []string
|
||||
for route := range documented {
|
||||
if !registered[route] {
|
||||
stale = append(stale, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(stale)
|
||||
for _, route := range stale {
|
||||
t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -770,6 +770,9 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
|
||||
if ib.TrafficReset != "" {
|
||||
v.Set("trafficReset", ib.TrafficReset)
|
||||
}
|
||||
if ib.TrafficResetDay > 0 {
|
||||
v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -252,9 +252,12 @@ func TestIsNonEmptySlice(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWireInboundTrafficReset(t *testing.T) {
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
|
||||
if got := with.Get("trafficReset"); got != "daily" {
|
||||
t.Fatalf("trafficReset = %q, want daily", got)
|
||||
with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
|
||||
if got := with.Get("trafficReset"); got != "monthly" {
|
||||
t.Fatalf("trafficReset = %q, want monthly", got)
|
||||
}
|
||||
if got := with.Get("trafficResetDay"); got != "15" {
|
||||
t.Fatalf("trafficResetDay = %q, want 15", got)
|
||||
}
|
||||
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
|
||||
without := wireInbound(&model.Inbound{}, 0)
|
||||
|
||||
@@ -69,13 +69,13 @@ func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Clie
|
||||
c.PublicKey = pub
|
||||
}
|
||||
if len(c.AllowedIPs) == 0 {
|
||||
addr, err := allocateWireguardAddress(used, v4Base)
|
||||
addr, err := allocateWireguardAddress(used, v4Base, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allowed := []string{addr}
|
||||
if v6Base != "" {
|
||||
addr6, err := allocateWireguardAddress(used, v6Base)
|
||||
addr6, err := allocateWireguardAddress(used, v6Base, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -429,8 +429,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().Unix() * 1000
|
||||
cond := depletedCond(db)
|
||||
cond, condArgs := depletedCond(db)
|
||||
candidateEmails := make([]string, 0, len(plan))
|
||||
for email, entry := range plan {
|
||||
if entry.applyExpiry || entry.applyTotal {
|
||||
@@ -441,7 +440,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
for _, batch := range chunkStrings(candidateEmails, sqlInChunk) {
|
||||
var rows []string
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND enable = ? AND email IN ?", now, false, batch).
|
||||
Where(cond+" AND enable = ? AND email IN ?", append(append([]any{}, condArgs...), false, batch)...).
|
||||
Pluck("email", &rows).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
@@ -503,7 +502,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
for _, batch := range chunkStrings(wasList, sqlInChunk) {
|
||||
var rows []string
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND email IN ?", now, batch).
|
||||
Where(cond+" AND email IN ?", append(append([]any{}, condArgs...), batch)...).
|
||||
Pluck("email", &rows).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
@@ -836,13 +835,16 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
|
||||
successEmails := make([]string, 0, len(recordsByEmail))
|
||||
successIds := make([]int, 0, len(recordsByEmail))
|
||||
failedEmails := make([]string, 0, len(recordsByEmail))
|
||||
for email, rec := range recordsByEmail {
|
||||
if _, skipped := skippedReasons[email]; skipped {
|
||||
failedEmails = append(failedEmails, email)
|
||||
continue
|
||||
}
|
||||
successEmails = append(successEmails, email)
|
||||
successIds = append(successIds, rec.Id)
|
||||
}
|
||||
withdrawClientTombstones(failedEmails...)
|
||||
|
||||
if len(successIds) > 0 {
|
||||
// Serialize the row cleanup against the traffic poll to avoid the
|
||||
@@ -876,6 +878,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
withdrawClientTombstones(successEmails...)
|
||||
return result, needRestart, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestCreateAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const uuid = "bbbbbbbb-1111-2222-3333-555555555555"
|
||||
ids := make([]int, 0, 6)
|
||||
for i := range 6 {
|
||||
ib := mkInbound(t, 23001+i, model.VLESS, `{"clients":[]}`)
|
||||
ids = append(ids, ib.Id)
|
||||
}
|
||||
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "fan@x", ID: uuid, SubID: "sub-fan", Enable: true},
|
||||
InboundIds: ids,
|
||||
}); err != nil {
|
||||
t.Fatalf("Create across %d inbounds: %v", len(ids), err)
|
||||
}
|
||||
|
||||
if n := countClientRecords(t); n != 1 {
|
||||
t.Fatalf("client records = %d, want 1", n)
|
||||
}
|
||||
rec := lookupClientRecord(t, "fan@x")
|
||||
if rec.UUID != uuid || rec.SubID != "sub-fan" {
|
||||
t.Fatalf("record = {uuid:%q sub:%q}, want {%q sub-fan}", rec.UUID, rec.SubID, uuid)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !settingsHoldUUID(t, inboundSvc, id, uuid) {
|
||||
t.Fatalf("inbound %d settings missing the client", id)
|
||||
}
|
||||
}
|
||||
|
||||
linked, err := svc.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundIdsForRecord: %v", err)
|
||||
}
|
||||
if len(linked) != len(ids) {
|
||||
t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
first := mkInbound(t, 23101, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "att@x", ID: "cccccccc-1111-2222-3333-666666666666", SubID: "sub-att", Enable: true},
|
||||
InboundIds: []int{first.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed Create: %v", err)
|
||||
}
|
||||
rec := lookupClientRecord(t, "att@x")
|
||||
|
||||
ids := []int{first.Id}
|
||||
for i := range 4 {
|
||||
ib := mkInbound(t, 23102+i, model.VLESS, `{"clients":[]}`)
|
||||
ids = append(ids, ib.Id)
|
||||
}
|
||||
|
||||
if _, err := svc.Attach(inboundSvc, rec.Id, ids); err != nil {
|
||||
t.Fatalf("Attach across %d inbounds: %v", len(ids), err)
|
||||
}
|
||||
|
||||
if n := countClientRecords(t); n != 1 {
|
||||
t.Fatalf("client records after attach = %d, want 1", n)
|
||||
}
|
||||
linked, err := svc.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInboundIdsForRecord: %v", err)
|
||||
}
|
||||
if len(linked) != len(ids) {
|
||||
t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -21,7 +22,7 @@ import (
|
||||
|
||||
func hasForbiddenClientChar(s string) bool {
|
||||
for _, r := range s {
|
||||
if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
|
||||
if r == '/' || r == '\\' || r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -110,6 +111,11 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
}
|
||||
}
|
||||
|
||||
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
|
||||
if sidErr != nil {
|
||||
return false, sidErr
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range payload.InboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
@@ -123,10 +129,10 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
|
||||
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
}, emailSubIDs)
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
@@ -375,7 +381,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
}
|
||||
}
|
||||
|
||||
if updated.SubID != "" {
|
||||
if updated.SubID != existing.SubID {
|
||||
var subCollision int64
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("sub_id = ? AND id <> ?", updated.SubID, id).
|
||||
@@ -518,6 +524,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
|
||||
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -555,7 +562,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
// A failed inbound still holds the client in its settings JSON: keep the
|
||||
// record so the next delete retries exactly the leftovers, and report it.
|
||||
// The tombstone lifts with it, or the next node merge finishes the deletion.
|
||||
if len(delErrs) > 0 {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return needRestart, errors.Join(delErrs...)
|
||||
}
|
||||
|
||||
@@ -588,6 +597,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
return tx.Delete(&model.ClientRecord{}, id).Error
|
||||
}); err != nil {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
@@ -615,6 +625,11 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
||||
clientWire.Flow = flow
|
||||
clientWire.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
|
||||
if sidErr != nil {
|
||||
return false, sidErr
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
if _, attached := have[ibId]; attached {
|
||||
@@ -632,10 +647,10 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
|
||||
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
}, emailSubIDs)
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Delete tombstones up front and keeps the record when an inbound fails. A
|
||||
// surviving tombstone lets the next node merge finish the refused deletion.
|
||||
func TestFailedDeleteWithdrawsTombstone(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
const email = "retry@x"
|
||||
broken := mkInbound(t, 30401, model.VLESS, `{"clients": [ THIS IS NOT JSON`)
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "33333333-3333-3333-3333-333333333333"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: broken.Id}).Error; err != nil {
|
||||
t.Fatalf("attach client: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { withdrawClientTombstones(email) })
|
||||
|
||||
if _, err := svc.Delete(inboundSvc, rec.Id, false); err == nil {
|
||||
t.Fatal("setup: delete was expected to fail on the unparseable inbound settings")
|
||||
}
|
||||
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("failed delete must keep the record for a retry, got %d rows", surviving)
|
||||
}
|
||||
if isClientEmailTombstoned(email) {
|
||||
t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
|
||||
}
|
||||
}
|
||||
@@ -495,7 +495,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
||||
}
|
||||
cipher := ""
|
||||
if oldInbound.Protocol == "shadowsocks" {
|
||||
cipher = oldSettings["method"].(string)
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||
"email": client.Email,
|
||||
@@ -899,7 +899,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
if clients[0].Enable {
|
||||
cipher := ""
|
||||
if oldInbound.Protocol == "shadowsocks" {
|
||||
cipher = oldSettings["method"].(string)
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||
"email": clients[0].Email,
|
||||
|
||||
@@ -115,6 +115,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||
}
|
||||
|
||||
incoming := clients[i].ToRecord()
|
||||
// ToRecord copies the raw email; store the trimmed key this function
|
||||
// looks up by, or a padded email is inserted and never found again.
|
||||
incoming.Email = email
|
||||
row, ok := existing[email]
|
||||
if !ok {
|
||||
if _, dup := pending[email]; !dup {
|
||||
|
||||
@@ -105,6 +105,19 @@ func tombstoneClientEmail(email string) {
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawClientTombstones(emails ...string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
recentlyDeletedMu.Lock()
|
||||
defer recentlyDeletedMu.Unlock()
|
||||
for _, email := range emails {
|
||||
if email != "" {
|
||||
delete(recentlyDeleted, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tombstoneClientEmails(emails []string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -103,6 +104,15 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
|
||||
if tgId <= 0 {
|
||||
return nil, errors.New("tg_id must be a positive integer")
|
||||
}
|
||||
var rows []*model.ClientRecord
|
||||
err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
||||
row := &model.ClientRecord{}
|
||||
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestGetRecordsByTgID(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
db := database.GetDB()
|
||||
|
||||
records := []model.ClientRecord{
|
||||
{Email: "alice@x", TgID: 100, SubID: "sa"},
|
||||
{Email: "bob@x", TgID: 100, SubID: "sb"},
|
||||
{Email: "carol@x", TgID: 200, SubID: "sc"},
|
||||
{Email: "dave@x", TgID: 0, SubID: "sd"},
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := db.Create(&r).Error; err != nil {
|
||||
t.Fatalf("create record %q: %v", r.Email, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("multiple clients share tgId", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(100): %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 records, got %d", len(got))
|
||||
}
|
||||
emails := make(map[string]bool)
|
||||
for _, r := range got {
|
||||
emails[r.Email] = true
|
||||
}
|
||||
if !emails["alice@x"] || !emails["bob@x"] {
|
||||
t.Fatalf("expected alice@x and bob@x, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single client by tgId", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(200)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(200): %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(got))
|
||||
}
|
||||
if got[0].Email != "carol@x" {
|
||||
t.Fatalf("expected carol@x, got %s", got[0].Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tgId zero rejected as sentinel", func(t *testing.T) {
|
||||
_, err := svc.GetRecordsByTgID(0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tgId=0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative tgId rejected", func(t *testing.T) {
|
||||
_, err := svc.GetRecordsByTgID(-5)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tgId=-5")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent tgId returns empty", func(t *testing.T) {
|
||||
got, err := svc.GetRecordsByTgID(999)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordsByTgID(999): %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected 0 records, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ClientSlim is the row-shape used by the clients page. It drops fields the
|
||||
@@ -74,33 +77,262 @@ type ClientPageResponse struct {
|
||||
|
||||
// ClientsSummary collects per-bucket counts plus the matching email lists so
|
||||
// the clients page can render the dashboard stat cards and their hover
|
||||
// popovers without shipping the full client array.
|
||||
// popovers without shipping the full client array. The counters are exact;
|
||||
// the lists stop at clientSummaryEmailCap entries and only back the popovers.
|
||||
type ClientsSummary struct {
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
Online []string `json:"online"`
|
||||
Depleted []string `json:"depleted"`
|
||||
Expiring []string `json:"expiring"`
|
||||
Deactive []string `json:"deactive"`
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
OnlineCount int `json:"onlineCount"`
|
||||
DepletedCount int `json:"depletedCount"`
|
||||
ExpiringCount int `json:"expiringCount"`
|
||||
DeactiveCount int `json:"deactiveCount"`
|
||||
Online []string `json:"online"`
|
||||
Depleted []string `json:"depleted"`
|
||||
Expiring []string `json:"expiring"`
|
||||
Deactive []string `json:"deactive"`
|
||||
}
|
||||
|
||||
const (
|
||||
clientPageDefaultSize = 25
|
||||
clientPageMaxSize = 200
|
||||
// clientSummaryEmailCap bounds each bucket's email list. Shipping every
|
||||
// matching email made the response — and the Zod validation the page runs
|
||||
// over it — grow with the client count on a request that repeats every 5s,
|
||||
// and left the hover popover rendering thousands of rows.
|
||||
clientSummaryEmailCap = 200
|
||||
// sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
|
||||
// matching the sentinel the in-memory comparator used.
|
||||
sqlNeverSentinel = "4611686018427387903"
|
||||
// sqlClientEnabled tolerates a NULL enable column, which GORM scans as
|
||||
// false: without the COALESCE such a row would match neither the enabled
|
||||
// nor the disabled branch of any predicate.
|
||||
sqlClientEnabled = "COALESCE(c.enable, FALSE)"
|
||||
)
|
||||
|
||||
// ListPaged loads every client (with traffic + attachments) into memory,
|
||||
// applies the requested filter / search / protocol predicates, sorts, and
|
||||
// returns the requested page along with total and filtered counts. The DB
|
||||
// query itself is unchanged from List(); the win is that the response
|
||||
// only carries 25-ish slim rows over the wire instead of all 2000 full
|
||||
// records, which on real panels was the dominant cost.
|
||||
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
|
||||
OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
|
||||
|
||||
// clientQuery builds the statements behind the clients page: a clients row
|
||||
// joined to its traffic counters, plus the expressions every bucket predicate
|
||||
// shares. Filtering, sorting, paging and the summary all run in the database.
|
||||
// Loading every client (with attachments and traffic) into Go and doing it in
|
||||
// memory cost ~200ms per request at 20k clients on a page that polls every
|
||||
// 5 seconds, which is what made the table feel stuck on large panels.
|
||||
type clientQuery struct {
|
||||
db *gorm.DB
|
||||
joins []clientQueryJoin
|
||||
usedExpr string
|
||||
nowMs int64
|
||||
expireDiffMs int64
|
||||
trafficDiffBytes int64
|
||||
}
|
||||
|
||||
type clientQueryJoin struct {
|
||||
sql string
|
||||
args []any
|
||||
}
|
||||
|
||||
func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
|
||||
q := clientQuery{
|
||||
db: db,
|
||||
nowMs: nowMs,
|
||||
expireDiffMs: expireDiffMs,
|
||||
trafficDiffBytes: trafficDiffBytes,
|
||||
joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
|
||||
usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
|
||||
}
|
||||
total := len(all)
|
||||
freshSince := globalTrafficFreshSince()
|
||||
var probe int64
|
||||
err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("updated_at >= ?", freshSince).
|
||||
Limit(1).Count(&probe).Error
|
||||
if err != nil || probe == 0 {
|
||||
return q
|
||||
}
|
||||
// A master still pushes cross-panel usage here, so the predicates have to
|
||||
// see the same raised counters overlayGlobalTraffic applies on read.
|
||||
q.joins = append(q.joins, clientQueryJoin{
|
||||
sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
|
||||
" WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
|
||||
args: []any{freshSince},
|
||||
})
|
||||
q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
|
||||
" + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
|
||||
return q
|
||||
}
|
||||
|
||||
func (q clientQuery) from() *gorm.DB {
|
||||
tx := q.db.Table("clients AS c")
|
||||
for _, j := range q.joins {
|
||||
tx = tx.Joins(j.sql, j.args...)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func (q clientQuery) depletedExpr() string {
|
||||
return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
|
||||
" OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
|
||||
}
|
||||
|
||||
func (q clientQuery) nearDepletionExpr() string {
|
||||
return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
|
||||
" OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
|
||||
}
|
||||
|
||||
func (q clientQuery) expiringExpr() string {
|
||||
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
|
||||
}
|
||||
|
||||
func (q clientQuery) activeExpr() string {
|
||||
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
|
||||
}
|
||||
|
||||
// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
|
||||
// client that also ran out counts once, under depleted, so the stat cards add
|
||||
// up to the client total.
|
||||
func (q clientQuery) summaryDeactiveExpr() string {
|
||||
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
|
||||
}
|
||||
|
||||
// applyParams narrows tx by every predicate the clients page sends. Matching is
|
||||
// OR within a field and AND across fields, mirroring the query-param contract.
|
||||
// The second return says whether anything narrowed the set, so an unfiltered
|
||||
// request can reuse the total count instead of scanning for it again.
|
||||
func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
|
||||
narrowed := false
|
||||
where := func(cond string, args ...any) {
|
||||
narrowed = true
|
||||
tx = tx.Where(cond, args...)
|
||||
}
|
||||
|
||||
if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
|
||||
pattern := "%" + escapeLikeLiteral(needle) + "%"
|
||||
where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
|
||||
}
|
||||
if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
|
||||
where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
|
||||
" WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
|
||||
}
|
||||
if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
|
||||
where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
|
||||
}
|
||||
if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
|
||||
cond, args := q.bucketCond(buckets, onlines)
|
||||
where(cond, args...)
|
||||
}
|
||||
if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
|
||||
// 0 means "never expires" and a negative value is the delayed-start
|
||||
// sentinel; both sit outside any bounded range.
|
||||
where("c.expiry_time > 0")
|
||||
if params.ExpiryFrom > 0 {
|
||||
where("c.expiry_time >= ?", params.ExpiryFrom)
|
||||
}
|
||||
if params.ExpiryTo > 0 {
|
||||
where("c.expiry_time <= ?", params.ExpiryTo)
|
||||
}
|
||||
}
|
||||
if params.UsageFrom > 0 {
|
||||
where(q.usedExpr+" >= ?", params.UsageFrom)
|
||||
}
|
||||
if params.UsageTo > 0 {
|
||||
where(q.usedExpr+" <= ?", params.UsageTo)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
|
||||
case "on":
|
||||
where("COALESCE(c.reset, 0) > 0")
|
||||
case "off":
|
||||
where("COALESCE(c.reset, 0) <= 0")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
|
||||
case "yes":
|
||||
where("COALESCE(c.tg_id, 0) <> 0")
|
||||
case "no":
|
||||
where("COALESCE(c.tg_id, 0) = 0")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
|
||||
case "yes":
|
||||
where("TRIM(COALESCE(c.comment, '')) <> ''")
|
||||
case "no":
|
||||
where("TRIM(COALESCE(c.comment, '')) = ''")
|
||||
}
|
||||
if groups := parseCSVStrings(params.Group); len(groups) > 0 {
|
||||
where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
|
||||
}
|
||||
return tx, narrowed
|
||||
}
|
||||
|
||||
func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
|
||||
conds := make([]string, 0, len(buckets))
|
||||
args := make([]any, 0, len(buckets))
|
||||
for _, b := range buckets {
|
||||
switch b {
|
||||
case "active":
|
||||
conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
|
||||
case "deactive":
|
||||
conds = append(conds, "(NOT "+sqlClientEnabled+")")
|
||||
case "depleted":
|
||||
conds = append(conds, q.depletedExpr())
|
||||
case "expiring":
|
||||
conds = append(conds, q.expiringExpr())
|
||||
case "online":
|
||||
cond, inArgs := emailInCond("c.email", onlines)
|
||||
conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
|
||||
args = append(args, inArgs...)
|
||||
default:
|
||||
// An unrecognised bucket name matched every client before the
|
||||
// predicates moved into SQL; keep that so a stale saved filter
|
||||
// cannot silently empty the table.
|
||||
conds = append(conds, "(1 = 1)")
|
||||
}
|
||||
}
|
||||
return "(" + strings.Join(conds, " OR ") + ")", args
|
||||
}
|
||||
|
||||
func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
|
||||
dir := " ASC"
|
||||
if order == "descend" {
|
||||
dir = " DESC"
|
||||
}
|
||||
// createdAt / updatedAt / lastOnline broke ties on the client id inside the
|
||||
// comparator, so reversing the sort reversed the tiebreak with it. The
|
||||
// other keys leaned on a stable sort over an id-ordered slice instead.
|
||||
tieDir := " ASC"
|
||||
var expr string
|
||||
switch sortKey {
|
||||
case "enable":
|
||||
expr = sqlClientEnabled
|
||||
case "email":
|
||||
expr = "LOWER(c.email)"
|
||||
case "inboundIds":
|
||||
expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
|
||||
case "traffic":
|
||||
expr = q.usedExpr
|
||||
case "remaining":
|
||||
expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
|
||||
case "expiryTime":
|
||||
expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
|
||||
case "createdAt":
|
||||
expr, tieDir = "c.created_at", dir
|
||||
case "updatedAt":
|
||||
expr, tieDir = "c.updated_at", dir
|
||||
case "lastOnline":
|
||||
expr, tieDir = "COALESCE(ct.last_online, 0)", dir
|
||||
default:
|
||||
return tx.Order("c.id ASC")
|
||||
}
|
||||
return tx.Order(expr + dir + ", c.id" + tieDir)
|
||||
}
|
||||
|
||||
// ListPaged returns one page of clients together with the counts the clients
|
||||
// page header needs. Every predicate runs in SQL, so the cost tracks the page
|
||||
// size rather than the number of clients on the panel.
|
||||
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
pageSize := params.PageSize
|
||||
if pageSize <= 0 {
|
||||
@@ -114,27 +346,6 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||
page = 1
|
||||
}
|
||||
|
||||
protocols := parseCSVStrings(params.Protocol)
|
||||
inboundIDs := parseCSVInts(params.Inbound)
|
||||
buckets := parseCSVStrings(params.Filter)
|
||||
|
||||
var protocolByInbound map[int]string
|
||||
if len(protocols) > 0 {
|
||||
inbounds, err := inboundSvc.GetAllInbounds()
|
||||
if err == nil {
|
||||
protocolByInbound = make(map[int]string, len(inbounds))
|
||||
for _, ib := range inbounds {
|
||||
protocolByInbound[ib.Id] = string(ib.Protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onlines := inboundSvc.GetOnlineClients()
|
||||
onlineSet := make(map[string]struct{}, len(onlines))
|
||||
for _, e := range onlines {
|
||||
onlineSet[e] = struct{}{}
|
||||
}
|
||||
|
||||
var expireDiffMs, trafficDiffBytes int64
|
||||
if settingSvc != nil {
|
||||
if v, err := settingSvc.GetExpireDiff(); err == nil {
|
||||
@@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||
}
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
|
||||
onlines := inboundSvc.GetOnlineClients()
|
||||
q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
|
||||
|
||||
needle := strings.ToLower(strings.TrimSpace(params.Search))
|
||||
|
||||
filtered := make([]ClientWithAttachments, 0, len(all))
|
||||
for _, c := range all {
|
||||
if needle != "" && !clientMatchesSearch(c, needle) {
|
||||
continue
|
||||
}
|
||||
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
|
||||
continue
|
||||
}
|
||||
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
|
||||
continue
|
||||
}
|
||||
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesAutoRenew(c, params.AutoRenew) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesHasTgID(c, params.HasTgID) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesHasComment(c, params.HasComment) {
|
||||
continue
|
||||
}
|
||||
if !clientMatchesAnyGroup(c, params.Group) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
var total int64
|
||||
if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sortClients(filtered, params.Sort, params.Order)
|
||||
|
||||
filteredCount := len(filtered)
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if start > filteredCount {
|
||||
start = filteredCount
|
||||
}
|
||||
if end > filteredCount {
|
||||
end = filteredCount
|
||||
}
|
||||
pageRows := filtered[start:end]
|
||||
|
||||
items := make([]ClientSlim, 0, len(pageRows))
|
||||
for _, c := range pageRows {
|
||||
items = append(items, toClientSlim(c))
|
||||
summary, err := q.summary(onlines, int(total))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupRows, gErr := s.ListGroups()
|
||||
if gErr != nil {
|
||||
return nil, gErr
|
||||
filtered := total
|
||||
if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
|
||||
if err := scoped.Count(&filtered).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
groups := make([]string, 0, len(groupRows))
|
||||
for _, g := range groupRows {
|
||||
groups = append(groups, g.Name)
|
||||
|
||||
items := []ClientSlim{}
|
||||
offset := (page - 1) * pageSize
|
||||
if int64(offset) < filtered {
|
||||
items, err = q.pageRows(params, onlines, offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
groups, err := s.listGroupNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ClientPageResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Filtered: filteredCount,
|
||||
Total: int(total),
|
||||
Filtered: int(filtered),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Summary: summary,
|
||||
@@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
|
||||
// pageRows resolves the requested page to client ids, then loads the records,
|
||||
// attachments and traffic for those ids only. A page never exceeds
|
||||
// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
|
||||
// lists need no chunking.
|
||||
func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
|
||||
tx, _ := q.applyParams(q.from(), params, onlines)
|
||||
var ids []int
|
||||
if err := q.applyOrder(tx, params.Sort, params.Order).
|
||||
Offset(offset).Limit(limit).
|
||||
Pluck("c.id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return []ClientSlim{}, nil
|
||||
}
|
||||
|
||||
var records []model.ClientRecord
|
||||
if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byId := make(map[int]*model.ClientRecord, len(records))
|
||||
emails := make([]string, 0, len(records))
|
||||
for i := range records {
|
||||
byId[records[i].Id] = &records[i]
|
||||
if records[i].Email != "" {
|
||||
emails = append(emails, records[i].Email)
|
||||
}
|
||||
}
|
||||
|
||||
var links []model.ClientInbound
|
||||
if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments := make(map[int][]int, len(ids))
|
||||
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 := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlayGlobalTrafficValues(q.db, stats)
|
||||
for i := range stats {
|
||||
trafficByEmail[stats[i].Email] = &stats[i]
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]ClientSlim, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
rec := byId[id]
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, ClientSlim{
|
||||
Email: rec.Email,
|
||||
SubID: rec.SubID,
|
||||
Enable: rec.Enable,
|
||||
TotalGB: rec.TotalGB,
|
||||
ExpiryTime: rec.ExpiryTime,
|
||||
LimitIP: rec.LimitIP,
|
||||
Reset: rec.Reset,
|
||||
Group: rec.Group,
|
||||
Comment: rec.Comment,
|
||||
InboundIds: attachments[rec.Id],
|
||||
Traffic: trafficByEmail[rec.Email],
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
|
||||
s := ClientsSummary{
|
||||
Total: len(all),
|
||||
Total: total,
|
||||
Online: []string{},
|
||||
Depleted: []string{},
|
||||
Expiring: []string{},
|
||||
Deactive: []string{},
|
||||
}
|
||||
for _, c := range all {
|
||||
used := int64(0)
|
||||
if c.Traffic != nil {
|
||||
used = c.Traffic.Up + c.Traffic.Down
|
||||
|
||||
var counts struct {
|
||||
Active int64
|
||||
Depleted int64
|
||||
Expiring int64
|
||||
Deactive int64
|
||||
}
|
||||
// SUM over an empty table yields NULL, which not every driver scans into an
|
||||
// int; COALESCE keeps a panel with no clients from erroring out.
|
||||
if err := q.from().Select(
|
||||
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
|
||||
" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
||||
).Scan(&counts).Error; err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Active = int(counts.Active)
|
||||
s.DepletedCount = int(counts.Depleted)
|
||||
s.ExpiringCount = int(counts.Expiring)
|
||||
s.DeactiveCount = int(counts.Deactive)
|
||||
|
||||
buckets := []struct {
|
||||
cond string
|
||||
count int
|
||||
out *[]string
|
||||
}{
|
||||
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
|
||||
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
|
||||
{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
|
||||
}
|
||||
for _, b := range buckets {
|
||||
// The counter already says the bucket is empty, so skip the scan that
|
||||
// would look for emails it cannot find.
|
||||
if b.count == 0 {
|
||||
continue
|
||||
}
|
||||
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
||||
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
||||
if c.Enable {
|
||||
if _, ok := onlineSet[c.Email]; ok {
|
||||
s.Online = append(s.Online, c.Email)
|
||||
var emails []string
|
||||
if err := q.from().Where(b.cond).
|
||||
Order("c.id ASC").Limit(clientSummaryEmailCap).
|
||||
Pluck("c.email", &emails).Error; err != nil {
|
||||
return s, err
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
*b.out = emails
|
||||
}
|
||||
}
|
||||
|
||||
online, onlineCount, err := q.onlineEmails(onlines)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Online = online
|
||||
s.OnlineCount = onlineCount
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// onlineEmails intersects the emails xray reports as connected with the enabled
|
||||
// clients this panel stores. The online set lives in memory and is bounded by
|
||||
// live connections, so it drives the query rather than a scan of every client.
|
||||
func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
|
||||
matched := []string{}
|
||||
count := 0
|
||||
for _, batch := range chunkStrings(onlines, sqlInChunk) {
|
||||
var page []string
|
||||
if err := q.db.Model(&model.ClientRecord{}).
|
||||
Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
|
||||
Order("id ASC").
|
||||
Pluck("email", &page).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
count += len(page)
|
||||
if room := clientSummaryEmailCap - len(matched); room > 0 {
|
||||
matched = append(matched, page[:min(room, len(page))]...)
|
||||
}
|
||||
}
|
||||
return matched, count, nil
|
||||
}
|
||||
|
||||
// listGroupNames returns the group names the clients page offers as filters:
|
||||
// the stored groups plus any name a client still carries. ListGroups also sums
|
||||
// per-client traffic per group, which this page never reads and which costs a
|
||||
// full join over client_traffics on every poll.
|
||||
func (s *ClientService) listGroupNames() ([]string, error) {
|
||||
db := database.GetDB()
|
||||
var stored []string
|
||||
if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var used []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("group_name <> ''").
|
||||
Distinct().
|
||||
Pluck("group_name", &used).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(stored)+len(used))
|
||||
out := make([]string, 0, len(stored)+len(used))
|
||||
for _, list := range [][]string{stored, used} {
|
||||
for _, name := range list {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if exhausted || expired {
|
||||
s.Depleted = append(s.Depleted, c.Email)
|
||||
continue
|
||||
}
|
||||
if !c.Enable {
|
||||
s.Deactive = append(s.Deactive, c.Email)
|
||||
continue
|
||||
}
|
||||
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
||||
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
||||
if nearExpiry || nearLimit {
|
||||
s.Expiring = append(s.Expiring, c.Email)
|
||||
} else {
|
||||
s.Active++
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return s
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i]) < strings.ToLower(out[j])
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toClientSlim(c ClientWithAttachments) ClientSlim {
|
||||
return ClientSlim{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
Enable: c.Enable,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
LimitIP: c.LimitIP,
|
||||
Reset: c.Reset,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
InboundIds: c.InboundIds,
|
||||
Traffic: c.Traffic,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
func sqlInt(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
|
||||
if needle == "" {
|
||||
return true
|
||||
// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
|
||||
// matching literally, the way strings.Contains did.
|
||||
func escapeLikeLiteral(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
|
||||
// emailInCond renders an IN over a possibly large email set, split so no single
|
||||
// IN list outgrows the drivers' bind-parameter ceiling.
|
||||
func emailInCond(column string, emails []string) (string, []any) {
|
||||
if len(emails) == 0 {
|
||||
return "1 = 0", nil
|
||||
}
|
||||
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
|
||||
for _, v := range candidates {
|
||||
if v != "" && strings.Contains(strings.ToLower(v), needle) {
|
||||
return true
|
||||
}
|
||||
chunks := chunkStrings(emails, sqlInChunk)
|
||||
parts := make([]string, 0, len(chunks))
|
||||
args := make([]any, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
parts = append(parts, column+" IN ?")
|
||||
args = append(args, chunk)
|
||||
}
|
||||
if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return "(" + strings.Join(parts, " OR ") + ")", args
|
||||
}
|
||||
|
||||
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
|
||||
@@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
|
||||
for _, id := range c.InboundIds {
|
||||
p := byInbound[id]
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(protocols, strings.ToLower(p)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
|
||||
for _, id := range c.InboundIds {
|
||||
if slices.Contains(inboundIds, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
|
||||
for _, b := range buckets {
|
||||
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
|
||||
if fromMs <= 0 && toMs <= 0 {
|
||||
return true
|
||||
}
|
||||
// expiryTime of 0 means "never expires"; treat it as outside any bounded
|
||||
// range so users filtering by date see only clients with concrete expiries.
|
||||
if c.ExpiryTime == 0 {
|
||||
return false
|
||||
}
|
||||
// Negative expiry is the "delayed start" sentinel; same treatment as never.
|
||||
if c.ExpiryTime < 0 {
|
||||
return false
|
||||
}
|
||||
if fromMs > 0 && c.ExpiryTime < fromMs {
|
||||
return false
|
||||
}
|
||||
if toMs > 0 && c.ExpiryTime > toMs {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
|
||||
if fromBytes <= 0 && toBytes <= 0 {
|
||||
return true
|
||||
}
|
||||
used := int64(0)
|
||||
if c.Traffic != nil {
|
||||
used = c.Traffic.Up + c.Traffic.Down
|
||||
}
|
||||
if fromBytes > 0 && used < fromBytes {
|
||||
return false
|
||||
}
|
||||
if toBytes > 0 && used > toBytes {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "on":
|
||||
return c.Reset > 0
|
||||
case "off":
|
||||
return c.Reset <= 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "yes":
|
||||
return c.TgID != 0
|
||||
case "no":
|
||||
return c.TgID == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "yes":
|
||||
return strings.TrimSpace(c.Comment) != ""
|
||||
case "no":
|
||||
return strings.TrimSpace(c.Comment) == ""
|
||||
}
|
||||
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
|
||||
}
|
||||
used := int64(0)
|
||||
if c.Traffic != nil {
|
||||
used = c.Traffic.Up + c.Traffic.Down
|
||||
}
|
||||
exhausted := c.TotalGB > 0 && used >= c.TotalGB
|
||||
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
|
||||
switch bucket {
|
||||
case "online":
|
||||
if onlineSet == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := onlineSet[c.Email]
|
||||
return ok && c.Enable
|
||||
case "depleted":
|
||||
return exhausted || expired
|
||||
case "deactive":
|
||||
return !c.Enable
|
||||
case "active":
|
||||
return c.Enable && !exhausted && !expired
|
||||
case "expiring":
|
||||
if !c.Enable || exhausted || expired {
|
||||
return false
|
||||
}
|
||||
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
|
||||
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
|
||||
return nearExpiry || nearLimit
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sortClients(rows []ClientWithAttachments, sortKey, order string) {
|
||||
if sortKey == "" {
|
||||
return
|
||||
}
|
||||
desc := order == "descend"
|
||||
less := func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
switch sortKey {
|
||||
case "enable":
|
||||
if a.Enable == b.Enable {
|
||||
return false
|
||||
}
|
||||
return !a.Enable && b.Enable
|
||||
case "email":
|
||||
return strings.ToLower(a.Email) < strings.ToLower(b.Email)
|
||||
case "inboundIds":
|
||||
return len(a.InboundIds) < len(b.InboundIds)
|
||||
case "traffic":
|
||||
ua := int64(0)
|
||||
if a.Traffic != nil {
|
||||
ua = a.Traffic.Up + a.Traffic.Down
|
||||
}
|
||||
ub := int64(0)
|
||||
if b.Traffic != nil {
|
||||
ub = b.Traffic.Up + b.Traffic.Down
|
||||
}
|
||||
return ua < ub
|
||||
case "remaining":
|
||||
ra := int64(1<<62 - 1)
|
||||
if a.TotalGB > 0 {
|
||||
used := int64(0)
|
||||
if a.Traffic != nil {
|
||||
used = a.Traffic.Up + a.Traffic.Down
|
||||
}
|
||||
ra = a.TotalGB - used
|
||||
}
|
||||
rb := int64(1<<62 - 1)
|
||||
if b.TotalGB > 0 {
|
||||
used := int64(0)
|
||||
if b.Traffic != nil {
|
||||
used = b.Traffic.Up + b.Traffic.Down
|
||||
}
|
||||
rb = b.TotalGB - used
|
||||
}
|
||||
return ra < rb
|
||||
case "expiryTime":
|
||||
ea := int64(1<<62 - 1)
|
||||
if a.ExpiryTime > 0 {
|
||||
ea = a.ExpiryTime
|
||||
}
|
||||
eb := int64(1<<62 - 1)
|
||||
if b.ExpiryTime > 0 {
|
||||
eb = b.ExpiryTime
|
||||
}
|
||||
return ea < eb
|
||||
case "createdAt":
|
||||
if a.CreatedAt == b.CreatedAt {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return a.CreatedAt < b.CreatedAt
|
||||
case "updatedAt":
|
||||
if a.UpdatedAt == b.UpdatedAt {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return a.UpdatedAt < b.UpdatedAt
|
||||
case "lastOnline":
|
||||
la := int64(0)
|
||||
if a.Traffic != nil {
|
||||
la = a.Traffic.LastOnline
|
||||
}
|
||||
lb := int64(0)
|
||||
if b.Traffic != nil {
|
||||
lb = b.Traffic.LastOnline
|
||||
}
|
||||
if la == lb {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return la < lb
|
||||
}
|
||||
return false
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if desc {
|
||||
return less(j, i)
|
||||
}
|
||||
return less(i, j)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const (
|
||||
pagingDay = int64(86400000)
|
||||
pagingGB = int64(1) << 30
|
||||
)
|
||||
|
||||
type pagingSeed struct {
|
||||
email string
|
||||
enable bool
|
||||
totalGB int64
|
||||
expiryTime int64
|
||||
used int64
|
||||
subID string
|
||||
uuid string
|
||||
password string
|
||||
auth string
|
||||
comment string
|
||||
group string
|
||||
tgID int64
|
||||
reset int
|
||||
lastOnline int64
|
||||
inbounds []int
|
||||
}
|
||||
|
||||
// seedPagingClients writes one vless and one trojan inbound plus a fixed client
|
||||
// set covering every bucket, sort key and search field ListPaged supports.
|
||||
// Returns "now" so the expectations can be phrased relative to it.
|
||||
func seedPagingClients(t *testing.T) (int64, []pagingSeed) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`}
|
||||
for _, ib := range []*model.Inbound{vless, trojan} {
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
seeds := []pagingSeed{
|
||||
{email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay},
|
||||
{email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay},
|
||||
{email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}},
|
||||
{email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}},
|
||||
{email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}},
|
||||
{email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}},
|
||||
{email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}},
|
||||
{email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}},
|
||||
{email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}},
|
||||
{email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}},
|
||||
{email: "kilo_1@x", enable: true, group: "vip", inbounds: nil},
|
||||
{email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}},
|
||||
}
|
||||
|
||||
for i, s := range seeds {
|
||||
rec := model.ClientRecord{
|
||||
Email: s.email,
|
||||
SubID: s.subID,
|
||||
UUID: s.uuid,
|
||||
Password: s.password,
|
||||
Auth: s.auth,
|
||||
Comment: s.comment,
|
||||
Group: s.group,
|
||||
TgID: s.tgID,
|
||||
Reset: s.reset,
|
||||
Enable: s.enable,
|
||||
TotalGB: s.totalGB,
|
||||
ExpiryTime: s.expiryTime,
|
||||
CreatedAt: now - int64(len(seeds)-i)*pagingDay,
|
||||
UpdatedAt: now - int64(i)*pagingDay,
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatalf("create client %s: %v", s.email, err)
|
||||
}
|
||||
if !s.enable {
|
||||
// clients.enable carries a `default:true` tag, so GORM leaves the
|
||||
// zero value out of the INSERT and the column comes back true.
|
||||
// Restate updated_at so the autoUpdateTime hook cannot reshuffle
|
||||
// the sort fixtures.
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
|
||||
Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil {
|
||||
t.Fatalf("disable %s: %v", s.email, err)
|
||||
}
|
||||
}
|
||||
traffic := xray.ClientTraffic{
|
||||
Email: s.email,
|
||||
Enable: s.enable,
|
||||
Up: s.used / 2,
|
||||
Down: s.used - s.used/2,
|
||||
Total: s.totalGB,
|
||||
ExpiryTime: s.expiryTime,
|
||||
LastOnline: s.lastOnline,
|
||||
}
|
||||
if err := db.Create(&traffic).Error; err != nil {
|
||||
t.Fatalf("create traffic %s: %v", s.email, err)
|
||||
}
|
||||
for _, id := range s.inbounds {
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil {
|
||||
t.Fatalf("attach %s to %d: %v", s.email, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return now, seeds
|
||||
}
|
||||
|
||||
func pagedEmails(items []ClientSlim) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.Email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) {
|
||||
t.Helper()
|
||||
setupBulkDB(t)
|
||||
settingSvc := &SettingService{}
|
||||
if err := settingSvc.setInt("expireDiff", 3); err != nil {
|
||||
t.Fatalf("set expireDiff: %v", err)
|
||||
}
|
||||
if err := settingSvc.setInt("trafficDiff", 1); err != nil {
|
||||
t.Fatalf("set trafficDiff: %v", err)
|
||||
}
|
||||
return &ClientService{}, &InboundService{}, settingSvc
|
||||
}
|
||||
|
||||
func TestListPagedFilters(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
now, _ := seedPagingClients(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params ClientPageParams
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns every client in id order",
|
||||
params: ClientPageParams{PageSize: 50},
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "depleted bucket covers quota and expiry",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "depleted"},
|
||||
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
|
||||
},
|
||||
{
|
||||
name: "deactive bucket is every disabled client",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
|
||||
want: []string{"echo@x", "foxtrot@x"},
|
||||
},
|
||||
{
|
||||
name: "expiring bucket covers near expiry and near quota",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "expiring"},
|
||||
want: []string{"golf@x", "hotel@x"},
|
||||
},
|
||||
{
|
||||
name: "active bucket keeps enabled clients that still have room",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "active"},
|
||||
want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "buckets are ORed",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"},
|
||||
want: []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"},
|
||||
},
|
||||
{
|
||||
name: "unknown bucket keeps matching everything",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "nonsense"},
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "protocol filter follows the attachments",
|
||||
params: ClientPageParams{PageSize: 50, Protocol: "trojan"},
|
||||
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "inbound filter follows the attachments",
|
||||
params: ClientPageParams{PageSize: 50, Inbound: "2"},
|
||||
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the email",
|
||||
params: ClientPageParams{PageSize: 50, Search: "KILO"},
|
||||
want: []string{"kilo_1@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "search treats LIKE wildcards literally",
|
||||
params: ClientPageParams{PageSize: 50, Search: "kilo_1"},
|
||||
want: []string{"kilo_1@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the subId",
|
||||
params: ClientPageParams{PageSize: 50, Search: "sub-alpha"},
|
||||
want: []string{"alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the uuid",
|
||||
params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"},
|
||||
want: []string{"alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the password",
|
||||
params: ClientPageParams{PageSize: 50, Search: "pw-bravo"},
|
||||
want: []string{"bravo@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the auth",
|
||||
params: ClientPageParams{PageSize: 50, Search: "auth-charlie"},
|
||||
want: []string{"charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the comment",
|
||||
params: ClientPageParams{PageSize: 50, Search: "vip customer"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "search matches the telegram id",
|
||||
params: ClientPageParams{PageSize: 50, Search: "555"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "group filter is case insensitive",
|
||||
params: ClientPageParams{PageSize: 50, Group: "vip"},
|
||||
want: []string{"juliet@x", "kilo_1@x"},
|
||||
},
|
||||
{
|
||||
name: "hasComment yes",
|
||||
params: ClientPageParams{PageSize: 50, HasComment: "yes"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "hasTgId yes",
|
||||
params: ClientPageParams{PageSize: 50, HasTgID: "yes"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "autoRenew on",
|
||||
params: ClientPageParams{PageSize: 50, AutoRenew: "on"},
|
||||
want: []string{"juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "usage range is inclusive on both bounds",
|
||||
params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB},
|
||||
want: []string{"alpha@x", "bravo@x", "delta@x"},
|
||||
},
|
||||
{
|
||||
name: "expiry range excludes never and delayed start",
|
||||
params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay},
|
||||
want: []string{"golf@x"},
|
||||
},
|
||||
{
|
||||
name: "filters combine with AND",
|
||||
params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"},
|
||||
want: []string{"charlie@x", "foxtrot@x"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
got := pagedEmails(resp.Items)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("emails = %v, want %v", got, tc.want)
|
||||
}
|
||||
if resp.Filtered != len(tc.want) {
|
||||
t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want))
|
||||
}
|
||||
if resp.Total != 12 {
|
||||
t.Fatalf("total = %d, want 12", resp.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedSorting(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sort string
|
||||
order string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no sort key keeps insertion order",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "email ascending", sort: "email", order: "ascend",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "email descending", sort: "email", order: "descend",
|
||||
want: []string{"kilo_1@x", "kilo1@x", "juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "traffic descending", sort: "traffic", order: "descend",
|
||||
want: []string{"charlie@x", "hotel@x", "alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend",
|
||||
want: []string{"alpha@x", "india@x", "juliet@x"},
|
||||
},
|
||||
{
|
||||
name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend",
|
||||
want: []string{"delta@x", "foxtrot@x", "golf@x"},
|
||||
},
|
||||
{
|
||||
name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend",
|
||||
want: []string{"alpha@x", "bravo@x", "charlie@x"},
|
||||
},
|
||||
{
|
||||
name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend",
|
||||
want: []string{"bravo@x", "alpha@x", "kilo1@x"},
|
||||
},
|
||||
{
|
||||
name: "enable ascending puts disabled first", sort: "enable", order: "ascend",
|
||||
want: []string{"echo@x", "foxtrot@x", "alpha@x"},
|
||||
},
|
||||
{
|
||||
name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend",
|
||||
want: []string{"bravo@x", "alpha@x", "charlie@x"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
got := pagedEmails(resp.Items)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("emails = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedPagination(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
t.Run("second page continues where the first stopped", func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"}
|
||||
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
|
||||
t.Fatalf("emails = %v, want %v", got, want)
|
||||
}
|
||||
if resp.Page != 2 || resp.PageSize != 5 {
|
||||
t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page past the end is empty but keeps the counts", func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("items = %v, want none", pagedEmails(resp.Items))
|
||||
}
|
||||
if resp.Filtered != 12 || resp.Total != 12 {
|
||||
t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page size is clamped to the maximum", func(t *testing.T) {
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
if resp.PageSize != clientPageMaxSize {
|
||||
t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListPagedRowContents(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
byEmail := make(map[string]ClientSlim, len(resp.Items))
|
||||
for _, it := range resp.Items {
|
||||
byEmail[it.Email] = it
|
||||
}
|
||||
|
||||
t.Run("attachments are reported in inbound order", func(t *testing.T) {
|
||||
got := byEmail["bravo@x"].InboundIds
|
||||
if !slices.Equal(got, []int{1, 2}) {
|
||||
t.Fatalf("inboundIds = %v, want [1 2]", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unattached client reports no inbounds", func(t *testing.T) {
|
||||
if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 {
|
||||
t.Fatalf("inboundIds = %v, want none", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traffic counters ride along with the row", func(t *testing.T) {
|
||||
got := byEmail["hotel@x"].Traffic
|
||||
if got == nil {
|
||||
t.Fatal("traffic = nil, want the seeded counters")
|
||||
}
|
||||
if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want {
|
||||
t.Fatalf("used = %d, want %d", got.Up+got.Down, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("groups list every name in use", func(t *testing.T) {
|
||||
if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) {
|
||||
t.Fatalf("groups = %v, want VIP and vip", resp.Groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListPagedSummary(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
s := resp.Summary
|
||||
|
||||
t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) {
|
||||
if s.Total != 12 {
|
||||
t.Fatalf("total = %d, want 12", s.Total)
|
||||
}
|
||||
if s.DepletedCount != 3 {
|
||||
t.Fatalf("depletedCount = %d, want 3", s.DepletedCount)
|
||||
}
|
||||
if s.ExpiringCount != 2 {
|
||||
t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount)
|
||||
}
|
||||
if s.DeactiveCount != 1 {
|
||||
t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount)
|
||||
}
|
||||
if s.Active != 6 {
|
||||
t.Fatalf("active = %d, want 6", s.Active)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every client lands in exactly one counter", func(t *testing.T) {
|
||||
if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total {
|
||||
t.Fatalf("buckets sum to %d, want %d", sum, s.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
|
||||
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
|
||||
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
|
||||
}
|
||||
if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) {
|
||||
t.Fatalf("expiring = %v, want %v", s.Expiring, want)
|
||||
}
|
||||
if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) {
|
||||
t.Fatalf("deactive = %v, want %v", s.Deactive, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListPagedSummaryEmailListsAreCapped(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const n = clientSummaryEmailCap + 25
|
||||
past := time.Now().UnixMilli() - pagingDay
|
||||
for i := range n {
|
||||
rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatalf("create client %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
if resp.Summary.DepletedCount != n {
|
||||
t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n)
|
||||
}
|
||||
if len(resp.Summary.Depleted) != clientSummaryEmailCap {
|
||||
t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedGlobalTrafficOverlay(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
// bravo has used 1GB of its 10GB locally; a master reporting 10GB of
|
||||
// cross-panel usage has to move it into the depleted bucket.
|
||||
if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{
|
||||
{Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB},
|
||||
}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged: %v", err)
|
||||
}
|
||||
want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"}
|
||||
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
|
||||
t.Fatalf("depleted = %v, want %v", got, want)
|
||||
}
|
||||
if resp.Summary.DepletedCount != 4 {
|
||||
t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount)
|
||||
}
|
||||
for _, it := range resp.Items {
|
||||
if it.Email != "bravo@x" {
|
||||
continue
|
||||
}
|
||||
if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB {
|
||||
t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryOnlineEmails(t *testing.T) {
|
||||
_, _, _ = setupPagingServices(t)
|
||||
seedPagingClients(t)
|
||||
|
||||
q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0)
|
||||
emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"})
|
||||
if err != nil {
|
||||
t.Fatalf("onlineEmails: %v", err)
|
||||
}
|
||||
if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) {
|
||||
t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailInCondChunksLargeSets(t *testing.T) {
|
||||
emails := make([]string, sqlInChunk+1)
|
||||
for i := range emails {
|
||||
emails[i] = "e" + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
cond, args := emailInCond("c.email", emails)
|
||||
if want := "(c.email IN ? OR c.email IN ?)"; cond != want {
|
||||
t.Fatalf("cond = %q, want %q", cond, want)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("args = %d chunks, want 2", len(args))
|
||||
}
|
||||
if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk {
|
||||
t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk)
|
||||
}
|
||||
|
||||
emptyCond, emptyArgs := emailInCond("c.email", nil)
|
||||
if emptyCond != "1 = 0" || emptyArgs != nil {
|
||||
t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeLikeLiteral(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"plain", "plain"},
|
||||
{"a_b", `a\_b`},
|
||||
{"50%", `50\%`},
|
||||
{`back\slash`, `back\\slash`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := escapeLikeLiteral(tc.in); got != tc.want {
|
||||
t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedEmptyPanel(t *testing.T) {
|
||||
svc, inboundSvc, settingSvc := setupPagingServices(t)
|
||||
|
||||
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged on a panel with no clients: %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 {
|
||||
t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered)
|
||||
}
|
||||
if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 {
|
||||
t.Fatalf("summary = %+v, want zeroed counters", resp.Summary)
|
||||
}
|
||||
if resp.Groups == nil {
|
||||
t.Fatal("groups = nil, want an empty list so the filter drawer renders")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// How long a client stays recoverable after a node merge concluded it is gone.
|
||||
// Any merge that sees it attached again inside this window clears the mark.
|
||||
const syncOrphanReapGrace = 15 * time.Minute
|
||||
|
||||
// A traffic row with no clients row behind it is drift the orphan sweep will
|
||||
// never mark, so it stays on the old delete-immediately path.
|
||||
func clientRecordExists(tx *gorm.DB, email string) bool {
|
||||
var n int64
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("email = ? AND sync_orphaned_at = 0", email).
|
||||
Update("sync_orphaned_at", nowMs).Error
|
||||
}
|
||||
|
||||
// A client that is attached again is not orphaned, whatever an earlier merge
|
||||
// concluded — this is what makes a bad merge recoverable instead of fatal.
|
||||
func clearSyncOrphanMarks(tx *gorm.DB) error {
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Update("sync_orphaned_at", 0).Error
|
||||
}
|
||||
|
||||
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no
|
||||
// later merge reclaimed. It is the only path that hard-deletes for that sweep.
|
||||
func (s *ClientService) ReapSyncOrphans() (int, error) {
|
||||
db := database.GetDB()
|
||||
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
|
||||
|
||||
var emails []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
|
||||
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
reaped := 0
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
|
||||
}); err != nil {
|
||||
return reaped, err
|
||||
}
|
||||
reaped += len(batch)
|
||||
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
|
||||
t.Helper()
|
||||
var row model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("read client %q: %v", email, err)
|
||||
}
|
||||
return row.SyncOrphanedAt
|
||||
}
|
||||
|
||||
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
|
||||
t.Helper()
|
||||
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("email = ?", email).
|
||||
Update("sync_orphaned_at", past).Error; err != nil {
|
||||
t.Fatalf("backdate orphan mark: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge must soft-orphan, not delete: everything stays recoverable until
|
||||
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
|
||||
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "gone@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("merge did not stamp sync_orphaned_at")
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap inside grace: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
|
||||
}
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatal("client removed before the grace period elapsed")
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
reaped, err = clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap after grace: %v", err)
|
||||
}
|
||||
if reaped != 1 {
|
||||
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
|
||||
}
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 0 || traf != 0 {
|
||||
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// A client the node reports again was never gone: clearing the mark is what
|
||||
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
||||
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "flaky@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("setup: expected the merge to mark the client")
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
||||
|
||||
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
||||
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
|
||||
// mark and keep their existing manual-cleanup semantics.
|
||||
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
const email = "manual@x"
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
|
||||
}
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,45 @@ func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "a@node", ID: "aaaaaaaa-0000-0000-0000-000000000005", SubID: "sub-shared", Enable: true},
|
||||
{Email: "b@node", ID: "aaaaaaaa-0000-0000-0000-000000000006", SubID: "sub-shared", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 22004, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
first := lookupClientRecord(t, "a@node")
|
||||
if first.SubID != "sub-shared" || lookupClientRecord(t, "b@node").SubID != "sub-shared" {
|
||||
t.Fatalf("seed did not produce a shared subId")
|
||||
}
|
||||
|
||||
updated := source[0]
|
||||
updated.TotalGB = 42
|
||||
if _, err := svc.Update(inboundSvc, first.Id, updated); err != nil {
|
||||
t.Fatalf("Update of a client whose subId is already shared: %v", err)
|
||||
}
|
||||
if got := lookupClientRecord(t, "a@node").TotalGB; got != 42 {
|
||||
t.Fatalf("totalGB after update = %d, want 42", got)
|
||||
}
|
||||
|
||||
omitted := source[0]
|
||||
omitted.SubID = ""
|
||||
omitted.TotalGB = 43
|
||||
if _, err := svc.Update(inboundSvc, first.Id, omitted); err != nil {
|
||||
t.Fatalf("Update with subId omitted entirely: %v", err)
|
||||
}
|
||||
other := lookupClientRecord(t, "b@node")
|
||||
if other.SubID != "sub-shared" {
|
||||
t.Fatalf("other client subId = %q, want %q", other.SubID, "sub-shared")
|
||||
}
|
||||
}
|
||||
|
||||
func mustInboundSettings(t *testing.T, inboundSvc *InboundService, id int) string {
|
||||
t.Helper()
|
||||
ib, err := inboundSvc.GetInbound(id)
|
||||
|
||||
@@ -46,11 +46,23 @@ func wireguardAllocationBase(used []string, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const wireguardPoolFloorBits = 16
|
||||
|
||||
// allocateWireguardAddress returns the first free single-host address in base
|
||||
// (suffixed /32 for an IPv4 base, /128 for IPv6) that is not already present
|
||||
// in used. The server holds the first host (.1 / ::1), so allocation starts
|
||||
// at the second host (.2 / ::2).
|
||||
func allocateWireguardAddress(used []string, base string) (string, error) {
|
||||
// at the second host (.2 / ::2). When allowWidening is set and base is an
|
||||
// IPv4 prefix narrower than wireguardPoolFloorBits, falls back to a wider
|
||||
// /16 scope once the configured base is exhausted, so a small pool doesn't
|
||||
// hard-fail while a wider address space is genuinely available.
|
||||
//
|
||||
// allowWidening must be false for AmneziaWG: unlike plain WireGuard, an
|
||||
// AmneziaWG peer's address must fall inside the kernel interface's own
|
||||
// configured Address subnet to be routable at all, so silently handing out
|
||||
// an address from a wider scope would produce an address the interface can
|
||||
// never actually reach — better to fail loudly on pool exhaustion than hand
|
||||
// out a client config that will never connect.
|
||||
func allocateWireguardAddress(used []string, base string, allowWidening bool) (string, error) {
|
||||
if base == "" {
|
||||
base = defaultWireguardBase
|
||||
}
|
||||
@@ -68,14 +80,22 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
|
||||
taken[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
addr := prefix.Masked().Addr().Next().Next()
|
||||
for prefix.Contains(addr) {
|
||||
if _, ok := taken[addr]; !ok {
|
||||
return addr.String() + "/" + hostBits, nil
|
||||
scopes := []netip.Prefix{prefix}
|
||||
if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
|
||||
if wider, wErr := prefix.Addr().Prefix(wireguardPoolFloorBits); wErr == nil {
|
||||
scopes = append(scopes, wider)
|
||||
}
|
||||
addr = addr.Next()
|
||||
}
|
||||
return "", common.NewError("wireguard: no free address available in", base)
|
||||
for _, scope := range scopes {
|
||||
addr := scope.Masked().Addr().Next().Next()
|
||||
for scope.Contains(addr) {
|
||||
if _, ok := taken[addr]; !ok {
|
||||
return addr.String() + "/" + hostBits, nil
|
||||
}
|
||||
addr = addr.Next()
|
||||
}
|
||||
}
|
||||
return "", common.NewError("wireguard: no free address available in", scopes[len(scopes)-1].String())
|
||||
}
|
||||
|
||||
// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
|
||||
@@ -148,7 +168,7 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
|
||||
c.PublicKey = pub
|
||||
}
|
||||
if len(c.AllowedIPs) == 0 {
|
||||
addr, err := allocateWireguardAddress(used, base)
|
||||
addr, err := allocateWireguardAddress(used, base, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -20,11 +21,12 @@ func TestAllocateWireguardAddress(t *testing.T) {
|
||||
{name: "fills gap", used: []string{"10.0.0.3/32", "10.0.0.4/32"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
|
||||
{name: "ignores catch-all", used: []string{"0.0.0.0/0", "::/0"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
|
||||
{name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"},
|
||||
{name: "exhausted /30", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", err: true},
|
||||
{name: "full ipv4 scope widens instead of failing", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", want: "10.9.0.4/32"},
|
||||
{name: "exhausted ipv6 scope errors", used: []string{"fd00::2/128", "fd00::3/128"}, base: "fd00::/126", err: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := allocateWireguardAddress(tt.used, tt.base)
|
||||
got, err := allocateWireguardAddress(tt.used, tt.base, true)
|
||||
if tt.err {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
@@ -130,6 +132,54 @@ func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
|
||||
used := make([]string, 0, 254)
|
||||
for i := 2; i <= 255; i++ {
|
||||
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
|
||||
}
|
||||
|
||||
got, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate with a full /24: %v", err)
|
||||
}
|
||||
if got != "10.0.1.0/32" {
|
||||
t.Fatalf("address after a full /24 = %q, want 10.0.1.0/32", got)
|
||||
}
|
||||
|
||||
used = append(used, got)
|
||||
next, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate after widening: %v", err)
|
||||
}
|
||||
if next != "10.0.1.1/32" {
|
||||
t.Fatalf("second widened address = %q, want 10.0.1.1/32", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
|
||||
got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24", true)
|
||||
if err != nil {
|
||||
t.Fatalf("allocateWireguardAddress: %v", err)
|
||||
}
|
||||
if got != "172.16.0.3/32" {
|
||||
t.Fatalf("address = %q, want 172.16.0.3/32 — the inbound's own /24 comes first", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateWireguardAddressWithoutWideningFailsClosed(t *testing.T) {
|
||||
// AmneziaWG's caller passes allowWidening=false: an address outside the
|
||||
// kernel interface's own configured subnet would never actually route,
|
||||
// so exhausting the pool must error instead of silently handing out an
|
||||
// address from a wider scope (unlike plain WireGuard's allowWidening=true).
|
||||
used := make([]string, 0, 254)
|
||||
for i := 2; i <= 255; i++ {
|
||||
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
|
||||
}
|
||||
if _, err := allocateWireguardAddress(used, "10.0.0.0/24", false); err == nil {
|
||||
t.Fatal("expected an error on a full /24 with widening disabled, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
|
||||
clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
|
||||
ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func streamWithNoiseItem(t *testing.T, item map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"network": "tcp",
|
||||
"finalmask": map[string]any{
|
||||
"udp": []any{map[string]any{
|
||||
"type": "noise",
|
||||
"settings": map[string]any{
|
||||
"reset": "60",
|
||||
"noise": []any{item},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func noiseItem(t *testing.T, stream map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
udp, _ := finalmask["udp"].([]any)
|
||||
mask, _ := udp[0].(map[string]any)
|
||||
settings, _ := mask["settings"].(map[string]any)
|
||||
noise, _ := settings["noise"].([]any)
|
||||
item, _ := noise[0].(map[string]any)
|
||||
return item
|
||||
}
|
||||
|
||||
// TestDropEmptyRandPacketsClearsEditorResidue is the regression for the mask
|
||||
// editor writing packet:[] alongside a rand. xray-core counts the empty array
|
||||
// as a packet and refuses the config, which keeps every inbound offline.
|
||||
func TestDropEmptyRandPacketsClearsEditorResidue(t *testing.T) {
|
||||
stream := streamWithNoiseItem(t, map[string]any{
|
||||
"type": "array",
|
||||
"rand": "1-8192",
|
||||
"packet": []any{},
|
||||
"delay": "5",
|
||||
})
|
||||
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 {
|
||||
t.Fatalf("cleared = %d, want 1", cleared)
|
||||
}
|
||||
item := noiseItem(t, stream)
|
||||
if _, present := item["packet"]; present {
|
||||
t.Fatalf("packet survived: %#v", item)
|
||||
}
|
||||
if item["rand"] != "1-8192" || item["delay"] != "5" {
|
||||
t.Fatalf("healing changed the mask: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptyRandPacketsLeavesRealPacketsAlone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item map[string]any
|
||||
}{
|
||||
{"packet without a rand", map[string]any{"type": "array", "packet": []any{1.0, 2.0}, "rand": 0.0}},
|
||||
{"empty packet without a rand", map[string]any{"type": "array", "packet": []any{}}},
|
||||
{"empty packet with a zero rand", map[string]any{"type": "array", "packet": []any{}, "rand": 0.0}},
|
||||
{"empty packet with a zero range", map[string]any{"type": "array", "packet": []any{}, "rand": "0-0"}},
|
||||
{"string packet", map[string]any{"type": "str", "packet": "ping", "rand": "1-10"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stream := streamWithNoiseItem(t, tt.item)
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
|
||||
t.Fatalf("cleared = %d, want the item left alone", cleared)
|
||||
}
|
||||
if _, present := noiseItem(t, stream)["packet"]; !present {
|
||||
t.Fatal("packet was dropped")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropEmptyRandPacketsReachesNestedItems covers header-custom, whose items
|
||||
// sit two arrays deep and are subject to the same exclusive-kind rule.
|
||||
func TestDropEmptyRandPacketsReachesNestedItems(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"finalmask": map[string]any{
|
||||
"tcp": []any{map[string]any{
|
||||
"type": "header-custom",
|
||||
"settings": map[string]any{
|
||||
"clients": []any{[]any{map[string]any{"type": "array", "rand": 64.0, "packet": []any{}}}},
|
||||
"servers": []any{[]any{map[string]any{"type": "array", "rand": 32.0, "packet": []any{}}}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 2 {
|
||||
t.Fatalf("cleared = %d, want 2", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) {
|
||||
stream := map[string]any{"network": "tcp"}
|
||||
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
|
||||
t.Fatalf("cleared = %d, want 0", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray
|
||||
// refuses outright must build once the panel has healed them.
|
||||
func TestHealedConfigsBuildInXray(t *testing.T) {
|
||||
t.Run("hysteria v1 row", func(t *testing.T) {
|
||||
in := model.Inbound{
|
||||
Protocol: model.Hysteria,
|
||||
Port: 36715,
|
||||
Listen: "127.0.0.1",
|
||||
Tag: "in-hysteria",
|
||||
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
|
||||
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(in.GenXrayInboundConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal generated inbound: %v", err)
|
||||
}
|
||||
var healed map[string]any
|
||||
if err := json.Unmarshal(raw, &healed); err != nil {
|
||||
t.Fatalf("decode generated inbound: %v", err)
|
||||
}
|
||||
assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed))
|
||||
|
||||
var unhealed map[string]any
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria",
|
||||
"settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil {
|
||||
t.Fatalf("decode raw inbound: %v", err)
|
||||
}
|
||||
if err := buildGoldenInbound(t, unhealed); err == nil {
|
||||
t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("noise item with an empty packet", func(t *testing.T) {
|
||||
item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"}
|
||||
stream := streamWithNoiseItem(t, item)
|
||||
inbound := map[string]any{
|
||||
"tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
|
||||
"settings": map[string]any{"clients": []any{}, "decryption": "none"},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
if err := buildGoldenInbound(t, inbound); err == nil {
|
||||
t.Fatal("xray-core is expected to refuse a packet and a rand on one item")
|
||||
}
|
||||
|
||||
dropEmptyRandPackets(stream["finalmask"])
|
||||
inbound["streamSettings"] = stream
|
||||
assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -70,7 +71,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
|
||||
|
||||
// No global rows: the cross-panel EXISTS branch is skipped (#5392), but a
|
||||
// client over its local quota is still disabled.
|
||||
if got := depletedCond(db); got != depletedClientsCondLocal {
|
||||
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
|
||||
t.Fatalf("empty globals must use the local-only predicate")
|
||||
}
|
||||
seedClientRow(t, "local-cap", 1, 600, 600, 1000)
|
||||
@@ -85,11 +86,77 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
|
||||
if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if got := depletedCond(db); got != depletedClientsCond {
|
||||
if got, _ := depletedCond(db); got != depletedClientsCond {
|
||||
t.Fatalf("with globals present the cross-panel predicate must be used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleGlobalTraffic_Ignored(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
staleAt := time.Now().Add(-globalTrafficFreshWindow - time.Hour).UnixMilli()
|
||||
seedStaleGlobal := func(t *testing.T, guid, email string, up, down int64) {
|
||||
t.Helper()
|
||||
if err := svc.AcceptGlobalTraffic(guid, []*xray.ClientTraffic{{Email: email, Up: up, Down: down}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic(%s): %v", guid, err)
|
||||
}
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("master_guid = ? AND email = ?", guid, email).
|
||||
Update("updated_at", staleAt).Error; err != nil {
|
||||
t.Fatalf("age row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("stale row alone neither enforces nor selects the cross-panel predicate", func(t *testing.T) {
|
||||
seedClientRow(t, "cap", 1, 100, 100, 1000)
|
||||
seedStaleGlobal(t, "dead-master", "cap", 1000, 900)
|
||||
|
||||
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
|
||||
t.Fatalf("only stale globals must fall back to the local-only predicate")
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 0 {
|
||||
t.Fatalf("stale global usage must not disable a client, disabled %d", count)
|
||||
}
|
||||
if got := readTraffic(t, db, "cap"); !got.Enable {
|
||||
t.Error("client within its local quota must stay enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale row does not inflate the displayed total", func(t *testing.T) {
|
||||
rows := []*xray.ClientTraffic{{Email: "cap", Up: 100, Down: 100}}
|
||||
overlayGlobalTraffic(db, rows)
|
||||
if rows[0].Up != 100 || rows[0].Down != 100 {
|
||||
t.Errorf("stale global must not overlay display counters, got up=%d down=%d", rows[0].Up, rows[0].Down)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a live master still enforces alongside the stale row", func(t *testing.T) {
|
||||
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 1, Down: 1}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if got, _ := depletedCond(db); got != depletedClientsCond {
|
||||
t.Fatalf("a fresh global row must select the cross-panel predicate")
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 0 {
|
||||
t.Fatalf("the live master reports usage well under quota, disabled %d", count)
|
||||
}
|
||||
|
||||
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
|
||||
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||
}
|
||||
if _, count, err := svc.disableInvalidClients(db); err != nil {
|
||||
t.Fatalf("disableInvalidClients: %v", err)
|
||||
} else if count != 1 {
|
||||
t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobalUsage_DisablesClient(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"maps"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
)
|
||||
|
||||
// The frontend's golden fixtures are the panel's model of an xray config: the
|
||||
// Zod snapshots pin what the forms parse and emit. Parsing proves the panel
|
||||
// agrees with itself, not that xray-core would accept the result, so every
|
||||
// fixture is also built here through the very config builders the panel hands
|
||||
// its config to — conf.InboundDetourConfig for the full-config and AddInbound
|
||||
// paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns
|
||||
// section. A fixture xray-core refuses is a config the panel would let an
|
||||
// admin save and then fail to start the core with, taking every inbound down.
|
||||
//
|
||||
// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by
|
||||
// xray, so xray-core has no config id for it.
|
||||
|
||||
func goldenFixtureDir(t *testing.T, category string) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve fixture dir: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// goldenFixtures returns every fixture in a category as name -> decoded object.
|
||||
func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
|
||||
t.Helper()
|
||||
dir := goldenFixtureDir(t, category)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
out := make(map[string]map[string]any)
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
t.Fatalf("no fixtures under %s", dir)
|
||||
}
|
||||
for _, name := range names {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", name, err)
|
||||
}
|
||||
out[strings.TrimSuffix(name, ".json")] = obj
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeTestCertificate writes a self-signed certificate and key, returning both
|
||||
// paths. The TLS fixtures point certificateFile/keyFile at deployment paths
|
||||
// that do not exist here, and xray-core reads them while building.
|
||||
func writeTestCertificate(t *testing.T) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "golden-fixture.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"golden-fixture.test"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath = filepath.Join(dir, "fixture.crt")
|
||||
keyPath = filepath.Join(dir, "fixture.key")
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
||||
t.Fatalf("write certificate: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
// repointCertificateFiles rewrites every certificateFile/keyFile reference to
|
||||
// the generated pair, so a fixture is judged on its shape rather than on paths
|
||||
// that only exist on a deployed server.
|
||||
func repointCertificateFiles(node any, certPath, keyPath string) {
|
||||
switch value := node.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range value {
|
||||
switch key {
|
||||
case "certificateFile":
|
||||
if _, ok := child.(string); ok {
|
||||
value[key] = certPath
|
||||
continue
|
||||
}
|
||||
case "keyFile":
|
||||
if _, ok := child.(string); ok {
|
||||
value[key] = keyPath
|
||||
continue
|
||||
}
|
||||
}
|
||||
repointCertificateFiles(child, certPath, keyPath)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range value {
|
||||
repointCertificateFiles(child, certPath, keyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming
|
||||
// geoip:/geosite: need the dat files the panel ships next to the xray binary;
|
||||
// where they are absent the loader fails on the missing file rather than on the
|
||||
// fixture, so those are skipped instead of reported as broken.
|
||||
func assertXrayAccepts(t *testing.T, subject string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if isMissingGeoAssetErr(err) {
|
||||
t.Skipf("geo data files not available, cannot judge %s: %v", subject, err)
|
||||
}
|
||||
t.Fatalf("xray-core refuses %s: %v", subject, err)
|
||||
}
|
||||
|
||||
func buildGoldenInbound(t *testing.T, inbound map[string]any) error {
|
||||
t.Helper()
|
||||
certPath, keyPath := writeTestCertificate(t)
|
||||
repointCertificateFiles(inbound, certPath, keyPath)
|
||||
|
||||
raw, err := json.Marshal(inbound)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal inbound: %v", err)
|
||||
}
|
||||
detour := new(conf.InboundDetourConfig)
|
||||
if err := json.Unmarshal(raw, detour); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = detour.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal
|
||||
// inbound and builds it.
|
||||
func TestGoldenInboundFixturesBuildInXray(t *testing.T) {
|
||||
for name, fixture := range goldenFixtures(t, "inbound") {
|
||||
if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
inbound := map[string]any{
|
||||
"tag": "golden-in",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 8443,
|
||||
"protocol": fixture["protocol"],
|
||||
"settings": fixture["settings"],
|
||||
}
|
||||
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound
|
||||
// model through GenXrayInboundConfig, the conversion both the full-config and
|
||||
// the live AddInbound paths use, and builds the result.
|
||||
func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) {
|
||||
for name, fixture := range goldenFixtures(t, "inbound-full") {
|
||||
protocol, _ := fixture["protocol"].(string)
|
||||
if protocol == string(model.MTProto) {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
section := func(key string) string {
|
||||
value, ok := fixture[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", key, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
port := 0
|
||||
if p, ok := fixture["port"].(float64); ok {
|
||||
port = int(p)
|
||||
}
|
||||
listen, _ := fixture["listen"].(string)
|
||||
tag, _ := fixture["tag"].(string)
|
||||
|
||||
ib := &model.Inbound{
|
||||
Protocol: model.Protocol(protocol),
|
||||
Port: port,
|
||||
Listen: listen,
|
||||
Tag: tag,
|
||||
Settings: section("settings"),
|
||||
StreamSettings: section("streamSettings"),
|
||||
Sniffing: section("sniffing"),
|
||||
}
|
||||
raw, err := json.Marshal(ib.GenXrayInboundConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal generated inbound: %v", err)
|
||||
}
|
||||
var generated map[string]any
|
||||
if err := json.Unmarshal(raw, &generated); err != nil {
|
||||
t.Fatalf("decode generated inbound: %v", err)
|
||||
}
|
||||
assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole
|
||||
// stream sections, the security block, sockopt and finalmask — to an inbound.
|
||||
func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
|
||||
for _, category := range []string{"stream", "security", "sockopt", "finalmask"} {
|
||||
for name, fixture := range goldenFixtures(t, category) {
|
||||
t.Run(category+"/"+name, func(t *testing.T) {
|
||||
stream := map[string]any{"network": "tcp"}
|
||||
switch category {
|
||||
case "stream":
|
||||
stream = fixture
|
||||
case "security":
|
||||
maps.Copy(stream, fixture)
|
||||
case "sockopt":
|
||||
stream["sockopt"] = fixture
|
||||
case "finalmask":
|
||||
stream["finalmask"] = fixture
|
||||
}
|
||||
inbound := map[string]any{
|
||||
"tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
|
||||
"settings": map[string]any{
|
||||
"clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}},
|
||||
"decryption": "none",
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildGoldenRouting(routing map[string]any) error {
|
||||
raw, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router := new(conf.RouterConfig)
|
||||
if err := json.Unmarshal(raw, router); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = router.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures
|
||||
// through the router config ApplyRoutingConfig hands to the running core.
|
||||
func TestGoldenRoutingFixturesBuildInXray(t *testing.T) {
|
||||
for name, rule := range goldenFixtures(t, "rule") {
|
||||
t.Run("rule/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []any{rule},
|
||||
"balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
for name, balancer := range goldenFixtures(t, "balancer") {
|
||||
t.Run("balancer/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"balancers": []any{balancer},
|
||||
"rules": []any{map[string]any{
|
||||
"type": "field", "port": "443", "balancerTag": balancer["tag"],
|
||||
}},
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server
|
||||
// fixture inside one.
|
||||
func TestGoldenDNSFixturesBuildInXray(t *testing.T) {
|
||||
build := func(dns map[string]any) error {
|
||||
raw, err := json.Marshal(dns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dnsConf := new(conf.DNSConfig)
|
||||
if err := json.Unmarshal(raw, dnsConf); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dnsConf.Build()
|
||||
return err
|
||||
}
|
||||
|
||||
for name, fixture := range goldenFixtures(t, "dns") {
|
||||
t.Run("dns/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this dns section", build(fixture))
|
||||
})
|
||||
}
|
||||
for name, server := range goldenFixtures(t, "dns-server") {
|
||||
t.Run("dns-server/"+name, func(t *testing.T) {
|
||||
assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isMissingGeoAssetErr(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat")
|
||||
}
|
||||
+272
-57
@@ -8,10 +8,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -32,6 +35,13 @@ type InboundService struct {
|
||||
fallbackService FallbackService
|
||||
}
|
||||
|
||||
func normalizeTrafficResetDay(day int) int {
|
||||
if day < 1 {
|
||||
return 1
|
||||
}
|
||||
return min(day, 31)
|
||||
}
|
||||
|
||||
func normalizeInboundShareAddrStrategy(strategy string) string {
|
||||
strategy = strings.TrimSpace(strategy)
|
||||
switch strategy {
|
||||
@@ -618,6 +628,188 @@ func validateFinalMaskRealityCombo(streamSettings string) error {
|
||||
return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.")
|
||||
}
|
||||
|
||||
var xmcProfileUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
|
||||
|
||||
// xmcMaskProfilesComplete reports whether an xmc finalmask carries the signed
|
||||
// Minecraft session profiles xray-core has required since v26.7.28 (#6487).
|
||||
// The core replaced the old `usernames` string list with `profiles` objects
|
||||
// and removed the "default to Dream when empty" fallback, so a mask still on
|
||||
// the legacy shape — or one whose profiles are incomplete — now fails
|
||||
// conf.XMC.Build() and takes the entire config down with it rather than
|
||||
// degrading that one inbound.
|
||||
//
|
||||
// The texture fields are a signed blob only Mojang's session server can issue
|
||||
// (resolve the UUID by username, then fetch the profile with unsigned=false),
|
||||
// so the panel cannot synthesize a valid profile from a legacy username; an
|
||||
// incomplete mask can only be reported or dropped.
|
||||
func xmcMaskProfilesComplete(mask map[string]any) bool {
|
||||
settings, ok := mask["settings"].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
profiles, _ := settings["profiles"].([]any)
|
||||
if len(profiles) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, entry := range profiles {
|
||||
profile, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
username, _ := profile["username"].(string)
|
||||
if !xmcProfileUsernamePattern.MatchString(username) {
|
||||
return false
|
||||
}
|
||||
id, _ := profile["uuid"].(string)
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return false
|
||||
}
|
||||
if value, _ := profile["texturesValue"].(string); value == "" {
|
||||
return false
|
||||
}
|
||||
if signature, _ := profile["texturesSignature"].(string); signature == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isIncompleteXmcMask reports whether a finalmask.tcp entry is an xmc mask
|
||||
// xray-core would refuse to build.
|
||||
func isIncompleteXmcMask(entry any) bool {
|
||||
mask, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if maskType, _ := mask["type"].(string); maskType != "xmc" {
|
||||
return false
|
||||
}
|
||||
return !xmcMaskProfilesComplete(mask)
|
||||
}
|
||||
|
||||
// incompleteXmcMaskCount counts the stream's xmc finalmask entries that
|
||||
// xray-core would refuse to build.
|
||||
func incompleteXmcMaskCount(stream map[string]any) int {
|
||||
finalmask, ok := stream["finalmask"].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
count := 0
|
||||
for _, entry := range tcp {
|
||||
if isIncompleteXmcMask(entry) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// stripIncompleteXmcMasks removes every xmc finalmask entry xray-core would
|
||||
// refuse to build, returning how many were dropped, and clears the finalmask
|
||||
// object once nothing is left in it.
|
||||
//
|
||||
// AddInbound and UpdateInbound reject an incomplete mask at save time, but a
|
||||
// row that never went through those paths — an upgrade from a panel predating
|
||||
// v26.7.28, node sync, a restored backup, a direct DB edit — would otherwise
|
||||
// fail the whole config build and keep every other inbound offline too.
|
||||
// Dropping only the offending mask degrades that one inbound instead, which
|
||||
// the accompanying warning tells the admin to reconfigure.
|
||||
func stripIncompleteXmcMasks(stream map[string]any) int {
|
||||
finalmask, ok := stream["finalmask"].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) == 0 {
|
||||
return 0
|
||||
}
|
||||
kept := make([]any, 0, len(tcp))
|
||||
dropped := 0
|
||||
for _, entry := range tcp {
|
||||
if isIncompleteXmcMask(entry) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
if dropped == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(finalmask, "tcp")
|
||||
} else {
|
||||
finalmask["tcp"] = kept
|
||||
}
|
||||
if len(finalmask) == 0 {
|
||||
delete(stream, "finalmask")
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
|
||||
// dropEmptyRandPackets removes the leftover empty "packet" from finalmask
|
||||
// items that also carry a rand, and reports how many it cleared.
|
||||
//
|
||||
// xray-core treats even an empty array as a packet, and every item kind is
|
||||
// exclusive: noise refuses "len(item.Packet) > 0 && item.Rand.To > 0" and
|
||||
// header-custom refuses "exactly one item kind must be set". Either error
|
||||
// fails the whole config build, so one such item keeps every inbound offline.
|
||||
// The panel's mask editor wrote that pair whenever an item was switched to the
|
||||
// rand-driven array kind, so stored rows carry it; clearing an empty packet
|
||||
// changes nothing about the mask the admin configured.
|
||||
func dropEmptyRandPackets(node any) int {
|
||||
switch value := node.(type) {
|
||||
case map[string]any:
|
||||
cleared := 0
|
||||
if packet, ok := value["packet"].([]any); ok && len(packet) == 0 && randIsSet(value["rand"]) {
|
||||
delete(value, "packet")
|
||||
cleared++
|
||||
}
|
||||
for _, child := range value {
|
||||
cleared += dropEmptyRandPackets(child)
|
||||
}
|
||||
return cleared
|
||||
case []any:
|
||||
cleared := 0
|
||||
for _, child := range value {
|
||||
cleared += dropEmptyRandPackets(child)
|
||||
}
|
||||
return cleared
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// randIsSet reports whether a finalmask item's rand selects a random packet.
|
||||
// It is a number on header-custom items and a dash-range string on noise ones.
|
||||
func randIsSet(value any) bool {
|
||||
switch rand := value.(type) {
|
||||
case float64:
|
||||
return rand > 0
|
||||
case string:
|
||||
return rand != "" && rand != "0" && rand != "0-0"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateFinalMaskXmcProfiles rejects an xmc finalmask without complete
|
||||
// profiles at save time, so the admin gets a targeted error instead of a core
|
||||
// that refuses to start (or, after GetXrayConfig heals it, an inbound quietly
|
||||
// serving without the obfuscation they configured).
|
||||
func validateFinalMaskXmcProfiles(streamSettings string) error {
|
||||
if streamSettings == "" {
|
||||
return nil
|
||||
}
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||
return nil
|
||||
}
|
||||
if incompleteXmcMaskCount(stream) == 0 {
|
||||
return nil
|
||||
}
|
||||
return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
|
||||
}
|
||||
|
||||
// normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
|
||||
// always valid before the row is persisted, and drops the vestigial inbound-level
|
||||
// secret and adTag: MTProto is multi-client, so mtg and every share link read
|
||||
@@ -746,11 +938,15 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.Id = 0
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
s.normalizeMtprotoSecret(inbound)
|
||||
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
|
||||
return inbound, false, err
|
||||
@@ -914,7 +1110,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
payload := inbound
|
||||
pushable := true
|
||||
if inbound.Protocol == model.MTProto {
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(tx, inbound); bErr == nil {
|
||||
if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", bErr)
|
||||
@@ -1162,7 +1358,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
|
||||
runtimeInbound, err := s.buildInboundForLocalRuntime(db, inbound)
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: build runtime config failed:", err)
|
||||
return true, nil
|
||||
@@ -1175,11 +1371,15 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
s.normalizeMtprotoSecret(inbound)
|
||||
if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
@@ -1307,6 +1507,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
oldInbound.Enable = inbound.Enable
|
||||
oldInbound.ExpiryTime = inbound.ExpiryTime
|
||||
oldInbound.TrafficReset = inbound.TrafficReset
|
||||
oldInbound.TrafficResetDay = inbound.TrafficResetDay
|
||||
oldInbound.Listen = inbound.Listen
|
||||
oldInbound.Port = inbound.Port
|
||||
oldInbound.Protocol = inbound.Protocol
|
||||
@@ -1348,7 +1549,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
payload := oldInbound
|
||||
pushable := true
|
||||
if inbound.Enable {
|
||||
if built, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound); err2 == nil {
|
||||
if built, err2 := s.buildInboundForLocalRuntime(tx, oldInbound); err2 == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
@@ -1374,7 +1575,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
var runtimeInbound *model.Inbound
|
||||
if inbound.Enable {
|
||||
var err2 error
|
||||
runtimeInbound, err2 = s.buildRuntimeInboundForAPI(tx, oldInbound)
|
||||
runtimeInbound, err2 = s.buildInboundForLocalRuntime(tx, oldInbound)
|
||||
if err2 != nil {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
needRestart = true
|
||||
@@ -1445,81 +1646,95 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
return inbound, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
// A node mirrors this payload into its own DB, so every client must survive:
|
||||
// filtering one out makes the node delete it, and the master then mirrors that.
|
||||
func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
if inbound == nil {
|
||||
return nil, fmt.Errorf("inbound is nil")
|
||||
}
|
||||
|
||||
runtimeInbound := *inbound
|
||||
built := *inbound
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mutated := false
|
||||
if clients, ok := settings["clients"].([]any); ok {
|
||||
var clientStats []xray.ClientTraffic
|
||||
err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", inbound.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inboundCanHostFallbacks(inbound) {
|
||||
return &built, nil
|
||||
}
|
||||
fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fallbacks) == 0 {
|
||||
return &built, nil
|
||||
}
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
modifiedSettings, mErr := json.MarshalIndent(settings, "", " ")
|
||||
if mErr != nil {
|
||||
return nil, mErr
|
||||
}
|
||||
built.Settings = string(modifiedSettings)
|
||||
return &built, nil
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
|
||||
settings["clients"] = finalClients
|
||||
mutated = true
|
||||
// Strips disabled clients on top of the node payload. Safe only because the
|
||||
// target here is an in-memory Xray/mtg config, not another panel's database.
|
||||
func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
built, err := s.buildInboundForNodePush(tx, inbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if inboundCanHostFallbacks(inbound) {
|
||||
fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if fbErr != nil {
|
||||
return nil, fbErr
|
||||
}
|
||||
if len(fallbacks) > 0 {
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
mutated = true
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
return built, nil
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
return &runtimeInbound, nil
|
||||
var clientStats []xray.ClientTraffic
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", built.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
settings["clients"] = finalClients
|
||||
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeInbound := *built
|
||||
runtimeInbound.Settings = string(modifiedSettings)
|
||||
|
||||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// TestAPIUserFromClientCarriesShadowsocksCipher pins what the renew path must
|
||||
// hand the runtime API. A shadowsocks client object holds no cipher of its own,
|
||||
// and xray's legacy and 2022 inbounds take different account types that they
|
||||
// cast without checking — an account built for the wrong one panics the core.
|
||||
func TestAPIUserFromClientCarriesShadowsocksCipher(t *testing.T) {
|
||||
client := map[string]any{"email": "a@x", "password": "pw", "method": "aes-256-gcm"}
|
||||
|
||||
user := apiUserFromClient(client, "aes-256-gcm")
|
||||
if got := user["cipher"]; got != "aes-256-gcm" {
|
||||
t.Fatalf("cipher = %v, want the inbound method", got)
|
||||
}
|
||||
if _, polluted := client["cipher"]; polluted {
|
||||
t.Fatal("the stored client object was mutated; the API-only cipher would be persisted into the inbound settings")
|
||||
}
|
||||
|
||||
user["email"] = "changed@x"
|
||||
if client["email"] != "a@x" {
|
||||
t.Fatal("the API user shares storage with the stored client object")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUserFromClientWithoutCipher(t *testing.T) {
|
||||
client := map[string]any{"email": "a@x", "id": "11111111-1111-1111-1111-111111111111"}
|
||||
user := apiUserFromClient(client, "")
|
||||
if _, ok := user["cipher"]; ok {
|
||||
t.Fatal("a non-shadowsocks client must not gain a cipher key")
|
||||
}
|
||||
if user["id"] != client["id"] {
|
||||
t.Fatalf("id = %v, want it copied from the client", user["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoRenewShadowsocksKeepsSettingsClean renews a shadowsocks client and
|
||||
// checks the inbound settings the panel writes back: the cipher the API needs
|
||||
// must not leak into a stored client object, where it would end up in the
|
||||
// generated xray config as a per-user key.
|
||||
func TestAutoRenewShadowsocksKeepsSettingsClean(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
past := time.Now().Add(-48 * time.Hour).UnixMilli()
|
||||
clients := []model.Client{
|
||||
{Email: "ss@x", Password: "pw", Enable: false, Reset: 30, ExpiryTime: past},
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"method": "aes-256-gcm",
|
||||
"network": "tcp,udp",
|
||||
"clients": []map[string]any{
|
||||
{"email": "ss@x", "password": "pw", "method": "aes-256-gcm", "enable": false, "expiryTime": past, "reset": 30},
|
||||
},
|
||||
}
|
||||
raw, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ib := mkInbound(t, 30011, model.Shadowsocks, string(raw))
|
||||
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&[]xray.ClientTraffic{
|
||||
{InboundId: ib.Id, Email: "ss@x", Enable: false, Up: 10, Down: 20, Reset: 30, ExpiryTime: past},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
|
||||
if _, count, err := svc.autoRenewClients(db); err != nil {
|
||||
t.Fatalf("autoRenewClients: %v", err)
|
||||
} else if count != 1 {
|
||||
t.Fatalf("renewed count = %d, want 1", count)
|
||||
}
|
||||
|
||||
var stored model.Inbound
|
||||
if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
|
||||
t.Fatalf("read inbound: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(stored.Settings), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal stored settings: %v", err)
|
||||
}
|
||||
storedClients, _ := parsed["clients"].([]any)
|
||||
if len(storedClients) != 1 {
|
||||
t.Fatalf("stored clients = %d, want 1", len(storedClients))
|
||||
}
|
||||
client, _ := storedClients[0].(map[string]any)
|
||||
if _, polluted := client["cipher"]; polluted {
|
||||
t.Fatalf("the renewed client was persisted with an API-only cipher key: %+v", client)
|
||||
}
|
||||
if client["method"] != "aes-256-gcm" {
|
||||
t.Fatalf("the client's method was lost: %+v", client)
|
||||
}
|
||||
if enabled, _ := client["enable"].(bool); !enabled {
|
||||
t.Fatalf("the renewed client was not re-enabled: %+v", client)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
||||
now := time.Now().Unix() * 1000
|
||||
needRestart := false
|
||||
|
||||
if p != nil {
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
var tags []string
|
||||
err := tx.Table("inbounds").
|
||||
Select("inbounds.tag").
|
||||
@@ -28,7 +28,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
_ = s.xrayApi.Init(p.GetAPIPort())
|
||||
_ = s.xrayApi.Init(process.GetAPIPort())
|
||||
for _, tag := range tags {
|
||||
err1 := s.xrayApi.DelInbound(tag)
|
||||
if err1 == nil {
|
||||
@@ -49,46 +49,58 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
|
||||
return needRestart, count, err
|
||||
}
|
||||
|
||||
const globalTrafficFreshWindow = 24 * time.Hour
|
||||
|
||||
func globalTrafficFreshSince() int64 {
|
||||
return time.Now().Add(-globalTrafficFreshWindow).UnixMilli()
|
||||
}
|
||||
|
||||
// depletedClientsCond matches clients that exhausted their quota or expired.
|
||||
// Besides the local counters it also trips on the cross-panel usage a master
|
||||
// pushed into client_global_traffics — that's what lets a node cut a client
|
||||
// whose combined usage exceeds the quota even though the local share doesn't
|
||||
// (placeholders: now).
|
||||
// whose combined usage exceeds the quota even though the local share doesn't.
|
||||
// Only rows a master refreshed recently count (placeholders: now, freshSince).
|
||||
const depletedClientsCond = `((total > 0 AND up + down >= total)
|
||||
OR (expiry_time > 0 AND expiry_time <= ?)
|
||||
OR (total > 0 AND EXISTS (
|
||||
SELECT 1 FROM client_global_traffics g
|
||||
WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
|
||||
WHERE g.email = client_traffics.email
|
||||
AND g.updated_at >= ?
|
||||
AND g.up + g.down >= client_traffics.total
|
||||
)))`
|
||||
|
||||
// depletedClientsCondLocal is depletedClientsCond without the cross-panel
|
||||
// client_global_traffics check. The EXISTS branch is a correlated subquery that
|
||||
// turns every traffic poll into a full client_traffics scan; on a panel no
|
||||
// master pushes to (the common case) client_global_traffics is empty, so the
|
||||
// branch can never match and is pure CPU cost (#5392).
|
||||
// branch can never match and is pure CPU cost (#5392). Placeholders: now.
|
||||
const depletedClientsCondLocal = `((total > 0 AND up + down >= total)
|
||||
OR (expiry_time > 0 AND expiry_time <= ?))`
|
||||
|
||||
// depletedCond returns the local-only predicate unless this panel actually
|
||||
// holds global-traffic rows, in which case the cross-panel EXISTS check is
|
||||
// needed to enforce combined quota. Both variants take the same single
|
||||
// expiry_time placeholder, so callers pass identical args either way.
|
||||
func depletedCond(tx *gorm.DB) string {
|
||||
// depletedCond returns the predicate matching depleted clients together with
|
||||
// the arguments it binds. The local-only variant is used unless this panel
|
||||
// holds a global-traffic row a master still refreshes, in which case the
|
||||
// cross-panel EXISTS check is needed to enforce combined quota.
|
||||
func depletedCond(tx *gorm.DB) (string, []any) {
|
||||
now := time.Now().UnixMilli()
|
||||
freshSince := globalTrafficFreshSince()
|
||||
var probe int64
|
||||
if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 {
|
||||
return depletedClientsCond
|
||||
err := tx.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("updated_at >= ?", freshSince).
|
||||
Limit(1).Count(&probe).Error
|
||||
if err == nil && probe > 0 {
|
||||
return depletedClientsCond, []any{now, freshSince}
|
||||
}
|
||||
return depletedClientsCondLocal
|
||||
return depletedClientsCondLocal, []any{now}
|
||||
}
|
||||
|
||||
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
|
||||
now := time.Now().Unix() * 1000
|
||||
needRestart := false
|
||||
cond := depletedCond(tx)
|
||||
cond, condArgs := depletedCond(tx)
|
||||
|
||||
var depletedRows []xray.ClientTraffic
|
||||
err := tx.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND enable = ?", now, true).
|
||||
Where(cond+" AND enable = ?", append(condArgs, true)...).
|
||||
Find(&depletedRows).Error
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
@@ -141,8 +153,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
}
|
||||
}
|
||||
|
||||
if p != nil && len(localTargets) > 0 {
|
||||
_ = s.xrayApi.Init(p.GetAPIPort())
|
||||
if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
|
||||
_ = s.xrayApi.Init(process.GetAPIPort())
|
||||
for _, t := range localTargets {
|
||||
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
|
||||
if err1 == nil {
|
||||
@@ -185,7 +197,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
|
||||
if len(depletedEmails) > 0 {
|
||||
if err := tx.Model(&model.ClientRecord{}).
|
||||
Where("email IN ?", depletedEmails).
|
||||
Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
|
||||
Updates(map[string]any{"enable": false, "updated_at": time.Now().UnixMilli()}).Error; err != nil {
|
||||
logger.Warning("disableInvalidClients update clients.enable:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) {
|
||||
t.Fatalf("seed fallback: %v", err)
|
||||
}
|
||||
|
||||
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master)
|
||||
runtimeIb, err := svc.buildInboundForLocalRuntime(db, master)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
@@ -68,9 +68,9 @@ func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) {
|
||||
t.Fatalf("seed fallback: %v", err)
|
||||
}
|
||||
|
||||
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib)
|
||||
runtimeIb, err := svc.buildInboundForLocalRuntime(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
if strings.Contains(runtimeIb.Settings, "fallbacks") {
|
||||
t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
)
|
||||
|
||||
const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
|
||||
|
||||
func TestValidateFinalMaskXmcProfiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
streamSettings string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty streamSettings",
|
||||
streamSettings: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no finalmask",
|
||||
streamSettings: `{"network":"tcp","security":"none"}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-xmc mask is untouched",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "xmc with a complete profile",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "legacy usernames shape without profiles",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","usernames":["Dream"]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "xmc with an empty profiles array",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "profile missing the textures signature",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":""}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "profile with an unparseable uuid",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"not-a-uuid","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "profile with an out-of-range username",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"ab","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "one complete and one incomplete profile",
|
||||
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `,{"username":"Herobrine","uuid":"","texturesValue":"","texturesSignature":""}]}}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateFinalMaskXmcProfiles(tt.streamSettings)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateFinalMaskXmcProfiles(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
|
||||
// to xray-core's own loader instead of a restatement of it: every profile the
|
||||
// panel accepts must build, and every one it rejects must fail to build. A
|
||||
// future core release that tightens or relaxes the rules fails here rather
|
||||
// than silently producing configs the core refuses to start on.
|
||||
func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
|
||||
profiles := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "complete", raw: completeXmcProfile},
|
||||
{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
|
||||
{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
|
||||
}
|
||||
|
||||
for _, tt := range profiles {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var coreProfile conf.XMCProfile
|
||||
if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
|
||||
t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
|
||||
}
|
||||
_, coreErr := coreProfile.Build()
|
||||
|
||||
mask := map[string]any{"type": "xmc"}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
|
||||
t.Fatalf("unmarshal settings: %v", err)
|
||||
}
|
||||
mask["settings"] = settings
|
||||
|
||||
panelAccepts := xmcMaskProfilesComplete(mask)
|
||||
coreAccepts := coreErr == nil
|
||||
if panelAccepts != coreAccepts {
|
||||
t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
|
||||
// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
|
||||
// with no profiles at all is now a build failure.
|
||||
func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
|
||||
var core conf.XMC
|
||||
if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
|
||||
t.Fatalf("unmarshal into conf.XMC: %v", err)
|
||||
}
|
||||
if _, err := core.Build(); err == nil {
|
||||
t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripIncompleteXmcMasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stream string
|
||||
wantDropped int
|
||||
wantStream string
|
||||
}{
|
||||
{
|
||||
name: "legacy usernames mask is dropped and finalmask removed",
|
||||
stream: `{"network":"tcp","finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"],"password":"pw"}}]}}`,
|
||||
wantDropped: 1,
|
||||
wantStream: `{"network":"tcp"}`,
|
||||
},
|
||||
{
|
||||
name: "complete mask is kept",
|
||||
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
|
||||
wantDropped: 0,
|
||||
wantStream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
|
||||
},
|
||||
{
|
||||
name: "sibling masks survive the drop",
|
||||
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"]}},{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
|
||||
wantDropped: 1,
|
||||
wantStream: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
|
||||
},
|
||||
{
|
||||
name: "udp masks are preserved when tcp empties out",
|
||||
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{}}],"udp":[{"type":"salamander"}]}}`,
|
||||
wantDropped: 1,
|
||||
wantStream: `{"finalmask":{"udp":[{"type":"salamander"}]}}`,
|
||||
},
|
||||
{
|
||||
name: "stream without finalmask is untouched",
|
||||
stream: `{"network":"tcp","security":"tls"}`,
|
||||
wantDropped: 0,
|
||||
wantStream: `{"network":"tcp","security":"tls"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(tt.stream), &stream); err != nil {
|
||||
t.Fatalf("unmarshal stream: %v", err)
|
||||
}
|
||||
|
||||
dropped := stripIncompleteXmcMasks(stream)
|
||||
if dropped != tt.wantDropped {
|
||||
t.Errorf("stripIncompleteXmcMasks dropped = %d, want %d", dropped, tt.wantDropped)
|
||||
}
|
||||
|
||||
var want map[string]any
|
||||
if err := json.Unmarshal([]byte(tt.wantStream), &want); err != nil {
|
||||
t.Fatalf("unmarshal wantStream: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stream: %v", err)
|
||||
}
|
||||
wantJSON, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal want: %v", err)
|
||||
}
|
||||
if string(got) != string(wantJSON) {
|
||||
t.Errorf("stream after strip = %s, want %s", got, wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,18 @@ import (
|
||||
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
||||
db := database.GetDB()
|
||||
query := fmt.Sprintf(
|
||||
"DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)",
|
||||
"DELETE FROM client_traffics WHERE email NOT IN (SELECT email FROM clients) AND email NOT IN (SELECT %s %s)",
|
||||
database.JSONFieldText("client.value", "email"),
|
||||
database.JSONClientsFromInbound(),
|
||||
)
|
||||
db.Exec(query)
|
||||
result := db.Exec(query)
|
||||
if result.Error != nil {
|
||||
logger.Warning("MigrationRemoveOrphanedTraffics failed:", result.Error)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("MigrationRemoveOrphanedTraffics: removed %d orphaned client_traffics row(s)", result.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) MigrationRequirements() {
|
||||
|
||||
@@ -129,6 +129,65 @@ func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRemoveOrphanedTraffics(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
clientSvc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const attachedEmail = "attached@example.com"
|
||||
attachedClient := model.Client{Email: attachedEmail, ID: "11111111-1111-1111-1111-111111111111", SubID: attachedEmail, Enable: true}
|
||||
attachedIb := mkInbound(t, 30003, model.VLESS, clientsSettings(t, []model.Client{attachedClient}))
|
||||
if err := clientSvc.SyncInbound(nil, attachedIb.Id, []model.Client{attachedClient}); err != nil {
|
||||
t.Fatalf("seed attached client: %v", err)
|
||||
}
|
||||
mkTraffic(t, attachedIb.Id, attachedEmail, 0, 0, 0, 0, true)
|
||||
|
||||
const detachedEmail = "detached@example.com"
|
||||
detachedClient := model.Client{Email: detachedEmail, ID: "22222222-2222-2222-2222-222222222222", SubID: detachedEmail, Enable: true}
|
||||
detachedIb := mkInbound(t, 30004, model.VLESS, clientsSettings(t, []model.Client{detachedClient}))
|
||||
if err := clientSvc.SyncInbound(nil, detachedIb.Id, []model.Client{detachedClient}); err != nil {
|
||||
t.Fatalf("seed detached client: %v", err)
|
||||
}
|
||||
mkTraffic(t, detachedIb.Id, detachedEmail, 123, 456, 0, 0, true)
|
||||
detachedRec := lookupClientRecord(t, detachedEmail)
|
||||
if _, err := clientSvc.Detach(inboundSvc, detachedRec.Id, []int{detachedIb.Id}); err != nil {
|
||||
t.Fatalf("Detach: %v", err)
|
||||
}
|
||||
|
||||
const jsonOnlyEmail = "jsononly@example.com"
|
||||
jsonOnlyClient := model.Client{Email: jsonOnlyEmail, ID: "33333333-3333-3333-3333-333333333333", SubID: jsonOnlyEmail, Enable: true}
|
||||
jsonOnlyIb := mkInbound(t, 30005, model.VLESS, clientsSettings(t, []model.Client{jsonOnlyClient}))
|
||||
mkTraffic(t, jsonOnlyIb.Id, jsonOnlyEmail, 0, 0, 0, 0, true)
|
||||
|
||||
const trulyOrphanedEmail = "deleted@example.com"
|
||||
mkTraffic(t, attachedIb.Id, trulyOrphanedEmail, 0, 0, 0, 0, true)
|
||||
|
||||
inboundSvc.MigrationRemoveOrphanedTraffics()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
want int64
|
||||
}{
|
||||
{"attached, in clients table and JSON", attachedEmail, 1},
|
||||
{"detached-but-alive, in clients table only", detachedEmail, 1},
|
||||
{"seeder-skipped-but-live, in JSON only", jsonOnlyEmail, 1},
|
||||
{"truly orphaned, in neither", trulyOrphanedEmail, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got int64
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", c.email).Count(&got).Error; err != nil {
|
||||
t.Fatalf("count client_traffics for %s: %v", c.email, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("client_traffics count for %s: got %d, want %d", c.email, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// running: one instance per enabled local mtproto inbound, serving only the
|
||||
// secrets of clients that are both enabled in the inbound settings and not
|
||||
// depletion-disabled in client_traffics. That is the same effective client set
|
||||
// buildRuntimeInboundForAPI pushes on interactive edits, so the reconcile job
|
||||
// buildInboundForLocalRuntime pushes on interactive edits, so the reconcile job
|
||||
// and the push paths agree on one fingerprint — a disagreement would surface
|
||||
// as a needless mtg restart, and a job that read only the raw settings would
|
||||
// keep serving depleted clients until an unrelated restart. Inbounds whose
|
||||
@@ -95,7 +95,7 @@ func (s *InboundService) applyLocalMtproto(inboundId int) {
|
||||
}
|
||||
payload := inbound
|
||||
if inbound.Enable {
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil {
|
||||
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
|
||||
payload = built
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ func TestDesiredMtprotoInstancesFiltersDepleted(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("matchesInteractivePushFiltering", func(t *testing.T) {
|
||||
built, err := svc.buildRuntimeInboundForAPI(database.GetDB(), served)
|
||||
built, err := svc.buildInboundForLocalRuntime(database.GetDB(), served)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
pushInst, ok := mtproto.InstanceFromInbound(built)
|
||||
if !ok {
|
||||
|
||||
@@ -125,11 +125,8 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reconcile with the same runtime-built payload interactive pushes
|
||||
// send (disabled clients filtered, settings.fallbacks injected) so
|
||||
// fingerprints line up and fallback edits actually reach the node.
|
||||
runtimeIb := ib
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil {
|
||||
if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
|
||||
runtimeIb = built
|
||||
}
|
||||
if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
|
||||
@@ -311,7 +308,8 @@ func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool {
|
||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||
c.StreamSettings != snapIb.StreamSettings ||
|
||||
c.Sniffing != snapIb.Sniffing ||
|
||||
c.TrafficReset != snapIb.TrafficReset
|
||||
c.TrafficReset != snapIb.TrafficReset ||
|
||||
c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
}
|
||||
|
||||
// adoptedWireInbound is the central inbound as it reads after adopting the
|
||||
@@ -330,6 +328,7 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
|
||||
a.StreamSettings = snapIb.StreamSettings
|
||||
a.Sniffing = snapIb.Sniffing
|
||||
a.TrafficReset = snapIb.TrafficReset
|
||||
a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
return &a
|
||||
}
|
||||
|
||||
@@ -350,7 +349,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
// origin (an inbound the node forwards from its own sub-node) is kept as-is,
|
||||
// so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3.
|
||||
var nodeRow model.Node
|
||||
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
|
||||
db.Select("guid", "config_dirty", "inbound_sync_mode", "inbound_tags").Where("id = ?", nodeID).First(&nodeRow)
|
||||
// Re-read inside the serialized writer: a client added while this snapshot
|
||||
// was in flight marks the node dirty after the caller sampled the flag.
|
||||
dirty = dirty || nodeRow.ConfigDirty
|
||||
nodeRow.Id = nodeID
|
||||
unmanagedTag := unmanagedTagPredicate(&nodeRow)
|
||||
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
|
||||
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
|
||||
originGuidFor := func(snapIb *model.Inbound) string {
|
||||
@@ -561,6 +565,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
StreamSettings: snapIb.StreamSettings,
|
||||
Sniffing: snapIb.Sniffing,
|
||||
TrafficReset: snapIb.TrafficReset,
|
||||
TrafficResetDay: normalizeTrafficResetDay(snapIb.TrafficResetDay),
|
||||
LastTrafficResetTime: snapIb.LastTrafficResetTime,
|
||||
Enable: snapIb.Enable,
|
||||
Remark: snapIb.Remark,
|
||||
@@ -616,6 +621,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
updates["stream_settings"] = snapIb.StreamSettings
|
||||
updates["sniffing"] = snapIb.Sniffing
|
||||
updates["traffic_reset"] = snapIb.TrafficReset
|
||||
updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay)
|
||||
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
|
||||
if adoptedWireChanged(c, snapIb, adoptedSettings) {
|
||||
adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(c, snapIb, adoptedSettings))
|
||||
@@ -667,6 +673,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if _, kept := snapTags[c.Tag]; kept {
|
||||
continue
|
||||
}
|
||||
if unmanagedTag(c.Tag) {
|
||||
continue
|
||||
}
|
||||
var goneEmails []string
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", c.Id).
|
||||
@@ -882,7 +891,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if uErr != nil {
|
||||
return false, uErr
|
||||
}
|
||||
if !stillUsed {
|
||||
// Usage, quota and expiry live on this row, so a client the orphan
|
||||
// sweep will mark keeps it until the reaper confirms the removal.
|
||||
if !stillUsed && !clientRecordExists(tx, existing.Email) {
|
||||
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
|
||||
Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return false, err
|
||||
@@ -897,6 +908,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
emails map[string]struct{}
|
||||
}
|
||||
var perInboundOld []oldSet
|
||||
syncFailedInbounds := map[int]struct{}{}
|
||||
for _, snapIb := range snap.Inbounds {
|
||||
if snapIb == nil {
|
||||
continue
|
||||
@@ -969,10 +981,16 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
|
||||
logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
|
||||
syncFailedInbounds[c.Id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, old := range perInboundOld {
|
||||
// The sweep's premise is that links were just rebuilt from the snapshot,
|
||||
// which is exactly what a failed SyncInbound violates.
|
||||
if _, failed := syncFailedInbounds[old.inboundID]; failed {
|
||||
continue
|
||||
}
|
||||
var stillAttached []string
|
||||
if err := tx.Table("clients").
|
||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||
@@ -998,19 +1016,20 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if attachmentCount > 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete ClientRecord %q failed: %v", email, err)
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
|
||||
// "Ended the merge unattached" is true for a real remote deletion and
|
||||
// equally true for a bad merge, so record a strike instead of deleting.
|
||||
if err := markSyncOrphan(tx, email, now); err != nil {
|
||||
logger.Warningf("setRemoteTraffic: mark orphan %q failed: %v", email, err)
|
||||
continue
|
||||
}
|
||||
structuralChange = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := clearSyncOrphanMarks(tx); err != nil {
|
||||
logger.Warning("setRemoteTraffic: clear orphan marks failed:", err)
|
||||
}
|
||||
|
||||
if err := liftActivatedClientRecordExpiries(tx); err != nil {
|
||||
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
|
||||
}
|
||||
@@ -1032,7 +1051,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
}
|
||||
|
||||
if p != nil {
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
tree := snap.OnlineTree
|
||||
switch {
|
||||
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
|
||||
@@ -1054,17 +1073,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
tree = remapped
|
||||
}
|
||||
}
|
||||
p.SetNodeOnlineTree(nodeID, tree)
|
||||
process.SetNodeOnlineTree(nodeID, tree)
|
||||
}
|
||||
|
||||
return structuralChange, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetOnlineClients() []string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return []string{}
|
||||
}
|
||||
return p.GetOnlineClients()
|
||||
return process.GetOnlineClients()
|
||||
}
|
||||
|
||||
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
|
||||
@@ -1073,11 +1093,12 @@ func (s *InboundService) GetOnlineClients() []string {
|
||||
// node-id keying so a client three hops down is attributed to its real node,
|
||||
// not the intermediate one it was synced through.
|
||||
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return map[string][]string{}
|
||||
}
|
||||
out := p.GetMergedNodeTrees()
|
||||
if local := p.GetLocalOnlineClients(); len(local) > 0 {
|
||||
out := process.GetMergedNodeTrees()
|
||||
if local := process.GetLocalOnlineClients(); len(local) > 0 {
|
||||
if guid := s.panelGuid(); guid != "" {
|
||||
out[guid] = mergeEmails(out[guid], local)
|
||||
}
|
||||
@@ -1090,10 +1111,11 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
|
||||
// report per-inbound activity, so a GUID missing from the map means "don't
|
||||
// gate" for that node's inbounds.
|
||||
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return map[string][]string{}
|
||||
}
|
||||
active := p.GetLocalActiveInbounds()
|
||||
active := process.GetLocalActiveInbounds()
|
||||
if len(active) == 0 {
|
||||
return map[string][]string{}
|
||||
}
|
||||
@@ -1105,14 +1127,14 @@ func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
|
||||
}
|
||||
|
||||
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
|
||||
if p != nil {
|
||||
p.SetNodeOnlineTree(nodeID, tree)
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
process.SetNodeOnlineTree(nodeID, tree)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
|
||||
if p != nil {
|
||||
p.ClearNodeOnlineClients(nodeID)
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
process.ClearNodeOnlineClients(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,8 +1195,8 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
|
||||
// xray.Process for why the local sets are kept separate from the shared
|
||||
// last_online column.
|
||||
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
|
||||
if p != nil {
|
||||
p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,23 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||
return dbClientTraffics, newExpiryByEmail, nil
|
||||
}
|
||||
|
||||
// apiUserFromClient prepares a stored client object for the runtime AddUser
|
||||
// call. The copy matters twice over: the stored object keeps being mutated and
|
||||
// marshalled back into the inbound's settings, which must not gain an API-only
|
||||
// key, and shadowsocks clients carry no cipher of their own — it lives on the
|
||||
// inbound, and without it the API cannot tell which of xray's two shadowsocks
|
||||
// account types the running inbound expects.
|
||||
func apiUserFromClient(client map[string]any, cipher string) map[string]any {
|
||||
user := maps.Clone(client)
|
||||
if user == nil {
|
||||
user = map[string]any{}
|
||||
}
|
||||
if cipher != "" {
|
||||
user["cipher"] = cipher
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
// check for time expired
|
||||
var traffics []*xray.ClientTraffic
|
||||
@@ -367,6 +384,10 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
if len(clients) == 0 {
|
||||
continue
|
||||
}
|
||||
cipher := ""
|
||||
if inbounds[inbound_index].Protocol == model.Shadowsocks {
|
||||
cipher, _ = settings["method"].(string)
|
||||
}
|
||||
for client_index := range clients {
|
||||
c := clients[client_index].(map[string]any)
|
||||
email, _ := c["email"].(string)
|
||||
@@ -393,7 +414,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
}{
|
||||
protocol: string(inbounds[inbound_index].Protocol),
|
||||
tag: inbounds[inbound_index].Tag,
|
||||
client: c,
|
||||
client: apiUserFromClient(c, cipher),
|
||||
})
|
||||
}
|
||||
clients[client_index] = any(c)
|
||||
@@ -431,8 +452,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
||||
if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if p != nil {
|
||||
err1 = s.xrayApi.Init(p.GetAPIPort())
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
err1 = s.xrayApi.Init(process.GetAPIPort())
|
||||
if err1 != nil {
|
||||
return true, int64(len(traffics)), nil
|
||||
}
|
||||
@@ -603,7 +624,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cipher = oldSettings["method"].(string)
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
err1 := rt.AddUser(context.Background(), inbound, map[string]any{
|
||||
"email": client.Email,
|
||||
|
||||
@@ -100,15 +100,21 @@ func chunkGlobalRows(rows []model.ClientGlobalTraffic, size int) [][]model.Clien
|
||||
}
|
||||
|
||||
// overlayGlobalTraffic raises Up/Down on the given rows to the largest global
|
||||
// value any master pushed for that email. Read-path only — callers hand it
|
||||
// rows about to be serialized for display; the stored counters are untouched.
|
||||
// value any master still pushing for that email reported. Read-path only —
|
||||
// callers hand it rows about to be serialized for display; the stored counters
|
||||
// are untouched. Rows older than globalTrafficFreshWindow are ignored: they
|
||||
// come from a master that stopped pushing, and folding their frozen counters
|
||||
// in would keep showing usage the client no longer has (#6113).
|
||||
func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
freshSince := globalTrafficFreshSince()
|
||||
// Cheap short-circuit for the common case (a panel no master pushes to).
|
||||
var probe int64
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err != nil || probe == 0 {
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("updated_at >= ?", freshSince).
|
||||
Limit(1).Count(&probe).Error; err != nil || probe == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,7 +132,7 @@ func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
|
||||
}
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var globals []model.ClientGlobalTraffic
|
||||
if err := db.Where("email IN ?", batch).Find(&globals).Error; err != nil {
|
||||
if err := db.Where("email IN ? AND updated_at >= ?", batch, freshSince).Find(&globals).Error; err != nil {
|
||||
logger.Warning("overlayGlobalTraffic:", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTrafficResetDay(t *testing.T) {
|
||||
tests := map[int]int{
|
||||
0: 1,
|
||||
1: 1,
|
||||
15: 15,
|
||||
31: 31,
|
||||
32: 31,
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := normalizeTrafficResetDay(input); got != want {
|
||||
t.Errorf("normalizeTrafficResetDay(%d) = %d, want %d", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/gob"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -170,8 +171,8 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
||||
h.mu.Unlock()
|
||||
|
||||
startIdx := len(raw)
|
||||
for i := len(raw) - 1; i >= 0; i-- {
|
||||
if raw[i].T < cutoff {
|
||||
for i, r := range slices.Backward(raw) {
|
||||
if r.T < cutoff {
|
||||
break
|
||||
}
|
||||
startIdx = i
|
||||
|
||||
@@ -652,9 +652,9 @@ func (s *NodeService) EnsureInboundTagAllowedTx(tx *gorm.DB, nodeID int, tag str
|
||||
Updates(map[string]any{"inbound_tags": string(buf)}).Error
|
||||
}
|
||||
|
||||
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
func nodeSelectedTagSet(n *model.Node) map[string]struct{} {
|
||||
if n == nil || n.InboundSyncMode != "selected" {
|
||||
return nil
|
||||
}
|
||||
prefix := nodeTagPrefix(&n.Id)
|
||||
allowed := make(map[string]struct{}, len(n.InboundTags)*2)
|
||||
@@ -668,6 +668,27 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// A deselected tag is still served by the node — FilterNodeSnapshot just stops
|
||||
// reporting it — so its absence must never be read as "the node deleted it".
|
||||
func unmanagedTagPredicate(n *model.Node) func(string) bool {
|
||||
managed := nodeSelectedTagSet(n)
|
||||
if managed == nil {
|
||||
return func(string) bool { return false }
|
||||
}
|
||||
return func(tag string) bool {
|
||||
_, ok := managed[tag]
|
||||
return !ok
|
||||
}
|
||||
}
|
||||
|
||||
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
}
|
||||
allowed := nodeSelectedTagSet(n)
|
||||
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
|
||||
for _, inbound := range snap.Inbounds {
|
||||
if inbound == nil {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func payloadClientEmails(t *testing.T, settings string) []string {
|
||||
t.Helper()
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
t.Fatalf("settings not valid json: %v", err)
|
||||
}
|
||||
raw, _ := parsed["clients"].([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if email, _ := cm["email"].(string); email != "" {
|
||||
out = append(out, email)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stripping disabled clients from a node push made the node delete the row and
|
||||
// the master mirror that back: every depleted client destroyed, not disabled.
|
||||
func TestNodePushKeepsDisabledClientsLocalPushStripsThem(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
clients := []model.Client{
|
||||
{Email: "alive@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
|
||||
{Email: "quota@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
|
||||
|
||||
for _, c := range clients {
|
||||
row := &xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed traffic %s: %v", c.Email, err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", "quota@x").
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("deplete quota@x: %v", err)
|
||||
}
|
||||
|
||||
t.Run("node payload keeps the depletion-disabled client", func(t *testing.T) {
|
||||
built, err := svc.buildInboundForNodePush(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInboundForNodePush: %v", err)
|
||||
}
|
||||
got := payloadClientEmails(t, built.Settings)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("node push dropped a client: got %v, want both alive@x and quota@x", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local xray payload still strips it", func(t *testing.T) {
|
||||
built, err := svc.buildInboundForLocalRuntime(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
got := payloadClientEmails(t, built.Settings)
|
||||
if len(got) != 1 || got[0] != "alive@x" {
|
||||
t.Fatalf("local runtime payload = %v, want only alive@x", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func countClientRows(t *testing.T, db *gorm.DB, email string) (records, traffics int64) {
|
||||
t.Helper()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&records).Error; err != nil {
|
||||
t.Fatalf("count clients %q: %v", email, err)
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&traffics).Error; err != nil {
|
||||
t.Fatalf("count client_traffics %q: %v", email, err)
|
||||
}
|
||||
return records, traffics
|
||||
}
|
||||
|
||||
func seedNodeRow(t *testing.T, db *gorm.DB, n *model.Node) {
|
||||
t.Helper()
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotWithClients(t *testing.T, tag, settings string, stats ...xray.ClientTraffic) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotWithoutClients(t *testing.T, tag string) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return snapshotWithClients(t, tag, `{"clients":[]}`)
|
||||
}
|
||||
|
||||
func snapshotWithTwoInbounds(t *testing.T, tagA, settingsA, emailA, tagB, settingsB, emailB string) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{Tag: tagA, Settings: settingsA, ClientStats: []xray.ClientTraffic{{Email: emailA, Enable: true}}},
|
||||
{Tag: tagB, Settings: settingsB, ClientStats: []xray.ClientTraffic{{Email: emailB, Enable: true}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The job samples config_dirty before the snapshot round-trip; a client added
|
||||
// in that window is deleted again unless the merge re-reads the flag itself.
|
||||
func TestSetRemoteTrafficRereadsConfigDirty(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "carol"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 1, Down: 1, Enable: true})
|
||||
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatalf("setup: client not attached, got %d rows", rec)
|
||||
}
|
||||
|
||||
if err := db.Model(model.Node{}).Where("id = ?", 1).Update("config_dirty", true).Error; err != nil {
|
||||
t.Fatalf("mark node dirty: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 1 || traf != 1 {
|
||||
t.Fatalf("stale dirty=false wiped a client added mid-flight: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// FilterNodeSnapshot stops reporting a deselected tag; reading that absence as
|
||||
// "the node deleted it" wiped the master's copy of an inbound still running.
|
||||
func TestDeselectedTagIsNotSwept(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{
|
||||
Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true,
|
||||
InboundSyncMode: "selected", InboundTags: []string{"keep"},
|
||||
})
|
||||
|
||||
createNodeInboundWithClient(t, db, 1, "keep", 41001, "kept@x")
|
||||
createNodeInboundWithClient(t, db, 1, "drop", 41002, "dropped@x")
|
||||
|
||||
keepSettings := `{"clients":[{"email":"kept@x","enable":true}]}`
|
||||
dropSettings := `{"clients":[{"email":"dropped@x","enable":true}]}`
|
||||
snap := snapshotWithTwoInbounds(t, "keep", keepSettings, "kept@x", "drop", dropSettings, "dropped@x")
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
|
||||
t.Fatalf("seed sync: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, "dropped@x"); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: dropped@x not seeded, clients=%d client_traffics=%d", rec, traf)
|
||||
}
|
||||
|
||||
keepOnly := snapshotWithClients(t, "keep", keepSettings, xray.ClientTraffic{Email: "kept@x", Enable: true})
|
||||
if _, err := svc.setRemoteTrafficLocked(1, keepOnly, false); err != nil {
|
||||
t.Fatalf("post-deselect sync: %v", err)
|
||||
}
|
||||
|
||||
rec, traf := countClientRows(t, db, "dropped@x")
|
||||
if rec != 1 || traf != 1 {
|
||||
t.Fatalf("deselecting a tag deleted its clients: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
var inbounds int64
|
||||
if err := db.Model(model.Inbound{}).Where("tag = ?", "drop").Count(&inbounds).Error; err != nil {
|
||||
t.Fatalf("count inbounds: %v", err)
|
||||
}
|
||||
if inbounds != 1 {
|
||||
t.Fatalf("deselecting a tag deleted the inbound the node still serves: got %d rows, want 1", inbounds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInboundStoresTrimmedEmail(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
ib := &model.Inbound{UserId: 1, Tag: "trim-in", Enable: true, Port: 41501, Protocol: model.VLESS}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
padded := "bob "
|
||||
clients := []model.Client{{Email: padded, Enable: true}}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("first SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
var stored []string
|
||||
if err := db.Model(&model.ClientRecord{}).Pluck("email", &stored).Error; err != nil {
|
||||
t.Fatalf("read clients: %v", err)
|
||||
}
|
||||
if len(stored) != 1 || stored[0] != "bob" {
|
||||
t.Fatalf("stored email = %q, want the trimmed %q — the lookup key must match what is written", stored, "bob")
|
||||
}
|
||||
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("second SyncInbound must not hit a unique-constraint on the untrimmed row: %v", err)
|
||||
}
|
||||
var links int64
|
||||
if err := db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&links).Error; err != nil {
|
||||
t.Fatalf("count links: %v", err)
|
||||
}
|
||||
if links != 1 {
|
||||
t.Fatalf("links after re-sync = %d, want 1", links)
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ import (
|
||||
// CheckXrayConfig's version gate.
|
||||
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
|
||||
coreVersion := "Unknown"
|
||||
if p != nil {
|
||||
coreVersion = p.GetXrayVersion()
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
coreVersion = process.GetXrayVersion()
|
||||
}
|
||||
kept := make([]any, 0, len(outbounds))
|
||||
var dropped []string
|
||||
|
||||
@@ -626,8 +626,8 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
||||
status.AppStats.Mem = rtm.Sys
|
||||
}
|
||||
status.AppStats.Threads = uint32(runtime.NumGoroutine())
|
||||
if p != nil && p.IsRunning() {
|
||||
status.AppStats.Uptime = p.GetUptime()
|
||||
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
||||
status.AppStats.Uptime = process.GetUptime()
|
||||
} else {
|
||||
status.AppStats.Uptime = 0
|
||||
}
|
||||
@@ -670,8 +670,8 @@ func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
||||
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
|
||||
systemMetrics.append("udpCount", t, float64(status.UdpCount))
|
||||
online := 0
|
||||
if p != nil && p.IsRunning() {
|
||||
online = len(p.GetOnlineClients())
|
||||
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
||||
online = len(process.GetOnlineClients())
|
||||
}
|
||||
systemMetrics.append("online", t, float64(online))
|
||||
if len(status.Loads) >= 3 {
|
||||
@@ -1345,25 +1345,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
|
||||
if database.IsPostgres() {
|
||||
return s.exportPostgresDB()
|
||||
}
|
||||
// Update by manually trigger a checkpoint operation
|
||||
err := database.Checkpoint()
|
||||
backupPath, cleanup, err := s.backupSQLite()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Open the file for reading
|
||||
file, err := os.Open(config.GetDBPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
defer cleanup()
|
||||
return os.ReadFile(backupPath)
|
||||
}
|
||||
|
||||
// Read the file contents
|
||||
fileContents, err := io.ReadAll(file)
|
||||
func (s *ServerService) backupSQLite() (string, func(), error) {
|
||||
backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return fileContents, nil
|
||||
cleanup := func() { _ = os.RemoveAll(backupDir) }
|
||||
backupPath := filepath.Join(backupDir, "backup.db")
|
||||
if err := database.BackupSQLite(backupPath); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
return backupPath, cleanup, nil
|
||||
}
|
||||
|
||||
// BackupFilename returns the filename for a database backup, named after the
|
||||
@@ -1463,11 +1464,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
|
||||
return data, "x-ui.db", nil
|
||||
}
|
||||
|
||||
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
|
||||
if err := database.Checkpoint(); err != nil {
|
||||
backupPath, cleanup, err := s.backupSQLite()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
|
||||
defer cleanup()
|
||||
data, err := database.DumpSQLiteToBytes(backupPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
|
||||
DefaultSubJsonUserAgentRegex = ``
|
||||
DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
|
||||
DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
|
||||
maxRegexLength = 2048
|
||||
)
|
||||
|
||||
@@ -61,11 +62,12 @@ var defaultValueMap = map[string]string{
|
||||
"nodeMtlsClientCAPem": "",
|
||||
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
|
||||
"sessionMaxAge": "360",
|
||||
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
|
||||
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
@@ -649,6 +651,10 @@ func (s *SettingService) GetRemarkTemplate() (string, error) {
|
||||
return s.getString("remarkTemplate")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
|
||||
return s.getBool("subShowIdentityOnAllLinks")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSecret() ([]byte, error) {
|
||||
secret, err := s.getString("secret")
|
||||
if secret == defaultValueMap["secret"] {
|
||||
@@ -1440,3 +1446,33 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var factoryDefaultSecretKeys = map[string]bool{
|
||||
"tgBotToken": true,
|
||||
"twoFactorToken": true,
|
||||
"ldapPassword": true,
|
||||
"smtpPassword": true,
|
||||
}
|
||||
|
||||
/*
|
||||
GetFactoryDefaults returns the shipped default value per setting, keyed by
|
||||
the AllSetting json field name. Unlike GetDefaultSettings (which reports
|
||||
current effective values), this is defaultValueMap projected through the
|
||||
AllSetting field set: only keys that exist as an AllSetting json tag are
|
||||
returned, minus the credential fields in factoryDefaultSecretKeys. Keys
|
||||
with no AllSetting field (secret, panelGuid, the node mTLS material,
|
||||
xrayTemplateConfig) are excluded structurally rather than by deny-list.
|
||||
*/
|
||||
func (s *SettingService) GetFactoryDefaults() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
|
||||
key := field.Tag.Get("json")
|
||||
if key == "" || factoryDefaultSecretKeys[key] {
|
||||
continue
|
||||
}
|
||||
if value, ok := defaultValueMap[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
)
|
||||
|
||||
func allSettingJSONTags(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
tags := make(map[string]bool)
|
||||
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
|
||||
if tag := field.Tag.Get("json"); tag != "" {
|
||||
tags[tag] = true
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func TestGetFactoryDefaultsExposesBrowserSafeKeys(t *testing.T) {
|
||||
defaults := (&SettingService{}).GetFactoryDefaults()
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{key: "webPort", want: "2053"},
|
||||
{key: "subPort", want: "2096"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
got, ok := defaults[tc.key]
|
||||
if !ok {
|
||||
t.Fatalf("expected key %q in factory defaults", tc.key)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("factory default for %q = %q, want %q", tc.key, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
|
||||
defaults := (&SettingService{}).GetFactoryDefaults()
|
||||
|
||||
for _, key := range []string{
|
||||
"secret",
|
||||
"panelGuid",
|
||||
"nodeMtlsCaCertPem",
|
||||
"nodeMtlsCaKeyPem",
|
||||
"nodeMtlsClientCertPem",
|
||||
"nodeMtlsClientKeyPem",
|
||||
"xrayTemplateConfig",
|
||||
"tgBotToken",
|
||||
"twoFactorToken",
|
||||
"ldapPassword",
|
||||
"smtpPassword",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
if _, ok := defaults[key]; ok {
|
||||
t.Errorf("factory defaults must not expose %q", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFactoryDefaultsInvariant(t *testing.T) {
|
||||
defaults := (&SettingService{}).GetFactoryDefaults()
|
||||
tags := allSettingJSONTags(t)
|
||||
|
||||
for key := range defaults {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
if !tags[key] {
|
||||
t.Errorf("key %q is not an entity.AllSetting json tag", key)
|
||||
}
|
||||
if factoryDefaultSecretKeys[key] {
|
||||
t.Errorf("key %q is in the credential deny-list and must not be returned", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||
t.Fatalf("missing setting = %t, %v; want false, nil", got, err)
|
||||
}
|
||||
settings, err := s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.SubShowIdentityOnAllLinks {
|
||||
t.Fatal("GetAllSetting returned true for a missing setting")
|
||||
}
|
||||
|
||||
settings.SubShowIdentityOnAllLinks = true
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
|
||||
t.Fatalf("persisted setting = %t, %v; want true, nil", got, err)
|
||||
}
|
||||
|
||||
settings, err = s.GetAllSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.SubShowIdentityOnAllLinks = false
|
||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||
t.Fatalf("persisted setting = %t, %v; want false, nil", got, err)
|
||||
}
|
||||
}
|
||||
@@ -494,8 +494,8 @@ func (t *Tgbot) clientInfoMsg(
|
||||
|
||||
status := t.I18nBot("tgbot.offline")
|
||||
isOnline := false
|
||||
if service.XrayProcess().IsRunning() {
|
||||
if slices.Contains(service.XrayProcess().GetOnlineClients(), traffic.Email) {
|
||||
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
||||
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
|
||||
status = t.I18nBot("tgbot.online")
|
||||
isOnline = true
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ func (t *Tgbot) SendBackupToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
dbFilename := t.serverService.BackupFilename("")
|
||||
for i, adminId := range adminIds {
|
||||
t.sendBackup(adminId)
|
||||
t.sendBackupData(adminId, dbData, dbFilename)
|
||||
// Add delay between sends to avoid Telegram rate limits
|
||||
if i < len(adminIds)-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
@@ -105,7 +110,10 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
||||
t.setCachedStatus(t.lastStatus)
|
||||
}
|
||||
onlines := service.XrayProcess().GetOnlineClients()
|
||||
var onlines []string
|
||||
if process := service.XrayProcess(); process != nil {
|
||||
onlines = process.GetOnlineClients()
|
||||
}
|
||||
|
||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
||||
@@ -361,11 +369,12 @@ func (t *Tgbot) notifyExhausted() {
|
||||
|
||||
// onlineClients retrieves and sends information about online clients.
|
||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||
if !service.XrayProcess().IsRunning() {
|
||||
process := service.XrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
onlines := service.XrayProcess().GetOnlineClients()
|
||||
onlines := process.GetOnlineClients()
|
||||
onlinesCount := len(onlines)
|
||||
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
@@ -400,26 +409,30 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||
|
||||
// sendBackup sends a backup of the database and configuration files.
|
||||
func (t *Tgbot) sendBackup(chatId int64) {
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err != nil {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
|
||||
}
|
||||
|
||||
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
|
||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
|
||||
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err == nil {
|
||||
dbFilename := t.serverService.BackupFilename("")
|
||||
if dbData != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(dbData, dbFilename),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
_, err := bot.SendDocument(ctx, document)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading backup: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
|
||||
// Small delay between file sends
|
||||
|
||||
@@ -21,13 +21,44 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
p *xray.Process
|
||||
lock sync.Mutex
|
||||
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
|
||||
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
|
||||
result string
|
||||
xrayState xrayLifecycle
|
||||
)
|
||||
|
||||
type xrayLifecycle struct {
|
||||
mu sync.RWMutex
|
||||
process *xray.Process
|
||||
result string
|
||||
}
|
||||
|
||||
func (s *xrayLifecycle) snapshot() (*xray.Process, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.process, s.result
|
||||
}
|
||||
|
||||
func (s *xrayLifecycle) replace(process *xray.Process) {
|
||||
s.mu.Lock()
|
||||
s.process = process
|
||||
s.result = ""
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *xrayLifecycle) storeResult(process *xray.Process, result string) {
|
||||
s.mu.Lock()
|
||||
if s.process == process && s.result == "" {
|
||||
s.result = result
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func currentXrayProcess() *xray.Process {
|
||||
process, _ := xrayState.snapshot()
|
||||
return process
|
||||
}
|
||||
|
||||
// XrayService provides business logic for Xray process management.
|
||||
// It handles starting, stopping, restarting Xray, and managing its configuration.
|
||||
type XrayService struct {
|
||||
@@ -39,23 +70,25 @@ type XrayService struct {
|
||||
|
||||
// IsXrayRunning checks if the Xray process is currently running.
|
||||
func (s *XrayService) IsXrayRunning() bool {
|
||||
return p != nil && p.IsRunning()
|
||||
process := currentXrayProcess()
|
||||
return process != nil && process.IsRunning()
|
||||
}
|
||||
|
||||
// XrayProcess returns the current Xray process instance (may be nil when Xray
|
||||
// is not running). It exposes the package-level process to callers outside this
|
||||
// package (e.g. the tgbot subpackage) without changing access semantics.
|
||||
// is not running). It exposes the lifecycle snapshot to callers outside this
|
||||
// package (e.g. the tgbot subpackage).
|
||||
func XrayProcess() *xray.Process {
|
||||
return p
|
||||
return currentXrayProcess()
|
||||
}
|
||||
|
||||
// GetXrayErr returns the error from the Xray process, if any.
|
||||
func (s *XrayService) GetXrayErr() error {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := p.GetErr()
|
||||
err := process.GetErr()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -71,17 +104,15 @@ func (s *XrayService) GetXrayErr() error {
|
||||
|
||||
// GetXrayResult returns the result string from the Xray process.
|
||||
func (s *XrayService) GetXrayResult() string {
|
||||
if result != "" {
|
||||
return result
|
||||
process, cachedResult := xrayState.snapshot()
|
||||
if cachedResult != "" {
|
||||
return cachedResult
|
||||
}
|
||||
if s.IsXrayRunning() {
|
||||
return ""
|
||||
}
|
||||
if p == nil {
|
||||
if process == nil || process.IsRunning() {
|
||||
return ""
|
||||
}
|
||||
|
||||
result = p.GetResult()
|
||||
result := process.GetResult()
|
||||
|
||||
if runtime.GOOS == "windows" && result == "exit status 1" {
|
||||
// exit status 1 on Windows means that Xray process was killed
|
||||
@@ -89,15 +120,17 @@ func (s *XrayService) GetXrayResult() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
xrayState.storeResult(process, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetXrayVersion returns the version of the running Xray process.
|
||||
func (s *XrayService) GetXrayVersion() string {
|
||||
if p == nil {
|
||||
process := currentXrayProcess()
|
||||
if process == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
return p.GetXrayVersion()
|
||||
return process.GetXrayVersion()
|
||||
}
|
||||
|
||||
// RemoveIndex removes an element at the specified index from a slice.
|
||||
@@ -157,7 +190,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
var finalClients []any
|
||||
finalClients := make([]any, 0, len(dbClients))
|
||||
var wgPeers []any
|
||||
for i := range dbClients {
|
||||
c := dbClients[i]
|
||||
@@ -282,6 +315,12 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
delete(stream, "finalmask")
|
||||
}
|
||||
|
||||
dropEmptyRandPackets(stream["finalmask"])
|
||||
|
||||
if dropped := stripIncompleteXmcMasks(stream); dropped > 0 {
|
||||
logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
|
||||
}
|
||||
|
||||
// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
|
||||
// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
|
||||
// new names here so inbounds stored before the rename keep working
|
||||
@@ -937,12 +976,13 @@ func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage {
|
||||
|
||||
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
|
||||
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
err := errors.New("xray is not running")
|
||||
logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
apiPort := p.GetAPIPort()
|
||||
apiPort := process.GetAPIPort()
|
||||
if err := s.xrayAPI.Init(apiPort); err != nil {
|
||||
logger.Debug("Failed to initialize Xray API:", err)
|
||||
return nil, nil, err
|
||||
@@ -965,13 +1005,14 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
||||
// core as unsupported until the next restart, while transient errors leave the
|
||||
// capability undecided so a flaky poll can't lock in legacy mode.
|
||||
func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return nil, false, nil
|
||||
}
|
||||
if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
|
||||
if process.OnlineAPISupport() == xray.OnlineAPIUnsupported {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
logger.Debug("Failed to initialize Xray API:", err)
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -980,15 +1021,15 @@ func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
|
||||
users, err := s.xrayAPI.GetOnlineUsers()
|
||||
if err != nil {
|
||||
if xray.IsUnimplementedErr(err) {
|
||||
p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
|
||||
process.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
|
||||
logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
|
||||
return nil, false, nil
|
||||
}
|
||||
logger.Debug("Failed to fetch Xray online users:", err)
|
||||
return nil, false, err
|
||||
}
|
||||
if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
|
||||
p.SetOnlineAPISupport(xray.OnlineAPISupported)
|
||||
if process.OnlineAPISupport() == xray.OnlineAPIUnknown {
|
||||
process.SetOnlineAPISupport(xray.OnlineAPISupported)
|
||||
logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
|
||||
}
|
||||
return users, true, nil
|
||||
@@ -1010,13 +1051,14 @@ type BalancerStatus struct {
|
||||
// balancers alongside live ones.
|
||||
func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
|
||||
statuses := make([]BalancerStatus, 0, len(tags))
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
for _, tag := range tags {
|
||||
statuses = append(statuses, BalancerStatus{Tag: tag})
|
||||
}
|
||||
return statuses, nil
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
@@ -1043,7 +1085,8 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error
|
||||
// another balancer, the override resolves to the loopback outbound that
|
||||
// routes traffic through the target balancer via the routing rules.
|
||||
func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
if target != "" {
|
||||
@@ -1055,7 +1098,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
@@ -1101,10 +1144,11 @@ func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
|
||||
// TestRoute asks the running core which outbound its router picks for the
|
||||
// described connection.
|
||||
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return nil, errors.New("xray is not running")
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
@@ -1130,23 +1174,24 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.IsXrayRunning() {
|
||||
configUnchanged := p.GetConfig().Equals(xrayConfig)
|
||||
process := currentXrayProcess()
|
||||
if process != nil && process.IsRunning() {
|
||||
configUnchanged := process.GetConfig().Equals(xrayConfig)
|
||||
if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
|
||||
logger.Debug("It does not need to restart Xray")
|
||||
return nil
|
||||
}
|
||||
if !isForce && !configUnchanged && s.tryHotApply(xrayConfig) {
|
||||
if !isForce && !configUnchanged && s.tryHotApply(process, xrayConfig) {
|
||||
logger.Info("Xray config changes applied through the core API, no restart needed")
|
||||
return nil
|
||||
}
|
||||
_ = p.Stop()
|
||||
_ = process.Stop()
|
||||
}
|
||||
|
||||
p = xray.NewProcess(xrayConfig)
|
||||
result = ""
|
||||
process = xray.NewProcess(xrayConfig)
|
||||
xrayState.replace(process)
|
||||
s.xrayAPI.StatsLastValues = nil
|
||||
err = p.Start()
|
||||
err = process.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1160,19 +1205,19 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
// instance now matches newCfg; on any failure it returns false and the
|
||||
// caller falls back to a full process restart, which cleans up whatever was
|
||||
// partially applied. Callers must hold the package-level lock.
|
||||
func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
oldCfg := p.GetConfig()
|
||||
func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bool {
|
||||
oldCfg := process.GetConfig()
|
||||
diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok {
|
||||
logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
|
||||
return false
|
||||
}
|
||||
if diff.Empty() {
|
||||
p.SetConfig(newCfg)
|
||||
process.SetConfig(newCfg)
|
||||
return true
|
||||
}
|
||||
|
||||
apiPort := p.GetAPIPort()
|
||||
apiPort := process.GetAPIPort()
|
||||
if apiPort <= 0 {
|
||||
return false
|
||||
}
|
||||
@@ -1230,7 +1275,7 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
}
|
||||
}
|
||||
|
||||
p.SetConfig(newCfg)
|
||||
process.SetConfig(newCfg)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1291,8 +1336,9 @@ func (s *XrayService) StopXray() error {
|
||||
defer lock.Unlock()
|
||||
isManuallyStopped.Store(true)
|
||||
logger.Debug("Attempting to stop Xray...")
|
||||
if s.IsXrayRunning() {
|
||||
return p.Stop()
|
||||
process := currentXrayProcess()
|
||||
if process != nil && process.IsRunning() {
|
||||
return process.Stop()
|
||||
}
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
@@ -1304,14 +1350,14 @@ func (s *XrayService) SetToNeedRestart() {
|
||||
|
||||
// GetXrayAPIPort returns the port the local xray process is listening on
|
||||
// for its gRPC HandlerService, or 0 when xray isn't currently running.
|
||||
// Exposed for the runtime package's LocalRuntime adapter — runtime can't
|
||||
// reach into the package-level `p` directly without a service-package
|
||||
// import cycle.
|
||||
// Exposed for the runtime package's LocalRuntime adapter without a
|
||||
// service-package import cycle.
|
||||
func (s *XrayService) GetXrayAPIPort() int {
|
||||
if p == nil || !p.IsRunning() {
|
||||
process := currentXrayProcess()
|
||||
if process == nil || !process.IsRunning() {
|
||||
return 0
|
||||
}
|
||||
return p.GetAPIPort()
|
||||
return process.GetAPIPort()
|
||||
}
|
||||
|
||||
// IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) {
|
||||
t.Helper()
|
||||
setupSettingTestDB(t)
|
||||
db := database.GetDB()
|
||||
in := &model.Inbound{
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Port: port,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[],"decryption":"none"}`,
|
||||
}
|
||||
if err := db.Create(in).Error; err != nil {
|
||||
t.Fatalf("create vless inbound: %v", err)
|
||||
}
|
||||
svc := ClientService{}
|
||||
if err := svc.SyncInbound(nil, in.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func disableClients(t *testing.T, emails ...string) {
|
||||
t.Helper()
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("email IN ?", emails).
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable clients: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func emittedClients(t *testing.T, tag string) (any, bool) {
|
||||
t.Helper()
|
||||
svc := &XrayService{}
|
||||
cfg, err := svc.GetXrayConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfig: %v", err)
|
||||
}
|
||||
for i := range cfg.InboundConfigs {
|
||||
if cfg.InboundConfigs[i].Tag != tag {
|
||||
continue
|
||||
}
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal([]byte(cfg.InboundConfigs[i].Settings), &s); err != nil {
|
||||
t.Fatalf("unmarshal emitted settings: %v", err)
|
||||
}
|
||||
v, ok := s["clients"]
|
||||
return v, ok
|
||||
}
|
||||
t.Fatalf("inbound %q not found in generated config", tag)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func TestGetXrayConfig_EmptyClientListIsArrayNotNull(t *testing.T) {
|
||||
seedVlessInbound(t, "vless-empty", 43101, []model.Client{
|
||||
{Email: "gone@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
|
||||
})
|
||||
disableClients(t, "gone@x")
|
||||
|
||||
value, present := emittedClients(t, "vless-empty")
|
||||
if !present {
|
||||
t.Fatal("the clients key must be present on a vless inbound")
|
||||
}
|
||||
if value == nil {
|
||||
t.Fatal(`settings.clients must be [] when every client is filtered out, got null`)
|
||||
}
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("settings.clients must be an array, got %T", value)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("a disabled client must not reach the config, got %d entries", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetXrayConfig_EnabledClientsStillEmitted(t *testing.T) {
|
||||
seedVlessInbound(t, "vless-mixed", 43102, []model.Client{
|
||||
{Email: "live@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
|
||||
{Email: "off@x", ID: "33333333-3333-3333-3333-333333333333", Enable: true},
|
||||
})
|
||||
disableClients(t, "off@x")
|
||||
|
||||
value, _ := emittedClients(t, "vless-mixed")
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("settings.clients must be an array, got %T", value)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected only the enabled client, got %d entries: %#v", len(list), list)
|
||||
}
|
||||
entry, ok := list[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("client entry must be an object, got %T", list[0])
|
||||
}
|
||||
if entry["email"] != "live@x" {
|
||||
t.Errorf("wrong client emitted: %#v", entry)
|
||||
}
|
||||
if entry["id"] != "22222222-2222-2222-2222-222222222222" {
|
||||
t.Errorf("client id not carried through: %#v", entry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func TestXrayLifecycleSnapshotDoesNotOverwriteNewerResult(t *testing.T) {
|
||||
state := xrayLifecycle{}
|
||||
first := xray.NewProcess(&xray.Config{})
|
||||
second := xray.NewProcess(&xray.Config{})
|
||||
|
||||
state.replace(first)
|
||||
state.storeResult(first, "first result")
|
||||
process, result := state.snapshot()
|
||||
if process != first || result != "first result" {
|
||||
t.Fatalf("snapshot = (%p, %q), want (%p, %q)", process, result, first, "first result")
|
||||
}
|
||||
state.replace(second)
|
||||
state.storeResult(first, "old result")
|
||||
|
||||
process, result = state.snapshot()
|
||||
if process != second {
|
||||
t.Fatal("snapshot returned the replaced process")
|
||||
}
|
||||
if result != "" {
|
||||
t.Fatalf("snapshot result = %q, want empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXrayLifecycleConcurrentStatusResultAndTrafficReads(t *testing.T) {
|
||||
previousProcess, previousResult := xrayState.snapshot()
|
||||
t.Cleanup(func() {
|
||||
xrayState.mu.Lock()
|
||||
xrayState.process = previousProcess
|
||||
xrayState.result = previousResult
|
||||
xrayState.mu.Unlock()
|
||||
})
|
||||
|
||||
first := xray.NewProcess(&xray.Config{})
|
||||
second := xray.NewProcess(&xray.Config{})
|
||||
service := XrayService{}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
for range 200 {
|
||||
xrayState.replace(first)
|
||||
xrayState.replace(second)
|
||||
}
|
||||
})
|
||||
|
||||
for range 4 {
|
||||
wg.Go(func() {
|
||||
for range 200 {
|
||||
_ = service.IsXrayRunning()
|
||||
_ = service.GetXrayResult()
|
||||
_, _, err := service.GetXrayTraffic()
|
||||
if err == nil || err.Error() != "xray is not running" {
|
||||
t.Errorf("GetXrayTraffic error = %v, want xray is not running", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -62,8 +62,8 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
return common.NewError("xray template config invalid: outbounds is not an array:", err)
|
||||
}
|
||||
coreVersion := "Unknown"
|
||||
if p != nil {
|
||||
coreVersion = p.GetXrayVersion()
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
coreVersion = process.GetXrayVersion()
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "اتنسخ بنجاح",
|
||||
"sure": "متأكد؟",
|
||||
"encryption": "تشفير",
|
||||
"useIPv4ForHost": "استخدم IPv4 للمضيف",
|
||||
"transmission": "نقل",
|
||||
"host": "المضيف",
|
||||
"path": "المسار",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "الكود",
|
||||
"remained": "المتبقي",
|
||||
"security": "أمان",
|
||||
"secAlertTitle": "تنبيه أمني",
|
||||
"secAlertSsl": "الاتصال ده مش آمن. ابعد عن إدخال معلومات حساسة لغاية ما تشغل TLS لحماية البيانات.",
|
||||
"secAlertConf": "بعض الإعدادات معرضة لهجمات. ينصح بتعزيز بروتوكولات الأمان عشان تمنع الاختراقات المحتملة.",
|
||||
"secAlertSSL": "البانل مش مؤمن. حمّل شهادة TLS لحماية البيانات.",
|
||||
"secAlertPanelPort": "بورت البانل الافتراضي معرض للخطر. ياريت تغير لبورت عشوائي أو محدد.",
|
||||
"secAlertPanelURI": "مسار URI الافتراضي للبانل مش آمن. ياريت تضبط مسار URI معقد.",
|
||||
"secAlertSubURI": "مسار URI الافتراضي للاشتراك مش آمن. ياريت تضبط مسار URI معقد.",
|
||||
"secAlertSubJsonURI": "مسار URI الافتراضي لاشتراك JSON مش آمن. ياريت تضبط مسار URI معقد.",
|
||||
"emptyDnsDesc": "مفيش سيرفر DNS مضاف.",
|
||||
"emptyFakeDnsDesc": "مفيش سيرفر Fake DNS مضاف.",
|
||||
"emptyBalancersDesc": "مفيش موازن تحميل مضاف.",
|
||||
"emptyReverseDesc": "مفيش بروكسي عكسي مضاف.",
|
||||
"somethingWentWrong": "حدث خطأ ما",
|
||||
"subscription": {
|
||||
"title": "معلومات الاشتراك",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "الثيم",
|
||||
"dark": "داكن",
|
||||
"ultraDark": "داكن جدًا",
|
||||
"dashboard": "نظرة عامة",
|
||||
"inbounds": "الواردات",
|
||||
"clients": "العملاء",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "التوجيه",
|
||||
"outbounds": "الصادرات",
|
||||
"apiDocs": "توثيق API",
|
||||
"logout": "تسجيل خروج",
|
||||
"link": "إدارة",
|
||||
"donate": "تبرع",
|
||||
"hosts": "المضيفات",
|
||||
"docs": "التوثيق",
|
||||
"openMenu": "فتح القائمة"
|
||||
"openMenu": "فتح القائمة",
|
||||
"pinSidebar": "تثبيت الشريط الجانبي",
|
||||
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "نظرة عامة",
|
||||
"cpu": "المعالج",
|
||||
"logicalProcessors": "المعالجات المنطقية",
|
||||
"frequency": "التردد",
|
||||
"swap": "التبديل",
|
||||
"storage": "تخزين",
|
||||
"memory": "الذاكرة",
|
||||
"threads": "خيوط",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "إيقاف",
|
||||
"restartXray": "إعادة تشغيل",
|
||||
"xraySwitch": "النسخة",
|
||||
"xrayUpdates": "تحديثات Xray",
|
||||
"xraySwitchClick": "اختار النسخة اللي عايز تتحول لها.",
|
||||
"xraySwitchClickDesk": "اختار بحذر، النسخ القديمة ممكن ما تتوافقش مع الإعدادات الحالية.",
|
||||
"updatePanel": "تحديث البانل",
|
||||
"panelUpdateDesc": "ده هيحدث 3X-UI لآخر إصدار وهيعيد تشغيل خدمة البانل.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "الكومِت الحالي",
|
||||
"latestCommit": "أحدث كومِت",
|
||||
"updateChannelChanged": "تم تغيير قناة التحديث",
|
||||
"upToDate": "محدث",
|
||||
"xrayStatusUnknown": "مش معروف",
|
||||
"xrayStatusRunning": "شغالة",
|
||||
"xrayStatusStop": "متوقفة",
|
||||
"xrayStatusError": "خطأ",
|
||||
"xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
|
||||
"operationHours": "مدة التشغيل",
|
||||
"systemHistoryTitle": "تاريخ النظام",
|
||||
"historyTitleCpu": "استخدام المعالج",
|
||||
"historyTitleMem": "استخدام الذاكرة",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "الحِمل",
|
||||
"historyTabConnections": "الاتصالات",
|
||||
"historyTabDiskUsage": "استخدام القرص",
|
||||
"charts": "الرسوم البيانية",
|
||||
"xrayMetricsTitle": "مقاييس Xray",
|
||||
"xrayTitleHeap": "ذاكرة الكومة المخصصة",
|
||||
"xrayTitleSys": "الذاكرة المحجوزة من نظام التشغيل",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "غير متصل",
|
||||
"xrayObservatoryLastSeen": "آخر مشاهدة",
|
||||
"xrayObservatoryLastTry": "آخر محاولة",
|
||||
"trendLast2Min": "آخر دقيقتين",
|
||||
"systemLoad": "تحميل النظام",
|
||||
"systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15",
|
||||
"connectionCount": "إحصائيات الاتصال",
|
||||
"ipAddresses": "عناوين IP",
|
||||
"toggleIpVisibility": "بدل إظهار IP",
|
||||
"overallSpeed": "السرعة الكلية",
|
||||
"upload": "رفع",
|
||||
"download": "تنزيل",
|
||||
"totalData": "إجمالي البيانات",
|
||||
"sent": "مرسل",
|
||||
"received": "مستقبل",
|
||||
"documentation": "التوثيق",
|
||||
"xraySwitchVersionDialog": "هل تريد حقًا تغيير إصدار Xray؟",
|
||||
"xraySwitchVersionDialogDesc": "سيؤدي هذا إلى تغيير إصدار Xray إلى #version#.",
|
||||
"xraySwitchVersionPopover": "تم تحديث Xray بنجاح",
|
||||
"panelUpdateDialog": "هل فعلاً عايز تحدث البانل؟",
|
||||
"panelUpdateDialogDesc": "ده هيحدث 3X-UI للإصدار #version# وهيعيد تشغيل البانل.",
|
||||
"panelUpdateCheckPopover": "فشل التحقق من تحديث البانل",
|
||||
"panelUpdateStartedPopover": "بدأ تحديث البانل",
|
||||
"panelUpdateFailedTitle": "فشل تحديث البانل",
|
||||
"panelUpdateFailedDesc": "لم يكتمل التحديث بنجاح. تحقق من سجلات الخادم، أو نفّذ الأمر «x-ui update» من سطر الأوامر.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "سجلات الوصول",
|
||||
"autoUpdate": "تحديث تلقائي",
|
||||
"config": "الإعدادات",
|
||||
"backup": "نسخ احتياطي",
|
||||
"backupTitle": "نسخ احتياطي واستعادة",
|
||||
"exportDatabase": "اخزن نسخة",
|
||||
"exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك. نفس الملف ممكن كمان يترجع على لوحة شغالة بـ PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
|
||||
"importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
|
||||
"migrationDownload": "تنزيل ملف الترحيل",
|
||||
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
|
||||
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite.",
|
||||
"avg": "متوسط",
|
||||
"peak": "ذروة",
|
||||
"free": "متاح",
|
||||
"openSockets": "مقابس مفتوحة",
|
||||
"throughputSub": "إجمالي الواجهة",
|
||||
"avgWindow": "متوسط الفترة",
|
||||
"healthWarm": "{list} — مرتفع قليلاً",
|
||||
"healthCritical": "{list} — حرج",
|
||||
"panel": "اللوحة",
|
||||
"threads": "الخيوط",
|
||||
"uptime": "مدة التشغيل"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "الواردات",
|
||||
"totalDownUp": "إجمالي المرسل/المستقبل",
|
||||
"totalUsage": "إجمالي الاستخدام",
|
||||
"inboundCount": "عدد الإدخالات",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "بانل محلي",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى مكان آخر. اختر inbound فرعيًا أدناه لملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقله، أو اترك القائمة فارغة واضبط Dest مباشرةً (مثل 8080 أو 127.0.0.1:8080) للتوجيه إلى خادم خارجي مثل Nginx. يجب أن يستمع كل inbound فرعي على 127.0.0.1 مع security=none.",
|
||||
"empty": "لا توجد fallbacks بعد",
|
||||
"add": "إضافة fallback",
|
||||
"pickInbound": "اختر inbound",
|
||||
"matchAny": "أي",
|
||||
"destPlaceholder": "تلقائي (listen:port للفرع)",
|
||||
"rederive": "إعادة الملء من الفرع",
|
||||
"rederived": "تم إعادة الملء من الفرع",
|
||||
"editAdvanced": "تحرير حقول التوجيه",
|
||||
"hideAdvanced": "إخفاء المتقدم",
|
||||
"quickAddAll": "إضافة سريعة لكل الـ inbounds المؤهلة",
|
||||
"quickAdded": "تمت إضافة {n} fallback",
|
||||
"quickAddedNone": "لا توجد inbounds جديدة مؤهلة للإضافة",
|
||||
"routesWhen": "يوجَّه عندما",
|
||||
"defaultCatchAll": "افتراضي — يلتقط أي شيء آخر",
|
||||
"needsTls": "تصبح الـ Fallbacks متاحة بعد اختيار TLS أو Reality في تبويب الأمان (فقط VLESS/Trojan عبر RAW)."
|
||||
},
|
||||
"protocol": "بروتوكول",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "تعيين المنفذ",
|
||||
"traffic": "حركة المرور",
|
||||
"speed": "السرعة",
|
||||
"details": "تفاصيل",
|
||||
"transportConfig": "النقل",
|
||||
"expireDate": "المدة",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"updatedAt": "تاريخ التحديث",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "أضف إدخال",
|
||||
"generalActions": "إجراءات عامة",
|
||||
"modifyInbound": "تعديل الإدخال",
|
||||
"deleteInbound": "حذف الإدخال",
|
||||
"deleteInboundContent": "متأكد إنك عايز تحذف الإدخال؟",
|
||||
"deleteConfirmTitle": "حذف الإدخال \"{remark}\"؟",
|
||||
"deleteConfirmContent": "سيؤدي هذا إلى إزالة الإدخال وجميع عملائه. لا يمكن التراجع.",
|
||||
"resetConfirmTitle": "إعادة تعيين ترافيك \"{remark}\"؟",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "جميع-الواردات",
|
||||
"exportAllSubsFileName": "جميع-الواردات-Subs",
|
||||
"inboundJsonTitle": "JSON الوارد",
|
||||
"deleteClient": "حذف العميل",
|
||||
"deleteClientContent": "متأكد إنك عايز تحذف العميل؟",
|
||||
"resetTrafficContent": "متأكد إنك عايز تعيد ضبط الترافيك؟",
|
||||
"copyLink": "انسخ الرابط",
|
||||
"address": "العنوان",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= غير محدود. (الوحدة: GB)",
|
||||
"totalFlow": "إجمالي التدفق",
|
||||
"leaveBlankToNeverExpire": "سيبها فاضية عشان ماتنتهيش",
|
||||
"noRecommendKeepDefault": "ننصح باستخدام الافتراضي",
|
||||
"certificatePath": "مسار الملف",
|
||||
"certificateContent": "محتوى الملف",
|
||||
"publicKey": "المفتاح العام",
|
||||
"privatekey": "المفتاح الخاص",
|
||||
"clickOnQRcode": "اضغط على كود QR للنسخ",
|
||||
"client": "عميل",
|
||||
"export": "تصدير كل الروابط",
|
||||
"clone": "استنساخ",
|
||||
"cloneInbound": "استنساخ الإدخال",
|
||||
"cloneInboundContent": "كل إعدادات الإدخال ده، غير البورت، IP الاستماع، والعملاء، هتتطبق على الاستنساخ.",
|
||||
"cloneInboundOk": "استنساخ",
|
||||
"resetAllTraffic": "إعادة ضبط ترافيك كل الإدخالات",
|
||||
"resetAllTrafficTitle": "إعادة ضبط ترافيك كل الإدخالات",
|
||||
"resetAllTrafficContent": "متأكد إنك عايز تعيد ضبط الترافيك لكل الإدخالات؟",
|
||||
"resetInboundClientTraffics": "إعادة ضبط ترافيك العملاء",
|
||||
"resetInboundClientTrafficTitle": "إعادة ضبط ترافيك العملاء",
|
||||
"resetInboundClientTrafficContent": "متأكد إنك عايز تعيد ضبط ترافيك عملاء الإدخال ده؟",
|
||||
"resetAllClientTraffics": "إعادة ضبط ترافيك كل العملاء",
|
||||
"resetAllClientTrafficTitle": "إعادة ضبط ترافيك كل العملاء",
|
||||
"resetAllClientTrafficContent": "متأكد إنك عايز تعيد ضبط ترافيك كل العملاء؟",
|
||||
"delDepletedClients": "حذف العملاء اللي خلصت",
|
||||
"delDepletedClientsTitle": "حذف العملاء اللي خلصت",
|
||||
"delDepletedClientsContent": "متأكد إنك عايز تحذف كل العملاء اللي خلصت؟",
|
||||
"email": "البريد",
|
||||
"emailDesc": "ادخل إيميل فريد.",
|
||||
"IPLimit": "تحديد IP",
|
||||
"IPLimitDesc": "بيعطل الإدخال لو العدد زاد عن القيمة المحددة. (0 = تعطيل)",
|
||||
"IPLimitlog": "سجل IP",
|
||||
"IPLimitlogDesc": "سجل تاريخ الـ IPs. (عشان تفعل الإدخال بعد التعطيل، امسح السجل)",
|
||||
"IPLimitlogclear": "امسح السجل",
|
||||
"setDefaultCert": "استخدم شهادة البانل",
|
||||
"setDefaultCertEmpty": "لا توجد شهادة معدّة للوحة. عينّ واحدة من الإعدادات أولاً.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "غلاف كتلة sniffing في Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "غلاف كتلة stream في Xray:",
|
||||
"jsonErrorPrefix": "JSON متقدم"
|
||||
"streamHelp": "غلاف كتلة stream في Xray:"
|
||||
},
|
||||
"telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
|
||||
"subSortIndex": "ترتيب الاشتراك",
|
||||
"same": "نفسه",
|
||||
"inboundInfo": "معلومات الإدخال",
|
||||
"exportInbound": "تصدير الإدخال",
|
||||
"import": "استيراد",
|
||||
"importInbound": "استيراد إدخال",
|
||||
"periodicTrafficResetTitle": "إعادة تعيين حركة المرور",
|
||||
"periodicTrafficResetDesc": "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة",
|
||||
"lastReset": "آخر إعادة تعيين",
|
||||
"periodicTrafficResetDay": "يوم إعادة التعيين الشهري",
|
||||
"periodicTrafficReset": {
|
||||
"never": "أبداً",
|
||||
"daily": "يومياً",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "تم الحصول عليه",
|
||||
"updateSuccess": "تم التحديث بنجاح",
|
||||
"logCleanSuccess": "تم مسح السجل",
|
||||
"inboundsUpdateSuccess": "تم تحديث الواردات بنجاح",
|
||||
"inboundUpdateSuccess": "تم تحديث الوارد بنجاح",
|
||||
"inboundCreateSuccess": "تم إنشاء الوارد بنجاح",
|
||||
"bulkDeleted": "تم حذف {count} إدخال",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "تم حذف عميل وارد",
|
||||
"inboundClientUpdateSuccess": "تم تحديث عميل وارد",
|
||||
"savedNodeOfflineWillSync": "تم الحفظ محليًا. إحدى العُقد الداعمة غير متصلة أو معطّلة — ستتم مزامنة التغيير بمجرد إعادة الاتصال.",
|
||||
"delDepletedClientsSuccess": "تم حذف جميع العملاء المستنفذين",
|
||||
"resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل",
|
||||
"resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور",
|
||||
"resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور",
|
||||
@@ -636,6 +578,10 @@
|
||||
"maxTimeDiff": "أقصى فرق زمن (ms)",
|
||||
"minClientVer": "أدنى إصدار للعميل",
|
||||
"maxClientVer": "أقصى إصدار للعميل",
|
||||
"minClientVerHint": "تركه فارغًا لا يعني بلا قيود: سيفرض Xray-core الحد الأدنى المدمج في إصدار النواة الذي تشغّله (26.3.27 في الإصدارات الحالية) ويرفض العملاء الذين يبلغون عن إصدار أقدم — بما في ذلك النوى الخارجية مثل Mihomo و sing-box. القيمة 1.0.0 تقبلها، مقابل السماح ببصمات TLS قديمة.",
|
||||
"maxClientVerHint": "تركه فارغًا يعني بلا حد أقصى. إذا عُيّن، يجب ألا يقل عن الحد الأدنى الفعلي — أدنى إصدار للعميل، أو الحد الأدنى المدمج في Xray-core عندما يكون ذلك الحقل فارغًا — وإلا سيُرفض جميع العملاء.",
|
||||
"clientVerInvalid": "يجب أن يتكون إصدار العميل من ثلاثة أرقام كحد أقصى مفصولة بنقاط، كل منها 0-255 (مثل 26.3.27)",
|
||||
"maxClientVerBelowMin": "أقصى إصدار للعميل يجب ألا يقل عن أدنى إصدار للعميل",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "مطلوب. يجب أن يتضمّن منفذًا (مثل example.com:443). بدون منفذ يرفض Xray-core البدء.",
|
||||
"realityTargetRequired": "هدف REALITY مطلوب",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "تكوين Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "طلب",
|
||||
"response": "رد",
|
||||
"name": "اسم",
|
||||
"value": "قيمة"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "نسخة",
|
||||
"method": "طريقة",
|
||||
"path": "المسار",
|
||||
"status": "الحالة",
|
||||
"statusDescription": "وصف الحالة",
|
||||
"requestHeader": "رأس الطلب",
|
||||
"responseHeader": "رأس الرد"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "تجاوز الوجهة"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "إضافة اشتراك خارجي",
|
||||
"noExternalLinks": "لا توجد روابط خارجية بعد.",
|
||||
"noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
|
||||
"add": "إضافة عميل",
|
||||
"edit": "تعديل العميل",
|
||||
"submitAdd": "إضافة عميل",
|
||||
"submitEdit": "حفظ التغييرات",
|
||||
"clientCount": "عدد العملاء",
|
||||
"bulk": "إضافة مجمعة",
|
||||
"copyFromInbound": "نسخ العملاء من الاتصال الوارد",
|
||||
"copyToInbound": "نسخ العملاء إلى",
|
||||
"copySelected": "نسخ المحدد",
|
||||
"copySource": "المصدر",
|
||||
"copyEmailPreview": "معاينة البريد الناتج",
|
||||
"copySelectSourceFirst": "يرجى تحديد اتصال وارد مصدر أولاً.",
|
||||
"copyResult": "نتيجة النسخ",
|
||||
"copyResultSuccess": "تم النسخ بنجاح",
|
||||
"copyResultNone": "لا شيء للنسخ: لم يتم تحديد عملاء أو أن المصدر فارغ",
|
||||
"copyResultErrors": "أخطاء النسخ",
|
||||
"copyFlowLabel": "Flow للعملاء الجدد (VLESS)",
|
||||
"copyFlowHint": "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغًا للتخطي.",
|
||||
"selectAll": "تحديد الكل",
|
||||
"clearAll": "مسح الكل",
|
||||
"method": "الطريقة",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "لاحقة",
|
||||
"delayedStart": "البدء بعد أول استخدام",
|
||||
"expireDays": "المدة (أيام)",
|
||||
"days": "يوم",
|
||||
"renew": "تجديد تلقائي",
|
||||
"renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل) (الوحدة: يوم)",
|
||||
"renewDays": "تجديد تلقائي (أيام)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "الأقرب انتهاءً",
|
||||
"has": "يملك",
|
||||
"hasNot": "لا يملك",
|
||||
"title": "العملاء",
|
||||
"actions": "الإجراءات",
|
||||
"totalGB": "حد البيانات (جيجابايت)",
|
||||
"totalGBDesc": "حصة البيانات لهذا العميل. 0 = غير محدود.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "إضافة عميل",
|
||||
"qrCode": "رمز QR",
|
||||
"clientInfo": "معلومات العميل",
|
||||
"delete": "حذف",
|
||||
"reset": "إعادة ضبط حركة المرور",
|
||||
"editClient": "تعديل العميل",
|
||||
"client": "العميل",
|
||||
"enabled": "مفعّل",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.",
|
||||
"link": "الرابط",
|
||||
"resetNotPossible": "قم بإرفاق هذا العميل بأحد الاتصالات الواردة أولاً.",
|
||||
"general": "عام",
|
||||
"resetAllTraffics": "إعادة ضبط حركة مرور كل العملاء",
|
||||
"resetAllTrafficsTitle": "إعادة ضبط حركة مرور كل العملاء؟",
|
||||
"resetAllTrafficsContent": "يُعاد ضبط عدّاد الإرسال/الاستقبال لكل عميل إلى الصفر. لا تتأثر الحصص ومواعيد الانتهاء. لا يمكن التراجع.",
|
||||
"deleteConfirmTitle": "حذف العميل {email}؟",
|
||||
"deleteConfirmContent": "سيؤدي هذا إلى إزالة العميل من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.",
|
||||
"deleteSelected": "حذف ({count})",
|
||||
"adjustSelected": "تعديل ({count})",
|
||||
"subLinksSelected": "روابط الاشتراك ({count})",
|
||||
"addToGroupTitle": "إضافة {count} عميل إلى مجموعة",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "تعطيل {count} عميل؟",
|
||||
"bulkDisableConfirmContent": "يُعطّل كل عميل محدد على جميع الإدخالات المرفقة. يفقدون الوصول فورًا لكن تُحفَظ سجلاتهم وحركة بياناتهم.",
|
||||
"selectedCount": "{count} محدد",
|
||||
"attachSelected": "إرفاق ({count})",
|
||||
"attachToInboundsTitle": "إرفاق {count} عميل بالواردات",
|
||||
"attachToInboundsDesc": "يربط {count} عميل المحدد (نفس UUID/كلمة المرور والمرور المشترك) بالواردات المختارة. يحتفظون بارتباطاتهم الحالية.",
|
||||
"attachToInboundsTargets": "الواردات الهدف",
|
||||
"attachToInboundsNoTargets": "لا توجد واردات متعددة المستخدمين للارتباط.",
|
||||
"detachSelected": "فصل ({count})",
|
||||
"detach": "فصل",
|
||||
"detachFromInboundsTitle": "فصل {count} عميل من الواردات",
|
||||
"detachFromInboundsDesc": "يزيل {count} عميل المحدد من الواردات المختارة. الأزواج التي لم يكن العميل مرتبطاً بها يتم تخطيها بصمت. تُحفظ سجلات العملاء (استخدم Delete للإزالة الكاملة).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Reverse tag اختياري",
|
||||
"telegramId": "معرّف مستخدم تلغرام",
|
||||
"telegramIdPlaceholder": "معرّف مستخدم تلغرام رقمي (0 = لا شيء)",
|
||||
"created": "تاريخ الإنشاء",
|
||||
"updated": "تاريخ التحديث",
|
||||
"ipLimit": "حد IP",
|
||||
"toasts": {
|
||||
"deleted": "تم حذف العميل",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "المجموعات",
|
||||
"name": "الاسم",
|
||||
"clientCount": "العملاء",
|
||||
"totalGroups": "إجمالي المجموعات",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "تمت إزالة {count} عميل من {name}."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "النودز",
|
||||
"addNode": "إضافة نود",
|
||||
"editNode": "تحرير العقدة",
|
||||
"totalNodes": "إجمالي النودز",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد",
|
||||
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في المصادقة → توكن API.",
|
||||
"apiTokenKeepHint": "اتركه فارغًا للإبقاء على التوكن الحالي",
|
||||
"regenerate": "تجديد التوكن",
|
||||
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
|
||||
"allowPrivateAddress": "السماح بالعنوان الخاص",
|
||||
"allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
|
||||
"outboundTag": "اتصال صادر",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "تحديث اللوحة",
|
||||
"updateSelected": "تحديث المحدد ({count})",
|
||||
"updateAvailable": "تحديث متاح",
|
||||
"upToDate": "محدّث",
|
||||
"updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
|
||||
"updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
|
||||
"updateDevChannel": "التحديث إلى قناة التطوير (أحدث كومِت)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "تعذّر جلب الشهادة"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "افتراضي",
|
||||
"title": "إعدادات البانل",
|
||||
"save": "حفظ",
|
||||
"infoDesc": "كل تغيير هتعمله هنا لازم يتخزن. ياريت تعيد تشغيل البانل عشان التعديلات تتفعل.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
|
||||
"remarkTemplate": "قالب الملاحظة",
|
||||
"remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
|
||||
"subShowIdentityOnAllLinks": "إظهار الهوية في كل رابط",
|
||||
"subShowIdentityOnAllLinksDesc": "عند التفعيل، يبقى {{EMAIL}} و{{USERNAME}} في ملاحظة كل رابط في محتوى الاشتراك. تظل رموز الاستخدام في الرابط الأول فقط.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
|
||||
},
|
||||
@@ -1448,13 +1350,8 @@
|
||||
"secretClearUndo": "تراجع عن المسح"
|
||||
},
|
||||
"xray": {
|
||||
"title": "إعدادات Xray",
|
||||
"save": "احفظ",
|
||||
"restart": "إعادة تشغيل Xray",
|
||||
"restartSuccess": "تم إعادة تشغيل Xray بنجاح",
|
||||
"restartOutputTitle": "مخرجات إعادة تشغيل Xray",
|
||||
"restartConfirmTitle": "إعادة تشغيل xray؟",
|
||||
"restartConfirmContent": "يعيد تحميل خدمة xray بالتكوين المحفوظ.",
|
||||
"stopSuccess": "تم إيقاف Xray بنجاح",
|
||||
"restartError": "حدث خطأ أثناء إعادة تشغيل Xray.",
|
||||
"stopError": "حدث خطأ أثناء إيقاف Xray.",
|
||||
@@ -1464,7 +1361,6 @@
|
||||
"generalConfigsDesc": "الخيارات دي هتحدد التعديلات العامة.",
|
||||
"logConfigs": "السجل",
|
||||
"logConfigsDesc": "السجلات ممكن تأثر على كفاءة السيرفر. ننصح بتفعيلها بحكمة لما تكون محتاجها.",
|
||||
"blockConfigsDesc": "الخيارات دي هتحجب الترافيك بناءً على بروتوكولات ومواقع محددة.",
|
||||
"basicRouting": "توجيه أساسي",
|
||||
"blockConnectionsConfigsDesc": "الخيارات دي هتحجب الترافيك بناءً على الدولة المطلوبة.",
|
||||
"directConnectionsConfigsDesc": "الاتصال المباشر بيضمن إن الترافيك المعين مايمرش من سيرفر تاني.",
|
||||
@@ -1474,10 +1370,6 @@
|
||||
"directdomains": "اتصالات مباشرة للدومينات",
|
||||
"ipv4Routing": "توجيه IPv4",
|
||||
"ipv4RoutingDesc": "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر IPv4.",
|
||||
"warpRouting": "توجيه WARP",
|
||||
"warpRoutingDesc": "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر WARP.",
|
||||
"nordRouting": "توجيه NordVPN",
|
||||
"nordRoutingDesc": "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر NordVPN.",
|
||||
"Template": "قالب إعدادات Xray المتقدم",
|
||||
"TemplateDesc": "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده.",
|
||||
"FreedomStrategy": "استراتيجية بروتوكول الحرية",
|
||||
@@ -1491,10 +1383,7 @@
|
||||
"outboundTestUrlDesc": "الرابط المستخدم عند اختبار اتصال المخرج",
|
||||
"Torrent": "حظر بروتوكول التورنت",
|
||||
"Inbounds": "الواردات",
|
||||
"InboundsDesc": "قبول العملاء المعينين.",
|
||||
"Outbounds": "الصادرات",
|
||||
"OutboundSubscriptions": "اشتراكات الصادرات",
|
||||
"OutboundSubscriptionsDesc": "استورد الصادرات من روابط اشتراك بعيدة (vmess/vless/trojan/ss/...). الوسوم بتفضل ثابتة عشان تستخدمها في موازنات التحميل وقواعد التوجيه. التحديثات بتتم تلقائياً.",
|
||||
"importRules": "استيراد القواعد",
|
||||
"exportRules": "تصدير القواعد",
|
||||
"importOutbounds": "استيراد الصادرات",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "الصادر المطابق",
|
||||
"routeTesterViaBalancer": "عبر موازن التحميل",
|
||||
"routeTesterDefaultOutbound": "ما في قاعدة توجيه اتطابقت — الترافيك رايح للصادر الافتراضي (الأول).",
|
||||
"OutboundsDesc": "حدد مسار الترافيك الصادر.",
|
||||
"Routings": "قواعد التوجيه",
|
||||
"RoutingsDesc": "أولوية كل قاعدة مهمة جداً!",
|
||||
"completeTemplate": "الكل",
|
||||
"logLevel": "مستوى السجلات",
|
||||
"logLevelDesc": "مستوى السجل الخاص بالأخطاء، اللي بيوضح المعلومات المطلوبة للتسجيل.",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "إخفاء عنوان الـ IP؛ لو مفعل، هيستبدل تلقائياً عنوان IP اللي بيظهر في السجل.",
|
||||
"statistics": "إحصائيات",
|
||||
"statsInboundUplink": "إحصائيات رفع الإدخال",
|
||||
"statsInboundUplinkDesc": "تفعيل جمع الإحصائيات لترافيك الرفع لكل بروكسي من الإدخالات.",
|
||||
"statsInboundDownlink": "إحصائيات تنزيل الإدخال",
|
||||
"statsInboundDownlinkDesc": "تفعيل جمع الإحصائيات لترافيك التنزيل لكل بروكسي من الإدخالات.",
|
||||
"statsOutboundUplink": "إحصائيات رفع المخرجات",
|
||||
"statsOutboundUplinkDesc": "تفعيل جمع الإحصائيات لترافيك الرفع لكل بروكسي من المخرجات.",
|
||||
"statsOutboundDownlink": "إحصائيات تنزيل المخرجات",
|
||||
"statsOutboundDownlinkDesc": "تفعيل جمع الإحصائيات لترافيك التنزيل لكل بروكسي من المخرجات.",
|
||||
"connectionLimits": "حدود الاتصال",
|
||||
"connectionLimitsDesc": "سياسات على مستوى الاتصال لمستوى المستخدم 0. اترك الحقل فارغًا لاستخدام القيمة الافتراضية لـ Xray.",
|
||||
"connIdle": "مهلة الخمول",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "تلقائي",
|
||||
"seconds": "ثانية",
|
||||
"rules": {
|
||||
"first": "أول",
|
||||
"last": "آخر",
|
||||
"up": "فوق",
|
||||
"down": "تحت",
|
||||
"source": "المصدر",
|
||||
"dest": "الوجهة",
|
||||
"inbound": "إدخال",
|
||||
"outbound": "مخرج",
|
||||
"balancer": "موازن",
|
||||
"info": "معلومات",
|
||||
"add": "أضف قاعدة",
|
||||
"edit": "عدل القاعدة",
|
||||
"useComma": "عناصر مفصولة بفواصل"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "القاعدة {n}",
|
||||
"action": "الإجراء",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "القواعد النهائية",
|
||||
"overrideXrayPrivateIp": "تجاوز حظر IP الخاص الافتراضي في Xray",
|
||||
"blockDelay": "تأخير الحظر (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "فاصل keep alive",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "الواجهة",
|
||||
"ipv6Only": "IPv6 فقط",
|
||||
"acceptProxyProtocol": "قبول proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (ثانية)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "أضف مخرج",
|
||||
"addReverse": "أضف عكسي",
|
||||
"editOutbound": "عدل المخرج",
|
||||
"editReverse": "عدل العكسي",
|
||||
"reverseTag": "وسم العكسي",
|
||||
"reverseTagDesc": "وسم الخروج لبروكسي VLESS العكسي البسيط. اتركه فارغاً لتعطيله.",
|
||||
"reverseTagPlaceholder": "وسم الخروج (اتركه فارغاً للتعطيل)",
|
||||
"tag": "الوسم",
|
||||
"tagDesc": "تاج فريد",
|
||||
"address": "العنوان",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "عكسي",
|
||||
"domain": "النطاق",
|
||||
"type": "النوع",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "الرابط",
|
||||
"intercon": "تواصل",
|
||||
"settings": "إعدادات",
|
||||
"accountInfo": "معلومات الحساب",
|
||||
"outboundStatus": "حالة المخرج",
|
||||
"sendThrough": "أرسل من خلال",
|
||||
"targetStrategy": "استراتيجية الوجهة",
|
||||
"test": "اختبار",
|
||||
"testResult": "نتيجة الاختبار",
|
||||
"testing": "جاري اختبار الاتصال...",
|
||||
"testSuccess": "الاختبار ناجح",
|
||||
"testFailed": "فشل الاختبار",
|
||||
"testError": "فشل اختبار المخرج",
|
||||
"modeRealDelay": "التأخير الفعلي",
|
||||
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray. التأخير الفعلي: الوقت الكامل شاملاً إنشاء الاتصال.",
|
||||
"testAll": "اختبار الكل",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "اتصال البروكسي",
|
||||
"breakdownTls": "TLS عبر الصادر",
|
||||
"breakdownTtfb": "أول بايت",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "رمز الوصول",
|
||||
"country": "الدولة",
|
||||
"server": "الخادم",
|
||||
"city": "المدينة",
|
||||
"allCities": "كل المدن",
|
||||
"privateKey": "المفتاح الخاص",
|
||||
"load": "الحمل",
|
||||
"moveToTop": "نقل إلى الأعلى"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "الاشتراكات النشطة",
|
||||
"empty": "مفيش اشتراكات لسه. أضف واحد من فوق.",
|
||||
"colRemark": "ملاحظة",
|
||||
"colPrefix": "بادئة",
|
||||
"colInterval": "الفاصل",
|
||||
"colLastFetch": "آخر جلب",
|
||||
"colEnabled": "مفعّل",
|
||||
"auto": "تلقائي",
|
||||
"never": "أبداً",
|
||||
"yes": "نعم",
|
||||
"no": "لا",
|
||||
"refreshNow": "حدّث الآن",
|
||||
"lastError": "آخر خطأ",
|
||||
"deleteConfirm": "تحذف الاشتراك ده؟",
|
||||
"restartHint": "بعد الإضافة أو التحديث، أعد تشغيل Xray (أو استنى إعادة التحميل التلقائي اللي جاية) عشان تفعّل الصادرات.",
|
||||
"fromSubsTitle": "من اشتراكات الصادرات (للقراءة فقط)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "إعدادات الموازن",
|
||||
"tabObservatory": "المرصد",
|
||||
"observatory": {
|
||||
"title": "المرصد",
|
||||
"burstTitle": "مرصد Burst",
|
||||
"autoManaged": "تتم إدارة المراصد تلقائيًا من الموازنات لديك. اضبط طريقة الفحص بالأسفل؛ تتبع الوجهات الصادرة المراقَبة محدِّدات الموازن.",
|
||||
"emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى تتمكن الموازنات المعتمدة على المرصد من فحص صحة الوجهات الصادرة قبل اختيار الهدف.",
|
||||
"mixedLegacy": "يحتوي هذا الإعداد على Observatory و Burst Observatory معًا. يستخدم Xray مرصدًا عالميًا واحدًا، لذلك هذه الحالة القديمة المختلطة غير مدعومة؛ حفظ الموازنات سيحوّلها إلى مرصد واحد.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "الموازن {tag} — أُزيل (لا توجد أهداف متبقية)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "أضف موازن تحميل",
|
||||
"editBalancer": "عدل موازن التحميل",
|
||||
"balancerStrategy": "استراتيجية الموازن",
|
||||
"balancerSelectors": "المحددات",
|
||||
"tag": "الوسم",
|
||||
"tagDesc": "تاج فريد",
|
||||
"tagDuplicate": "الوسم مستخدم بالفعل من قبل موازن آخر",
|
||||
"tagPlaceholder": "وسم موازن فريد",
|
||||
"selector": "المحدد",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "التحمل",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
|
||||
"costMatch": "نمط الوسم",
|
||||
"costValue": "الوزن",
|
||||
"costRegexp": "مطابقة تعبير نمطي",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "المفتاح العام",
|
||||
"allowedIPs": "عناوين IP المسموح بها",
|
||||
"endpoint": "النهاية",
|
||||
"psk": "المفتاح المشترك",
|
||||
"domainStrategy": "استراتيجية الدومين"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "خاص بـ AmneziaWG 2.0 فقط. اتركه فارغًا للتوافق مع الإصدار 1.x."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "اسم واجهة TUN. القيمة الافتراضية هي 'xray0'",
|
||||
"mtuDesc": "وحدة النقل الأقصى. الحد الأقصى لحجم حزم البيانات. القيمة الافتراضية هي 1500",
|
||||
"userLevel": "مستوى المستخدم",
|
||||
"userLevelDesc": "ستستخدم جميع الاتصالات المُرسلة عبر هذا الإدخال مستوى المستخدم هذا. القيمة الافتراضية هي 0"
|
||||
"userLevel": "مستوى المستخدم"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "أضف Fake DNS",
|
||||
"edit": "عدل Fake DNS",
|
||||
"ipPool": "نطاق IP Pool",
|
||||
"poolSize": "حجم المجموعة"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "إضافة",
|
||||
"month": "شهر",
|
||||
"months": "أشهر",
|
||||
"day": "يوم",
|
||||
"days": "أيام",
|
||||
"hours": "ساعات",
|
||||
"minutes": "دقائق",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ حفظت بيانات مستخدم Telegram.",
|
||||
"loginSuccess": "✅ تسجيل الدخول للبانل تم بنجاح.\r\n",
|
||||
"loginFailed": "❗️فشل محاولة تسجيل الدخول للبانل.\r\n",
|
||||
"2faFailed": "فشل 2FA",
|
||||
"report": "🕰 التقارير المجدولة: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ التاريخ والوقت: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 المضيف: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 التنزيل: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 الإجمالي: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 مستخدم Telegram: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 نفذ {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 عدد النفاذ لـ {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 العملاء الأونلاين: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 معطل: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 اتحدّث في: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ أيوه",
|
||||
"no": "❌ لا",
|
||||
"received_id": "🔑📥 الـ ID اتحدث.",
|
||||
"received_password": "🔑📥 الباسورد اتحدث.",
|
||||
"received_email": "📧📥 الإيميل اتحدث.",
|
||||
"received_comment": "💬📥 التعليق اتحدث.",
|
||||
"id_prompt": "🔑 الـ ID الافتراضي: {{ .ClientId }}\n\nادخل الـ ID بتاعك.",
|
||||
"pass_prompt": "🔑 الباسورد الافتراضي: {{ .ClientPassword }}\n\nادخل الباسورد بتاعك.",
|
||||
"email_prompt": "📧 الإيميل الافتراضي: {{ .ClientEmail }}\n\nادخل الإيميل بتاعك.",
|
||||
"comment_prompt": "💬 التعليق الافتراضي: {{ .ClientComment }}\n\nادخل تعليقك.",
|
||||
"inbound_client_data_id": "🔄 الدخول: {{ .InboundRemark }}\n\n🔑 المعرف: {{ .ClientId }}\n📧 البريد الإلكتروني: {{ .ClientEmail }}\n📊 الترافيك: {{ .ClientTraffic }}\n📅 تاريخ الانتهاء: {{ .ClientExp }}\n🌐 حدّ IP: {{ .IpLimit }}\n💬 تعليق: {{ .ClientComment }}\n\nدلوقتي تقدر تضيف العميل على الدخول!",
|
||||
"inbound_client_data_pass": "🔄 الدخول: {{ .InboundRemark }}\n\n🔑 كلمة المرور: {{ .ClientPass }}\n📧 البريد الإلكتروني: {{ .ClientEmail }}\n📊 الترافيك: {{ .ClientTraffic }}\n📅 تاريخ الانتهاء: {{ .ClientExp }}\n🌐 حدّ IP: {{ .IpLimit }}\n💬 تعليق: {{ .ClientComment }}\n\nدلوقتي تقدر تضيف العميل على الدخول!",
|
||||
"cancel": "❌ العملية اتلغت! \n\nممكن تبدأ من /start في أي وقت. 🔄",
|
||||
"error_add_client": "⚠️ خطأ:\n\n {{ .error }}",
|
||||
"using_default_value": "تمام، هشيل على القيمة الافتراضية. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "الخطأ: {{ .Error }}",
|
||||
"eventNodeDown": "العقدة {{ .Name }} غير متصلة",
|
||||
"eventNodeUp": "العقدة {{ .Name }} متصلة",
|
||||
"eventCPUHigh": "ارتفاع استخدام المعالج",
|
||||
"eventCPUHighDetail": "المعالج: {{ .Detail }}",
|
||||
"eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
|
||||
"memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "إرسال كمعطّل ☑️",
|
||||
"submitEnable": "إرسال كمفعّل ✅",
|
||||
"use_default": "🏷️ استخدام الإعدادات الافتراضية",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 كلمة السر",
|
||||
"change_email": "⚙️📧 البريد",
|
||||
"change_comment": "⚙️💬 تعليق",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "إعادة ضبط جميع الترافيك",
|
||||
"SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "الصادر {{ .Tag }} غير متصل",
|
||||
"subjectOutboundUp": "الصادر {{ .Tag }} متصل",
|
||||
"subjectXrayCrash": "تعطّل Xray",
|
||||
"subjectCPUHigh": "ارتفاع استخدام المعالج",
|
||||
"subjectLoginSuccess": "نجح تسجيل الدخول",
|
||||
"subjectLoginFailed": "فشل تسجيل الدخول",
|
||||
"titleOutboundDown": "الصادر غير متصل",
|
||||
"titleOutboundUp": "الصادر متصل",
|
||||
"titleXrayCrash": "تعطّل Xray",
|
||||
"titleCPUHigh": "ارتفاع استخدام المعالج",
|
||||
"titleLoginSuccess": "نجح تسجيل الدخول",
|
||||
"titleLoginFailed": "فشل تسجيل الدخول",
|
||||
"labelStatus": "الحالة",
|
||||
"labelOutbound": "الصادر",
|
||||
"labelNode": "العقدة",
|
||||
"labelError": "الخطأ",
|
||||
"labelDelay": "التأخير",
|
||||
"labelDetail": "التفاصيل",
|
||||
"labelUsername": "اسم المستخدم",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "السبب",
|
||||
"labelSource": "المصدر",
|
||||
"labelTime": "الوقت",
|
||||
"statusCrashed": "متعطّل",
|
||||
"statusRunning": "يعمل",
|
||||
"statusHigh": "مرتفع",
|
||||
"statusSuccess": "نجاح",
|
||||
"statusFailed": "فشل",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Copied successfully",
|
||||
"sure": "Sure",
|
||||
"encryption": "Encryption",
|
||||
"useIPv4ForHost": "Use IPv4 for host",
|
||||
"transmission": "Transmission",
|
||||
"host": "Host",
|
||||
"path": "Path",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Code",
|
||||
"remained": "Remaining",
|
||||
"security": "Security",
|
||||
"secAlertTitle": "Security Alert",
|
||||
"secAlertSsl": "This connection is not secure. Please avoid entering sensitive information until TLS is activated for data protection.",
|
||||
"secAlertConf": "Certain settings are vulnerable to attacks. It is recommended to reinforce security protocols to prevent potential breaches.",
|
||||
"secAlertSSL": "Panel lacks secure connection. Please install TLS certificate for data protection.",
|
||||
"secAlertPanelPort": "Panel default port is vulnerable. Please configure a random or specific port.",
|
||||
"secAlertPanelURI": "Panel default URI path is insecure. Please configure a complex URI path.",
|
||||
"secAlertSubURI": "Subscription default URI path is insecure. Please configure a complex URI path.",
|
||||
"secAlertSubJsonURI": "Subscription JSON default URI path is insecure. Please configure a complex URI path.",
|
||||
"emptyDnsDesc": "No added DNS servers.",
|
||||
"emptyFakeDnsDesc": "No added Fake DNS servers.",
|
||||
"emptyBalancersDesc": "No added balancers.",
|
||||
"emptyReverseDesc": "No added reverse proxies.",
|
||||
"somethingWentWrong": "Something went wrong",
|
||||
"subscription": {
|
||||
"title": "Subscription info",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Theme",
|
||||
"dark": "Dark",
|
||||
"ultraDark": "Ultra Dark",
|
||||
"dashboard": "Overview",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
@@ -119,11 +107,11 @@
|
||||
"routing": "Routing",
|
||||
"outbounds": "Outbounds",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Log Out",
|
||||
"link": "Manage",
|
||||
"donate": "Donate",
|
||||
"docs": "Documentation",
|
||||
"openMenu": "Open menu"
|
||||
"openMenu": "Open menu",
|
||||
"pinSidebar": "Pin sidebar",
|
||||
"unpinSidebar": "Unpin sidebar"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Overview",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "Logical Processors",
|
||||
"frequency": "Frequency",
|
||||
"swap": "Swap",
|
||||
"storage": "Storage",
|
||||
"memory": "RAM",
|
||||
"threads": "Threads",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Stop",
|
||||
"restartXray": "Restart",
|
||||
"xraySwitch": "Version",
|
||||
"xrayUpdates": "Xray Updates",
|
||||
"xraySwitchClick": "Choose the version you want to switch to.",
|
||||
"xraySwitchClickDesk": "Choose carefully, as older versions may not be compatible with current configurations.",
|
||||
"updatePanel": "Update Panel",
|
||||
"panelUpdateDesc": "This will update 3X-UI itself to the latest release and restart the panel service.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Current commit",
|
||||
"latestCommit": "Latest commit",
|
||||
"updateChannelChanged": "Update channel changed",
|
||||
"upToDate": "Up to date",
|
||||
"xrayStatusUnknown": "Unknown",
|
||||
"xrayStatusRunning": "Running",
|
||||
"xrayStatusStop": "Stopped",
|
||||
"xrayStatusError": "Error",
|
||||
"xrayErrorPopoverTitle": "An error occurred while running Xray",
|
||||
"operationHours": "Uptime",
|
||||
"systemHistoryTitle": "System History",
|
||||
"historyTitleCpu": "CPU Usage",
|
||||
"historyTitleMem": "Memory Usage",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Load",
|
||||
"historyTabConnections": "Connections",
|
||||
"historyTabDiskUsage": "Disk Usage",
|
||||
"charts": "Charts",
|
||||
"xrayMetricsTitle": "Xray Metrics",
|
||||
"xrayTitleHeap": "Allocated Heap Memory",
|
||||
"xrayTitleSys": "Memory Reserved from OS",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Down",
|
||||
"xrayObservatoryLastSeen": "Last seen",
|
||||
"xrayObservatoryLastTry": "Last try",
|
||||
"trendLast2Min": "Last 2 minutes",
|
||||
"systemLoad": "System Load",
|
||||
"systemLoadDesc": "System load average for the past 1, 5, and 15 minutes",
|
||||
"connectionCount": "Connection Stats",
|
||||
"ipAddresses": "IP Addresses",
|
||||
"toggleIpVisibility": "Toggle visibility of the IP",
|
||||
"overallSpeed": "Overall Speed",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"totalData": "Total Data",
|
||||
"sent": "Sent",
|
||||
"received": "Received",
|
||||
"documentation": "Documentation",
|
||||
"xraySwitchVersionDialog": "Do you really want to change the Xray version?",
|
||||
"xraySwitchVersionDialogDesc": "This will change the Xray version to #version#.",
|
||||
"xraySwitchVersionPopover": "Xray updated successfully",
|
||||
"panelUpdateDialog": "Do you really want to update the panel?",
|
||||
"panelUpdateDialogDesc": "This will update 3X-UI to #version# and restart the panel service.",
|
||||
"panelUpdateCheckPopover": "Panel update check failed",
|
||||
"panelUpdateStartedPopover": "Panel update started",
|
||||
"panelUpdateFailedTitle": "Panel update failed",
|
||||
"panelUpdateFailedDesc": "The update did not finish successfully. Check the server logs, or run 'x-ui update' from the command line.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Access Logs",
|
||||
"autoUpdate": "Auto Update",
|
||||
"config": "Config",
|
||||
"backup": "Backup",
|
||||
"backupTitle": "Backup & Restore",
|
||||
"exportDatabase": "Back Up",
|
||||
"exportDatabaseDesc": "Click to download a .db file containing a backup of your current database to your device. The same file can also be restored into a panel running on PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
|
||||
"importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
|
||||
"migrationDownload": "Download Migration",
|
||||
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
|
||||
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite.",
|
||||
"avg": "avg",
|
||||
"peak": "peak",
|
||||
"free": "free",
|
||||
"openSockets": "open sockets",
|
||||
"throughputSub": "Interface total",
|
||||
"avgWindow": "Avg over window",
|
||||
"healthWarm": "{list} — running warm",
|
||||
"healthCritical": "{list} — critical",
|
||||
"panel": "Panel",
|
||||
"threads": "Threads",
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Inbounds",
|
||||
"totalDownUp": "Total Sent/Received",
|
||||
"totalUsage": "Total Usage",
|
||||
"inboundCount": "Total Inbounds",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Local panel",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "When a connection on this inbound does not match any client, route it elsewhere. Pick a child inbound below to auto-fill the routing fields (SNI / ALPN / path / xver) from its transport, or leave the picker empty and set Dest directly (e.g. 8080 or 127.0.0.1:8080) to route to an external server such as Nginx. Each child inbound should listen on 127.0.0.1 with security=none.",
|
||||
"empty": "No fallbacks yet",
|
||||
"add": "Add fallback",
|
||||
"pickInbound": "Pick an inbound",
|
||||
"matchAny": "any",
|
||||
"destPlaceholder": "auto (child listen:port)",
|
||||
"rederive": "Re-fill from child",
|
||||
"rederived": "Re-filled from child",
|
||||
"editAdvanced": "Edit routing fields",
|
||||
"hideAdvanced": "Hide advanced",
|
||||
"quickAddAll": "Quick add all eligible",
|
||||
"quickAdded": "Added {n} fallback(s)",
|
||||
"quickAddedNone": "No new eligible inbounds to add",
|
||||
"routesWhen": "Routes when",
|
||||
"defaultCatchAll": "Default — catches anything else",
|
||||
"needsTls": "Fallbacks become available once Security is set to TLS or Reality on the Security tab (VLESS/Trojan over RAW only)."
|
||||
},
|
||||
"protocol": "Protocol",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Port Mapping",
|
||||
"traffic": "Traffic",
|
||||
"speed": "Speed",
|
||||
"details": "Details",
|
||||
"transportConfig": "Transport",
|
||||
"expireDate": "Duration",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Updated",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Add Inbound",
|
||||
"generalActions": "General Actions",
|
||||
"modifyInbound": "Modify Inbound",
|
||||
"deleteInbound": "Delete Inbound",
|
||||
"deleteInboundContent": "Are you sure you want to delete this inbound?",
|
||||
"deleteConfirmTitle": "Delete inbound \"{remark}\"?",
|
||||
"deleteConfirmContent": "This removes the inbound and all its clients. This cannot be undone.",
|
||||
"resetConfirmTitle": "Reset traffic for \"{remark}\"?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "All-Inbounds",
|
||||
"exportAllSubsFileName": "All-Inbounds-Subs",
|
||||
"inboundJsonTitle": "Inbound JSON",
|
||||
"deleteClient": "Delete Client",
|
||||
"deleteClientContent": "Are you sure you want to delete this client?",
|
||||
"resetTrafficContent": "Are you sure you want to reset traffic?",
|
||||
"copyLink": "Copy URL",
|
||||
"address": "Address",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Unlimited. (unit: GB)",
|
||||
"totalFlow": "Total Flow",
|
||||
"leaveBlankToNeverExpire": "Leave blank to never expire",
|
||||
"noRecommendKeepDefault": "It is recommended to keep the default",
|
||||
"certificatePath": "File Path",
|
||||
"certificateContent": "File Content",
|
||||
"publicKey": "Public Key",
|
||||
"privatekey": "Private Key",
|
||||
"clickOnQRcode": "Click on QR Code to Copy",
|
||||
"client": "Client",
|
||||
"export": "Export All URLs",
|
||||
"clone": "Clone",
|
||||
"cloneInbound": "Clone",
|
||||
"cloneInboundContent": "All settings of this inbound, except Port, Listening IP, and Clients, will be applied to the clone.",
|
||||
"cloneInboundOk": "Clone",
|
||||
"resetAllTraffic": "Reset Traffic for All Inbounds",
|
||||
"resetAllTrafficTitle": "Reset Traffic for All Inbounds",
|
||||
"resetAllTrafficContent": "Are you sure you want to reset the traffic of all inbounds?",
|
||||
"resetInboundClientTraffics": "Reset Clients' Traffic",
|
||||
"resetInboundClientTrafficTitle": "Reset Clients' Traffic",
|
||||
"resetInboundClientTrafficContent": "Are you sure you want to reset the traffic of this inbound's clients?",
|
||||
"resetAllClientTraffics": "Reset All Clients' Traffic",
|
||||
"resetAllClientTrafficTitle": "Reset All Clients' Traffic",
|
||||
"resetAllClientTrafficContent": "Are you sure you want to reset the traffic of all clients?",
|
||||
"delDepletedClients": "Delete Depleted Clients",
|
||||
"delDepletedClientsTitle": "Delete Depleted Clients",
|
||||
"delDepletedClientsContent": "Are you sure you want to delete all the depleted clients?",
|
||||
"email": "Email",
|
||||
"emailDesc": "Please provide a unique email address.",
|
||||
"IPLimit": "IP Limit",
|
||||
"IPLimitDesc": "Disables inbound if the count exceeds the set value. (0 = disable)",
|
||||
"IPLimitlog": "IP Log",
|
||||
"IPLimitlogDesc": "The IP history log. (to re-enable the inbound after disabling, clear the log)",
|
||||
"IPLimitlogclear": "Clear the Log",
|
||||
"setDefaultCert": "Set Cert from Panel",
|
||||
"setDefaultCertEmpty": "No certificate is configured for the panel. Set one under Settings first.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Xray sniffing block wrapper:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Xray stream block wrapper:",
|
||||
"jsonErrorPrefix": "Advanced JSON"
|
||||
"streamHelp": "Xray stream block wrapper:"
|
||||
},
|
||||
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
|
||||
"subSortIndex": "Sub order",
|
||||
"same": "Same",
|
||||
"inboundInfo": "Inbound Information",
|
||||
"exportInbound": "Export Inbound",
|
||||
"import": "Import",
|
||||
"importInbound": "Import an Inbound",
|
||||
"periodicTrafficResetTitle": "Traffic Reset",
|
||||
"periodicTrafficResetDesc": "Automatically reset traffic counter at specified intervals",
|
||||
"lastReset": "Last Reset",
|
||||
"periodicTrafficResetDay": "Monthly reset day",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Never",
|
||||
"daily": "Daily",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Obtain",
|
||||
"updateSuccess": "The update was successful.",
|
||||
"logCleanSuccess": "The log has been cleared.",
|
||||
"inboundsUpdateSuccess": "Inbounds have been successfully updated.",
|
||||
"inboundUpdateSuccess": "Inbound has been successfully updated.",
|
||||
"inboundCreateSuccess": "Inbound has been successfully created.",
|
||||
"bulkDeleted": "{count} inbounds deleted",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
||||
"inboundClientUpdateSuccess": "Inbound client has been updated.",
|
||||
"savedNodeOfflineWillSync": "Saved locally. A backing node is offline or disabled — the change will sync once it reconnects.",
|
||||
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
|
||||
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
||||
"resetAllTrafficSuccess": "All traffic has been reset.",
|
||||
"resetInboundClientTrafficSuccess": "Traffic has been reset.",
|
||||
@@ -648,6 +590,10 @@
|
||||
"maxTimeDiff": "Max Time Diff (ms)",
|
||||
"minClientVer": "Min Client Ver",
|
||||
"maxClientVer": "Max Client Ver",
|
||||
"minClientVerHint": "Empty does not mean unrestricted: Xray-core then enforces the built-in minimum of the core build you run (26.3.27 in current releases) and rejects clients that report an older version — including third-party cores such as Mihomo and sing-box. Set 1.0.0 to accept them, at the cost of admitting outdated TLS fingerprints.",
|
||||
"maxClientVerHint": "Empty means no upper limit. If set, it must not be lower than the effective minimum — Min Client Ver, or Xray-core's built-in minimum when that field is empty — otherwise every client is rejected.",
|
||||
"clientVerInvalid": "Client version must be up to three dot-separated numbers, each 0-255 (e.g. 26.3.27)",
|
||||
"maxClientVerBelowMin": "Max Client Ver must not be lower than Min Client Ver",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Required. Must include a port (e.g. example.com:443). Without a port Xray-core refuses to start.",
|
||||
"realityTargetRequired": "REALITY target is required",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Peer {n} config"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "Request",
|
||||
"response": "Response",
|
||||
"name": "Name",
|
||||
"value": "Value"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Version",
|
||||
"method": "Method",
|
||||
"path": "Path",
|
||||
"status": "Status",
|
||||
"statusDescription": "Status Desc",
|
||||
"requestHeader": "Request Header",
|
||||
"responseHeader": "Response Header"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Destination override"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Add External Subscription",
|
||||
"noExternalLinks": "No external links yet.",
|
||||
"noExternalSubscriptions": "No external subscriptions yet.",
|
||||
"add": "Add Client",
|
||||
"edit": "Edit Client",
|
||||
"submitAdd": "Add Client",
|
||||
"submitEdit": "Save Changes",
|
||||
"clientCount": "Number of Clients",
|
||||
"bulk": "Add Bulk",
|
||||
"copyFromInbound": "Copy Clients from Inbound",
|
||||
"copyToInbound": "Copy clients to",
|
||||
"copySelected": "Copy Selected",
|
||||
"copySource": "Source",
|
||||
"copyEmailPreview": "Resulting email preview",
|
||||
"copySelectSourceFirst": "Please select a source inbound first.",
|
||||
"copyResult": "Copy result",
|
||||
"copyResultSuccess": "Copied successfully",
|
||||
"copyResultNone": "Nothing to copy: no clients selected or source is empty",
|
||||
"copyResultErrors": "Copy errors",
|
||||
"copyFlowLabel": "Flow for new clients (VLESS)",
|
||||
"copyFlowHint": "Applied to all copied clients. Leave empty to skip.",
|
||||
"selectAll": "Select all",
|
||||
"clearAll": "Clear all",
|
||||
"method": "Method",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Postfix",
|
||||
"delayedStart": "Start After First Use",
|
||||
"expireDays": "Duration (days)",
|
||||
"days": "Day(s)",
|
||||
"renew": "Auto Renew",
|
||||
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
|
||||
"renewDays": "Auto Renew (days)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Expiring soonest",
|
||||
"has": "Has",
|
||||
"hasNot": "Doesn't have",
|
||||
"title": "Clients",
|
||||
"actions": "Actions",
|
||||
"totalGB": "Traffic Limit (GB)",
|
||||
"totalGBDesc": "Data quota for this client. 0 = unlimited.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Add Client",
|
||||
"qrCode": "QR Code",
|
||||
"clientInfo": "Client Information",
|
||||
"delete": "Delete",
|
||||
"reset": "Reset Traffic",
|
||||
"editClient": "Edit Client",
|
||||
"client": "Client",
|
||||
"enabled": "Enabled",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "No shareable links — attach this client to a protocol-capable inbound first.",
|
||||
"link": "Link",
|
||||
"resetNotPossible": "Attach this client to an inbound first.",
|
||||
"general": "General",
|
||||
"resetAllTraffics": "Reset all client traffic",
|
||||
"resetAllTrafficsTitle": "Reset all client traffic?",
|
||||
"resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.",
|
||||
"deleteConfirmTitle": "Delete client {email}?",
|
||||
"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})",
|
||||
"addToGroupTitle": "Add {count} client(s) to a group",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "Disable {count} clients?",
|
||||
"bulkDisableConfirmContent": "Disables each selected client on every attached inbound. They lose access immediately but their records and traffic are kept.",
|
||||
"selectedCount": "{count} selected",
|
||||
"attachSelected": "Attach ({count})",
|
||||
"attachToInboundsTitle": "Attach {count} client(s) to inbound(s)",
|
||||
"attachToInboundsDesc": "Attaches the selected {count} client(s) (same UUID/password and shared traffic) to the chosen inbound(s). They keep their existing attachments too.",
|
||||
"attachToInboundsTargets": "Target inbounds",
|
||||
"attachToInboundsNoTargets": "No multi-user inbounds available to attach to.",
|
||||
"detachSelected": "Detach ({count})",
|
||||
"detach": "Detach",
|
||||
"detachFromInboundsTitle": "Detach {count} client(s) from inbound(s)",
|
||||
"detachFromInboundsDesc": "Removes the selected {count} client(s) from the chosen inbound(s). Pairs where the client wasn't attached are silently skipped. Client records are kept (use Delete to remove fully).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Optional reverse tag",
|
||||
"telegramId": "Telegram user ID",
|
||||
"telegramIdPlaceholder": "Numeric Telegram user ID (0 = none)",
|
||||
"created": "Created",
|
||||
"updated": "Updated",
|
||||
"ipLimit": "IP limit",
|
||||
"toasts": {
|
||||
"deleted": "Client deleted",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"name": "Name",
|
||||
"clientCount": "Clients",
|
||||
"totalGroups": "Total groups",
|
||||
@@ -1114,7 +1017,6 @@
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Nodes",
|
||||
"addNode": "Add Node",
|
||||
"editNode": "Edit Node",
|
||||
"totalNodes": "Total Nodes",
|
||||
@@ -1133,8 +1035,6 @@
|
||||
"apiTokenPlaceholder": "Token from the remote panel's Settings page",
|
||||
"apiTokenHint": "The remote panel exposes its API token under Authentication → API Token.",
|
||||
"apiTokenKeepHint": "Leave blank to keep the current token",
|
||||
"regenerate": "Regenerate Token",
|
||||
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
|
||||
"allowPrivateAddress": "Allow private address",
|
||||
"allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
|
||||
"outboundTag": "Connection outbound",
|
||||
@@ -1166,7 +1066,6 @@
|
||||
"updatePanel": "Update Panel",
|
||||
"updateSelected": "Update Selected ({count})",
|
||||
"updateAvailable": "Update available",
|
||||
"upToDate": "Up to date",
|
||||
"updateConfirmTitle": "Update {count} node(s) to the latest version?",
|
||||
"updateConfirmContent": "Each selected node downloads the latest release and restarts onto it. Only enabled, online nodes are updated.",
|
||||
"updateDevChannel": "Update to Dev channel (latest commit)",
|
||||
@@ -1230,6 +1129,7 @@
|
||||
"pinFetchFailed": "Could not fetch the certificate"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Default",
|
||||
"title": "Panel Settings",
|
||||
"save": "Save",
|
||||
"infoDesc": "Every change made here needs to be saved. Please restart the panel to apply changes.",
|
||||
@@ -1267,6 +1167,8 @@
|
||||
"panelOutboundPh": "Direct connection",
|
||||
"remarkTemplate": "Remark Template",
|
||||
"remarkTemplateDesc": "When set, this replaces the remark model for every subscription link — write your own format with the variable tokens (use the button to insert them). Leave empty to use the model above.",
|
||||
"subShowIdentityOnAllLinks": "Show identity on every link",
|
||||
"subShowIdentityOnAllLinksDesc": "When enabled, {{EMAIL}} and {{USERNAME}} stay on every subscription-body remark. Usage tokens still appear on the first link only.",
|
||||
"datepicker": "Calendar Type",
|
||||
"datepickerPlaceholder": "Select date",
|
||||
"datepickerDescription": "Scheduled tasks will run based on this calendar.",
|
||||
@@ -1565,13 +1467,8 @@
|
||||
"secretClearUndo": "Undo clear"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray Configs",
|
||||
"save": "Save",
|
||||
"restart": "Restart Xray",
|
||||
"restartSuccess": "Xray has been successfully relaunched.",
|
||||
"restartOutputTitle": "Xray restart output",
|
||||
"restartConfirmTitle": "Restart xray?",
|
||||
"restartConfirmContent": "Reloads the xray service with the saved configuration.",
|
||||
"stopSuccess": "Xray has been successfully stopped.",
|
||||
"restartError": "There was an error when rebooting the Xray.",
|
||||
"stopError": "There was an error when stopping the Xray.",
|
||||
@@ -1581,7 +1478,6 @@
|
||||
"generalConfigsDesc": "These options will determine general adjustments.",
|
||||
"logConfigs": "Log",
|
||||
"logConfigsDesc": "Logs may affect your server's efficiency. It is recommended to enable them wisely only when needed.",
|
||||
"blockConfigsDesc": "These options will block traffic based on specific requested protocols and websites.",
|
||||
"basicRouting": "Basic Routing",
|
||||
"blockConnectionsConfigsDesc": "These options will block traffic based on the specific requested country.",
|
||||
"directConnectionsConfigsDesc": "A direct connection ensures that specific traffic is not routed through another server.",
|
||||
@@ -1591,10 +1487,6 @@
|
||||
"directdomains": "Direct Domains",
|
||||
"ipv4Routing": "IPv4 Routing",
|
||||
"ipv4RoutingDesc": "These options will route traffic based on a specific destination via IPv4.",
|
||||
"warpRouting": "WARP Routing",
|
||||
"warpRoutingDesc": "These options will route traffic based on a specific destination via WARP.",
|
||||
"nordRouting": "NordVPN Routing",
|
||||
"nordRoutingDesc": "These options will route traffic based on a specific destination via NordVPN.",
|
||||
"Template": "Advanced Xray Configuration Template",
|
||||
"TemplateDesc": "The final Xray config file will be generated based on this template.",
|
||||
"FreedomStrategy": "Freedom Protocol Strategy",
|
||||
@@ -1608,10 +1500,7 @@
|
||||
"outboundTestUrlDesc": "URL used when testing outbound connectivity.",
|
||||
"Torrent": "Block BitTorrent Protocol",
|
||||
"Inbounds": "Inbounds",
|
||||
"InboundsDesc": "Accepting the specific clients.",
|
||||
"Outbounds": "Outbounds",
|
||||
"OutboundSubscriptions": "Outbound Subscriptions",
|
||||
"OutboundSubscriptionsDesc": "Import outbounds from remote subscription URLs (vmess/vless/trojan/ss/...). Tags are kept stable for use in balancers and routing rules. Updates are automatic.",
|
||||
"Balancers": "Balancers",
|
||||
"balancerTagRequired": "Tag is required",
|
||||
"balancerSelectorRequired": "Pick at least one outbound",
|
||||
@@ -1630,9 +1519,7 @@
|
||||
"routeTesterMatchedOutbound": "Matched outbound",
|
||||
"routeTesterViaBalancer": "via balancer",
|
||||
"routeTesterDefaultOutbound": "No routing rule matched — traffic goes to the default (first) outbound.",
|
||||
"OutboundsDesc": "Set the outgoing traffic pathway.",
|
||||
"Routings": "Routing Rules",
|
||||
"RoutingsDesc": "The priority of each rule is important!",
|
||||
"importRules": "Import Rules",
|
||||
"exportRules": "Export Rules",
|
||||
"importOutbounds": "Import Outbounds",
|
||||
@@ -1651,13 +1538,9 @@
|
||||
"maskAddressDesc": "IP address mask, when enabled, will automatically replace the IP address that appears in the log.",
|
||||
"statistics": "Statistics",
|
||||
"statsInboundUplink": "Inbound Upload Statistics",
|
||||
"statsInboundUplinkDesc": "Enables the statistics collection for upstream traffic of all inbound proxies.",
|
||||
"statsInboundDownlink": "Inbound Download Statistics",
|
||||
"statsInboundDownlinkDesc": "Enables the statistics collection for downstream traffic of all inbound proxies.",
|
||||
"statsOutboundUplink": "Outbound Upload Statistics",
|
||||
"statsOutboundUplinkDesc": "Enables the statistics collection for upstream traffic of all outbound proxies.",
|
||||
"statsOutboundDownlink": "Outbound Download Statistics",
|
||||
"statsOutboundDownlinkDesc": "Enables the statistics collection for downstream traffic of all outbound proxies.",
|
||||
"metricsListen": "Metrics Endpoint",
|
||||
"metricsListenDesc": "Expose Xray's Prometheus-style metrics on this address:port (e.g. 127.0.0.1:11111). Leave empty to disable. Bind to localhost and reverse-proxy it — it is unauthenticated.",
|
||||
"metricsTag": "Metrics Tag",
|
||||
@@ -1670,18 +1553,10 @@
|
||||
"bufferSizePlaceholder": "auto",
|
||||
"seconds": "seconds",
|
||||
"rules": {
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"source": "Source",
|
||||
"dest": "Destination",
|
||||
"inbound": "Inbound",
|
||||
"outbound": "Outbound",
|
||||
"balancer": "Balancer",
|
||||
"info": "Info",
|
||||
"add": "Add Rule",
|
||||
"edit": "Edit Rule",
|
||||
"useComma": "Comma-separated list"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1722,7 +1597,6 @@
|
||||
"ruleN": "Rule {n}",
|
||||
"action": "Action",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Final Rules",
|
||||
"overrideXrayPrivateIp": "Override Xray's default private-IP block",
|
||||
"blockDelay": "Block delay (ms)",
|
||||
@@ -1748,43 +1622,17 @@
|
||||
"keepAliveInterval": "Keep alive interval",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Interface",
|
||||
"ipv6Only": "IPv6 only",
|
||||
"acceptProxyProtocol": "Accept proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Add Outbound",
|
||||
"addReverse": "Add Reverse",
|
||||
"editOutbound": "Edit Outbound",
|
||||
"editReverse": "Edit Reverse",
|
||||
"reverseTag": "Reverse Tag",
|
||||
"reverseTagDesc": "VLESS simple reverse proxy tag. Leave empty to disable.",
|
||||
"reverseTagPlaceholder": "reverse tag (leave empty to disable)",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Unique Tag",
|
||||
"address": "Address",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Reverse",
|
||||
"domain": "Domain",
|
||||
"type": "Type",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Link",
|
||||
"intercon": "Interconnection",
|
||||
"settings": "Settings",
|
||||
"accountInfo": "Account Information",
|
||||
"outboundStatus": "Outbound Status",
|
||||
"sendThrough": "Send Through",
|
||||
"targetStrategy": "Target Strategy",
|
||||
"test": "Test",
|
||||
"testResult": "Test Result",
|
||||
"testing": "Testing connection...",
|
||||
"testSuccess": "Test successful",
|
||||
"testFailed": "Test failed",
|
||||
"testError": "Failed to test outbound",
|
||||
"modeRealDelay": "Real delay",
|
||||
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray. Real delay: total time including connection setup.",
|
||||
"testAll": "Test all",
|
||||
@@ -1792,14 +1640,10 @@
|
||||
"breakdownConnect": "Proxy connect",
|
||||
"breakdownTls": "TLS via outbound",
|
||||
"breakdownTtfb": "First byte",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Access Token",
|
||||
"country": "Country",
|
||||
"server": "Server",
|
||||
"city": "City",
|
||||
"allCities": "All Cities",
|
||||
"privateKey": "Private Key",
|
||||
"load": "Load",
|
||||
"moveToTop": "Move to top"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1829,16 +1673,11 @@
|
||||
"active": "Active subscriptions",
|
||||
"empty": "No subscriptions yet. Add one above.",
|
||||
"colRemark": "Remark",
|
||||
"colPrefix": "Prefix",
|
||||
"colInterval": "Interval",
|
||||
"colLastFetch": "Last fetch",
|
||||
"colEnabled": "Enabled",
|
||||
"auto": "auto",
|
||||
"never": "never",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"refreshNow": "Refresh now",
|
||||
"lastError": "Last error",
|
||||
"deleteConfirm": "Delete this subscription?",
|
||||
"restartHint": "After adding or refreshing, restart Xray (or wait for the next auto-reload) to make the outbounds active.",
|
||||
"fromSubsTitle": "From outbound subscriptions (read-only)",
|
||||
@@ -1855,8 +1694,6 @@
|
||||
"tabBalancerSettings": "Balancer Settings",
|
||||
"tabObservatory": "Observatory",
|
||||
"observatory": {
|
||||
"title": "Observatory",
|
||||
"burstTitle": "Burst Observatory",
|
||||
"autoManaged": "Observers are managed automatically from your balancers. Tune how they probe below — the watched outbounds follow your balancer selectors.",
|
||||
"emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so observer-backed balancers can check outbound health before choosing a target.",
|
||||
"mixedLegacy": "This config contains both Observatory and Burst Observatory. Xray uses one global observer, so this mixed legacy state is not supported; saving balancers will normalize it to one observer.",
|
||||
@@ -1890,12 +1727,8 @@
|
||||
"balancerRemoved": "Balancer {tag} — removed (no targets left)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Add Balancer",
|
||||
"editBalancer": "Edit Balancer",
|
||||
"balancerStrategy": "Strategy",
|
||||
"balancerSelectors": "Selectors",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Unique Tag",
|
||||
"tagDuplicate": "Tag already used by another balancer",
|
||||
"tagPlaceholder": "unique balancer tag",
|
||||
"selector": "Selector",
|
||||
@@ -1912,7 +1745,6 @@
|
||||
"tolerance": "Tolerance",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "It is not possible to use balancerTag and outboundTag at the same time. If used at the same time, only outboundTag will work.",
|
||||
"costMatch": "Tag pattern",
|
||||
"costValue": "Weight",
|
||||
"costRegexp": "Regular expression match"
|
||||
@@ -1922,7 +1754,6 @@
|
||||
"publicKey": "Public Key",
|
||||
"allowedIPs": "Allowed IPs",
|
||||
"endpoint": "Endpoint",
|
||||
"psk": "PreShared Key",
|
||||
"domainStrategy": "Domain Strategy"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1960,10 +1791,7 @@
|
||||
"i1Hint": "AmneziaWG 2.0 only. Leave empty for a 1.x-compatible config."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "The name of the TUN interface. Default is 'xray0'",
|
||||
"mtuDesc": "Maximum Transmission Unit. The maximum size of data packets. Default is 1500",
|
||||
"userLevel": "User Level",
|
||||
"userLevelDesc": "All connections made through this inbound will use this user level. Default is 0"
|
||||
"userLevel": "User Level"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -2048,7 +1876,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Add Fake DNS",
|
||||
"edit": "Edit Fake DNS",
|
||||
"ipPool": "IP Pool Subnet",
|
||||
"poolSize": "Pool Size"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Add",
|
||||
"month": "Month",
|
||||
"months": "Months",
|
||||
"day": "Day",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Telegram User saved.",
|
||||
"loginSuccess": "✅ Logged in to the panel successfully.\r\n",
|
||||
"loginFailed": "❗️Login attempt to the panel failed.\r\n",
|
||||
"2faFailed": "2FA Failed",
|
||||
"report": "🕰 Scheduled Reports: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Date&Time: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 Download: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Telegram User: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 Exhausted {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 Exhausted {{ .Type }} count:\r\n",
|
||||
"onlinesCount": "🌐 Online Clients: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Disabled: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Refreshed On: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Yes",
|
||||
"no": "❌ No",
|
||||
"received_id": "🔑📥 ID updated.",
|
||||
"received_password": "🔑📥 Password updated.",
|
||||
"received_email": "📧📥 Email updated.",
|
||||
"received_comment": "💬📥 Comment updated.",
|
||||
"id_prompt": "🔑 Default ID: {{ .ClientId }}\n\nEnter your ID.",
|
||||
"pass_prompt": "🔑 Default Password: {{ .ClientPassword }}\n\nEnter your password.",
|
||||
"email_prompt": "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email.",
|
||||
"comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment.",
|
||||
"inbound_client_data_id": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
|
||||
"inbound_client_data_pass": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 Password: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
|
||||
"cancel": "❌ Process Canceled! \n\nYou can /start again anytime. 🔄",
|
||||
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
|
||||
"using_default_value": "Okay, I'll stick with the default value. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Error: {{ .Error }}",
|
||||
"eventNodeDown": "Node {{ .Name }} is DOWN",
|
||||
"eventNodeUp": "Node {{ .Name }} is UP",
|
||||
"eventCPUHigh": "CPU high",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Login failed from {{ .Source }}",
|
||||
"memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Submit As Disable ☑️",
|
||||
"submitEnable": "Submit As Enable ✅",
|
||||
"use_default": "🏷️ Use default",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 Password",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Comment",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Reset All Traffic",
|
||||
"SortedTrafficUsageReport": "Sorted Traffic Usage Report"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "Outbound {{ .Tag }} is DOWN",
|
||||
"subjectOutboundUp": "Outbound {{ .Tag }} is UP",
|
||||
"subjectXrayCrash": "Xray CRASHED",
|
||||
"subjectCPUHigh": "CPU high",
|
||||
"subjectLoginSuccess": "Login successful",
|
||||
"subjectLoginFailed": "Login failed",
|
||||
"titleOutboundDown": "Outbound DOWN",
|
||||
"titleOutboundUp": "Outbound UP",
|
||||
"titleXrayCrash": "Xray CRASHED",
|
||||
"titleCPUHigh": "CPU high",
|
||||
"titleLoginSuccess": "Login successful",
|
||||
"titleLoginFailed": "Login failed",
|
||||
"labelStatus": "Status",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelNode": "Node",
|
||||
"labelError": "Error",
|
||||
"labelDelay": "Delay",
|
||||
"labelDetail": "Detail",
|
||||
"labelUsername": "Username",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Reason",
|
||||
"labelSource": "Source",
|
||||
"labelTime": "Time",
|
||||
"statusCrashed": "CRASHED",
|
||||
"statusRunning": "Running",
|
||||
"statusHigh": "HIGH",
|
||||
"statusSuccess": "SUCCESS",
|
||||
"statusFailed": "FAILED",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Copiado exitosamente",
|
||||
"sure": "Seguro",
|
||||
"encryption": "Encriptación",
|
||||
"useIPv4ForHost": "Usar IPv4 para el host",
|
||||
"transmission": "Transmisión",
|
||||
"host": "Host",
|
||||
"path": "Ruta",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Código",
|
||||
"remained": "Restante",
|
||||
"security": "Seguridad",
|
||||
"secAlertTitle": "Alerta de Seguridad",
|
||||
"secAlertSsl": "Esta conexión no es segura. Por favor, evite ingresar información sensible hasta que se active TLS para la protección de datos.",
|
||||
"secAlertConf": "Ciertas configuraciones son vulnerables a ataques. Se recomienda reforzar los protocolos de seguridad para prevenir posibles violaciones.",
|
||||
"secAlertSSL": "El panel carece de una conexión segura. Por favor, instale un certificado TLS para la protección de datos.",
|
||||
"secAlertPanelPort": "El puerto predeterminado del panel es vulnerable. Por favor, configure un puerto aleatorio o específico.",
|
||||
"secAlertPanelURI": "La ruta URI predeterminada del panel no es segura. Por favor, configure una ruta URI compleja.",
|
||||
"secAlertSubURI": "La ruta URI predeterminada de la suscripción no es segura. Por favor, configure una ruta URI compleja.",
|
||||
"secAlertSubJsonURI": "La ruta URI JSON predeterminada de la suscripción no es segura. Por favor, configure una ruta URI compleja.",
|
||||
"emptyDnsDesc": "No hay servidores DNS añadidos.",
|
||||
"emptyFakeDnsDesc": "No hay servidores Fake DNS añadidos.",
|
||||
"emptyBalancersDesc": "No hay balanceadores añadidos.",
|
||||
"emptyReverseDesc": "No hay proxies inversos añadidos.",
|
||||
"somethingWentWrong": "Algo salió mal",
|
||||
"subscription": {
|
||||
"title": "Información de suscripción",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
"dark": "Oscuro",
|
||||
"ultraDark": "Ultra Oscuro",
|
||||
"dashboard": "Estado del Sistema",
|
||||
"inbounds": "Entradas",
|
||||
"clients": "Clientes",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "Enrutamiento",
|
||||
"outbounds": "Salidas",
|
||||
"apiDocs": "Documentación de la API",
|
||||
"logout": "Cerrar Sesión",
|
||||
"link": "Gestionar",
|
||||
"donate": "Donar",
|
||||
"hosts": "Hosts",
|
||||
"docs": "Documentación",
|
||||
"openMenu": "Abrir menú"
|
||||
"openMenu": "Abrir menú",
|
||||
"pinSidebar": "Fijar barra lateral",
|
||||
"unpinSidebar": "Desfijar barra lateral"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Estado del Sistema",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "Procesadores lógicos",
|
||||
"frequency": "Frecuencia",
|
||||
"swap": "Swap",
|
||||
"storage": "Almacenamiento",
|
||||
"memory": "Memoria",
|
||||
"threads": "Hilos",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Detener",
|
||||
"restartXray": "Reiniciar",
|
||||
"xraySwitch": "Versión",
|
||||
"xrayUpdates": "Actualizaciones de Xray",
|
||||
"xraySwitchClick": "Elige la versión a la que deseas cambiar.",
|
||||
"xraySwitchClickDesk": "Elige sabiamente, ya que las versiones anteriores pueden no ser compatibles con las configuraciones actuales.",
|
||||
"updatePanel": "Actualizar panel",
|
||||
"panelUpdateDesc": "Esto actualizará 3X-UI a la última versión y reiniciará el servicio del panel.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Commit actual",
|
||||
"latestCommit": "Último commit",
|
||||
"updateChannelChanged": "Canal de actualización cambiado",
|
||||
"upToDate": "Actualizado",
|
||||
"xrayStatusUnknown": "Desconocido",
|
||||
"xrayStatusRunning": "En ejecución",
|
||||
"xrayStatusStop": "Detenido",
|
||||
"xrayStatusError": "Error",
|
||||
"xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
|
||||
"operationHours": "Tiempo de Funcionamiento",
|
||||
"systemHistoryTitle": "Historial del Sistema",
|
||||
"historyTitleCpu": "Uso de CPU",
|
||||
"historyTitleMem": "Uso de Memoria",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Carga",
|
||||
"historyTabConnections": "Conexiones",
|
||||
"historyTabDiskUsage": "Uso de Disco",
|
||||
"charts": "Gráficos",
|
||||
"xrayMetricsTitle": "Métricas de Xray",
|
||||
"xrayTitleHeap": "Memoria Heap Asignada",
|
||||
"xrayTitleSys": "Memoria Reservada del SO",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Caído",
|
||||
"xrayObservatoryLastSeen": "Visto por última vez",
|
||||
"xrayObservatoryLastTry": "Último intento",
|
||||
"trendLast2Min": "Últimos 2 minutos",
|
||||
"systemLoad": "Carga del Sistema",
|
||||
"systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos",
|
||||
"connectionCount": "Número de Conexiones",
|
||||
"ipAddresses": "Direcciones IP",
|
||||
"toggleIpVisibility": "Alternar visibilidad de la IP",
|
||||
"overallSpeed": "Velocidad general",
|
||||
"upload": "Subida",
|
||||
"download": "Descargar",
|
||||
"totalData": "Datos totales",
|
||||
"sent": "Enviado",
|
||||
"received": "Recibido",
|
||||
"documentation": "Documentación",
|
||||
"xraySwitchVersionDialog": "¿Realmente deseas cambiar la versión de Xray?",
|
||||
"xraySwitchVersionDialogDesc": "Esto cambiará la versión de Xray a #version#.",
|
||||
"xraySwitchVersionPopover": "Xray se actualizó correctamente",
|
||||
"panelUpdateDialog": "¿Deseas actualizar el panel?",
|
||||
"panelUpdateDialogDesc": "Esto actualizará 3X-UI a la versión #version# y reiniciará el servicio del panel.",
|
||||
"panelUpdateCheckPopover": "Fallo al comprobar actualización del panel",
|
||||
"panelUpdateStartedPopover": "Actualización del panel iniciada",
|
||||
"panelUpdateFailedTitle": "Error al actualizar el panel",
|
||||
"panelUpdateFailedDesc": "La actualización no se completó correctamente. Revisa los registros del servidor o ejecuta 'x-ui update' desde la línea de comandos.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Registros de acceso",
|
||||
"autoUpdate": "Actualización automática",
|
||||
"config": "Configuración",
|
||||
"backup": "Copia de seguridad",
|
||||
"backupTitle": "Copia & Restauración",
|
||||
"exportDatabase": "Copia de seguridad",
|
||||
"exportDatabaseDesc": "Haz clic para descargar un archivo .db que contiene una copia de seguridad de tu base de datos actual en tu dispositivo. El mismo archivo también puede restaurarse en un panel que funcione con PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
|
||||
"importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
|
||||
"migrationDownload": "Descargar migración",
|
||||
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite."
|
||||
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite.",
|
||||
"avg": "media",
|
||||
"peak": "pico",
|
||||
"free": "libre",
|
||||
"openSockets": "sockets abiertos",
|
||||
"throughputSub": "Total de la interfaz",
|
||||
"avgWindow": "Media del periodo",
|
||||
"healthWarm": "{list} — algo elevado",
|
||||
"healthCritical": "{list} — crítico",
|
||||
"panel": "Panel",
|
||||
"threads": "Hilos",
|
||||
"uptime": "Tiempo activo"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Entradas",
|
||||
"totalDownUp": "Subidas/Descargas Totales",
|
||||
"totalUsage": "Uso Total",
|
||||
"inboundCount": "Número de Entradas",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Panel local",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "Cuando una conexión en este inbound no coincide con ningún cliente, redirígela a otro lugar. Elige un inbound hijo abajo para rellenar automáticamente los campos de enrutamiento (SNI / ALPN / Path / xver) desde su transporte, o deja el selector vacío y define Dest directamente (p. ej. 8080 o 127.0.0.1:8080) para redirigir a un servidor externo como Nginx. Cada inbound hijo debe escuchar en 127.0.0.1 con security=none.",
|
||||
"empty": "Aún no hay fallbacks",
|
||||
"add": "Añadir fallback",
|
||||
"pickInbound": "Selecciona un inbound",
|
||||
"matchAny": "cualquiera",
|
||||
"destPlaceholder": "automático (listen:puerto del hijo)",
|
||||
"rederive": "Rellenar desde el hijo",
|
||||
"rederived": "Rellenado desde el hijo",
|
||||
"editAdvanced": "Editar campos de enrutamiento",
|
||||
"hideAdvanced": "Ocultar avanzado",
|
||||
"quickAddAll": "Añadir todos los elegibles",
|
||||
"quickAdded": "Se añadieron {n} fallback(s)",
|
||||
"quickAddedNone": "No hay nuevos inbounds elegibles",
|
||||
"routesWhen": "Enruta cuando",
|
||||
"defaultCatchAll": "Por defecto — captura cualquier otra cosa",
|
||||
"needsTls": "Los fallbacks estarán disponibles al seleccionar TLS o Reality en la pestaña de Seguridad (solo VLESS/Trojan sobre RAW)."
|
||||
},
|
||||
"protocol": "Protocolo",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Asignación de puertos",
|
||||
"traffic": "Tráfico",
|
||||
"speed": "Velocidad",
|
||||
"details": "Detalles",
|
||||
"transportConfig": "Transporte",
|
||||
"expireDate": "Fecha de Expiración",
|
||||
"createdAt": "Creado",
|
||||
"updatedAt": "Actualizado",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Agregar Entrada",
|
||||
"generalActions": "Acciones Generales",
|
||||
"modifyInbound": "Modificar Entrada",
|
||||
"deleteInbound": "Eliminar Entrada",
|
||||
"deleteInboundContent": "¿Confirmar eliminación de entrada?",
|
||||
"deleteConfirmTitle": "¿Eliminar el inbound \"{remark}\"?",
|
||||
"deleteConfirmContent": "Esto elimina el inbound y todos sus clientes. No se puede deshacer.",
|
||||
"resetConfirmTitle": "¿Restablecer el tráfico de \"{remark}\"?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "Todas-las-entradas",
|
||||
"exportAllSubsFileName": "Todas-las-entradas-Subs",
|
||||
"inboundJsonTitle": "JSON de entrada",
|
||||
"deleteClient": "Eliminar cliente",
|
||||
"deleteClientContent": "¿Está seguro de que desea eliminar el cliente?",
|
||||
"resetTrafficContent": "¿Confirmar restablecimiento de tráfico?",
|
||||
"copyLink": "Copiar Enlace",
|
||||
"address": "Dirección",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Ilimitado. (unidad: GB)",
|
||||
"totalFlow": "Flujo Total",
|
||||
"leaveBlankToNeverExpire": "Dejar en Blanco para Nunca Expirar",
|
||||
"noRecommendKeepDefault": "No hay requisitos especiales para mantener la configuración predeterminada",
|
||||
"certificatePath": "Ruta Cert",
|
||||
"certificateContent": "Datos Cert",
|
||||
"publicKey": "Clave Pública",
|
||||
"privatekey": "Clave Privada",
|
||||
"clickOnQRcode": "Haz clic en el Código QR para Copiar",
|
||||
"client": "Cliente",
|
||||
"export": "Exportar Enlaces",
|
||||
"clone": "Clonar",
|
||||
"cloneInbound": "Clonar Entradas",
|
||||
"cloneInboundContent": "Se aplicarán todas las configuraciones de esta entrada, excepto el Puerto, la IP de Escucha y los Clientes, al clon.",
|
||||
"cloneInboundOk": "Clonar",
|
||||
"resetAllTraffic": "Restablecer Tráfico de Todas las Entradas",
|
||||
"resetAllTrafficTitle": "Restablecer tráfico de todas las entradas",
|
||||
"resetAllTrafficContent": "¿Estás seguro de que deseas restablecer el tráfico de todas las entradas?",
|
||||
"resetInboundClientTraffics": "Restablecer Tráfico de Clientes",
|
||||
"resetInboundClientTrafficTitle": "Restablecer todo el tráfico de clientes",
|
||||
"resetInboundClientTrafficContent": "¿Estás seguro de que deseas restablecer todo el tráfico para los clientes de esta entrada?",
|
||||
"resetAllClientTraffics": "Restablecer Tráfico de Todos los Clientes",
|
||||
"resetAllClientTrafficTitle": "Restablecer todo el tráfico de clientes",
|
||||
"resetAllClientTrafficContent": "¿Estás seguro de que deseas restablecer todo el tráfico para todos los clientes?",
|
||||
"delDepletedClients": "Eliminar Clientes Agotados",
|
||||
"delDepletedClientsTitle": "Eliminar clientes agotados",
|
||||
"delDepletedClientsContent": "¿Estás seguro de que deseas eliminar todos los clientes agotados?",
|
||||
"email": "Email",
|
||||
"emailDesc": "Por favor proporciona una dirección de correo electrónico única.",
|
||||
"IPLimit": "Límite de IP",
|
||||
"IPLimitDesc": "Desactiva la entrada si la cantidad supera el valor ingresado (ingresa 0 para desactivar el límite de IP).",
|
||||
"IPLimitlog": "Registro de IP",
|
||||
"IPLimitlogDesc": "Registro de historial de IPs (antes de habilitar la entrada después de que haya sido desactivada por el límite de IP, debes borrar el registro).",
|
||||
"IPLimitlogclear": "Limpiar el Registro",
|
||||
"setDefaultCert": "Establecer certificado desde el panel",
|
||||
"setDefaultCertEmpty": "No hay certificado configurado para el panel. Configura uno en Ajustes primero.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Envoltorio del bloque sniffing de Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Envoltorio del bloque stream de Xray:",
|
||||
"jsonErrorPrefix": "JSON avanzado"
|
||||
"streamHelp": "Envoltorio del bloque stream de Xray:"
|
||||
},
|
||||
"telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.",
|
||||
"subSortIndex": "Orden sub",
|
||||
"same": "misma",
|
||||
"inboundInfo": "Información de entrada",
|
||||
"exportInbound": "Exportación entrante",
|
||||
"import": "Importar",
|
||||
"importInbound": "Importar un entrante",
|
||||
"periodicTrafficResetTitle": "Reset de Tráfico",
|
||||
"periodicTrafficResetDesc": "Reiniciar automáticamente el contador de tráfico en intervalos especificados",
|
||||
"lastReset": "Último reinicio",
|
||||
"periodicTrafficResetDay": "Día de reinicio mensual",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Nunca",
|
||||
"daily": "Diariamente",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Recibir",
|
||||
"updateSuccess": "La actualización fue exitosa",
|
||||
"logCleanSuccess": "El registro ha sido limpiado",
|
||||
"inboundsUpdateSuccess": "Entradas actualizadas correctamente",
|
||||
"inboundUpdateSuccess": "Entrada actualizada correctamente",
|
||||
"inboundCreateSuccess": "Entrada creada correctamente",
|
||||
"bulkDeleted": "{count} inbounds eliminados",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Cliente de entrada eliminado",
|
||||
"inboundClientUpdateSuccess": "Cliente de entrada actualizado",
|
||||
"savedNodeOfflineWillSync": "Guardado localmente. Un nodo de respaldo está desconectado o deshabilitado: el cambio se sincronizará cuando vuelva a conectarse.",
|
||||
"delDepletedClientsSuccess": "Todos los clientes con tráfico agotado fueron eliminados",
|
||||
"resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
|
||||
"resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado",
|
||||
"resetInboundClientTrafficSuccess": "El tráfico ha sido reiniciado",
|
||||
@@ -657,6 +599,10 @@
|
||||
"maxTimeDiff": "Máx. diferencia de tiempo (ms)",
|
||||
"minClientVer": "Mín. versión cliente",
|
||||
"maxClientVer": "Máx. versión cliente",
|
||||
"minClientVerHint": "Vacío no significa sin restricción: Xray-core aplica entonces el mínimo integrado de la build del núcleo en uso (26.3.27 en las versiones actuales) y rechaza a los clientes que reportan una versión anterior, incluidos núcleos de terceros como Mihomo y sing-box. Con 1.0.0 se aceptan, a costa de admitir huellas TLS obsoletas.",
|
||||
"maxClientVerHint": "Vacío significa sin límite superior. Si se establece, no debe ser inferior al mínimo efectivo — la versión mínima del cliente o, si ese campo está vacío, el mínimo integrado de Xray-core — o todos los clientes serán rechazados.",
|
||||
"clientVerInvalid": "La versión del cliente debe tener hasta tres números separados por puntos, cada uno 0-255 (p. ej. 26.3.27)",
|
||||
"maxClientVerBelowMin": "La versión máxima del cliente no debe ser inferior a la versión mínima",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Obligatorio. Debe incluir un puerto (p. ej. example.com:443). Sin puerto, Xray-core no arranca.",
|
||||
"realityTargetRequired": "El destino REALITY es obligatorio",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Config Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "Pedido",
|
||||
"response": "Respuesta",
|
||||
"name": "Nombre",
|
||||
"value": "Valor"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Versión",
|
||||
"method": "Método",
|
||||
"path": "Ruta",
|
||||
"status": "Estado",
|
||||
"statusDescription": "Descripción de la Situación",
|
||||
"requestHeader": "Encabezado de solicitud",
|
||||
"responseHeader": "Encabezado de respuesta"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Anulación de destino"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Añadir suscripción externa",
|
||||
"noExternalLinks": "Aún no hay enlaces externos.",
|
||||
"noExternalSubscriptions": "Aún no hay suscripciones externas.",
|
||||
"add": "Añadir cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Añadir cliente",
|
||||
"submitEdit": "Guardar cambios",
|
||||
"clientCount": "Número de clientes",
|
||||
"bulk": "Añadir en lote",
|
||||
"copyFromInbound": "Copiar clientes desde inbound",
|
||||
"copyToInbound": "Copiar clientes a",
|
||||
"copySelected": "Copiar selección",
|
||||
"copySource": "Origen",
|
||||
"copyEmailPreview": "Vista previa del correo resultante",
|
||||
"copySelectSourceFirst": "Selecciona primero un inbound de origen.",
|
||||
"copyResult": "Resultado de la copia",
|
||||
"copyResultSuccess": "Copiado correctamente",
|
||||
"copyResultNone": "Nada que copiar: no hay clientes seleccionados o el origen está vacío",
|
||||
"copyResultErrors": "Errores de copia",
|
||||
"copyFlowLabel": "Flow para clientes nuevos (VLESS)",
|
||||
"copyFlowHint": "Se aplica a todos los clientes copiados. Déjalo vacío para omitir.",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"clearAll": "Limpiar todo",
|
||||
"method": "Método",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Sufijo",
|
||||
"delayedStart": "Iniciar tras el primer uso",
|
||||
"expireDays": "Duración (días)",
|
||||
"days": "Día(s)",
|
||||
"renew": "Renovación automática",
|
||||
"renewDesc": "Renovación automática tras la expiración. (0 = desactivado) (unidad: día)",
|
||||
"renewDays": "Renovación automática (días)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Caducidad más próxima",
|
||||
"has": "Tiene",
|
||||
"hasNot": "No tiene",
|
||||
"title": "Clientes",
|
||||
"actions": "Acciones",
|
||||
"totalGB": "Límite de tráfico (GB)",
|
||||
"totalGBDesc": "Cuota de datos para este cliente. 0 = ilimitado.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Añadir cliente",
|
||||
"qrCode": "Código QR",
|
||||
"clientInfo": "Información del cliente",
|
||||
"delete": "Eliminar",
|
||||
"reset": "Restablecer tráfico",
|
||||
"editClient": "Editar cliente",
|
||||
"client": "Cliente",
|
||||
"enabled": "Habilitado",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "No hay enlaces compartibles — asocia primero este cliente a un inbound con protocolo válido.",
|
||||
"link": "Enlace",
|
||||
"resetNotPossible": "Asocia primero este cliente a un inbound.",
|
||||
"general": "General",
|
||||
"resetAllTraffics": "Restablecer tráfico de todos los clientes",
|
||||
"resetAllTrafficsTitle": "¿Restablecer tráfico de todos los clientes?",
|
||||
"resetAllTrafficsContent": "El contador de subida/bajada de cada cliente vuelve a cero. Las cuotas y la expiración no se modifican. Esta acción no se puede deshacer.",
|
||||
"deleteConfirmTitle": "¿Eliminar al cliente {email}?",
|
||||
"deleteConfirmContent": "Esto elimina al cliente de cada inbound asociado y descarta su registro de tráfico. No se puede deshacer.",
|
||||
"deleteSelected": "Eliminar ({count})",
|
||||
"adjustSelected": "Ajustar ({count})",
|
||||
"subLinksSelected": "Enlaces sub ({count})",
|
||||
"addToGroupTitle": "Añadir {count} cliente(s) a un grupo",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "¿Deshabilitar {count} clientes?",
|
||||
"bulkDisableConfirmContent": "Deshabilita cada cliente seleccionado en todos los inbounds asociados. Pierden el acceso de inmediato, pero se conservan sus registros y su tráfico.",
|
||||
"selectedCount": "{count} seleccionado(s)",
|
||||
"attachSelected": "Asociar ({count})",
|
||||
"attachToInboundsTitle": "Asociar {count} cliente(s) a entrada(s)",
|
||||
"attachToInboundsDesc": "Asocia los {count} cliente(s) seleccionados (mismo UUID/contraseña y tráfico compartido) a las entradas elegidas. Mantienen sus asociaciones existentes.",
|
||||
"attachToInboundsTargets": "Entradas objetivo",
|
||||
"attachToInboundsNoTargets": "No hay entradas multiusuario disponibles para asociar.",
|
||||
"detachSelected": "Desasociar ({count})",
|
||||
"detach": "Desasociar",
|
||||
"detachFromInboundsTitle": "Desasociar {count} cliente(s) de entrada(s)",
|
||||
"detachFromInboundsDesc": "Quita los {count} cliente(s) seleccionados de las entradas elegidas. Las parejas donde el cliente no estaba asociado se omiten silenciosamente. Los registros de los clientes se conservan (usa Delete para eliminar por completo).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||
"telegramId": "ID de usuario de Telegram",
|
||||
"telegramIdPlaceholder": "ID numérico de usuario de Telegram (0 = ninguno)",
|
||||
"created": "Creado",
|
||||
"updated": "Actualizado",
|
||||
"ipLimit": "Límite de IP",
|
||||
"toasts": {
|
||||
"deleted": "Cliente eliminado",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Grupos",
|
||||
"name": "Nombre",
|
||||
"clientCount": "Clientes",
|
||||
"totalGroups": "Total de grupos",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "Quitados {count} cliente(s) de {name}."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Nodos",
|
||||
"addNode": "Agregar nodo",
|
||||
"editNode": "Editar nodo",
|
||||
"totalNodes": "Total de nodos",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto",
|
||||
"apiTokenHint": "El panel remoto expone su token de API en Configuraciones de Seguridad → Token de API.",
|
||||
"apiTokenKeepHint": "Déjalo en blanco para mantener el token actual",
|
||||
"regenerate": "Regenerar token",
|
||||
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
|
||||
"allowPrivateAddress": "Permitir dirección privada",
|
||||
"allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
|
||||
"outboundTag": "Outbound de conexión",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "Actualizar panel",
|
||||
"updateSelected": "Actualizar seleccionados ({count})",
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"upToDate": "Actualizado",
|
||||
"updateConfirmTitle": "¿Actualizar {count} nodo(s) a la última versión?",
|
||||
"updateConfirmContent": "Cada nodo seleccionado descarga la última versión y se reinicia con ella. Solo se actualizan los nodos habilitados y en línea.",
|
||||
"updateDevChannel": "Actualizar al canal de desarrollo (último commit)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "No se pudo obtener el certificado"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Predeterminado",
|
||||
"title": "Configuraciones",
|
||||
"save": "Guardar",
|
||||
"infoDesc": "Cada cambio realizado aquí debe ser guardado. Por favor, reinicie el panel para aplicar los cambios.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "Uso de memoria alto (%)",
|
||||
"remarkTemplate": "Plantilla de notas",
|
||||
"remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
|
||||
"subShowIdentityOnAllLinks": "Mostrar identidad en cada enlace",
|
||||
"subShowIdentityOnAllLinksDesc": "Si está activado, {{EMAIL}} y {{USERNAME}} permanecen en la nota de cada enlace del cuerpo de la suscripción. Los tokens de uso siguen solo en el primer enlace.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "La ruta debe comenzar con /"
|
||||
},
|
||||
@@ -1448,13 +1350,8 @@
|
||||
"secretClearUndo": "Deshacer borrado"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray Configuración",
|
||||
"save": "Guardar configuración",
|
||||
"restart": "Reiniciar Xray",
|
||||
"restartSuccess": "Xray se ha reiniciado correctamente",
|
||||
"restartOutputTitle": "Salida del reinicio de Xray",
|
||||
"restartConfirmTitle": "¿Reiniciar xray?",
|
||||
"restartConfirmContent": "Recarga el servicio xray con la configuración guardada.",
|
||||
"stopSuccess": "Xray se ha detenido correctamente",
|
||||
"restartError": "Ocurrió un error al reiniciar Xray.",
|
||||
"stopError": "Ocurrió un error al detener Xray.",
|
||||
@@ -1464,7 +1361,6 @@
|
||||
"generalConfigsDesc": "Estas opciones proporcionarán ajustes generales.",
|
||||
"logConfigs": "Registro",
|
||||
"logConfigsDesc": "Los registros pueden afectar la eficiencia de su servidor. Se recomienda habilitarlos sabiamente solo en caso de sus necesidades.",
|
||||
"blockConfigsDesc": "Estas opciones evitarán que los usuarios se conecten a protocolos y sitios web específicos.",
|
||||
"basicRouting": "Enrutamiento Básico",
|
||||
"blockConnectionsConfigsDesc": "Estas opciones bloquearán el tráfico según el país solicitado específico.",
|
||||
"directConnectionsConfigsDesc": "Una conexión directa asegura que el tráfico específico no sea enrutado a través de otro servidor.",
|
||||
@@ -1474,10 +1370,6 @@
|
||||
"directdomains": "Dominios Directos",
|
||||
"ipv4Routing": "Enrutamiento IPv4",
|
||||
"ipv4RoutingDesc": "Estas opciones solo enrutarán a los dominios objetivo a través de IPv4.",
|
||||
"warpRouting": "Enrutamiento WARP",
|
||||
"warpRoutingDesc": "Precaución: Antes de usar estas opciones, instale WARP en modo de proxy socks5 en su servidor siguiendo los pasos en el GitHub del panel. WARP enrutará el tráfico a los sitios web a través de los servidores de Cloudflare.",
|
||||
"nordRouting": "Enrutamiento NordVPN",
|
||||
"nordRoutingDesc": "Estas opciones enrutarán el tráfico basado en un destino específico a través de NordVPN.",
|
||||
"Template": "Plantilla de Configuración de Xray",
|
||||
"TemplateDesc": "Genera el archivo de configuración final de Xray basado en esta plantilla.",
|
||||
"FreedomStrategy": "Configurar Estrategia para el Protocolo Freedom",
|
||||
@@ -1491,10 +1383,7 @@
|
||||
"outboundTestUrlDesc": "URL usada al probar la conectividad del outbound",
|
||||
"Torrent": "Prohibir Uso de BitTorrent",
|
||||
"Inbounds": "Entradas",
|
||||
"InboundsDesc": "Cambia la plantilla de configuración para aceptar clientes específicos.",
|
||||
"Outbounds": "Salidas",
|
||||
"OutboundSubscriptions": "Suscripciones de salida",
|
||||
"OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
|
||||
"Balancers": "Equilibradores",
|
||||
"balancerTagRequired": "La etiqueta es obligatoria",
|
||||
"balancerSelectorRequired": "Elige al menos una salida",
|
||||
@@ -1513,9 +1402,7 @@
|
||||
"routeTesterMatchedOutbound": "Salida coincidente",
|
||||
"routeTesterViaBalancer": "vía balanceador",
|
||||
"routeTesterDefaultOutbound": "Ninguna regla de enrutamiento coincidió — el tráfico va a la salida predeterminada (primera).",
|
||||
"OutboundsDesc": "Cambia la plantilla de configuración para definir formas de salida para este servidor.",
|
||||
"Routings": "Reglas de enrutamiento",
|
||||
"RoutingsDesc": "¡La prioridad de cada regla es importante!",
|
||||
"completeTemplate": "Todo",
|
||||
"logLevel": "Nivel de registro",
|
||||
"logLevelDesc": "El nivel de registro para registros de errores, que indica la información que debe registrarse.",
|
||||
@@ -1529,13 +1416,9 @@
|
||||
"maskAddressDesc": "Máscara de dirección IP, cuando se habilita, reemplazará automáticamente la dirección IP que aparece en el registro.",
|
||||
"statistics": "Estadísticas",
|
||||
"statsInboundUplink": "Estadísticas de Subida de Entrada",
|
||||
"statsInboundUplinkDesc": "Habilita la recopilación de estadísticas para el tráfico ascendente de todos los proxies de entrada.",
|
||||
"statsInboundDownlink": "Estadísticas de Bajada de Entrada",
|
||||
"statsInboundDownlinkDesc": "Habilita la recopilación de estadísticas para el tráfico descendente de todos los proxies de entrada.",
|
||||
"statsOutboundUplink": "Estadísticas de Subida de Salida",
|
||||
"statsOutboundUplinkDesc": "Habilita la recopilación de estadísticas para el tráfico ascendente de todos los proxies de salida.",
|
||||
"statsOutboundDownlink": "Estadísticas de Bajada de Salida",
|
||||
"statsOutboundDownlinkDesc": "Habilita la recopilación de estadísticas para el tráfico descendente de todos los proxies de salida.",
|
||||
"connectionLimits": "Límites de conexión",
|
||||
"connectionLimitsDesc": "Políticas a nivel de conexión para el nivel de usuario 0. Deja un campo vacío para usar el valor predeterminado de Xray.",
|
||||
"connIdle": "Tiempo de inactividad",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"metricsListenDesc": "Expone las métricas estilo Prometheus de Xray en esta dirección:puerto (por ejemplo, 127.0.0.1:11111). Déjalo vacío para deshabilitarlo. Vincúlalo a localhost y ponlo tras un proxy inverso — no está autenticado.",
|
||||
"metricsTag": "Etiqueta de métricas",
|
||||
"rules": {
|
||||
"first": "Primero",
|
||||
"last": "Último",
|
||||
"up": "Arriba",
|
||||
"down": "Abajo",
|
||||
"source": "Fuente",
|
||||
"dest": "Destino",
|
||||
"inbound": "Entrante",
|
||||
"outbound": "Saliente",
|
||||
"balancer": "Equilibrador",
|
||||
"info": "Info",
|
||||
"add": "Agregar Regla",
|
||||
"edit": "Editar Regla",
|
||||
"useComma": "Elementos separados por comas"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "Regla {n}",
|
||||
"action": "Acción",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Reglas finales",
|
||||
"overrideXrayPrivateIp": "Sobrescribir el bloqueo de IP privada por defecto de Xray",
|
||||
"blockDelay": "Retraso de bloqueo (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "Intervalo keep alive",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Interfaz",
|
||||
"ipv6Only": "Solo IPv6",
|
||||
"acceptProxyProtocol": "Aceptar proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Agregar salida",
|
||||
"addReverse": "Agregar reverso",
|
||||
"editOutbound": "Editar salida",
|
||||
"editReverse": "Editar reverso",
|
||||
"reverseTag": "Etiqueta Reverso",
|
||||
"reverseTagDesc": "Etiqueta de salida del proxy inverso simple VLESS. Dejar vacío para deshabilitar. Cuando se establece, las conexiones de este cliente pueden usarse como túnel de proxy inverso.",
|
||||
"reverseTagPlaceholder": "etiqueta de salida (vacío para deshabilitar)",
|
||||
"tag": "Etiqueta",
|
||||
"tagDesc": "etiqueta única",
|
||||
"address": "Dirección",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Reverso",
|
||||
"domain": "Dominio",
|
||||
"type": "Tipo",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Enlace",
|
||||
"intercon": "Interconexión",
|
||||
"settings": "Configuración",
|
||||
"accountInfo": "Información de la Cuenta",
|
||||
"outboundStatus": "Estado de Salida",
|
||||
"sendThrough": "Enviar a través de",
|
||||
"targetStrategy": "Estrategia de destino",
|
||||
"test": "Probar",
|
||||
"testResult": "Resultado de la prueba",
|
||||
"testing": "Probando conexión...",
|
||||
"testSuccess": "Prueba exitosa",
|
||||
"testFailed": "Prueba fallida",
|
||||
"testError": "Error al probar la salida",
|
||||
"modeRealDelay": "Retardo real",
|
||||
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray. Retardo real: tiempo total incluyendo el establecimiento de la conexión.",
|
||||
"testAll": "Probar todo",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "Conexión al proxy",
|
||||
"breakdownTls": "TLS vía salida",
|
||||
"breakdownTtfb": "Primer byte",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Token de acceso",
|
||||
"country": "País",
|
||||
"server": "Servidor",
|
||||
"city": "Ciudad",
|
||||
"allCities": "Todas las ciudades",
|
||||
"privateKey": "Clave privada",
|
||||
"load": "Carga",
|
||||
"moveToTop": "Mover al principio"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "Suscripciones activas",
|
||||
"empty": "Aún no hay suscripciones. Añade una arriba.",
|
||||
"colRemark": "Notas",
|
||||
"colPrefix": "Prefijo",
|
||||
"colInterval": "Intervalo",
|
||||
"colLastFetch": "Última descarga",
|
||||
"colEnabled": "Habilitado",
|
||||
"auto": "auto",
|
||||
"never": "nunca",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"refreshNow": "Actualizar ahora",
|
||||
"lastError": "Último error",
|
||||
"deleteConfirm": "¿Eliminar esta suscripción?",
|
||||
"restartHint": "Después de añadir o actualizar, reinicia Xray (o espera a la próxima recarga automática) para activar las salidas.",
|
||||
"fromSubsTitle": "Desde suscripciones de salida (solo lectura)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "Ajustes del balanceador",
|
||||
"tabObservatory": "Observatorio",
|
||||
"observatory": {
|
||||
"title": "Observatorio",
|
||||
"burstTitle": "Observatorio Burst",
|
||||
"autoManaged": "Los observadores se gestionan automáticamente a partir de tus balanceadores. Ajusta abajo cómo sondean; las salidas vigiladas siguen los selectores del balanceador.",
|
||||
"emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que los balanceadores que usan observador puedan comprobar la salud de las salidas antes de elegir un destino.",
|
||||
"mixedLegacy": "Esta configuración contiene Observatory y Burst Observatory a la vez. Xray usa un único observador global, por lo que este estado mixto heredado no está soportado; al guardar balanceadores se normalizará a un solo observador.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "Balanceador {tag} — eliminado (sin destinos restantes)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Agregar equilibrador",
|
||||
"editBalancer": "Editar balanceador",
|
||||
"balancerStrategy": "Estrategia",
|
||||
"balancerSelectors": "Selectores",
|
||||
"tag": "Etiqueta",
|
||||
"tagDesc": "etiqueta única",
|
||||
"tagDuplicate": "Etiqueta ya usada por otro balanceador",
|
||||
"tagPlaceholder": "etiqueta única de balanceador",
|
||||
"selector": "Selector",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "Tolerancia",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag.",
|
||||
"costMatch": "Patrón de etiqueta",
|
||||
"costValue": "Peso",
|
||||
"costRegexp": "Coincidencia por expresión regular",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "Llave pública",
|
||||
"allowedIPs": "IP permitidas",
|
||||
"endpoint": "Punto final",
|
||||
"psk": "Clave precompartida",
|
||||
"domainStrategy": "Estrategia de dominio"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "Solo para AmneziaWG 2.0. Déjalo vacío para compatibilidad con 1.x."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "El nombre de la interfaz TUN. El valor predeterminado es 'xray0'",
|
||||
"mtuDesc": "Unidad Máxima de Transmisión. El tamaño máximo de los paquetes de datos. El valor predeterminado es 1500",
|
||||
"userLevel": "Nivel de Usuario",
|
||||
"userLevelDesc": "Todas las conexiones realizadas a través de este entrada utilizarán este nivel de usuario. El valor predeterminado es 0"
|
||||
"userLevel": "Nivel de Usuario"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Agregar DNS Falso",
|
||||
"edit": "Editar DNS Falso",
|
||||
"ipPool": "Subred del grupo de IP",
|
||||
"poolSize": "Tamaño del grupo"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Añadir",
|
||||
"month": "Mes",
|
||||
"months": "Meses",
|
||||
"day": "Día",
|
||||
"days": "Días",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Usuario de Telegram guardado.",
|
||||
"loginSuccess": "✅ Has iniciado sesión en el panel con éxito.\r\n",
|
||||
"loginFailed": "❗️ Falló el inicio de sesión en el panel.\r\n",
|
||||
"2faFailed": "Error de 2FA",
|
||||
"report": "🕰 Informes programados: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Fecha y Hora: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 Descarga: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Usuario de Telegram: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 Agotado {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 Cantidad de Agotados {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Clientes en línea: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Desactivado: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Actualizado en: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Sí",
|
||||
"no": "❌ No",
|
||||
"received_id": "🔑📥 ID actualizado.",
|
||||
"received_password": "🔑📥 Contraseña actualizada.",
|
||||
"received_email": "📧📥 Correo electrónico actualizado.",
|
||||
"received_comment": "💬📥 Comentario actualizado.",
|
||||
"id_prompt": "🔑 ID predeterminado: {{ .ClientId }}\n\nIntroduce tu ID.",
|
||||
"pass_prompt": "🔑 Contraseña predeterminada: {{ .ClientPassword }}\n\nIntroduce tu contraseña.",
|
||||
"email_prompt": "📧 Correo electrónico predeterminado: {{ .ClientEmail }}\n\nIntroduce tu correo electrónico.",
|
||||
"comment_prompt": "💬 Comentario predeterminado: {{ .ClientComment }}\n\nIntroduce tu comentario.",
|
||||
"inbound_client_data_id": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Correo: {{ .ClientEmail }}\n📊 Tráfico: {{ .ClientTraffic }}\n📅 Fecha de expiración: {{ .ClientExp }}\n🌐 Límite de IP: {{ .IpLimit }}\n💬 Comentario: {{ .ClientComment }}\n\n¡Ahora puedes agregar al cliente a la entrada!",
|
||||
"inbound_client_data_pass": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 Contraseña: {{ .ClientPass }}\n📧 Correo: {{ .ClientEmail }}\n📊 Tráfico: {{ .ClientTraffic }}\n📅 Fecha de expiración: {{ .ClientExp }}\n🌐 Límite de IP: {{ .IpLimit }}\n💬 Comentario: {{ .ClientComment }}\n\n¡Ahora puedes agregar al cliente a la entrada!",
|
||||
"cancel": "❌ ¡Proceso cancelado! \n\nPuedes /start de nuevo en cualquier momento. 🔄",
|
||||
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
|
||||
"using_default_value": "Está bien, me quedaré con el valor predeterminado. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Error: {{ .Error }}",
|
||||
"eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
|
||||
"eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
|
||||
"eventCPUHigh": "CPU alta",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
|
||||
"memoryThreshold": "Uso de memoria {{ .Percent }}% supera el umbral de {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Enviar como deshabilitado ☑️",
|
||||
"submitEnable": "Enviar como habilitado ✅",
|
||||
"use_default": "🏷️ Usar por defecto",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 Contraseña",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Comentario",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Reiniciar todo el tráfico",
|
||||
"SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
|
||||
"subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
|
||||
"subjectXrayCrash": "Xray se ha BLOQUEADO",
|
||||
"subjectCPUHigh": "CPU alta",
|
||||
"subjectLoginSuccess": "Inicio de sesión correcto",
|
||||
"subjectLoginFailed": "Inicio de sesión fallido",
|
||||
"titleOutboundDown": "Saliente CAÍDO",
|
||||
"titleOutboundUp": "Saliente ACTIVO",
|
||||
"titleXrayCrash": "Xray se ha BLOQUEADO",
|
||||
"titleCPUHigh": "CPU alta",
|
||||
"titleLoginSuccess": "Inicio de sesión correcto",
|
||||
"titleLoginFailed": "Inicio de sesión fallido",
|
||||
"labelStatus": "Estado",
|
||||
"labelOutbound": "Saliente",
|
||||
"labelNode": "Nodo",
|
||||
"labelError": "Error",
|
||||
"labelDelay": "Retardo",
|
||||
"labelDetail": "Detalle",
|
||||
"labelUsername": "Usuario",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Motivo",
|
||||
"labelSource": "Origen",
|
||||
"labelTime": "Hora",
|
||||
"statusCrashed": "BLOQUEADO",
|
||||
"statusRunning": "En ejecución",
|
||||
"statusHigh": "ALTA",
|
||||
"statusSuccess": "CORRECTO",
|
||||
"statusFailed": "FALLIDO",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "باموفقیت کپیشد",
|
||||
"sure": "مطمئن",
|
||||
"encryption": "رمزگذاری",
|
||||
"useIPv4ForHost": "از IPv4 برای میزبان استفاده کنید",
|
||||
"transmission": "راهاتصال",
|
||||
"host": "میزبان",
|
||||
"path": "مسیر",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "کد",
|
||||
"remained": "باقیمانده",
|
||||
"security": "امنیت",
|
||||
"secAlertTitle": "هشدارامنیتی",
|
||||
"secAlertSsl": "ایناتصالامن نیست. لطفا تازمانیکه تیالاس برای محافظت از دادهها فعال نشدهاست، از وارد کردن اطلاعات حساس خودداری کنید",
|
||||
"secAlertConf": "تنظیمات خاصی در برابر حملات آسیب پذیر هستند. توصیه میشود پروتکلهای امنیتی را برای جلوگیری از نفوذ احتمالی تقویت کنید",
|
||||
"secAlertSSL": "پنل فاقد ارتباط امن است. لطفاً یک گواهینامه تیالاس برای محافظت از دادهها نصب کنید",
|
||||
"secAlertPanelPort": "استفاده از پورت پیشفرض پنل ناامن است. لطفاً یک پورت تصادفی یا خاص تنظیم کنید",
|
||||
"secAlertPanelURI": "مسیر پیشفرض لینک پنل ناامن است. لطفاً یک مسیر پیچیده تنظیم کنید",
|
||||
"secAlertSubURI": "مسیر پیشفرض لینک سابسکریپشن ناامن است. لطفاً یک مسیر پیچیده تنظیم کنید",
|
||||
"secAlertSubJsonURI": "مسیر پیشفرض لینک سابسکریپشن جیسون ناامن است. لطفاً یک مسیر پیچیده تنظیم کنید",
|
||||
"emptyDnsDesc": "هیچ سرور DNS اضافه نشده است.",
|
||||
"emptyFakeDnsDesc": "هیچ سرور Fake DNS اضافه نشده است.",
|
||||
"emptyBalancersDesc": "هیچ بالانسر اضافه نشده است.",
|
||||
"emptyReverseDesc": "هیچ پروکسی معکوس اضافه نشده است.",
|
||||
"somethingWentWrong": "مشکلی پیش آمد",
|
||||
"subscription": {
|
||||
"title": "اطلاعات سابسکریپشن",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "تم",
|
||||
"dark": "تیره",
|
||||
"ultraDark": "فوق تیره",
|
||||
"dashboard": "نمای کلی",
|
||||
"inbounds": "ورودیها",
|
||||
"clients": "کلاینتها",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "مسیریابی",
|
||||
"outbounds": "خروجیها",
|
||||
"apiDocs": "مستندات API",
|
||||
"logout": "خروج",
|
||||
"link": "مدیریت",
|
||||
"donate": "حمایت مالی",
|
||||
"hosts": "میزبانها",
|
||||
"docs": "مستندات",
|
||||
"openMenu": "باز کردن منو"
|
||||
"openMenu": "باز کردن منو",
|
||||
"pinSidebar": "ثابت کردن نوار کناری",
|
||||
"unpinSidebar": "برداشتن تثبیت نوار کناری"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "نمای کلی",
|
||||
"cpu": "پردازنده",
|
||||
"logicalProcessors": "پردازندههای منطقی",
|
||||
"frequency": "فرکانس",
|
||||
"swap": "سواپ",
|
||||
"storage": "ذخیرهسازی",
|
||||
"memory": "حافظه",
|
||||
"threads": "نخها",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "توقف",
|
||||
"restartXray": "راهاندازی مجدد",
|
||||
"xraySwitch": "نسخه",
|
||||
"xrayUpdates": "بهروزرسانیهای Xray",
|
||||
"xraySwitchClick": "نسخه مورد نظر را انتخاب کنید",
|
||||
"xraySwitchClickDesk": "لطفا بادقت انتخاب کنید. درصورت انتخاب نسخه قدیمیتر، امکان ناهماهنگی با پیکربندی فعلی وجود دارد",
|
||||
"updatePanel": "بهروزرسانی پنل",
|
||||
"panelUpdateDesc": "این عملیات 3X-UI را به آخرین نسخه بهروزرسانی میکند و سرویس پنل را مجدداً راهاندازی میکند.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "کامیت فعلی",
|
||||
"latestCommit": "آخرین کامیت",
|
||||
"updateChannelChanged": "کانال بهروزرسانی تغییر کرد",
|
||||
"upToDate": "بهروز",
|
||||
"xrayStatusUnknown": "ناشناخته",
|
||||
"xrayStatusRunning": "در حال اجرا",
|
||||
"xrayStatusStop": "متوقف",
|
||||
"xrayStatusError": "خطا",
|
||||
"xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
|
||||
"operationHours": "مدتکارکرد",
|
||||
"systemHistoryTitle": "تاریخچه سیستم",
|
||||
"historyTitleCpu": "مصرف پردازنده",
|
||||
"historyTitleMem": "مصرف حافظه",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "بار",
|
||||
"historyTabConnections": "اتصالات",
|
||||
"historyTabDiskUsage": "مصرف دیسک",
|
||||
"charts": "نمودارها",
|
||||
"xrayMetricsTitle": "متریکهای Xray",
|
||||
"xrayTitleHeap": "حافظهی Heap تخصیصیافته",
|
||||
"xrayTitleSys": "حافظهی رزروشده از سیستمعامل",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "غیرفعال",
|
||||
"xrayObservatoryLastSeen": "آخرین مشاهده",
|
||||
"xrayObservatoryLastTry": "آخرین تلاش",
|
||||
"trendLast2Min": "۲ دقیقه اخیر",
|
||||
"systemLoad": "بارسیستم",
|
||||
"systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته",
|
||||
"connectionCount": "تعداد کانکشن ها",
|
||||
"ipAddresses": "آدرسهای IP",
|
||||
"toggleIpVisibility": "تغییر وضعیت نمایش IP",
|
||||
"overallSpeed": "سرعت کلی",
|
||||
"upload": "آپلود",
|
||||
"download": "دانلود",
|
||||
"totalData": "دادههای کل",
|
||||
"sent": "ارسال شده",
|
||||
"received": "دریافت شده",
|
||||
"documentation": "مستندات",
|
||||
"xraySwitchVersionDialog": "آیا واقعاً میخواهید نسخه Xray را تغییر دهید؟",
|
||||
"xraySwitchVersionDialogDesc": "این کار نسخه Xray را به #version# تغییر میدهد.",
|
||||
"xraySwitchVersionPopover": "Xray با موفقیت بهروز شد",
|
||||
"panelUpdateDialog": "آیا مطمئن هستید که میخواهید پنل را بهروزرسانی کنید؟",
|
||||
"panelUpdateDialogDesc": "این 3X-UI را به نسخه #version# بهروزرسانی کرده و سرویس پنل را مجدداً راهاندازی میکند.",
|
||||
"panelUpdateCheckPopover": "خطا در بررسی بهروزرسانی پنل",
|
||||
"panelUpdateStartedPopover": "بهروزرسانی پنل آغاز شد",
|
||||
"panelUpdateFailedTitle": "بهروزرسانی پنل ناموفق بود",
|
||||
"panelUpdateFailedDesc": "بهروزرسانی با موفقیت به پایان نرسید. گزارشهای سرور را بررسی کنید یا دستور «x-ui update» را از خط فرمان اجرا کنید.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "لاگهای دسترسی",
|
||||
"autoUpdate": "بهروزرسانی خودکار",
|
||||
"config": "پیکربندی",
|
||||
"backup": "پشتیبانگیری",
|
||||
"backupTitle": "پشتیبانگیری و بازیابی",
|
||||
"exportDatabase": "پشتیبانگیری",
|
||||
"exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید. همین فایل در پنلی که روی PostgreSQL اجرا میشود نیز قابل بازیابی است.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
|
||||
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاهدادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند.",
|
||||
"migrationDownload": "دانلود فایل مهاجرت",
|
||||
"migrationDownloadPgDesc": "برای دانلود یک پایگاهدادهٔ SQLite با پسوند .db که از دادههای PostgreSQL شما ساخته میشود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
|
||||
"migrationDownloadPgDesc": "برای دانلود یک پایگاهدادهٔ SQLite با پسوند .db که از دادههای PostgreSQL شما ساخته میشود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite.",
|
||||
"avg": "میانگین",
|
||||
"peak": "اوج",
|
||||
"free": "آزاد",
|
||||
"openSockets": "اتصال باز",
|
||||
"throughputSub": "کل اینترفیس",
|
||||
"avgWindow": "میانگین بازه",
|
||||
"healthWarm": "{list} — رو به گرم شدن",
|
||||
"healthCritical": "{list} — بحرانی",
|
||||
"panel": "پنل",
|
||||
"threads": "نخها",
|
||||
"uptime": "مدت کارکرد"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "ورودیها",
|
||||
"totalDownUp": "دریافت/ارسال کل",
|
||||
"totalUsage": "مصرف کل",
|
||||
"inboundCount": "کل ورودیها",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "پنل لوکال",
|
||||
"fallbacks": {
|
||||
"title": "Fallbackها",
|
||||
"help": "وقتی اتصالی روی این اینباند با هیچ کلاینتی تطبیق پیدا نمیکند، به جای دیگری ارجاع داده میشود. یک اینباند فرزند از پایین انتخاب کنید تا فیلدهای مسیریابی (SNI / ALPN / Path / xver) خودکار از روی transport آن پر شود، یا انتخابگر را خالی بگذارید و مقدار Dest را مستقیم تنظیم کنید (مثلاً 8080 یا 127.0.0.1:8080) تا به یک سرور بیرونی مانند Nginx ارجاع شود. هر اینباند فرزند باید روی 127.0.0.1 با security=none گوش بدهد.",
|
||||
"empty": "هنوز فالبکی اضافه نشده",
|
||||
"add": "افزودن فالبک",
|
||||
"pickInbound": "یک اینباند انتخاب کنید",
|
||||
"matchAny": "همه",
|
||||
"destPlaceholder": "خودکار (listen:port فرزند)",
|
||||
"rederive": "پر کردن مجدد از فرزند",
|
||||
"rederived": "از فرزند پر شد",
|
||||
"editAdvanced": "ویرایش فیلدهای مسیریابی",
|
||||
"hideAdvanced": "بستن پیشرفته",
|
||||
"quickAddAll": "افزودن سریع همهی موارد واجد شرایط",
|
||||
"quickAdded": "{n} فالبک افزوده شد",
|
||||
"quickAddedNone": "اینباند جدیدی برای افزودن وجود ندارد",
|
||||
"routesWhen": "هدایت میشود وقتی",
|
||||
"defaultCatchAll": "پیشفرض — همهی موارد دیگر را میگیرد",
|
||||
"needsTls": "فالبکها پس از انتخاب TLS یا Reality در برگهی امنیت در دسترس میشوند (فقط VLESS/Trojan روی RAW)."
|
||||
},
|
||||
"protocol": "پروتکل",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "نگاشت پورت",
|
||||
"traffic": "ترافیک",
|
||||
"speed": "سرعت",
|
||||
"details": "توضیحات",
|
||||
"transportConfig": "انتقال",
|
||||
"expireDate": "مدت زمان",
|
||||
"createdAt": "ایجاد",
|
||||
"updatedAt": "بهروزرسانی",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "افزودن ورودی",
|
||||
"generalActions": "عملیات کلی",
|
||||
"modifyInbound": "ویرایش ورودی",
|
||||
"deleteInbound": "حذف ورودی",
|
||||
"deleteInboundContent": "آیا مطمئن به حذف ورودی هستید؟",
|
||||
"deleteConfirmTitle": "اینباند «{remark}» حذف شود؟",
|
||||
"deleteConfirmContent": "این اینباند و تمام کلاینتهای آن حذف میشود. این عمل غیرقابل بازگشت است.",
|
||||
"resetConfirmTitle": "ترافیک اینباند «{remark}» صفر شود؟",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "همه-ورودیها",
|
||||
"exportAllSubsFileName": "همه-ورودیها-Subs",
|
||||
"inboundJsonTitle": "JSON ورودی",
|
||||
"deleteClient": "حذف کاربر",
|
||||
"deleteClientContent": "آیا مطمئن به حذف کاربر هستید؟",
|
||||
"resetTrafficContent": "آیا مطمئن به ریست ترافیک هستید؟",
|
||||
"copyLink": "کپی لینک",
|
||||
"address": "آدرس",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= نامحدود. (واحد: GB)",
|
||||
"totalFlow": "ترافیک کل",
|
||||
"leaveBlankToNeverExpire": "برای منقضینشدن خالیبگذارید",
|
||||
"noRecommendKeepDefault": "توصیهمیشود بهطور پیشفرض حفظشود",
|
||||
"certificatePath": "مسیر فایل",
|
||||
"certificateContent": "محتوای فایل",
|
||||
"publicKey": "کلید عمومی",
|
||||
"privatekey": "کلید خصوصی",
|
||||
"clickOnQRcode": "برای کپی بر روی کدتصویری کلیک کنید",
|
||||
"client": "کاربر",
|
||||
"export": "استخراج لینکها",
|
||||
"clone": "شبیهسازی",
|
||||
"cloneInbound": "شبیهسازی ورودی",
|
||||
"cloneInboundContent": "همه موارد این ورودی بجز پورت، آیپی و کاربرها شبیهسازی خواهند شد",
|
||||
"cloneInboundOk": "ساختن شبیه ساز",
|
||||
"resetAllTraffic": "ریست ترافیک کل ورودیها",
|
||||
"resetAllTrafficTitle": "ریست ترافیک کل ورودیها",
|
||||
"resetAllTrafficContent": "آیا مطمئن به ریست ترافیک تمام ورودیها هستید؟",
|
||||
"resetInboundClientTraffics": "ریست ترافیک کاربران",
|
||||
"resetInboundClientTrafficTitle": "ریست ترافیک کاربران",
|
||||
"resetInboundClientTrafficContent": "آیا مطمئن به ریست ترافیک تمام کاربران این ورودی هستید؟",
|
||||
"resetAllClientTraffics": "ریست ترافیک کل کاربران",
|
||||
"resetAllClientTrafficTitle": "ریست ترافیک کل کاربران",
|
||||
"resetAllClientTrafficContent": "آیا مطمئن به ریست ترافیک تمام کاربران هستید؟",
|
||||
"delDepletedClients": "حذف کاربران منقضی",
|
||||
"delDepletedClientsTitle": "حذف کاربران منقضی",
|
||||
"delDepletedClientsContent": "آیا مطمئن به حذف تمام کاربران منقضیشده هستید؟",
|
||||
"email": "ایمیل",
|
||||
"emailDesc": "باید یک ایمیل یکتا باشد",
|
||||
"IPLimit": "محدودیت آیپی",
|
||||
"IPLimitDesc": "(اگر تعداد از مقدار تنظیم شده بیشتر شود، ورودی را غیرفعال می کند. (0 = غیرفعال",
|
||||
"IPLimitlog": "گزارشها",
|
||||
"IPLimitlogDesc": "گزارش تاریخچه آیپی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید",
|
||||
"IPLimitlogclear": "پاک کردن گزارشها",
|
||||
"setDefaultCert": "استفاده از گواهی پنل",
|
||||
"setDefaultCertEmpty": "هیچ گواهیای برای پنل پیکربندی نشده. ابتدا از تنظیمات یکی تعیین کنید.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "ساختار بلوک sniffing در Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "ساختار بلوک stream در Xray:",
|
||||
"jsonErrorPrefix": "JSON پیشرفته"
|
||||
"streamHelp": "ساختار بلوک stream در Xray:"
|
||||
},
|
||||
"telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "شما میتوانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین میتوانید از همین نام برای چندین کاربر استفادهکنید",
|
||||
"subSortIndex": "ترتیب اشتراک",
|
||||
"same": "همسان",
|
||||
"inboundInfo": "اطلاعات ورودی",
|
||||
"exportInbound": "استخراج ورودی",
|
||||
"import": "افزودن",
|
||||
"importInbound": "افزودن یک ورودی",
|
||||
"periodicTrafficResetTitle": "بازنشانی ترافیک",
|
||||
"periodicTrafficResetDesc": "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص",
|
||||
"lastReset": "آخرین بازنشانی",
|
||||
"periodicTrafficResetDay": "روز بازنشانی ماهانه",
|
||||
"periodicTrafficReset": {
|
||||
"never": "هرگز",
|
||||
"daily": "روزانه",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "فراهمسازی",
|
||||
"updateSuccess": "بروزرسانی با موفقیت انجام شد",
|
||||
"logCleanSuccess": "لاگ پاکسازی شد",
|
||||
"inboundsUpdateSuccess": "ورودیها با موفقیت بهروزرسانی شدند",
|
||||
"inboundUpdateSuccess": "ورودی با موفقیت بهروزرسانی شد",
|
||||
"inboundCreateSuccess": "ورودی با موفقیت ایجاد شد",
|
||||
"bulkDeleted": "{count} اینباند حذف شد",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "کلاینت ورودی حذف شد",
|
||||
"inboundClientUpdateSuccess": "کلاینت ورودی بهروزرسانی شد",
|
||||
"savedNodeOfflineWillSync": "بهصورت محلی ذخیره شد. یک نود پشتیبان آفلاین یا غیرفعال است — تغییر پس از اتصال مجدد همگامسازی میشود.",
|
||||
"delDepletedClientsSuccess": "تمام کلاینتهای مصرف شده حذف شدند",
|
||||
"resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
|
||||
"resetAllTrafficSuccess": "تمام ترافیکها بازنشانی شدند",
|
||||
"resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد",
|
||||
@@ -648,6 +590,10 @@
|
||||
"maxTimeDiff": "حداکثر اختلاف زمان (ms)",
|
||||
"minClientVer": "حداقل نسخه کلاینت",
|
||||
"maxClientVer": "حداکثر نسخه کلاینت",
|
||||
"minClientVerHint": "خالی بودن به معنای بدون محدودیت نیست: در این حالت Xray-core حداقل داخلیِ نسخهٔ هستهای را که اجرا میکنید (در نسخههای فعلی 26.3.27) اعمال میکند و کلاینتهایی را که نسخهٔ قدیمیتری اعلام میکنند رد میکند — از جمله هستههای شخص ثالث مانند Mihomo و sing-box. مقدار 1.0.0 آنها را میپذیرد، به بهای پذیرش اثر انگشتهای TLS قدیمی.",
|
||||
"maxClientVerHint": "خالی یعنی بدون سقف. در صورت تنظیم، نباید از حداقلِ مؤثر — حداقل نسخه کلاینت، و در صورت خالی بودن آن فیلد، حداقل داخلی Xray-core — کمتر باشد، وگرنه همهٔ کلاینتها رد میشوند.",
|
||||
"clientVerInvalid": "نسخهٔ کلاینت باید حداکثر سه عدد جداشده با نقطه باشد، هر یک 0-255 (مثلاً 26.3.27)",
|
||||
"maxClientVerBelowMin": "حداکثر نسخهٔ کلاینت نباید از حداقل نسخهٔ کلاینت کمتر باشد",
|
||||
"shortIds": "Short IDها",
|
||||
"realityTargetHint": "الزامی است. باید شامل پورت باشد (مثلاً example.com:443). بدون پورت، Xray-core اجرا نمیشود.",
|
||||
"realityTargetRequired": "هدف REALITY الزامی است",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "پیکربندی Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "درخواست",
|
||||
"response": "پاسخ",
|
||||
"name": "نام",
|
||||
"value": "مقدار"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "نسخه",
|
||||
"method": "متد",
|
||||
"path": "مسیر",
|
||||
"status": "وضعیت",
|
||||
"statusDescription": "توضیحات وضعیت",
|
||||
"requestHeader": "سربرگ درخواست",
|
||||
"responseHeader": "سربرگ پاسخ"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "بازنویسی مقصد"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "افزودن سابسکریپشن خارجی",
|
||||
"noExternalLinks": "هنوز لینک خارجیای اضافه نشده.",
|
||||
"noExternalSubscriptions": "هنوز سابسکریپشن خارجیای اضافه نشده.",
|
||||
"add": "افزودن کلاینت",
|
||||
"edit": "ویرایش کلاینت",
|
||||
"submitAdd": "افزودن کلاینت",
|
||||
"submitEdit": "ذخیره تغییرات",
|
||||
"clientCount": "تعداد کلاینتها",
|
||||
"bulk": "افزودن گروهی",
|
||||
"copyFromInbound": "کپی کلاینتها از اینباند",
|
||||
"copyToInbound": "کپی کلاینتها به",
|
||||
"copySelected": "کپی انتخابشدهها",
|
||||
"copySource": "منبع",
|
||||
"copyEmailPreview": "پیشنمایش ایمیل خروجی",
|
||||
"copySelectSourceFirst": "ابتدا یک اینباند مبدأ انتخاب کنید.",
|
||||
"copyResult": "نتیجه کپی",
|
||||
"copyResultSuccess": "با موفقیت کپی شد",
|
||||
"copyResultNone": "چیزی برای کپی نیست: کلاینتی انتخاب نشده یا منبع خالی است",
|
||||
"copyResultErrors": "خطاهای کپی",
|
||||
"copyFlowLabel": "Flow برای کلاینتهای جدید (VLESS)",
|
||||
"copyFlowHint": "روی همه کلاینتهای کپیشده اعمال میشود. خالی بگذارید تا رد شود.",
|
||||
"selectAll": "انتخاب همه",
|
||||
"clearAll": "پاک کردن همه",
|
||||
"method": "روش",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "پسوند",
|
||||
"delayedStart": "شروع پس از اولین استفاده",
|
||||
"expireDays": "مدت اعتبار (روز)",
|
||||
"days": "روز",
|
||||
"renew": "تمدید خودکار",
|
||||
"renewDesc": "تمدید خودکار پس از انقضا. (۰ = غیرفعال) (واحد: روز)",
|
||||
"renewDays": "تمدید خودکار (روز)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "نزدیکترین انقضا",
|
||||
"has": "دارد",
|
||||
"hasNot": "ندارد",
|
||||
"title": "کلاینتها",
|
||||
"actions": "عملیات",
|
||||
"totalGB": "سقف حجم (گیگابایت)",
|
||||
"totalGBDesc": "سهمیهی حجم مصرفی کلاینت. ۰ = نامحدود",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "افزودن کلاینت",
|
||||
"qrCode": "کد QR",
|
||||
"clientInfo": "اطلاعات کلاینت",
|
||||
"delete": "حذف",
|
||||
"reset": "بازنشانی ترافیک",
|
||||
"editClient": "ویرایش کلاینت",
|
||||
"client": "کلاینت",
|
||||
"enabled": "فعال",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "لینکی برای اشتراکگذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.",
|
||||
"link": "لینک",
|
||||
"resetNotPossible": "ابتدا این کلاینت را به یک اینباند متصل کنید.",
|
||||
"general": "عمومی",
|
||||
"resetAllTraffics": "بازنشانی ترافیک همه کلاینتها",
|
||||
"resetAllTrafficsTitle": "بازنشانی ترافیک همه کلاینتها؟",
|
||||
"resetAllTrafficsContent": "شمارنده ارسال/دریافت همه کلاینتها به صفر میرسد. سهمیه و تاریخ انقضا تغییری نمیکند. این عمل غیرقابل بازگشت است.",
|
||||
"deleteConfirmTitle": "حذف کلاینت {email}؟",
|
||||
"deleteConfirmContent": "این کلاینت از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک میشود. این عمل غیرقابل بازگشت است.",
|
||||
"deleteSelected": "حذف ({count})",
|
||||
"adjustSelected": "تنظیم ({count})",
|
||||
"subLinksSelected": "لینکهای اشتراک ({count})",
|
||||
"addToGroupTitle": "افزودن {count} کاربر به یک گروه",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "{count} کلاینت غیرفعال شوند؟",
|
||||
"bulkDisableConfirmContent": "هر کلاینت انتخابشده روی تمام اینباندهای متصل غیرفعال میشود. دسترسی آنها بلافاصله قطع میشود اما رکورد و ترافیکشان حفظ میگردد.",
|
||||
"selectedCount": "{count} انتخابشده",
|
||||
"attachSelected": "الصاق ({count})",
|
||||
"attachToInboundsTitle": "الصاق {count} کاربر به ورودی(ها)",
|
||||
"attachToInboundsDesc": "{count} کاربر انتخابشده (همان UUID/رمز و ترافیک مشترک) را به ورودیهای انتخابی الصاق میکند. الصاقهای قبلی حفظ میشوند.",
|
||||
"attachToInboundsTargets": "ورودیهای مقصد",
|
||||
"attachToInboundsNoTargets": "هیچ ورودی چندکاربرهای برای الصاق در دسترس نیست.",
|
||||
"detachSelected": "جداسازی ({count})",
|
||||
"detach": "جداسازی",
|
||||
"detachFromInboundsTitle": "جداسازی {count} کاربر از ورودی(ها)",
|
||||
"detachFromInboundsDesc": "{count} کاربر انتخابشده را از ورودیهای انتخابی حذف میکند. در مواردی که کاربر الصاق نبوده، نادیده گرفته میشود. رکورد کاربر حفظ میشود (برای حذف کامل از Delete استفاده کنید).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
||||
"telegramId": "شناسه کاربر تلگرام",
|
||||
"telegramIdPlaceholder": "شناسه عددی کاربر تلگرام (۰ = هیچ)",
|
||||
"created": "ساختهشده",
|
||||
"updated": "بهروزشده",
|
||||
"ipLimit": "محدودیت IP",
|
||||
"toasts": {
|
||||
"deleted": "کلاینت حذف شد",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "گروهها",
|
||||
"name": "نام",
|
||||
"clientCount": "کاربران",
|
||||
"totalGroups": "تعداد گروهها",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "{count} کاربر از {name} حذف شد."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "نودها",
|
||||
"addNode": "افزودن نود",
|
||||
"editNode": "ویرایش نود",
|
||||
"totalNodes": "کل نودها",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت",
|
||||
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش احرازهویت → توکن API نمایش میدهد.",
|
||||
"apiTokenKeepHint": "برای حفظ توکن فعلی خالی بگذارید",
|
||||
"regenerate": "تولید مجدد توکن",
|
||||
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل میکند. هر پنل مرکزیای که از این توکن استفاده میکند تا زمان بهروزرسانی، دسترسیاش قطع میشود. ادامه میدهید؟",
|
||||
"allowPrivateAddress": "اجازه آدرس خصوصی",
|
||||
"allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
|
||||
"outboundTag": "خروجی اتصال",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "بهروزرسانی پنل",
|
||||
"updateSelected": "بهروزرسانی انتخابشدهها ({count})",
|
||||
"updateAvailable": "بهروزرسانی موجود",
|
||||
"upToDate": "بهروز",
|
||||
"updateConfirmTitle": "{count} نود به آخرین نسخه بهروزرسانی شوند؟",
|
||||
"updateConfirmContent": "هر نود انتخابشده آخرین نسخه را دانلود و روی آن ریاستارت میشود. فقط نودهای فعال و آنلاین بهروزرسانی میشوند.",
|
||||
"updateDevChannel": "بهروزرسانی به کانال دِو (آخرین کامیت)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "دریافت گواهی ممکن نشد"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "پیشفرض",
|
||||
"title": "تنظیمات پنل",
|
||||
"save": "ذخیره",
|
||||
"infoDesc": "برای اعمال تغییرات در این بخش باید پس از ذخیره کردن، پنل را ریستارت کنید",
|
||||
@@ -1150,6 +1050,8 @@
|
||||
"panelOutboundPh": "اتصال مستقیم",
|
||||
"remarkTemplate": "قالب ریمارک",
|
||||
"remarkTemplateDesc": "اگر پر شود، جای مدلِ ریمارک را برای همهی لینکهای اشتراک میگیرد — فرمت دلخواهت را با توکنهای متغیر بنویس (از دکمه برای درج استفاده کن). خالی = استفاده از مدلِ بالا.",
|
||||
"subShowIdentityOnAllLinks": "نمایش هویت در همه لینکها",
|
||||
"subShowIdentityOnAllLinksDesc": "در صورت فعال بودن، {{EMAIL}} و {{USERNAME}} در یادداشت هر لینک بدنه اشتراک باقی میمانند. توکنهای مصرف همچنان فقط در لینک اول نمایش داده میشوند.",
|
||||
"datepicker": "نوع تقویم",
|
||||
"datepickerPlaceholder": "انتخاب تاریخ",
|
||||
"datepickerDescription": "وظایف برنامه ریزی شده بر اساس این تقویم اجرا میشود",
|
||||
@@ -1448,13 +1350,8 @@
|
||||
"secretClearUndo": "لغو پاک کردن"
|
||||
},
|
||||
"xray": {
|
||||
"title": "پیکربندی ایکسری",
|
||||
"save": "ذخیره",
|
||||
"restart": "راهاندازی مجدد Xray",
|
||||
"restartSuccess": "Xray با موفقیت راهاندازی مجدد شد",
|
||||
"restartOutputTitle": "خروجی راهاندازی مجدد Xray",
|
||||
"restartConfirmTitle": "راهاندازی مجدد xray؟",
|
||||
"restartConfirmContent": "سرویس xray با پیکربندی ذخیرهشده دوباره بارگذاری میشود.",
|
||||
"stopSuccess": "Xray با موفقیت متوقف شد",
|
||||
"restartError": "خطا در راهاندازی مجدد Xray.",
|
||||
"stopError": "خطا در توقف Xray.",
|
||||
@@ -1464,7 +1361,6 @@
|
||||
"generalConfigsDesc": "این گزینهها استراتژی کلی ترافیک را تعیین میکنند",
|
||||
"logConfigs": "لاگ",
|
||||
"logConfigsDesc": "گزارشها ممکن است بر کارایی سرور شما تأثیر بگذارد. توصیه می شود فقط در صورت نیاز آن را عاقلانه فعال کنید",
|
||||
"blockConfigsDesc": "این گزینهها ترافیک را بر اساس پروتکلهای درخواستی خاص، و وب سایتها مسدود میکند",
|
||||
"basicRouting": "مسیریابی پایه",
|
||||
"blockConnectionsConfigsDesc": "این گزینهها ترافیک را بر اساس کشور درخواستشده خاص مسدود میکنند.",
|
||||
"directConnectionsConfigsDesc": "یک اتصال مستقیم تضمین میکند که ترافیک خاص از طریق سرور دیگری مسیریابی نشود.",
|
||||
@@ -1474,10 +1370,6 @@
|
||||
"directdomains": "دامنههای مستقیم",
|
||||
"ipv4Routing": "IPv4 مسیریابی",
|
||||
"ipv4RoutingDesc": "این گزینهها ترافیک را از طریق آیپی نسخه4 سرور، به مقصد هدایت میکند",
|
||||
"warpRouting": "WARP مسیریابی",
|
||||
"warpRoutingDesc": "این گزینهها ترافیک را از طریق وارپ کلادفلر به مقصد هدایت میکند",
|
||||
"nordRouting": "مسیریابی NordVPN",
|
||||
"nordRoutingDesc": "این گزینهها ترافیک را بر اساس مقصد خاص از طریق NordVPN مسیریابی میکنند.",
|
||||
"Template": "پیکربندی پیشرفته الگو ایکسری",
|
||||
"TemplateDesc": "فایل پیکربندی نهایی ایکسری بر اساس این الگو ایجاد میشود",
|
||||
"FreedomStrategy": "Freedom استراتژی پروتکل",
|
||||
@@ -1491,10 +1383,7 @@
|
||||
"outboundTestUrlDesc": "آدرسی که برای تست اتصال خروجی استفاده میشود.",
|
||||
"Torrent": "مسدودسازی پروتکل بیتتورنت",
|
||||
"Inbounds": "ورودیها",
|
||||
"InboundsDesc": "پذیرش کلاینت خاص",
|
||||
"Outbounds": "خروجیها",
|
||||
"OutboundSubscriptions": "سابسکریپشنهای خروجی",
|
||||
"OutboundSubscriptionsDesc": "خروجیها را از آدرسهای سابسکریپشن راهدور (vmess/vless/trojan/ss/...) وارد کنید. تگها ثابت میمانند تا در بالانسرها و قوانین مسیریابی قابل استفاده باشند. بهروزرسانیها بهصورت خودکار انجام میشوند.",
|
||||
"Balancers": "بالانسرها",
|
||||
"balancerTagRequired": "تگ الزامی است",
|
||||
"balancerSelectorRequired": "حداقل یک خروجی انتخاب کنید",
|
||||
@@ -1513,9 +1402,7 @@
|
||||
"routeTesterMatchedOutbound": "خروجی منطبق",
|
||||
"routeTesterViaBalancer": "از طریق بالانسر",
|
||||
"routeTesterDefaultOutbound": "هیچ قانونی منطبق نشد — ترافیک به خروجی پیشفرض (اولین خروجی) میرود.",
|
||||
"OutboundsDesc": "مسیر ترافیک خروجی را تنظیم کنید",
|
||||
"Routings": "قوانین مسیریابی",
|
||||
"RoutingsDesc": "اولویت هر قانون مهم است",
|
||||
"importRules": "ورود قوانین",
|
||||
"exportRules": "خروج قوانین",
|
||||
"importOutbounds": "ورود خروجیها",
|
||||
@@ -1534,13 +1421,9 @@
|
||||
"maskAddressDesc": "پوشش آدرس IP، هنگامی که فعال میشود، به طور خودکار آدرس IP که در لاگ ظاهر میشود را جایگزین میکند.",
|
||||
"statistics": "آمار",
|
||||
"statsInboundUplink": "آمار آپلود ورودی",
|
||||
"statsInboundUplinkDesc": "جمعآوری آمار برای ترافیک بالارو (آپلود) تمام پروکسیهای ورودی را فعال میکند.",
|
||||
"statsInboundDownlink": "آمار دانلود ورودی",
|
||||
"statsInboundDownlinkDesc": "جمعآوری آمار برای ترافیک پایینرو (دانلود) تمام پروکسیهای ورودی را فعال میکند.",
|
||||
"statsOutboundUplink": "آمار آپلود خروجی",
|
||||
"statsOutboundUplinkDesc": "جمعآوری آمار برای ترافیک بالارو (آپلود) تمام پروکسیهای خروجی را فعال میکند.",
|
||||
"statsOutboundDownlink": "آمار دانلود خروجی",
|
||||
"statsOutboundDownlinkDesc": "جمعآوری آمار برای ترافیک پایینرو (دانلود) تمام پروکسیهای خروجی را فعال میکند.",
|
||||
"metricsListen": "نقطه پایانی متریک",
|
||||
"metricsListenDesc": "متریکهای Xray (سبک Prometheus) را روی این آدرس:پورت در دسترس قرار میدهد (مثلاً 127.0.0.1:11111). برای غیرفعالکردن خالی بگذارید. روی localhost ببندید و با ریورسپروکسی ارائه دهید — احراز هویت ندارد.",
|
||||
"metricsTag": "تگ متریک",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "خودکار",
|
||||
"seconds": "ثانیه",
|
||||
"rules": {
|
||||
"first": "اولین",
|
||||
"last": "آخرین",
|
||||
"up": "بالا",
|
||||
"down": "پایین",
|
||||
"source": "مبدا",
|
||||
"dest": "مقصد",
|
||||
"inbound": "ورودی",
|
||||
"outbound": "خروجی",
|
||||
"balancer": "بالانسر",
|
||||
"info": "اطلاعات",
|
||||
"add": "افزودن قانون",
|
||||
"edit": "ویرایش قانون",
|
||||
"useComma": "موارد جدا شده با کاما"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "قانون {n}",
|
||||
"action": "عمل",
|
||||
"redirect": "بازهدایت",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "قوانین نهایی",
|
||||
"overrideXrayPrivateIp": "override بلاک پیشفرض IP خصوصی Xray",
|
||||
"blockDelay": "تأخیر بلاک (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "بازه Keep alive",
|
||||
"markFwmark": "علامت (fwmark)",
|
||||
"interface": "رابط",
|
||||
"ipv6Only": "فقط IPv6",
|
||||
"acceptProxyProtocol": "پذیرش Proxy Protocol",
|
||||
"proxyProtocol": "Proxy Protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "افزودن خروجی",
|
||||
"addReverse": "افزودن معکوس",
|
||||
"editOutbound": "ویرایش خروجی",
|
||||
"editReverse": "ویرایش معکوس",
|
||||
"reverseTag": "تگ معکوس",
|
||||
"reverseTagDesc": "تگ خروجی پروکسی معکوس ساده VLESS. برای غیرفعال کردن خالی بگذارید. در صورت تنظیم، اتصالات این کلاینت میتوانند به عنوان تونل پروکسی معکوس استفاده شوند.",
|
||||
"reverseTagPlaceholder": "تگ خروجی (خالی = غیرفعال)",
|
||||
"tag": "تگ",
|
||||
"tagDesc": "برچسب یگانه",
|
||||
"address": "آدرس",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "معکوس",
|
||||
"domain": "دامنه",
|
||||
"type": "نوع",
|
||||
"bridge": "پل",
|
||||
"portal": "پورتال",
|
||||
"link": "لینک",
|
||||
"intercon": "اتصال میانی",
|
||||
"settings": "تنظیمات",
|
||||
"accountInfo": "اطلاعات حساب",
|
||||
"outboundStatus": "وضعیت خروجی",
|
||||
"sendThrough": "ارسال با",
|
||||
"targetStrategy": "استراتژی مقصد",
|
||||
"test": "تست",
|
||||
"testResult": "نتیجه تست",
|
||||
"testing": "در حال تست اتصال...",
|
||||
"testSuccess": "تست موفقیتآمیز",
|
||||
"testFailed": "تست ناموفق",
|
||||
"testError": "خطا در تست خروجی",
|
||||
"modeRealDelay": "تأخیر واقعی",
|
||||
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray. تأخیر واقعی: کل زمان همراه با برقراری اتصال.",
|
||||
"testAll": "تست همه",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "اتصال پروکسی",
|
||||
"breakdownTls": "TLS از طریق خروجی",
|
||||
"breakdownTtfb": "اولین بایت",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "توکن دسترسی",
|
||||
"country": "کشور",
|
||||
"server": "سرور",
|
||||
"city": "شهر",
|
||||
"allCities": "همه شهرها",
|
||||
"privateKey": "کلید خصوصی",
|
||||
"load": "فشار سرور",
|
||||
"moveToTop": "انتقال به بالا"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "سابسکریپشنهای فعال",
|
||||
"empty": "هنوز سابسکریپشنی وجود ندارد. از بالا یکی اضافه کنید.",
|
||||
"colRemark": "نام",
|
||||
"colPrefix": "پیشوند",
|
||||
"colInterval": "بازه",
|
||||
"colLastFetch": "آخرین دریافت",
|
||||
"colEnabled": "فعال",
|
||||
"auto": "خودکار",
|
||||
"never": "هرگز",
|
||||
"yes": "بله",
|
||||
"no": "خیر",
|
||||
"refreshNow": "تازهسازی اکنون",
|
||||
"lastError": "آخرین خطا",
|
||||
"deleteConfirm": "این سابسکریپشن حذف شود؟",
|
||||
"restartHint": "پس از افزودن یا تازهسازی، برای فعالشدن خروجیها Xray را راهاندازی مجدد کنید (یا منتظر بارگذاری مجدد خودکار بعدی بمانید).",
|
||||
"fromSubsTitle": "از سابسکریپشنهای خروجی (فقطخواندنی)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "تنظیمات بالانسر",
|
||||
"tabObservatory": "رصدخانه",
|
||||
"observatory": {
|
||||
"title": "رصدخانه",
|
||||
"burstTitle": "رصدخانه Burst",
|
||||
"autoManaged": "رصدگرها بهصورت خودکار از روی بالانسرهای شما مدیریت میشوند. در ادامه میتوانید نحوهٔ پروبزدن را تنظیم کنید؛ خروجیهای تحت نظر از سلکتورهای بالانسر پیروی میکنند.",
|
||||
"emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — بهصورت خودکار یکی اضافه میشود تا بالانسرهای متکی به رصدگر بتوانند پیش از انتخاب مقصد، سلامت خروجیها را بررسی کنند.",
|
||||
"mixedLegacy": "این پیکربندی هم Observatory و هم Burst Observatory دارد. Xray فقط از یک رصدگر سراسری استفاده میکند، بنابراین این حالت قدیمیِ ترکیبی پشتیبانی نمیشود؛ ذخیرهٔ بالانسرها آن را به یک رصدگر عادیسازی میکند.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "بالانسر {tag} — حذف شد (هدفی باقی نماند)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "افزودن بالانسر",
|
||||
"editBalancer": "ویرایش بالانسر",
|
||||
"balancerStrategy": "استراتژی",
|
||||
"balancerSelectors": "انتخابگرها",
|
||||
"tag": "تگ",
|
||||
"tagDesc": "برچسب یگانه",
|
||||
"tagDuplicate": "این تگ توسط بالانسر دیگری استفاده شده است",
|
||||
"tagPlaceholder": "تگ منحصربهفرد بالانسر",
|
||||
"selector": "انتخابگر",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "تحمل",
|
||||
"baselines": "خطوط پایه",
|
||||
"costs": "هزینهها",
|
||||
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
|
||||
"costMatch": "الگوی برچسب",
|
||||
"costValue": "وزن",
|
||||
"costRegexp": "تطبیق با عبارت باقاعده",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "کلید عمومی",
|
||||
"allowedIPs": "آیپیهای مجاز",
|
||||
"endpoint": "نقطه پایانی",
|
||||
"psk": "کلید مشترک",
|
||||
"domainStrategy": "استراتژی حل دامنه"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "فقط برای AmneziaWG 2.0. برای سازگاری با نسخه ۱.x خالی بگذارید."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "نام رابط TUN. مقدار پیشفرض 'xray0' است",
|
||||
"mtuDesc": "واحد انتقال حداکثر. بیشترین اندازه بستههای داده. مقدار پیشفرض 1500 است",
|
||||
"userLevel": "سطح کاربر",
|
||||
"userLevelDesc": "تمام اتصالات انجامشده از طریق این ورودی از این سطح کاربری استفاده خواهند کرد. مقدار پیشفرض 0 است"
|
||||
"userLevel": "سطح کاربر"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "توکن دسترسی",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "افزودن دیاناس جعلی",
|
||||
"edit": "ویرایش دیاناس جعلی",
|
||||
"ipPool": "زیرشبکه استخر آیپی",
|
||||
"poolSize": "اندازه استخر"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "افزودن",
|
||||
"month": "ماه",
|
||||
"months": "ماه",
|
||||
"day": "روز",
|
||||
"days": "روز",
|
||||
"hours": "ساعت",
|
||||
"minutes": "دقیقه",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ کاربر تلگرام ذخیره شد.",
|
||||
"loginSuccess": "✅ با موفقیت به پنل وارد شدید.\r\n",
|
||||
"loginFailed": "❗️ ورود به پنل ناموفقبود \r\n",
|
||||
"2faFailed": "خطای 2FA",
|
||||
"report": "🕰 گزارشاتزمانبندیشده: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ تاریخوزمان: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 میزبان: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 دانلود: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 کل: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 کاربر تلگرام: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 {{ .Type }} بهاتمامرسیدهاست:\r\n",
|
||||
"exhaustedCount": "🚨 تعداد {{ .Type }} بهاتمامرسیدهاست:\r\n",
|
||||
"onlinesCount": "🌐 کاربرانآنلاین: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 غیرفعال: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 تازهسازی شده در: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ بله",
|
||||
"no": "❌ خیر",
|
||||
"received_id": "🔑📥 شناسه بهروزرسانی شد.",
|
||||
"received_password": "🔑📥 رمز عبور بهروزرسانی شد.",
|
||||
"received_email": "📧📥 ایمیل بهروزرسانی شد.",
|
||||
"received_comment": "💬📥 نظر بهروزرسانی شد.",
|
||||
"id_prompt": "🔑 شناسه پیشفرض: {{ .ClientId }}\n\nشناسه خود را وارد کنید.",
|
||||
"pass_prompt": "🔑 رمز عبور پیشفرض: {{ .ClientPassword }}\n\nرمز عبور خود را وارد کنید.",
|
||||
"email_prompt": "📧 ایمیل پیشفرض: {{ .ClientEmail }}\n\nایمیل خود را وارد کنید.",
|
||||
"comment_prompt": "💬 نظر پیشفرض: {{ .ClientComment }}\n\nنظر خود را وارد کنید.",
|
||||
"inbound_client_data_id": "🔄 ورودی: {{ .InboundRemark }}\n\n🔑 شناسه: {{ .ClientId }}\n📧 ایمیل: {{ .ClientEmail }}\n📊 ترافیک: {{ .ClientTraffic }}\n📅 تاریخ انقضا: {{ .ClientExp }}\n🌐 محدودیت IP: {{ .IpLimit }}\n💬 توضیح: {{ .ClientComment }}\n\nاکنون میتونی مشتری را به ورودی اضافه کنی!",
|
||||
"inbound_client_data_pass": "🔄 ورودی: {{ .InboundRemark }}\n\n🔑 رمز عبور: {{ .ClientPass }}\n📧 ایمیل: {{ .ClientEmail }}\n📊 ترافیک: {{ .ClientTraffic }}\n📅 تاریخ انقضا: {{ .ClientExp }}\n🌐 محدودیت IP: {{ .IpLimit }}\n💬 توضیح: {{ .ClientComment }}\n\nاکنون میتونی مشتری را به ورودی اضافه کنی!",
|
||||
"cancel": "❌ فرآیند لغو شد! \n\nمیتوانید هر زمان که خواستید /start را دوباره اجرا کنید. 🔄",
|
||||
"error_add_client": "⚠️ خطا:\n\n {{ .error }}",
|
||||
"using_default_value": "باشه، از مقدار پیشفرض استفاده میکنم. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "خطا: {{ .Error }}",
|
||||
"eventNodeDown": "نود {{ .Name }} قطع است",
|
||||
"eventNodeUp": "نود {{ .Name }} وصل است",
|
||||
"eventCPUHigh": "بالا بودن CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "ورود ناموفق از {{ .Source }}",
|
||||
"memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "ارسال به عنوان غیرفعال ☑️",
|
||||
"submitEnable": "ارسال به عنوان فعال ✅",
|
||||
"use_default": "🏷️ استفاده از پیشفرض",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 گذرواژه",
|
||||
"change_email": "⚙️📧 ایمیل",
|
||||
"change_comment": "⚙️💬 نظر",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "بازنشانی همه ترافیکها",
|
||||
"SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتبشده"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "خروجی {{ .Tag }} قطع است",
|
||||
"subjectOutboundUp": "خروجی {{ .Tag }} وصل است",
|
||||
"subjectXrayCrash": "Xray کرش کرد",
|
||||
"subjectCPUHigh": "بالا بودن CPU",
|
||||
"subjectLoginSuccess": "ورود موفق",
|
||||
"subjectLoginFailed": "ورود ناموفق",
|
||||
"titleOutboundDown": "خروجی قطع شد",
|
||||
"titleOutboundUp": "خروجی وصل شد",
|
||||
"titleXrayCrash": "Xray کرش کرد",
|
||||
"titleCPUHigh": "بالا بودن CPU",
|
||||
"titleLoginSuccess": "ورود موفق",
|
||||
"titleLoginFailed": "ورود ناموفق",
|
||||
"labelStatus": "وضعیت",
|
||||
"labelOutbound": "خروجی",
|
||||
"labelNode": "نود",
|
||||
"labelError": "خطا",
|
||||
"labelDelay": "تأخیر",
|
||||
"labelDetail": "جزئیات",
|
||||
"labelUsername": "نامکاربری",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "دلیل",
|
||||
"labelSource": "مبدأ",
|
||||
"labelTime": "زمان",
|
||||
"statusCrashed": "کرش کرد",
|
||||
"statusRunning": "در حال اجرا",
|
||||
"statusHigh": "بالا",
|
||||
"statusSuccess": "موفق",
|
||||
"statusFailed": "ناموفق",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Berhasil Disalin",
|
||||
"sure": "Yakin",
|
||||
"encryption": "Enkripsi",
|
||||
"useIPv4ForHost": "Gunakan IPv4 untuk host",
|
||||
"transmission": "Transmisi",
|
||||
"host": "Host",
|
||||
"path": "Path",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Kode",
|
||||
"remained": "Tersisa",
|
||||
"security": "Keamanan",
|
||||
"secAlertTitle": "Peringatan keamanan",
|
||||
"secAlertSsl": "Koneksi ini tidak aman. Harap hindari memasukkan informasi sensitif sampai TLS diaktifkan untuk perlindungan data.",
|
||||
"secAlertConf": "Beberapa pengaturan rentan terhadap serangan. Disarankan untuk memperkuat protokol keamanan guna mencegah pelanggaran potensial.",
|
||||
"secAlertSSL": "Panel kekurangan koneksi yang aman. Harap instal sertifikat TLS untuk perlindungan data.",
|
||||
"secAlertPanelPort": "Port default panel rentan. Harap konfigurasi port acak atau tertentu.",
|
||||
"secAlertPanelURI": "Jalur URI default panel tidak aman. Harap konfigurasi jalur URI kompleks.",
|
||||
"secAlertSubURI": "Jalur URI default langganan tidak aman. Harap konfigurasi jalur URI kompleks.",
|
||||
"secAlertSubJsonURI": "Jalur URI default JSON langganan tidak aman. Harap konfigurasikan jalur URI kompleks.",
|
||||
"emptyDnsDesc": "Tidak ada server DNS yang ditambahkan.",
|
||||
"emptyFakeDnsDesc": "Tidak ada server Fake DNS yang ditambahkan.",
|
||||
"emptyBalancersDesc": "Tidak ada penyeimbang yang ditambahkan.",
|
||||
"emptyReverseDesc": "Tidak ada proxy terbalik yang ditambahkan.",
|
||||
"somethingWentWrong": "Terjadi kesalahan",
|
||||
"subscription": {
|
||||
"title": "Info langganan",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
"dark": "Gelap",
|
||||
"ultraDark": "Sangat Gelap",
|
||||
"dashboard": "Ikhtisar",
|
||||
"inbounds": "Inbound",
|
||||
"clients": "Klien",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "Pengalihan",
|
||||
"outbounds": "Outbound",
|
||||
"apiDocs": "Dokumentasi API",
|
||||
"logout": "Keluar",
|
||||
"link": "Kelola",
|
||||
"donate": "Donasi",
|
||||
"hosts": "Host",
|
||||
"docs": "Dokumentasi",
|
||||
"openMenu": "Buka menu"
|
||||
"openMenu": "Buka menu",
|
||||
"pinSidebar": "Sematkan bilah sisi",
|
||||
"unpinSidebar": "Lepas sematan bilah sisi"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Ikhtisar",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "Prosesor logis",
|
||||
"frequency": "Frekuensi",
|
||||
"swap": "Swap",
|
||||
"storage": "Penyimpanan",
|
||||
"memory": "Memori",
|
||||
"threads": "Thread",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Hentikan",
|
||||
"restartXray": "Mulai ulang",
|
||||
"xraySwitch": "Versi",
|
||||
"xrayUpdates": "Pembaruan Xray",
|
||||
"xraySwitchClick": "Pilih versi yang ingin Anda pindah.",
|
||||
"xraySwitchClickDesk": "Pilih dengan hati-hati, karena versi yang lebih lama mungkin tidak kompatibel dengan konfigurasi saat ini.",
|
||||
"updatePanel": "Perbarui Panel",
|
||||
"panelUpdateDesc": "Ini akan memperbarui 3X-UI ke rilis terbaru dan me-restart layanan panel.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Commit saat ini",
|
||||
"latestCommit": "Commit terbaru",
|
||||
"updateChannelChanged": "Kanal pembaruan diubah",
|
||||
"upToDate": "Terbaru",
|
||||
"xrayStatusUnknown": "Tidak diketahui",
|
||||
"xrayStatusRunning": "Berjalan",
|
||||
"xrayStatusStop": "Berhenti",
|
||||
"xrayStatusError": "Error",
|
||||
"xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
|
||||
"operationHours": "Waktu Aktif",
|
||||
"systemHistoryTitle": "Riwayat Sistem",
|
||||
"historyTitleCpu": "Penggunaan CPU",
|
||||
"historyTitleMem": "Penggunaan Memori",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Beban",
|
||||
"historyTabConnections": "Koneksi",
|
||||
"historyTabDiskUsage": "Penggunaan Disk",
|
||||
"charts": "Grafik",
|
||||
"xrayMetricsTitle": "Metrik Xray",
|
||||
"xrayTitleHeap": "Memori Heap Teralokasi",
|
||||
"xrayTitleSys": "Memori Dicadangkan dari OS",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Mati",
|
||||
"xrayObservatoryLastSeen": "Terakhir terlihat",
|
||||
"xrayObservatoryLastTry": "Percobaan terakhir",
|
||||
"trendLast2Min": "2 menit terakhir",
|
||||
"systemLoad": "Beban Sistem",
|
||||
"systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir",
|
||||
"connectionCount": "Statistik Koneksi",
|
||||
"ipAddresses": "Alamat IP",
|
||||
"toggleIpVisibility": "Alihkan visibilitas IP",
|
||||
"overallSpeed": "Kecepatan keseluruhan",
|
||||
"upload": "Unggah",
|
||||
"download": "Unduh",
|
||||
"totalData": "Total data",
|
||||
"sent": "Dikirim",
|
||||
"received": "Diterima",
|
||||
"documentation": "Dokumentasi",
|
||||
"xraySwitchVersionDialog": "Apakah Anda yakin ingin mengubah versi Xray?",
|
||||
"xraySwitchVersionDialogDesc": "Ini akan mengubah versi Xray ke #version#.",
|
||||
"xraySwitchVersionPopover": "Xray berhasil diperbarui",
|
||||
"panelUpdateDialog": "Apakah Anda benar-benar ingin memperbarui panel?",
|
||||
"panelUpdateDialogDesc": "Ini akan memperbarui 3X-UI ke #version# dan me-restart layanan panel.",
|
||||
"panelUpdateCheckPopover": "Pemeriksaan pembaruan panel gagal",
|
||||
"panelUpdateStartedPopover": "Pembaruan panel dimulai",
|
||||
"panelUpdateFailedTitle": "Pembaruan panel gagal",
|
||||
"panelUpdateFailedDesc": "Pembaruan tidak selesai dengan sukses. Periksa log server, atau jalankan 'x-ui update' dari baris perintah.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Log Akses",
|
||||
"autoUpdate": "Pembaruan Otomatis",
|
||||
"config": "Konfigurasi",
|
||||
"backup": "Cadangan",
|
||||
"backupTitle": "Cadangan & Pulihkan",
|
||||
"exportDatabase": "Cadangkan",
|
||||
"exportDatabaseDesc": "Klik untuk mengunduh file .db yang berisi cadangan dari database Anda saat ini ke perangkat Anda. Berkas yang sama juga dapat dipulihkan ke panel yang berjalan di PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
|
||||
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
|
||||
"migrationDownload": "Unduh migrasi",
|
||||
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
|
||||
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite.",
|
||||
"avg": "rerata",
|
||||
"peak": "puncak",
|
||||
"free": "tersisa",
|
||||
"openSockets": "soket terbuka",
|
||||
"throughputSub": "Total antarmuka",
|
||||
"avgWindow": "Rerata periode",
|
||||
"healthWarm": "{list} — mulai tinggi",
|
||||
"healthCritical": "{list} — kritis",
|
||||
"panel": "Panel",
|
||||
"threads": "Thread",
|
||||
"uptime": "Waktu aktif"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Inbound",
|
||||
"totalDownUp": "Total Terkirim/Diterima",
|
||||
"totalUsage": "Penggunaan Total",
|
||||
"inboundCount": "Total Masuk",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Panel lokal",
|
||||
"fallbacks": {
|
||||
"title": "Fallback",
|
||||
"help": "Saat koneksi pada inbound ini tidak cocok dengan client mana pun, arahkan ke tempat lain. Pilih inbound child di bawah untuk mengisi otomatis field routing (SNI / ALPN / Path / xver) dari transport-nya, atau biarkan pemilih kosong dan atur Dest langsung (mis. 8080 atau 127.0.0.1:8080) untuk mengarahkan ke server eksternal seperti Nginx. Setiap inbound child harus listen di 127.0.0.1 dengan security=none.",
|
||||
"empty": "Belum ada fallback",
|
||||
"add": "Tambah fallback",
|
||||
"pickInbound": "Pilih inbound",
|
||||
"matchAny": "apa pun",
|
||||
"destPlaceholder": "otomatis (listen:port child)",
|
||||
"rederive": "Isi ulang dari child",
|
||||
"rederived": "Diisi ulang dari child",
|
||||
"editAdvanced": "Edit field routing",
|
||||
"hideAdvanced": "Sembunyikan lanjutan",
|
||||
"quickAddAll": "Tambah cepat semua yang memenuhi syarat",
|
||||
"quickAdded": "Menambahkan {n} fallback",
|
||||
"quickAddedNone": "Tidak ada inbound baru yang memenuhi syarat",
|
||||
"routesWhen": "Diarahkan ketika",
|
||||
"defaultCatchAll": "Default — menangkap apa pun lainnya",
|
||||
"needsTls": "Fallback tersedia setelah memilih TLS atau Reality di tab Keamanan (hanya VLESS/Trojan melalui RAW)."
|
||||
},
|
||||
"protocol": "Protokol",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Pemetaan port",
|
||||
"traffic": "Trafik",
|
||||
"speed": "Kecepatan",
|
||||
"details": "Rincian",
|
||||
"transportConfig": "Transport",
|
||||
"expireDate": "Durasi",
|
||||
"createdAt": "Dibuat",
|
||||
"updatedAt": "Diperbarui",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Tambahkan Masuk",
|
||||
"generalActions": "Tindakan Umum",
|
||||
"modifyInbound": "Ubah Masuk",
|
||||
"deleteInbound": "Hapus Masuk",
|
||||
"deleteInboundContent": "Apakah Anda yakin ingin menghapus masuk?",
|
||||
"deleteConfirmTitle": "Hapus inbound \"{remark}\"?",
|
||||
"deleteConfirmContent": "Tindakan ini menghapus inbound beserta semua kliennya. Tidak dapat dibatalkan.",
|
||||
"resetConfirmTitle": "Reset trafik \"{remark}\"?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "Semua-Inbound",
|
||||
"exportAllSubsFileName": "Semua-Inbound-Subs",
|
||||
"inboundJsonTitle": "JSON inbound",
|
||||
"deleteClient": "Hapus Klien",
|
||||
"deleteClientContent": "Apakah Anda yakin ingin menghapus klien?",
|
||||
"resetTrafficContent": "Apakah Anda yakin ingin mereset traffic?",
|
||||
"copyLink": "Salin URL",
|
||||
"address": "Alamat",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Tanpa batas. (satuan: GB)",
|
||||
"totalFlow": "Total Aliran",
|
||||
"leaveBlankToNeverExpire": "Biarkan kosong untuk tidak pernah kedaluwarsa",
|
||||
"noRecommendKeepDefault": "Disarankan untuk tetap menggunakan pengaturan default",
|
||||
"certificatePath": "Path Berkas",
|
||||
"certificateContent": "Konten Berkas",
|
||||
"publicKey": "Kunci Publik",
|
||||
"privatekey": "Kunci Pribadi",
|
||||
"clickOnQRcode": "Klik pada Kode QR untuk Menyalin",
|
||||
"client": "Klien",
|
||||
"export": "Ekspor Semua URL",
|
||||
"clone": "Duplikat",
|
||||
"cloneInbound": "Duplikat",
|
||||
"cloneInboundContent": "Semua pengaturan masuk ini, kecuali Port, Listening IP, dan Klien, akan diterapkan pada duplikat.",
|
||||
"cloneInboundOk": "Duplikat",
|
||||
"resetAllTraffic": "Reset Semua Traffic Masuk",
|
||||
"resetAllTrafficTitle": "Reset Semua Traffic Masuk",
|
||||
"resetAllTrafficContent": "Apakah Anda yakin ingin mereset traffic semua masuk?",
|
||||
"resetInboundClientTraffics": "Reset Traffic Klien Masuk",
|
||||
"resetInboundClientTrafficTitle": "Reset Traffic Klien Masuk",
|
||||
"resetInboundClientTrafficContent": "Apakah Anda yakin ingin mereset traffic klien masuk ini?",
|
||||
"resetAllClientTraffics": "Reset Traffic Semua Klien",
|
||||
"resetAllClientTrafficTitle": "Reset Traffic Semua Klien",
|
||||
"resetAllClientTrafficContent": "Apakah Anda yakin ingin mereset traffic semua klien?",
|
||||
"delDepletedClients": "Hapus Klien Habis",
|
||||
"delDepletedClientsTitle": "Hapus Klien Habis",
|
||||
"delDepletedClientsContent": "Apakah Anda yakin ingin menghapus semua klien yang habis?",
|
||||
"email": "Email",
|
||||
"emailDesc": "Harap berikan alamat email yang unik.",
|
||||
"IPLimit": "Batas IP",
|
||||
"IPLimitDesc": "Menonaktifkan masuk jika jumlah melebihi nilai yang ditetapkan. (0 = nonaktif)",
|
||||
"IPLimitlog": "Log IP",
|
||||
"IPLimitlogDesc": "Log histori IP. (untuk mengaktifkan masuk setelah menonaktifkan, hapus log)",
|
||||
"IPLimitlogclear": "Hapus Log",
|
||||
"setDefaultCert": "Atur Sertifikat dari Panel",
|
||||
"setDefaultCertEmpty": "Tidak ada sertifikat yang dikonfigurasi untuk panel. Atur dulu di Pengaturan.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Pembungkus blok sniffing Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Pembungkus blok stream Xray:",
|
||||
"jsonErrorPrefix": "JSON lanjutan"
|
||||
"streamHelp": "Pembungkus blok stream Xray:"
|
||||
},
|
||||
"telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.",
|
||||
"subSortIndex": "Urutan sub",
|
||||
"same": "Sama",
|
||||
"inboundInfo": "Informasi Inbound",
|
||||
"exportInbound": "Ekspor Masuk",
|
||||
"import": "Impor",
|
||||
"importInbound": "Impor Masuk",
|
||||
"periodicTrafficResetTitle": "Reset Trafik Berkala",
|
||||
"periodicTrafficResetDesc": "Reset otomatis penghitung trafik pada interval tertentu",
|
||||
"lastReset": "Reset Terakhir",
|
||||
"periodicTrafficResetDay": "Hari reset bulanan",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Tidak Pernah",
|
||||
"daily": "Harian",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Dapatkan",
|
||||
"updateSuccess": "Pembaruan berhasil",
|
||||
"logCleanSuccess": "Log telah dibersihkan",
|
||||
"inboundsUpdateSuccess": "Inbound berhasil diperbarui",
|
||||
"inboundUpdateSuccess": "Inbound berhasil diperbarui",
|
||||
"inboundCreateSuccess": "Inbound berhasil dibuat",
|
||||
"bulkDeleted": "{count} inbound dihapus",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Klien inbound telah dihapus",
|
||||
"inboundClientUpdateSuccess": "Klien inbound telah diperbarui",
|
||||
"savedNodeOfflineWillSync": "Disimpan secara lokal. Node pendukung sedang offline atau dinonaktifkan — perubahan akan disinkronkan setelah terhubung kembali.",
|
||||
"delDepletedClientsSuccess": "Semua klien yang habis telah dihapus",
|
||||
"resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset",
|
||||
"resetAllTrafficSuccess": "Semua lalu lintas telah direset",
|
||||
"resetInboundClientTrafficSuccess": "Lalu lintas telah direset",
|
||||
@@ -636,6 +578,10 @@
|
||||
"maxTimeDiff": "Maks. selisih waktu (ms)",
|
||||
"minClientVer": "Min. versi klien",
|
||||
"maxClientVer": "Maks. versi klien",
|
||||
"minClientVerHint": "Kosong bukan berarti tanpa batas: Xray-core akan memakai minimum bawaan dari build core yang dijalankan (26.3.27 pada rilis saat ini) dan menolak klien yang melaporkan versi lebih lama — termasuk core pihak ketiga seperti Mihomo dan sing-box. Isi 1.0.0 untuk menerimanya, dengan risiko mengizinkan sidik jari TLS yang usang.",
|
||||
"maxClientVerHint": "Kosong berarti tanpa batas atas. Jika diisi, tidak boleh lebih rendah dari minimum efektif — versi klien minimum, atau minimum bawaan Xray-core saat kolom itu kosong — atau semua klien akan ditolak.",
|
||||
"clientVerInvalid": "Versi klien harus berupa maksimal tiga angka dipisah titik, masing-masing 0-255 (mis. 26.3.27)",
|
||||
"maxClientVerBelowMin": "Versi klien maksimum tidak boleh lebih rendah dari versi klien minimum",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Wajib. Harus menyertakan port (mis. example.com:443). Tanpa port, Xray-core menolak untuk mulai.",
|
||||
"realityTargetRequired": "Target REALITY wajib diisi",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Konfig Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "Permintaan",
|
||||
"response": "Respons",
|
||||
"name": "Nama",
|
||||
"value": "Nilai"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Versi",
|
||||
"method": "Metode",
|
||||
"path": "Path",
|
||||
"status": "Status",
|
||||
"statusDescription": "Deskripsi Status",
|
||||
"requestHeader": "Header Permintaan",
|
||||
"responseHeader": "Header Respons"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Penggantian tujuan"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Tambah Langganan Eksternal",
|
||||
"noExternalLinks": "Belum ada tautan eksternal.",
|
||||
"noExternalSubscriptions": "Belum ada langganan eksternal.",
|
||||
"add": "Tambah klien",
|
||||
"edit": "Ubah klien",
|
||||
"submitAdd": "Tambah klien",
|
||||
"submitEdit": "Simpan perubahan",
|
||||
"clientCount": "Jumlah klien",
|
||||
"bulk": "Tambah massal",
|
||||
"copyFromInbound": "Salin klien dari inbound",
|
||||
"copyToInbound": "Salin klien ke",
|
||||
"copySelected": "Salin terpilih",
|
||||
"copySource": "Sumber",
|
||||
"copyEmailPreview": "Pratinjau email hasil",
|
||||
"copySelectSourceFirst": "Pilih inbound sumber terlebih dahulu.",
|
||||
"copyResult": "Hasil salinan",
|
||||
"copyResultSuccess": "Berhasil disalin",
|
||||
"copyResultNone": "Tidak ada yang disalin: tidak ada klien terpilih atau sumber kosong",
|
||||
"copyResultErrors": "Kesalahan salin",
|
||||
"copyFlowLabel": "Flow untuk klien baru (VLESS)",
|
||||
"copyFlowHint": "Diterapkan ke semua klien yang disalin. Kosongkan untuk dilewati.",
|
||||
"selectAll": "Pilih semua",
|
||||
"clearAll": "Hapus semua",
|
||||
"method": "Metode",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Akhiran",
|
||||
"delayedStart": "Mulai setelah penggunaan pertama",
|
||||
"expireDays": "Durasi (hari)",
|
||||
"days": "Hari",
|
||||
"renew": "Perpanjangan otomatis",
|
||||
"renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif) (satuan: hari)",
|
||||
"renewDays": "Perpanjangan otomatis (hari)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Segera kedaluwarsa",
|
||||
"has": "Memiliki",
|
||||
"hasNot": "Tidak memiliki",
|
||||
"title": "Klien",
|
||||
"actions": "Aksi",
|
||||
"totalGB": "Batas Trafik (GB)",
|
||||
"totalGBDesc": "Kuota data untuk klien ini. 0 = tidak terbatas.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Tambah klien",
|
||||
"qrCode": "Kode QR",
|
||||
"clientInfo": "Informasi Klien",
|
||||
"delete": "Hapus",
|
||||
"reset": "Reset lalu lintas",
|
||||
"editClient": "Ubah klien",
|
||||
"client": "Klien",
|
||||
"enabled": "Aktif",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "Tidak ada tautan yang bisa dibagikan — lampirkan klien ini ke inbound yang mendukung protokol terlebih dahulu.",
|
||||
"link": "Tautan",
|
||||
"resetNotPossible": "Lampirkan klien ini ke inbound terlebih dahulu.",
|
||||
"general": "Umum",
|
||||
"resetAllTraffics": "Reset lalu lintas semua klien",
|
||||
"resetAllTrafficsTitle": "Reset lalu lintas semua klien?",
|
||||
"resetAllTrafficsContent": "Penghitung kirim/terima setiap klien turun ke nol. Kuota dan kedaluwarsa tidak terpengaruh. Tidak dapat dibatalkan.",
|
||||
"deleteConfirmTitle": "Hapus klien {email}?",
|
||||
"deleteConfirmContent": "Tindakan ini menghapus klien dari setiap inbound terlampir dan menghapus catatan lalu lintasnya. Tidak dapat dibatalkan.",
|
||||
"deleteSelected": "Hapus ({count})",
|
||||
"adjustSelected": "Sesuaikan ({count})",
|
||||
"subLinksSelected": "Tautan sub ({count})",
|
||||
"addToGroupTitle": "Tambahkan {count} klien ke grup",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "Nonaktifkan {count} klien?",
|
||||
"bulkDisableConfirmContent": "Menonaktifkan setiap klien yang dipilih di semua inbound yang terlampir. Mereka langsung kehilangan akses, tetapi catatan dan trafiknya tetap disimpan.",
|
||||
"selectedCount": "{count} dipilih",
|
||||
"attachSelected": "Lampirkan ({count})",
|
||||
"attachToInboundsTitle": "Lampirkan {count} klien ke inbound",
|
||||
"attachToInboundsDesc": "Melampirkan {count} klien terpilih (UUID/kata sandi sama dan trafik bersama) ke inbound terpilih. Lampiran yang ada tetap dipertahankan.",
|
||||
"attachToInboundsTargets": "Inbound tujuan",
|
||||
"attachToInboundsNoTargets": "Tidak ada inbound multi-pengguna untuk dilampirkan.",
|
||||
"detachSelected": "Lepas ({count})",
|
||||
"detach": "Lepas",
|
||||
"detachFromInboundsTitle": "Lepas {count} klien dari inbound",
|
||||
"detachFromInboundsDesc": "Menghapus {count} klien terpilih dari inbound terpilih. Pasangan di mana klien tidak terlampir akan dilewati secara diam-diam. Catatan klien dipertahankan (gunakan Delete untuk menghapus sepenuhnya).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Reverse tag opsional",
|
||||
"telegramId": "ID pengguna Telegram",
|
||||
"telegramIdPlaceholder": "ID numerik pengguna Telegram (0 = tidak ada)",
|
||||
"created": "Dibuat",
|
||||
"updated": "Diperbarui",
|
||||
"ipLimit": "Batas IP",
|
||||
"toasts": {
|
||||
"deleted": "Klien dihapus",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Grup",
|
||||
"name": "Nama",
|
||||
"clientCount": "Klien",
|
||||
"totalGroups": "Total grup",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "{count} klien dihapus dari {name}."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Node",
|
||||
"addNode": "Tambah Node",
|
||||
"editNode": "Edit node",
|
||||
"totalNodes": "Total Node",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh",
|
||||
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Otentikasi → Token API.",
|
||||
"apiTokenKeepHint": "Biarkan kosong untuk mempertahankan token saat ini",
|
||||
"regenerate": "Buat Ulang Token",
|
||||
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
|
||||
"allowPrivateAddress": "Izinkan alamat pribadi",
|
||||
"allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
|
||||
"outboundTag": "Outbound koneksi",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "Perbarui Panel",
|
||||
"updateSelected": "Perbarui Terpilih ({count})",
|
||||
"updateAvailable": "Pembaruan tersedia",
|
||||
"upToDate": "Terbaru",
|
||||
"updateConfirmTitle": "Perbarui {count} node ke versi terbaru?",
|
||||
"updateConfirmContent": "Setiap node terpilih mengunduh rilis terbaru dan memulai ulang. Hanya node aktif dan online yang diperbarui.",
|
||||
"updateDevChannel": "Perbarui ke kanal dev (commit terbaru)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "Tidak dapat mengambil sertifikat"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Bawaan",
|
||||
"title": "Pengaturan Panel",
|
||||
"save": "Simpan",
|
||||
"infoDesc": "Setiap perubahan yang dibuat di sini perlu disimpan. Harap restart panel untuk menerapkan perubahan.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "Penggunaan memori tinggi (%)",
|
||||
"remarkTemplate": "Templat Catatan",
|
||||
"remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
|
||||
"subShowIdentityOnAllLinks": "Tampilkan identitas di setiap tautan",
|
||||
"subShowIdentityOnAllLinksDesc": "Jika diaktifkan, {{EMAIL}} dan {{USERNAME}} tetap ada di catatan setiap tautan isi langganan. Token penggunaan tetap hanya di tautan pertama.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Path harus diawali dengan /"
|
||||
},
|
||||
@@ -1448,13 +1350,8 @@
|
||||
"secretClearUndo": "Batalkan hapus"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Konfigurasi Xray",
|
||||
"save": "Simpan",
|
||||
"restart": "Mulai ulang Xray",
|
||||
"restartSuccess": "Xray berhasil diluncurkan ulang",
|
||||
"restartOutputTitle": "Output mulai ulang Xray",
|
||||
"restartConfirmTitle": "Mulai ulang xray?",
|
||||
"restartConfirmContent": "Memuat ulang layanan xray dengan konfigurasi tersimpan.",
|
||||
"stopSuccess": "Xray telah berhasil dihentikan",
|
||||
"restartError": "Terjadi kesalahan saat memulai ulang Xray.",
|
||||
"stopError": "Terjadi kesalahan saat menghentikan Xray.",
|
||||
@@ -1464,7 +1361,6 @@
|
||||
"generalConfigsDesc": "Opsi ini akan menentukan penyesuaian strategi umum.",
|
||||
"logConfigs": "Log",
|
||||
"logConfigsDesc": "Log dapat mempengaruhi efisiensi server Anda. Disarankan untuk mengaktifkannya dengan bijak hanya jika diperlukan",
|
||||
"blockConfigsDesc": "Opsi ini akan memblokir lalu lintas berdasarkan protokol dan situs web yang diminta.",
|
||||
"basicRouting": "Perutean Dasar",
|
||||
"blockConnectionsConfigsDesc": "Opsi ini akan memblokir lalu lintas berdasarkan negara yang diminta.",
|
||||
"directConnectionsConfigsDesc": "Koneksi langsung memastikan bahwa lalu lintas tertentu tidak dialihkan melalui server lain.",
|
||||
@@ -1474,10 +1370,6 @@
|
||||
"directdomains": "Domain Langsung",
|
||||
"ipv4Routing": "Perutean IPv4",
|
||||
"ipv4RoutingDesc": "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui IPv4.",
|
||||
"warpRouting": "Perutean WARP",
|
||||
"warpRoutingDesc": "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui WARP.",
|
||||
"nordRouting": "Routing NordVPN",
|
||||
"nordRoutingDesc": "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui NordVPN.",
|
||||
"Template": "Template Konfigurasi Xray Lanjutan",
|
||||
"TemplateDesc": "File konfigurasi Xray akhir akan dibuat berdasarkan template ini.",
|
||||
"FreedomStrategy": "Strategi Protokol Freedom",
|
||||
@@ -1491,7 +1383,6 @@
|
||||
"outboundTestUrlDesc": "URL yang digunakan saat menguji konektivitas outbound",
|
||||
"Torrent": "Blokir Protokol BitTorrent",
|
||||
"Inbounds": "Inbound",
|
||||
"InboundsDesc": "Menerima klien tertentu.",
|
||||
"Outbounds": "Outbound",
|
||||
"importRules": "Impor aturan",
|
||||
"exportRules": "Ekspor aturan",
|
||||
@@ -1501,8 +1392,6 @@
|
||||
"metricsListen": "Endpoint metrik",
|
||||
"metricsListenDesc": "Tampilkan metrik gaya Prometheus dari Xray pada alamat:port ini (mis. 127.0.0.1:11111). Biarkan kosong untuk menonaktifkan. Ikat ke localhost dan reverse-proxy — endpoint ini tanpa autentikasi.",
|
||||
"metricsTag": "Tag metrik",
|
||||
"OutboundSubscriptions": "Langganan Outbound",
|
||||
"OutboundSubscriptionsDesc": "Impor outbound dari URL langganan jarak jauh (vmess/vless/trojan/ss/...). Tag dijaga tetap stabil untuk digunakan pada penyeimbang dan aturan routing. Pembaruan berjalan otomatis.",
|
||||
"Balancers": "Penyeimbang",
|
||||
"balancerTagRequired": "Tag wajib diisi",
|
||||
"balancerSelectorRequired": "Pilih setidaknya satu outbound",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "Outbound yang cocok",
|
||||
"routeTesterViaBalancer": "melalui penyeimbang",
|
||||
"routeTesterDefaultOutbound": "Tidak ada aturan routing yang cocok — lalu lintas menuju outbound default (pertama).",
|
||||
"OutboundsDesc": "Atur jalur lalu lintas keluar.",
|
||||
"Routings": "Aturan Pengalihan",
|
||||
"RoutingsDesc": "Prioritas setiap aturan penting!",
|
||||
"completeTemplate": "Semua",
|
||||
"logLevel": "Tingkat Log",
|
||||
"logLevelDesc": "Tingkat log untuk log kesalahan, menunjukkan informasi yang perlu dicatat.",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "Masker alamat IP, ketika diaktifkan, akan secara otomatis mengganti alamat IP yang muncul di log.",
|
||||
"statistics": "Statistik",
|
||||
"statsInboundUplink": "Statistik Unggah Masuk",
|
||||
"statsInboundUplinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unggah dari semua proxy masuk.",
|
||||
"statsInboundDownlink": "Statistik Unduh Masuk",
|
||||
"statsInboundDownlinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unduh dari semua proxy masuk.",
|
||||
"statsOutboundUplink": "Statistik Unggah Keluar",
|
||||
"statsOutboundUplinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unggah dari semua proxy keluar.",
|
||||
"statsOutboundDownlink": "Statistik Unduh Keluar",
|
||||
"statsOutboundDownlinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unduh dari semua proxy keluar.",
|
||||
"connectionLimits": "Batas Koneksi",
|
||||
"connectionLimitsDesc": "Kebijakan tingkat koneksi untuk level pengguna 0. Biarkan kolom kosong untuk menggunakan nilai bawaan Xray.",
|
||||
"connIdle": "Batas Waktu Idle",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "otomatis",
|
||||
"seconds": "detik",
|
||||
"rules": {
|
||||
"first": "Pertama",
|
||||
"last": "Terakhir",
|
||||
"up": "Naik",
|
||||
"down": "Turun",
|
||||
"source": "Sumber",
|
||||
"dest": "Tujuan",
|
||||
"inbound": "Masuk",
|
||||
"outbound": "Keluar",
|
||||
"balancer": "Pengimbang",
|
||||
"info": "Info",
|
||||
"add": "Tambahkan Aturan",
|
||||
"edit": "Edit Aturan",
|
||||
"useComma": "Item yang dipisahkan koma"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "Aturan {n}",
|
||||
"action": "Aksi",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Aturan akhir",
|
||||
"overrideXrayPrivateIp": "Timpa blok IP privat default Xray",
|
||||
"blockDelay": "Penundaan blokir (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "Interval keep alive",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Interface",
|
||||
"ipv6Only": "Hanya IPv6",
|
||||
"acceptProxyProtocol": "Terima proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (d)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Tambahkan Keluar",
|
||||
"addReverse": "Tambahkan Revers",
|
||||
"editOutbound": "Edit Keluar",
|
||||
"editReverse": "Edit Revers",
|
||||
"reverseTag": "Tag Revers",
|
||||
"reverseTagDesc": "Tag outbound proxy revers sederhana VLESS. Kosongkan untuk menonaktifkan.",
|
||||
"reverseTagPlaceholder": "tag outbound (kosong untuk menonaktifkan)",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Tag Unik",
|
||||
"address": "Alamat",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Revers",
|
||||
"domain": "Domain",
|
||||
"type": "Tipe",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Tautan",
|
||||
"intercon": "Interkoneksi",
|
||||
"settings": "Pengaturan",
|
||||
"accountInfo": "Informasi Akun",
|
||||
"outboundStatus": "Status Keluar",
|
||||
"sendThrough": "Kirim Melalui",
|
||||
"targetStrategy": "Strategi Target",
|
||||
"test": "Tes",
|
||||
"testResult": "Hasil Tes",
|
||||
"testing": "Menguji koneksi...",
|
||||
"testSuccess": "Tes berhasil",
|
||||
"testFailed": "Tes gagal",
|
||||
"testError": "Gagal menguji outbound",
|
||||
"modeRealDelay": "Delay nyata",
|
||||
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray. Delay nyata: total waktu termasuk pembentukan koneksi.",
|
||||
"testAll": "Tes semua",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "Koneksi proxy",
|
||||
"breakdownTls": "TLS melalui outbound",
|
||||
"breakdownTtfb": "Byte pertama",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Token Akses",
|
||||
"country": "Negara",
|
||||
"server": "Server",
|
||||
"city": "Kota",
|
||||
"allCities": "Semua Kota",
|
||||
"privateKey": "Kunci Privat",
|
||||
"load": "Beban",
|
||||
"moveToTop": "Pindahkan ke atas"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "Langganan aktif",
|
||||
"empty": "Belum ada langganan. Tambahkan satu di atas.",
|
||||
"colRemark": "Catatan",
|
||||
"colPrefix": "Awalan",
|
||||
"colInterval": "Interval",
|
||||
"colLastFetch": "Pengambilan terakhir",
|
||||
"colEnabled": "Aktif",
|
||||
"auto": "otomatis",
|
||||
"never": "tidak pernah",
|
||||
"yes": "Ya",
|
||||
"no": "Tidak",
|
||||
"refreshNow": "Segarkan sekarang",
|
||||
"lastError": "Kesalahan terakhir",
|
||||
"deleteConfirm": "Hapus langganan ini?",
|
||||
"restartHint": "Setelah menambahkan atau menyegarkan, mulai ulang Xray (atau tunggu muat ulang otomatis berikutnya) agar outbound menjadi aktif.",
|
||||
"fromSubsTitle": "Dari langganan outbound (hanya-baca)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "Pengaturan Balancer",
|
||||
"tabObservatory": "Observatory",
|
||||
"observatory": {
|
||||
"title": "Observatory",
|
||||
"burstTitle": "Burst Observatory",
|
||||
"autoManaged": "Observer dikelola otomatis dari balancer Anda. Atur cara mereka melakukan probe di bawah; outbound yang dipantau mengikuti selector balancer.",
|
||||
"emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer yang memakai observer dapat memeriksa kesehatan outbound sebelum memilih target.",
|
||||
"mixedLegacy": "Konfigurasi ini berisi Observatory dan Burst Observatory sekaligus. Xray memakai satu observer global, jadi status campuran lama ini tidak didukung; menyimpan balancer akan menormalkannya menjadi satu observer.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "Balancer {tag} — dihapus (tidak ada target tersisa)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Tambahkan Penyeimbang",
|
||||
"editBalancer": "Sunting Penyeimbang",
|
||||
"balancerStrategy": "Strategi",
|
||||
"balancerSelectors": "Penyeleksi",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Label Unik",
|
||||
"tagDuplicate": "Tag sudah digunakan oleh balancer lain",
|
||||
"tagPlaceholder": "tag balancer unik",
|
||||
"selector": "Selector",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "Toleransi",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi.",
|
||||
"costMatch": "Pola tag",
|
||||
"costValue": "Bobot",
|
||||
"costRegexp": "Pencocokan ekspresi reguler",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "Kunci Publik",
|
||||
"allowedIPs": "IP yang Diizinkan",
|
||||
"endpoint": "Titik Akhir",
|
||||
"psk": "Kunci Pra-Bagi",
|
||||
"domainStrategy": "Strategi Domain"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "Hanya untuk AmneziaWG 2.0. Biarkan kosong untuk kompatibilitas dengan 1.x."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "Nama antarmuka TUN. Standar adalah 'xray0'",
|
||||
"mtuDesc": "Unit Transmisi Maksimum. Ukuran maksimum paket data. Standar adalah 1500",
|
||||
"userLevel": "Level Pengguna",
|
||||
"userLevelDesc": "Semua koneksi yang dibuat melalui inbound ini akan menggunakan level pengguna ini. Standar adalah 0"
|
||||
"userLevel": "Level Pengguna"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Tambahkan DNS Palsu",
|
||||
"edit": "Edit DNS Palsu",
|
||||
"ipPool": "Subnet Kumpulan IP",
|
||||
"poolSize": "Ukuran Kolam"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Tambah",
|
||||
"month": "Bulan",
|
||||
"months": "Bulan",
|
||||
"day": "Hari",
|
||||
"days": "Hari",
|
||||
"hours": "Jam",
|
||||
"minutes": "Menit",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Pengguna Telegram tersimpan.",
|
||||
"loginSuccess": "✅ Berhasil masuk ke panel.\r\n",
|
||||
"loginFailed": "❗️ Gagal masuk ke panel.\r\n",
|
||||
"2faFailed": "2FA Gagal",
|
||||
"report": "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 Unduh: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Pengguna Telegram: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 Habis {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 Jumlah Habis {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Klien Online: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Dinonaktifkan: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Diperbarui Pada: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Ya",
|
||||
"no": "❌ Tidak",
|
||||
"received_id": "🔑📥 ID diperbarui.",
|
||||
"received_password": "🔑📥 Kata sandi diperbarui.",
|
||||
"received_email": "📧📥 Email diperbarui.",
|
||||
"received_comment": "💬📥 Komentar diperbarui.",
|
||||
"id_prompt": "🔑 ID Default: {{ .ClientId }}\n\nMasukkan ID Anda.",
|
||||
"pass_prompt": "🔑 Kata Sandi Default: {{ .ClientPassword }}\n\nMasukkan kata sandi Anda.",
|
||||
"email_prompt": "📧 Email Default: {{ .ClientEmail }}\n\nMasukkan email Anda.",
|
||||
"comment_prompt": "💬 Komentar Default: {{ .ClientComment }}\n\nMasukkan komentar Anda.",
|
||||
"inbound_client_data_id": "🔄 Masuk: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Lalu lintas: {{ .ClientTraffic }}\n📅 Tanggal Kedaluwarsa: {{ .ClientExp }}\n🌐 Batas IP: {{ .IpLimit }}\n💬 Komentar: {{ .ClientComment }}\n\nSekarang kamu bisa menambahkan klien ke inbound!",
|
||||
"inbound_client_data_pass": "🔄 Masuk: {{ .InboundRemark }}\n\n🔑 Kata sandi: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Lalu lintas: {{ .ClientTraffic }}\n📅 Tanggal Kedaluwarsa: {{ .ClientExp }}\n🌐 Batas IP: {{ .IpLimit }}\n💬 Komentar: {{ .ClientComment }}\n\nSekarang kamu bisa menambahkan klien ke inbound!",
|
||||
"cancel": "❌ Proses Dibatalkan! \n\nAnda dapat /start lagi kapan saja. 🔄",
|
||||
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
|
||||
"using_default_value": "Oke, saya akan tetap menggunakan nilai default. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Kesalahan: {{ .Error }}",
|
||||
"eventNodeDown": "Node {{ .Name }} MATI",
|
||||
"eventNodeUp": "Node {{ .Name }} AKTIF",
|
||||
"eventCPUHigh": "CPU tinggi",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Gagal masuk dari {{ .Source }}",
|
||||
"memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Kirim Sebagai Nonaktif ☑️",
|
||||
"submitEnable": "Kirim Sebagai Aktif ✅",
|
||||
"use_default": "🏷️ Gunakan Default",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 Kata Sandi",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Komentar",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Reset Semua Lalu Lintas",
|
||||
"SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "Outbound {{ .Tag }} MATI",
|
||||
"subjectOutboundUp": "Outbound {{ .Tag }} AKTIF",
|
||||
"subjectXrayCrash": "Xray CRASH",
|
||||
"subjectCPUHigh": "CPU tinggi",
|
||||
"subjectLoginSuccess": "Berhasil masuk",
|
||||
"subjectLoginFailed": "Gagal masuk",
|
||||
"titleOutboundDown": "Outbound MATI",
|
||||
"titleOutboundUp": "Outbound AKTIF",
|
||||
"titleXrayCrash": "Xray CRASH",
|
||||
"titleCPUHigh": "CPU tinggi",
|
||||
"titleLoginSuccess": "Berhasil masuk",
|
||||
"titleLoginFailed": "Gagal masuk",
|
||||
"labelStatus": "Status",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelNode": "Node",
|
||||
"labelError": "Kesalahan",
|
||||
"labelDelay": "Penundaan",
|
||||
"labelDetail": "Detail",
|
||||
"labelUsername": "Nama Pengguna",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Alasan",
|
||||
"labelSource": "Sumber",
|
||||
"labelTime": "Waktu",
|
||||
"statusCrashed": "CRASH",
|
||||
"statusRunning": "Berjalan",
|
||||
"statusHigh": "TINGGI",
|
||||
"statusSuccess": "BERHASIL",
|
||||
"statusFailed": "GAGAL",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "コピー成功",
|
||||
"sure": "確定",
|
||||
"encryption": "暗号化",
|
||||
"useIPv4ForHost": "ホストにIPv4を使用",
|
||||
"transmission": "伝送",
|
||||
"host": "ホスト",
|
||||
"path": "パス",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "コード",
|
||||
"remained": "残り",
|
||||
"security": "セキュリティ",
|
||||
"secAlertTitle": "セキュリティアラート",
|
||||
"secAlertSsl": "この接続は安全ではありません。TLSを有効にしてデータ保護を行うまで、機密情報を入力しないでください。",
|
||||
"secAlertConf": "一部の設定は脆弱です。潜在的な脆弱性を防ぐために、セキュリティプロトコルを強化することをお勧めします。",
|
||||
"secAlertSSL": "セキュアな接続がありません。データ保護のためにTLS証明書をインストールしてください。",
|
||||
"secAlertPanelPort": "デフォルトのポートにはセキュリティリスクがあります。ランダムなポートまたは特定のポートを設定してください。",
|
||||
"secAlertPanelURI": "デフォルトのURIパスは安全ではありません。複雑なURIパスを設定してください。",
|
||||
"secAlertSubURI": "サブスクリプションのデフォルトURIパスは安全ではありません。複雑なURIパスを設定してください。",
|
||||
"secAlertSubJsonURI": "JSONサブスクリプションのデフォルトURIパスは安全ではありません。複雑なURIパスを設定してください。",
|
||||
"emptyDnsDesc": "追加されたDNSサーバーはありません。",
|
||||
"emptyFakeDnsDesc": "追加されたFake DNSサーバーはありません。",
|
||||
"emptyBalancersDesc": "追加されたバランサーはありません。",
|
||||
"emptyReverseDesc": "追加されたリバースプロキシはありません。",
|
||||
"somethingWentWrong": "エラーが発生しました",
|
||||
"subscription": {
|
||||
"title": "サブスクリプション情報",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "テーマ",
|
||||
"dark": "ダーク",
|
||||
"ultraDark": "ウルトラダーク",
|
||||
"dashboard": "ダッシュボード",
|
||||
"inbounds": "インバウンド",
|
||||
"clients": "クライアント",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "ルーティング",
|
||||
"outbounds": "アウトバウンド",
|
||||
"apiDocs": "API ドキュメント",
|
||||
"logout": "ログアウト",
|
||||
"link": "リンク管理",
|
||||
"donate": "寄付",
|
||||
"hosts": "ホスト",
|
||||
"docs": "ドキュメント",
|
||||
"openMenu": "メニューを開く"
|
||||
"openMenu": "メニューを開く",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "システムステータス",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "論理プロセッサ",
|
||||
"frequency": "周波数",
|
||||
"swap": "スワップ",
|
||||
"storage": "ストレージ",
|
||||
"memory": "メモリ",
|
||||
"threads": "スレッド",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "停止",
|
||||
"restartXray": "再起動",
|
||||
"xraySwitch": "バージョン",
|
||||
"xrayUpdates": "Xrayの更新",
|
||||
"xraySwitchClick": "切り替えるバージョンを選択してください",
|
||||
"xraySwitchClickDesk": "慎重に選択してください。古いバージョンは現在の設定と互換性がない可能性があります。",
|
||||
"updatePanel": "パネルを更新",
|
||||
"panelUpdateDesc": "これにより3X-UIが最新リリースに更新され、パネルサービスが再起動されます。",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "現在のコミット",
|
||||
"latestCommit": "最新のコミット",
|
||||
"updateChannelChanged": "更新チャンネルを変更しました",
|
||||
"upToDate": "最新",
|
||||
"xrayStatusUnknown": "不明",
|
||||
"xrayStatusRunning": "実行中",
|
||||
"xrayStatusStop": "停止",
|
||||
"xrayStatusError": "エラー",
|
||||
"xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
|
||||
"operationHours": "システム稼働時間",
|
||||
"systemHistoryTitle": "システム履歴",
|
||||
"historyTitleCpu": "CPU 使用率",
|
||||
"historyTitleMem": "メモリ使用率",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "負荷",
|
||||
"historyTabConnections": "接続数",
|
||||
"historyTabDiskUsage": "ディスク使用量",
|
||||
"charts": "チャート",
|
||||
"xrayMetricsTitle": "Xray メトリクス",
|
||||
"xrayTitleHeap": "割り当て済みヒープメモリ",
|
||||
"xrayTitleSys": "OS から確保したメモリ",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "停止",
|
||||
"xrayObservatoryLastSeen": "最終確認",
|
||||
"xrayObservatoryLastTry": "最終試行",
|
||||
"trendLast2Min": "直近2分",
|
||||
"systemLoad": "システム負荷",
|
||||
"systemLoadDesc": "過去1、5、15分間のシステム平均負荷",
|
||||
"connectionCount": "接続数",
|
||||
"ipAddresses": "IPアドレス",
|
||||
"toggleIpVisibility": "IPの表示を切り替える",
|
||||
"overallSpeed": "全体の速度",
|
||||
"upload": "アップロード",
|
||||
"download": "ダウンロード",
|
||||
"totalData": "総データ量",
|
||||
"sent": "送信",
|
||||
"received": "受信",
|
||||
"documentation": "ドキュメント",
|
||||
"xraySwitchVersionDialog": "Xrayのバージョンを本当に変更しますか?",
|
||||
"xraySwitchVersionDialogDesc": "Xrayのバージョンが#version#に変更されます。",
|
||||
"xraySwitchVersionPopover": "Xrayの更新が成功しました",
|
||||
"panelUpdateDialog": "本当にパネルを更新しますか?",
|
||||
"panelUpdateDialogDesc": "これにより3X-UIが#version#に更新され、パネルサービスが再起動されます。",
|
||||
"panelUpdateCheckPopover": "パネルの更新確認に失敗しました",
|
||||
"panelUpdateStartedPopover": "パネルの更新を開始しました",
|
||||
"panelUpdateFailedTitle": "パネルの更新に失敗しました",
|
||||
"panelUpdateFailedDesc": "更新が正常に完了しませんでした。サーバーのログを確認するか、コマンドラインで「x-ui update」を実行してください。",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "アクセスログ",
|
||||
"autoUpdate": "自動更新",
|
||||
"config": "設定",
|
||||
"backup": "バックアップ",
|
||||
"backupTitle": "バックアップと復元",
|
||||
"exportDatabase": "バックアップ",
|
||||
"exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。同じファイルは PostgreSQL で動作するパネルにも復元できます。",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
|
||||
"importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
|
||||
"migrationDownload": "移行ファイルをダウンロード",
|
||||
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
|
||||
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。",
|
||||
"avg": "平均",
|
||||
"peak": "ピーク",
|
||||
"free": "空き",
|
||||
"openSockets": "オープンソケット",
|
||||
"throughputSub": "インターフェース合計",
|
||||
"avgWindow": "期間平均",
|
||||
"healthWarm": "{list} — 高めです",
|
||||
"healthCritical": "{list} — 危険水準",
|
||||
"panel": "パネル",
|
||||
"threads": "スレッド",
|
||||
"uptime": "稼働時間"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "インバウンド",
|
||||
"totalDownUp": "総アップロード / ダウンロード",
|
||||
"totalUsage": "総使用量",
|
||||
"inboundCount": "インバウンド数",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "ローカルパネル",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "このインバウンドへの接続がどのクライアントにも一致しない場合、別の宛先へルーティングします。下から子インバウンドを選ぶとルーティング項目(SNI / ALPN / Path / xver)がそのトランスポートから自動的に埋められます。あるいは選択を空のままにして Dest を直接指定すると(例: 8080 または 127.0.0.1:8080)、Nginx などの外部サーバーへルーティングできます。各子インバウンドは 127.0.0.1 で security=none をリッスンする必要があります。",
|
||||
"empty": "フォールバックはまだありません",
|
||||
"add": "フォールバックを追加",
|
||||
"pickInbound": "インバウンドを選択",
|
||||
"matchAny": "任意",
|
||||
"destPlaceholder": "自動(子の listen:port)",
|
||||
"rederive": "子から再取得",
|
||||
"rederived": "子から再取得しました",
|
||||
"editAdvanced": "ルーティング項目を編集",
|
||||
"hideAdvanced": "詳細を隠す",
|
||||
"quickAddAll": "対象のインバウンドをすべて一括追加",
|
||||
"quickAdded": "{n} 件のフォールバックを追加しました",
|
||||
"quickAddedNone": "追加可能な新規インバウンドはありません",
|
||||
"routesWhen": "次の条件でルーティング",
|
||||
"defaultCatchAll": "デフォルト — その他すべてを捕捉",
|
||||
"needsTls": "フォールバックは、セキュリティタブで TLS または Reality を選択すると設定できます(RAW 上の VLESS/Trojan のみ)。"
|
||||
},
|
||||
"protocol": "プロトコル",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "ポートマッピング",
|
||||
"traffic": "トラフィック",
|
||||
"speed": "速度",
|
||||
"details": "詳細情報",
|
||||
"transportConfig": "トランスポート",
|
||||
"expireDate": "有効期限",
|
||||
"createdAt": "作成",
|
||||
"updatedAt": "更新",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "インバウンド追加",
|
||||
"generalActions": "一般操作",
|
||||
"modifyInbound": "インバウンド修正",
|
||||
"deleteInbound": "インバウンド削除",
|
||||
"deleteInboundContent": "インバウンドを削除してもよろしいですか?",
|
||||
"deleteConfirmTitle": "インバウンド「{remark}」を削除しますか?",
|
||||
"deleteConfirmContent": "インバウンドと関連付けされたすべてのクライアントを削除します。元に戻せません。",
|
||||
"resetConfirmTitle": "「{remark}」のトラフィックをリセットしますか?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "全インバウンド",
|
||||
"exportAllSubsFileName": "全インバウンド-Subs",
|
||||
"inboundJsonTitle": "インバウンド JSON",
|
||||
"deleteClient": "クライアント削除",
|
||||
"deleteClientContent": "クライアントを削除してもよろしいですか?",
|
||||
"resetTrafficContent": "トラフィックをリセットしてもよろしいですか?",
|
||||
"copyLink": "リンクをコピー",
|
||||
"address": "アドレス",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= 無制限。(単位: GB)",
|
||||
"totalFlow": "総トラフィック",
|
||||
"leaveBlankToNeverExpire": "空白にすると期限なし",
|
||||
"noRecommendKeepDefault": "デフォルト値を保持することをお勧めします",
|
||||
"certificatePath": "ファイルパス",
|
||||
"certificateContent": "ファイル内容",
|
||||
"publicKey": "公開鍵",
|
||||
"privatekey": "秘密鍵",
|
||||
"clickOnQRcode": "QRコードをクリックしてコピー",
|
||||
"client": "クライアント",
|
||||
"export": "リンクエクスポート",
|
||||
"clone": "複製",
|
||||
"cloneInbound": "複製",
|
||||
"cloneInboundContent": "このインバウンドルールは、ポート(Port)、リスニングIP(Listening IP)、クライアント(Clients)を除くすべての設定がクローンされます",
|
||||
"cloneInboundOk": "クローン作成",
|
||||
"resetAllTraffic": "すべてのインバウンドトラフィックをリセット",
|
||||
"resetAllTrafficTitle": "すべてのインバウンドトラフィックをリセット",
|
||||
"resetAllTrafficContent": "すべてのインバウンドトラフィックをリセットしてもよろしいですか?",
|
||||
"resetInboundClientTraffics": "クライアントトラフィックをリセット",
|
||||
"resetInboundClientTrafficTitle": "すべてのクライアントトラフィックをリセット",
|
||||
"resetInboundClientTrafficContent": "このインバウンドクライアントのすべてのトラフィックをリセットしてもよろしいですか?",
|
||||
"resetAllClientTraffics": "すべてのクライアントトラフィックをリセット",
|
||||
"resetAllClientTrafficTitle": "すべてのクライアントトラフィックをリセット",
|
||||
"resetAllClientTrafficContent": "すべてのクライアントのトラフィックをリセットしてもよろしいですか?",
|
||||
"delDepletedClients": "トラフィックが尽きたクライアントを削除",
|
||||
"delDepletedClientsTitle": "トラフィックが尽きたクライアントを削除",
|
||||
"delDepletedClientsContent": "トラフィックが尽きたすべてのクライアントを削除してもよろしいですか?",
|
||||
"email": "メール",
|
||||
"emailDesc": "メールアドレスは一意でなければなりません",
|
||||
"IPLimit": "IP制限",
|
||||
"IPLimitDesc": "設定値を超えるとインバウンドトラフィックが無効になります。(0 = 無効)",
|
||||
"IPLimitlog": "IPログ",
|
||||
"IPLimitlogDesc": "IP履歴ログ(無効なインバウンドトラフィックを有効にするには、ログをクリアしてください)",
|
||||
"IPLimitlogclear": "ログをクリア",
|
||||
"setDefaultCert": "パネル設定から証明書を設定",
|
||||
"setDefaultCertEmpty": "パネル用の証明書が設定されていません。先に設定から指定してください。",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Xray sniffing ブロックのラッパー:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Xray stream ブロックのラッパー:",
|
||||
"jsonErrorPrefix": "高度な JSON"
|
||||
"streamHelp": "Xray stream ブロックのラッパー:"
|
||||
},
|
||||
"telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot)",
|
||||
"subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
|
||||
"subSortIndex": "サブ並び順",
|
||||
"same": "同じ",
|
||||
"inboundInfo": "インバウンド情報",
|
||||
"exportInbound": "インバウンドルールをエクスポート",
|
||||
"import": "インポート",
|
||||
"importInbound": "インバウンドルールをインポート",
|
||||
"periodicTrafficResetTitle": "トラフィックリセット",
|
||||
"periodicTrafficResetDesc": "指定された間隔でトラフィックカウンタを自動的にリセット",
|
||||
"lastReset": "最後のリセット",
|
||||
"periodicTrafficResetDay": "毎月のリセット日",
|
||||
"periodicTrafficReset": {
|
||||
"never": "なし",
|
||||
"daily": "毎日",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "取得",
|
||||
"updateSuccess": "更新が成功しました",
|
||||
"logCleanSuccess": "ログがクリアされました",
|
||||
"inboundsUpdateSuccess": "インバウンドが正常に更新されました",
|
||||
"inboundUpdateSuccess": "インバウンドが正常に更新されました",
|
||||
"inboundCreateSuccess": "インバウンドが正常に作成されました",
|
||||
"bulkDeleted": "{count} 件のインバウンドを削除しました",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "インバウンドクライアントが削除されました",
|
||||
"inboundClientUpdateSuccess": "インバウンドクライアントが更新されました",
|
||||
"savedNodeOfflineWillSync": "ローカルに保存しました。バックエンドのノードがオフラインまたは無効になっています — 再接続後に変更が同期されます。",
|
||||
"delDepletedClientsSuccess": "すべての枯渇したクライアントが削除されました",
|
||||
"resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
|
||||
"resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
|
||||
"resetInboundClientTrafficSuccess": "トラフィックがリセットされました",
|
||||
@@ -657,6 +599,10 @@
|
||||
"maxTimeDiff": "最大時間差 (ms)",
|
||||
"minClientVer": "最小クライアントバージョン",
|
||||
"maxClientVer": "最大クライアントバージョン",
|
||||
"minClientVerHint": "空欄は無制限ではありません。Xray-core は実行中のコアに組み込まれた最低バージョン(現行リリースでは 26.3.27)を適用し、それより古いバージョンを名乗るクライアント(Mihomo や sing-box などのサードパーティコアを含む)を拒否します。1.0.0 を設定すると許可されますが、古い TLS フィンガープリントも受け入れることになります。",
|
||||
"maxClientVerHint": "空欄は上限なしを意味します。設定する場合は実効的な下限(最小クライアントバージョン。その欄が空欄の場合は Xray-core 組み込みの最低バージョン)を下回らないでください。下回るとすべてのクライアントが拒否されます。",
|
||||
"clientVerInvalid": "クライアントバージョンはドット区切りの数値(最大 3 つ、各 0-255)で指定してください(例:26.3.27)",
|
||||
"maxClientVerBelowMin": "最大クライアントバージョンは最小クライアントバージョンを下回れません",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "必須です。ポートを含める必要があります(例: example.com:443)。ポートがないと Xray-core は起動しません。",
|
||||
"realityTargetRequired": "REALITY ターゲットは必須です",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Peer {n} 設定"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "リクエスト",
|
||||
"response": "レスポンス",
|
||||
"name": "名前",
|
||||
"value": "値"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "バージョン",
|
||||
"method": "方法",
|
||||
"path": "パス",
|
||||
"status": "ステータス",
|
||||
"statusDescription": "ステータス説明",
|
||||
"requestHeader": "リクエストヘッダー",
|
||||
"responseHeader": "レスポンスヘッダー"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "宛先のオーバーライド"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "外部サブスクリプションを追加",
|
||||
"noExternalLinks": "外部リンクはまだありません。",
|
||||
"noExternalSubscriptions": "外部サブスクリプションはまだありません。",
|
||||
"add": "クライアントを追加",
|
||||
"edit": "クライアントを編集",
|
||||
"submitAdd": "クライアントを追加",
|
||||
"submitEdit": "変更を保存",
|
||||
"clientCount": "クライアント数",
|
||||
"bulk": "一括追加",
|
||||
"copyFromInbound": "インバウンドからクライアントをコピー",
|
||||
"copyToInbound": "コピー先",
|
||||
"copySelected": "選択をコピー",
|
||||
"copySource": "コピー元",
|
||||
"copyEmailPreview": "生成されるメールのプレビュー",
|
||||
"copySelectSourceFirst": "まずコピー元のインバウンドを選択してください。",
|
||||
"copyResult": "コピー結果",
|
||||
"copyResultSuccess": "コピーに成功しました",
|
||||
"copyResultNone": "コピーする対象がありません。クライアントが選択されていないか、コピー元が空です",
|
||||
"copyResultErrors": "コピーエラー",
|
||||
"copyFlowLabel": "新規クライアントの Flow (VLESS)",
|
||||
"copyFlowHint": "コピーされる全クライアントに適用されます。空欄でスキップします。",
|
||||
"selectAll": "すべて選択",
|
||||
"clearAll": "すべてクリア",
|
||||
"method": "メソッド",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "サフィックス",
|
||||
"delayedStart": "初回使用から開始",
|
||||
"expireDays": "期間 (日)",
|
||||
"days": "日",
|
||||
"renew": "自動更新",
|
||||
"renewDesc": "有効期限切れ後に自動更新します。(0 = 無効) (単位: 日)",
|
||||
"renewDays": "自動更新 (日)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "もうすぐ期限切れ",
|
||||
"has": "あり",
|
||||
"hasNot": "なし",
|
||||
"title": "クライアント",
|
||||
"actions": "操作",
|
||||
"totalGB": "トラフィック上限 (GB)",
|
||||
"totalGBDesc": "このクライアントのデータ割当量。0 = 無制限。",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "クライアントを追加",
|
||||
"qrCode": "QR コード",
|
||||
"clientInfo": "クライアント情報",
|
||||
"delete": "削除",
|
||||
"reset": "トラフィックをリセット",
|
||||
"editClient": "クライアントを編集",
|
||||
"client": "クライアント",
|
||||
"enabled": "有効",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。",
|
||||
"link": "リンク",
|
||||
"resetNotPossible": "まずこのクライアントをインバウンドに関連付けてください。",
|
||||
"general": "一般",
|
||||
"resetAllTraffics": "すべてのクライアントのトラフィックをリセット",
|
||||
"resetAllTrafficsTitle": "すべてのクライアントのトラフィックをリセットしますか?",
|
||||
"resetAllTrafficsContent": "すべてのクライアントの送受信カウンターがゼロにリセットされます。クォータと有効期限には影響しません。元に戻せません。",
|
||||
"deleteConfirmTitle": "クライアント {email} を削除しますか?",
|
||||
"deleteConfirmContent": "クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。",
|
||||
"deleteSelected": "削除 ({count})",
|
||||
"adjustSelected": "調整 ({count})",
|
||||
"subLinksSelected": "サブリンク ({count})",
|
||||
"addToGroupTitle": "{count} クライアントをグループに追加",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "{count} 件のクライアントを無効化しますか?",
|
||||
"bulkDisableConfirmContent": "選択した各クライアントを、接続されているすべてのインバウンドで無効化します。アクセスはすぐに失われますが、記録とトラフィックは保持されます。",
|
||||
"selectedCount": "{count} 選択中",
|
||||
"attachSelected": "アタッチ ({count})",
|
||||
"attachToInboundsTitle": "{count} クライアントをインバウンドにアタッチ",
|
||||
"attachToInboundsDesc": "選択した {count} クライアント(同じ UUID/パスワードと共有トラフィック)を選択したインバウンドにアタッチします。既存のアタッチは維持されます。",
|
||||
"attachToInboundsTargets": "ターゲットインバウンド",
|
||||
"attachToInboundsNoTargets": "アタッチ可能なマルチユーザーインバウンドがありません。",
|
||||
"detachSelected": "デタッチ ({count})",
|
||||
"detach": "デタッチ",
|
||||
"detachFromInboundsTitle": "{count} クライアントをインバウンドからデタッチ",
|
||||
"detachFromInboundsDesc": "選択した {count} クライアントを選択したインバウンドから外します。アタッチされていなかったペアは黙ってスキップされます。クライアントレコードは保持されます (完全に削除するには Delete を使用)。",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "任意の Reverse tag",
|
||||
"telegramId": "Telegram ユーザー ID",
|
||||
"telegramIdPlaceholder": "数値の Telegram ユーザー ID (0 = なし)",
|
||||
"created": "作成日",
|
||||
"updated": "更新日",
|
||||
"ipLimit": "IP 制限",
|
||||
"toasts": {
|
||||
"deleted": "クライアントを削除しました",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "グループ",
|
||||
"name": "名前",
|
||||
"clientCount": "クライアント",
|
||||
"totalGroups": "グループ合計",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "{count} クライアントを {name} から外しました。"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "ノード",
|
||||
"addNode": "ノードを追加",
|
||||
"editNode": "ノード編集",
|
||||
"totalNodes": "ノード総数",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン",
|
||||
"apiTokenHint": "リモートパネルでは、セキュリティ設定 → APIトークン でAPIトークンを確認できます。",
|
||||
"apiTokenKeepHint": "現在のトークンを保持するには空欄のままにします",
|
||||
"regenerate": "トークンを再生成",
|
||||
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
|
||||
"allowPrivateAddress": "プライベートアドレスを許可",
|
||||
"allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
|
||||
"outboundTag": "接続アウトバウンド",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "パネルを更新",
|
||||
"updateSelected": "選択を更新 ({count})",
|
||||
"updateAvailable": "更新あり",
|
||||
"upToDate": "最新",
|
||||
"updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
|
||||
"updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
|
||||
"updateDevChannel": "開発チャンネルに更新(最新コミット)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "証明書を取得できませんでした"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "デフォルト",
|
||||
"title": "パネル設定",
|
||||
"save": "保存",
|
||||
"infoDesc": "ここでのすべての変更は、保存してパネルを再起動する必要があります",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "メモリ使用率が高い (%)",
|
||||
"remarkTemplate": "備考テンプレート",
|
||||
"remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
|
||||
"subShowIdentityOnAllLinks": "すべてのリンクに識別情報を表示",
|
||||
"subShowIdentityOnAllLinksDesc": "有効にすると、{{EMAIL}} と {{USERNAME}} がサブスクリプション本文のすべてのリンク備考に残ります。使用量トークンは引き続き最初のリンクのみです。",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "パスは / で始まる必要があります"
|
||||
},
|
||||
@@ -1448,7 +1350,6 @@
|
||||
"secretClearUndo": "クリアを取り消す"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray 設定",
|
||||
"importRules": "ルールをインポート",
|
||||
"exportRules": "ルールをエクスポート",
|
||||
"importOutbounds": "アウトバウンドをインポート",
|
||||
@@ -1458,11 +1359,7 @@
|
||||
"metricsListenDesc": "この アドレス:ポート で Xray の Prometheus 形式メトリクスを公開します(例: 127.0.0.1:11111)。空欄にすると無効になります。認証されないため、localhost にバインドしてリバースプロキシ経由で公開してください。",
|
||||
"metricsTag": "メトリクスタグ",
|
||||
"save": "保存",
|
||||
"restart": "Xray を再起動",
|
||||
"restartSuccess": "Xrayの再起動に成功しました",
|
||||
"restartOutputTitle": "Xray 再起動の出力",
|
||||
"restartConfirmTitle": "xray を再起動?",
|
||||
"restartConfirmContent": "保存された構成で xray サービスを再ロードします。",
|
||||
"stopSuccess": "Xrayが正常に停止しました",
|
||||
"restartError": "Xrayの再起動中にエラーが発生しました。",
|
||||
"stopError": "Xrayの停止中にエラーが発生しました。",
|
||||
@@ -1472,7 +1369,6 @@
|
||||
"generalConfigsDesc": "これらのオプションは一般設定を決定します",
|
||||
"logConfigs": "ログ",
|
||||
"logConfigsDesc": "ログはサーバーのパフォーマンスに影響を与える可能性があるため、必要な場合にのみ有効にすることをお勧めします",
|
||||
"blockConfigsDesc": "これらのオプションは、特定のプロトコルやウェブサイトへのユーザー接続をブロックします",
|
||||
"basicRouting": "基本ルーティング",
|
||||
"blockConnectionsConfigsDesc": "これらのオプションにより、特定のリクエスト元の国に基づいてトラフィックをブロックします。",
|
||||
"directConnectionsConfigsDesc": "直接接続により、特定のトラフィックが他のサーバーを経由しないようにします。",
|
||||
@@ -1482,10 +1378,6 @@
|
||||
"directdomains": "直接ドメイン",
|
||||
"ipv4Routing": "IPv4 ルーティング",
|
||||
"ipv4RoutingDesc": "このオプションはIPv4のみを介してターゲットドメインへルーティングします",
|
||||
"warpRouting": "WARP ルーティング",
|
||||
"warpRoutingDesc": "注意:これらのオプションを使用する前に、パネルのGitHubの手順に従って、サーバーにsocks5プロキシモードでWARPをインストールしてください。WARPはCloudflareサーバー経由でトラフィックをウェブサイトにルーティングします。",
|
||||
"nordRouting": "NordVPN ルーティング",
|
||||
"nordRoutingDesc": "これらのオプションはNordVPN経由で特定の宛先にトラフィックをルーティングします。",
|
||||
"Template": "高度なXray設定テンプレート",
|
||||
"TemplateDesc": "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます",
|
||||
"FreedomStrategy": "Freedom プロトコル戦略",
|
||||
@@ -1499,10 +1391,7 @@
|
||||
"outboundTestUrlDesc": "アウトバウンド接続テストに使用する URL。既定値",
|
||||
"Torrent": "BitTorrent プロトコルをブロック",
|
||||
"Inbounds": "インバウンド",
|
||||
"InboundsDesc": "特定のクライアントからのトラフィックを受け入れる",
|
||||
"Outbounds": "アウトバウンド",
|
||||
"OutboundSubscriptions": "アウトバウンドサブスクリプション",
|
||||
"OutboundSubscriptionsDesc": "リモートのサブスクリプションURL(vmess/vless/trojan/ss/...)からアウトバウンドをインポートします。タグはバランサーやルーティングルールで使えるように安定して保持されます。更新は自動的に行われます。",
|
||||
"Balancers": "負荷分散",
|
||||
"balancerTagRequired": "タグは必須です",
|
||||
"balancerSelectorRequired": "アウトバウンドを少なくとも1つ選んでください",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "マッチしたアウトバウンド",
|
||||
"routeTesterViaBalancer": "バランサー経由",
|
||||
"routeTesterDefaultOutbound": "ルーティングルールに一致しませんでした — トラフィックはデフォルト(最初の)アウトバウンドに送られます。",
|
||||
"OutboundsDesc": "アウトバウンドトラフィックの送信方法を設定する",
|
||||
"Routings": "ルーティングルール",
|
||||
"RoutingsDesc": "各ルールの優先順位が重要です",
|
||||
"completeTemplate": "すべて",
|
||||
"logLevel": "ログレベル",
|
||||
"logLevelDesc": "エラーログのレベルを指定し、記録する情報を示します",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "IPアドレスをマスクし、有効にするとログに表示されるIPアドレスを自動的に置き換えます",
|
||||
"statistics": "統計",
|
||||
"statsInboundUplink": "インバウンドアップロード統計",
|
||||
"statsInboundUplinkDesc": "すべてのインバウンドプロキシのアップストリームトラフィックの統計収集を有効にします。",
|
||||
"statsInboundDownlink": "インバウンドダウンロード統計",
|
||||
"statsInboundDownlinkDesc": "すべてのインバウンドプロキシのダウンストリームトラフィックの統計収集を有効にします。",
|
||||
"statsOutboundUplink": "アウトバウンドアップロード統計",
|
||||
"statsOutboundUplinkDesc": "すべてのアウトバウンドプロキシのアップストリームトラフィックの統計収集を有効にします。",
|
||||
"statsOutboundDownlink": "アウトバウンドダウンロード統計",
|
||||
"statsOutboundDownlinkDesc": "すべてのアウトバウンドプロキシのダウンストリームトラフィックの統計収集を有効にします。",
|
||||
"connectionLimits": "接続制限",
|
||||
"connectionLimitsDesc": "ユーザーレベル0の接続レベルのポリシーです。フィールドを空のままにすると Xray のデフォルト値が使用されます。",
|
||||
"connIdle": "アイドルタイムアウト",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "自動",
|
||||
"seconds": "秒",
|
||||
"rules": {
|
||||
"first": "最初",
|
||||
"last": "最後",
|
||||
"up": "上へ",
|
||||
"down": "下へ",
|
||||
"source": "ソース",
|
||||
"dest": "宛先アドレス",
|
||||
"inbound": "インバウンド",
|
||||
"outbound": "アウトバウンド",
|
||||
"balancer": "負荷分散",
|
||||
"info": "情報",
|
||||
"add": "ルール追加",
|
||||
"edit": "ルール編集",
|
||||
"useComma": "カンマ区切りの項目"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "ルール {n}",
|
||||
"action": "アクション",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "最終ルール",
|
||||
"overrideXrayPrivateIp": "Xray のデフォルトプライベート IP ブロックを上書き",
|
||||
"blockDelay": "ブロック遅延 (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "keep alive 間隔",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "インターフェース",
|
||||
"ipv6Only": "IPv6 のみ",
|
||||
"acceptProxyProtocol": "proxy protocol を受け入れる",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (秒)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "アウトバウンド追加",
|
||||
"addReverse": "リバース追加",
|
||||
"editOutbound": "アウトバウンド編集",
|
||||
"editReverse": "リバース編集",
|
||||
"reverseTag": "リバースタグ",
|
||||
"reverseTagDesc": "VLESSシンプルリバースプロキシのアウトバウンドタグ。無効にするには空欄にしてください。",
|
||||
"reverseTagPlaceholder": "アウトバウンドタグ(空欄で無効)",
|
||||
"tag": "タグ",
|
||||
"tagDesc": "一意のタグ",
|
||||
"address": "アドレス",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "リバース",
|
||||
"domain": "ドメイン",
|
||||
"type": "タイプ",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "リンク",
|
||||
"intercon": "インターコネクション",
|
||||
"settings": "設定",
|
||||
"accountInfo": "アカウント情報",
|
||||
"outboundStatus": "アウトバウンドステータス",
|
||||
"sendThrough": "送信経路",
|
||||
"targetStrategy": "ターゲット解決戦略",
|
||||
"test": "テスト",
|
||||
"testResult": "テスト結果",
|
||||
"testing": "接続をテスト中...",
|
||||
"testSuccess": "テスト成功",
|
||||
"testFailed": "テスト失敗",
|
||||
"testError": "アウトバウンドのテストに失敗しました",
|
||||
"modeRealDelay": "実際の遅延",
|
||||
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。実際の遅延: 接続確立を含む合計時間。",
|
||||
"testAll": "すべてテスト",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "プロキシ接続",
|
||||
"breakdownTls": "アウトバウンド経由のTLS",
|
||||
"breakdownTtfb": "最初のバイト",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "アクセストークン",
|
||||
"country": "国",
|
||||
"server": "サーバー",
|
||||
"city": "都市",
|
||||
"allCities": "すべての都市",
|
||||
"privateKey": "秘密鍵",
|
||||
"load": "負荷",
|
||||
"moveToTop": "先頭に移動"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "有効なサブスクリプション",
|
||||
"empty": "サブスクリプションはまだありません。上から追加してください。",
|
||||
"colRemark": "備考",
|
||||
"colPrefix": "プレフィックス",
|
||||
"colInterval": "間隔",
|
||||
"colLastFetch": "最終取得",
|
||||
"colEnabled": "有効",
|
||||
"auto": "自動",
|
||||
"never": "なし",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"refreshNow": "今すぐ更新",
|
||||
"lastError": "最後のエラー",
|
||||
"deleteConfirm": "このサブスクリプションを削除しますか?",
|
||||
"restartHint": "追加または更新した後、アウトバウンドを有効にするにはXrayを再起動してください(または次の自動リロードをお待ちください)。",
|
||||
"fromSubsTitle": "アウトバウンドサブスクリプションから(読み取り専用)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "バランサー設定",
|
||||
"tabObservatory": "オブザーバトリ",
|
||||
"observatory": {
|
||||
"title": "オブザーバトリ",
|
||||
"burstTitle": "バースト オブザーバトリ",
|
||||
"autoManaged": "オブザーバはバランサーから自動的に管理されます。プローブの方法は下で調整できます。監視対象のアウトバウンドはバランサーのセレクターに従います。",
|
||||
"emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、オブザーバを使うバランサーがターゲットを選ぶ前にアウトバウンドの健全性を確認できるようになります。",
|
||||
"mixedLegacy": "この設定には Observatory と Burst Observatory の両方が含まれています。Xray は単一のグローバルオブザーバを使用するため、この古い混在状態はサポートされません。バランサーを保存すると 1 つのオブザーバに正規化されます。",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "バランサー {tag} — 削除(対象が残っていません)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "負荷分散追加",
|
||||
"editBalancer": "負荷分散編集",
|
||||
"balancerStrategy": "戦略",
|
||||
"balancerSelectors": "セレクター",
|
||||
"tag": "タグ",
|
||||
"tagDesc": "一意のタグ",
|
||||
"tagDuplicate": "このタグは他のバランサーで使用されています",
|
||||
"tagPlaceholder": "一意のバランサータグ",
|
||||
"selector": "セレクター",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "許容範囲",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
|
||||
"costMatch": "タグパターン",
|
||||
"costValue": "重み",
|
||||
"costRegexp": "正規表現で一致",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "公開鍵",
|
||||
"allowedIPs": "許可されたIP",
|
||||
"endpoint": "エンドポイント",
|
||||
"psk": "共有キー",
|
||||
"domainStrategy": "ドメイン戦略"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "AmneziaWG 2.0専用。空欄で1.x互換の設定になります。"
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "TUN インターフェースの名前。デフォルトは 'xray0' です",
|
||||
"mtuDesc": "最大伝送単位。データパケットの最大サイズ。デフォルトは 1500 です",
|
||||
"userLevel": "ユーザーレベル",
|
||||
"userLevelDesc": "このインバウンドを通じて確立されたすべての接続は、このユーザーレベルを使用します。デフォルトは 0 です"
|
||||
"userLevel": "ユーザーレベル"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "フェイクDNS追加",
|
||||
"edit": "フェイクDNS編集",
|
||||
"ipPool": "IPプールサブネット",
|
||||
"poolSize": "プールサイズ"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "追加",
|
||||
"month": "月",
|
||||
"months": "ヶ月",
|
||||
"day": "日",
|
||||
"days": "日間",
|
||||
"hours": "時間",
|
||||
"minutes": "分",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Telegramユーザーが保存されました。",
|
||||
"loginSuccess": "✅ パネルに正常にログインしました。\r\n",
|
||||
"loginFailed": "❗️ パネルのログインに失敗しました。\r\n",
|
||||
"2faFailed": "2FAエラー",
|
||||
"report": "🕰 定期報告:{{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ 日時:{{ .DateTime }}\r\n",
|
||||
"hostname": "💻 ホスト: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 ダウンロード: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 合計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Telegramユーザー:{{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 消耗済みの {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 消耗済みの {{ .Type }} 数量:\r\n",
|
||||
"onlinesCount": "🌐 オンラインクライアント:{{ .Count }}\r\n",
|
||||
"disabled": "🛑 無効化:{{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 更新時間:{{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ はい",
|
||||
"no": "❌ いいえ",
|
||||
"received_id": "🔑📥 IDが更新されました。",
|
||||
"received_password": "🔑📥 パスワードが更新されました。",
|
||||
"received_email": "📧📥 メールが更新されました。",
|
||||
"received_comment": "💬📥 コメントが更新されました。",
|
||||
"id_prompt": "🔑 デフォルトID: {{ .ClientId }}\n\nIDを入力してください。",
|
||||
"pass_prompt": "🔑 デフォルトパスワード: {{ .ClientPassword }}\n\nパスワードを入力してください。",
|
||||
"email_prompt": "📧 デフォルトメール: {{ .ClientEmail }}\n\nメールを入力してください。",
|
||||
"comment_prompt": "💬 デフォルトコメント: {{ .ClientComment }}\n\nコメントを入力してください。",
|
||||
"inbound_client_data_id": "🔄 インバウンド: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 メール: {{ .ClientEmail }}\n📊 トラフィック: {{ .ClientTraffic }}\n📅 有効期限: {{ .ClientExp }}\n🌐 IP制限: {{ .IpLimit }}\n💬 コメント: {{ .ClientComment }}\n\n今すぐこのクライアントをインバウンドに追加できます!",
|
||||
"inbound_client_data_pass": "🔄 インバウンド: {{ .InboundRemark }}\n\n🔑 パスワード: {{ .ClientPass }}\n📧 メール: {{ .ClientEmail }}\n📊 トラフィック: {{ .ClientTraffic }}\n📅 有効期限: {{ .ClientExp }}\n🌐 IP制限: {{ .IpLimit }}\n💬 コメント: {{ .ClientComment }}\n\n今すぐこのクライアントをインバウンドに追加できます!",
|
||||
"cancel": "❌ プロセスがキャンセルされました!\n\nいつでも /start で再開できます。 🔄",
|
||||
"error_add_client": "⚠️ エラー:\n\n {{ .error }}",
|
||||
"using_default_value": "わかりました、デフォルト値を使用します。 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "エラー: {{ .Error }}",
|
||||
"eventNodeDown": "ノード {{ .Name }} がダウンしています",
|
||||
"eventNodeUp": "ノード {{ .Name }} が復旧しました",
|
||||
"eventCPUHigh": "CPU高負荷",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
|
||||
"memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "無効として送信 ☑️",
|
||||
"submitEnable": "有効として送信 ✅",
|
||||
"use_default": "🏷️ デフォルトを使用",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 パスワード",
|
||||
"change_email": "⚙️📧 メール",
|
||||
"change_comment": "⚙️💬 コメント",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "すべてのトラフィックをリセット",
|
||||
"SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
|
||||
"subjectOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
|
||||
"subjectXrayCrash": "Xrayがクラッシュしました",
|
||||
"subjectCPUHigh": "CPU高負荷",
|
||||
"subjectLoginSuccess": "ログイン成功",
|
||||
"subjectLoginFailed": "ログイン失敗",
|
||||
"titleOutboundDown": "アウトバウンド ダウン",
|
||||
"titleOutboundUp": "アウトバウンド 復旧",
|
||||
"titleXrayCrash": "Xrayがクラッシュしました",
|
||||
"titleCPUHigh": "CPU高負荷",
|
||||
"titleLoginSuccess": "ログイン成功",
|
||||
"titleLoginFailed": "ログイン失敗",
|
||||
"labelStatus": "ステータス",
|
||||
"labelOutbound": "アウトバウンド",
|
||||
"labelNode": "ノード",
|
||||
"labelError": "エラー",
|
||||
"labelDelay": "遅延",
|
||||
"labelDetail": "詳細",
|
||||
"labelUsername": "ユーザー名",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "理由",
|
||||
"labelSource": "送信元",
|
||||
"labelTime": "時刻",
|
||||
"statusCrashed": "クラッシュ",
|
||||
"statusRunning": "実行中",
|
||||
"statusHigh": "高負荷",
|
||||
"statusSuccess": "成功",
|
||||
"statusFailed": "失敗",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Copiado com Sucesso",
|
||||
"sure": "Certo",
|
||||
"encryption": "Criptografia",
|
||||
"useIPv4ForHost": "Usar IPv4 para o host",
|
||||
"transmission": "Transmissão",
|
||||
"host": "Host",
|
||||
"path": "Caminho",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Código",
|
||||
"remained": "Restante",
|
||||
"security": "Segurança",
|
||||
"secAlertTitle": "Alerta de Segurança",
|
||||
"secAlertSsl": "Esta conexão não é segura. Evite inserir informações confidenciais até que o TLS seja ativado para proteção de dados.",
|
||||
"secAlertConf": "Algumas configurações estão vulneráveis a ataques. Recomenda-se reforçar os protocolos de segurança para evitar possíveis violações.",
|
||||
"secAlertSSL": "O painel não possui uma conexão segura. Instale o certificado TLS para proteção de dados.",
|
||||
"secAlertPanelPort": "A porta padrão do painel é vulnerável. Configure uma porta aleatória ou específica.",
|
||||
"secAlertPanelURI": "O caminho URI padrão do painel não é seguro. Configure um caminho URI complexo.",
|
||||
"secAlertSubURI": "O caminho URI padrão de inscrição não é seguro. Configure um caminho URI complexo.",
|
||||
"secAlertSubJsonURI": "O caminho URI JSON de inscrição padrão não é seguro. Configure um caminho URI complexo.",
|
||||
"emptyDnsDesc": "Nenhum servidor DNS adicionado.",
|
||||
"emptyFakeDnsDesc": "Nenhum servidor Fake DNS adicionado.",
|
||||
"emptyBalancersDesc": "Nenhum balanceador adicionado.",
|
||||
"emptyReverseDesc": "Nenhum proxy reverso adicionado.",
|
||||
"somethingWentWrong": "Algo deu errado",
|
||||
"subscription": {
|
||||
"title": "Informações da assinatura",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
"dark": "Escuro",
|
||||
"ultraDark": "Ultra Escuro",
|
||||
"dashboard": "Visão Geral",
|
||||
"inbounds": "Entradas",
|
||||
"clients": "Clientes",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "Roteamento",
|
||||
"outbounds": "Saídas",
|
||||
"apiDocs": "Documentação da API",
|
||||
"logout": "Sair",
|
||||
"link": "Gerenciar",
|
||||
"donate": "Doar",
|
||||
"hosts": "Hosts",
|
||||
"docs": "Documentação",
|
||||
"openMenu": "Abrir menu"
|
||||
"openMenu": "Abrir menu",
|
||||
"pinSidebar": "Fixar barra lateral",
|
||||
"unpinSidebar": "Desafixar barra lateral"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Visão Geral",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "Processadores lógicos",
|
||||
"frequency": "Frequência",
|
||||
"swap": "Swap",
|
||||
"storage": "Armazenamento",
|
||||
"memory": "Memória",
|
||||
"threads": "Threads",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Parar",
|
||||
"restartXray": "Reiniciar",
|
||||
"xraySwitch": "Versão",
|
||||
"xrayUpdates": "Atualizações do Xray",
|
||||
"xraySwitchClick": "Escolha a versão para a qual deseja alternar.",
|
||||
"xraySwitchClickDesk": "Escolha com cuidado, pois versões mais antigas podem não ser compatíveis com as configurações atuais.",
|
||||
"updatePanel": "Atualizar painel",
|
||||
"panelUpdateDesc": "Isso atualizará o 3X-UI para a versão mais recente e reiniciará o serviço do painel.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Commit atual",
|
||||
"latestCommit": "Último commit",
|
||||
"updateChannelChanged": "Canal de atualização alterado",
|
||||
"upToDate": "Atualizado",
|
||||
"xrayStatusUnknown": "Desconhecido",
|
||||
"xrayStatusRunning": "Em execução",
|
||||
"xrayStatusStop": "Parado",
|
||||
"xrayStatusError": "Erro",
|
||||
"xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
|
||||
"operationHours": "Tempo de Atividade",
|
||||
"systemHistoryTitle": "Histórico do Sistema",
|
||||
"historyTitleCpu": "Uso da CPU",
|
||||
"historyTitleMem": "Uso de Memória",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Carga",
|
||||
"historyTabConnections": "Conexões",
|
||||
"historyTabDiskUsage": "Uso de Disco",
|
||||
"charts": "Gráficos",
|
||||
"xrayMetricsTitle": "Métricas do Xray",
|
||||
"xrayTitleHeap": "Memória Heap Alocada",
|
||||
"xrayTitleSys": "Memória Reservada do SO",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Inativo",
|
||||
"xrayObservatoryLastSeen": "Visto pela última vez",
|
||||
"xrayObservatoryLastTry": "Última tentativa",
|
||||
"trendLast2Min": "Últimos 2 minutos",
|
||||
"systemLoad": "Carga do Sistema",
|
||||
"systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos",
|
||||
"connectionCount": "Estatísticas de Conexão",
|
||||
"ipAddresses": "Endereços IP",
|
||||
"toggleIpVisibility": "Alternar visibilidade do IP",
|
||||
"overallSpeed": "Velocidade geral",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"totalData": "Dados totais",
|
||||
"sent": "Enviado",
|
||||
"received": "Recebido",
|
||||
"documentation": "Documentação",
|
||||
"xraySwitchVersionDialog": "Você realmente deseja alterar a versão do Xray?",
|
||||
"xraySwitchVersionDialogDesc": "Isso mudará a versão do Xray para #version#.",
|
||||
"xraySwitchVersionPopover": "Xray atualizado com sucesso",
|
||||
"panelUpdateDialog": "Deseja realmente atualizar o painel?",
|
||||
"panelUpdateDialogDesc": "Isso atualizará o 3X-UI para #version# e reiniciará o serviço do painel.",
|
||||
"panelUpdateCheckPopover": "Falha na verificação de atualização do painel",
|
||||
"panelUpdateStartedPopover": "Atualização do painel iniciada",
|
||||
"panelUpdateFailedTitle": "Falha ao atualizar o painel",
|
||||
"panelUpdateFailedDesc": "A atualização não foi concluída com sucesso. Verifique os logs do servidor ou execute 'x-ui update' na linha de comando.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Logs de acesso",
|
||||
"autoUpdate": "Atualização automática",
|
||||
"config": "Configuração",
|
||||
"backup": "Backup",
|
||||
"backupTitle": "Backup & Restauração",
|
||||
"exportDatabase": "Backup",
|
||||
"exportDatabaseDesc": "Clique para baixar um arquivo .db contendo um backup do seu banco de dados atual para o seu dispositivo. O mesmo arquivo também pode ser restaurado em um painel executando PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
|
||||
"importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
|
||||
"migrationDownload": "Baixar migração",
|
||||
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite."
|
||||
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite.",
|
||||
"avg": "média",
|
||||
"peak": "pico",
|
||||
"free": "livre",
|
||||
"openSockets": "sockets abertos",
|
||||
"throughputSub": "Total da interface",
|
||||
"avgWindow": "Média do período",
|
||||
"healthWarm": "{list} — um pouco alto",
|
||||
"healthCritical": "{list} — crítico",
|
||||
"panel": "Painel",
|
||||
"threads": "Threads",
|
||||
"uptime": "Tempo ativo"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Entradas",
|
||||
"totalDownUp": "Total Enviado/Recebido",
|
||||
"totalUsage": "Uso Total",
|
||||
"inboundCount": "Total de Inbounds",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Painel local",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "Quando uma conexão neste inbound não corresponde a nenhum cliente, redirecione-a para outro lugar. Escolha um inbound filho abaixo para preencher automaticamente os campos de roteamento (SNI / ALPN / Path / xver) a partir do transporte dele, ou deixe o seletor vazio e defina Dest diretamente (ex.: 8080 ou 127.0.0.1:8080) para rotear para um servidor externo como o Nginx. Cada inbound filho deve escutar em 127.0.0.1 com security=none.",
|
||||
"empty": "Ainda sem fallbacks",
|
||||
"add": "Adicionar fallback",
|
||||
"pickInbound": "Escolha um inbound",
|
||||
"matchAny": "qualquer",
|
||||
"destPlaceholder": "automático (listen:porta do filho)",
|
||||
"rederive": "Preencher a partir do filho",
|
||||
"rederived": "Preenchido a partir do filho",
|
||||
"editAdvanced": "Editar campos de roteamento",
|
||||
"hideAdvanced": "Ocultar avançado",
|
||||
"quickAddAll": "Adicionar todos os elegíveis",
|
||||
"quickAdded": "{n} fallback(s) adicionado(s)",
|
||||
"quickAddedNone": "Nenhum inbound novo elegível para adicionar",
|
||||
"routesWhen": "Roteia quando",
|
||||
"defaultCatchAll": "Padrão — captura qualquer outra coisa",
|
||||
"needsTls": "Os fallbacks ficam disponíveis após selecionar TLS ou Reality na aba Segurança (apenas VLESS/Trojan sobre RAW)."
|
||||
},
|
||||
"protocol": "Protocolo",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Mapeamento de portas",
|
||||
"traffic": "Tráfego",
|
||||
"speed": "Velocidade",
|
||||
"details": "Detalhes",
|
||||
"transportConfig": "Transporte",
|
||||
"expireDate": "Duração",
|
||||
"createdAt": "Criado",
|
||||
"updatedAt": "Atualizado",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Adicionar Inbound",
|
||||
"generalActions": "Ações Gerais",
|
||||
"modifyInbound": "Modificar Inbound",
|
||||
"deleteInbound": "Excluir Inbound",
|
||||
"deleteInboundContent": "Tem certeza de que deseja excluir o inbound?",
|
||||
"deleteConfirmTitle": "Excluir o inbound \"{remark}\"?",
|
||||
"deleteConfirmContent": "Isto remove o inbound e todos os seus clientes. Não é possível desfazer.",
|
||||
"resetConfirmTitle": "Redefinir o tráfego de \"{remark}\"?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "Todas-as-entradas",
|
||||
"exportAllSubsFileName": "Todas-as-entradas-Subs",
|
||||
"inboundJsonTitle": "JSON da entrada",
|
||||
"deleteClient": "Excluir Cliente",
|
||||
"deleteClientContent": "Tem certeza de que deseja excluir o cliente?",
|
||||
"resetTrafficContent": "Tem certeza de que deseja redefinir o tráfego?",
|
||||
"copyLink": "Copiar URL",
|
||||
"address": "Endereço",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Ilimitado. (unidade: GB)",
|
||||
"totalFlow": "Fluxo Total",
|
||||
"leaveBlankToNeverExpire": "Deixe em branco para nunca expirar",
|
||||
"noRecommendKeepDefault": "Recomenda-se manter o padrão",
|
||||
"certificatePath": "Caminho",
|
||||
"certificateContent": "Conteúdo",
|
||||
"publicKey": "Chave Pública",
|
||||
"privatekey": "Chave Privada",
|
||||
"clickOnQRcode": "Clique no Código QR para Copiar",
|
||||
"client": "Cliente",
|
||||
"export": "Exportar Todos os URLs",
|
||||
"clone": "Clonar",
|
||||
"cloneInbound": "Clonar",
|
||||
"cloneInboundContent": "Todas as configurações deste inbound, exceto Porta, IP de Escuta e Clientes, serão aplicadas ao clone.",
|
||||
"cloneInboundOk": "Clonar",
|
||||
"resetAllTraffic": "Redefinir Tráfego de Todos os Inbounds",
|
||||
"resetAllTrafficTitle": "Redefinir Tráfego de Todos os Inbounds",
|
||||
"resetAllTrafficContent": "Tem certeza de que deseja redefinir o tráfego de todos os inbounds?",
|
||||
"resetInboundClientTraffics": "Redefinir Tráfego dos Clientes",
|
||||
"resetInboundClientTrafficTitle": "Redefinir Tráfego dos Clientes",
|
||||
"resetInboundClientTrafficContent": "Tem certeza de que deseja redefinir o tráfego dos clientes deste inbound?",
|
||||
"resetAllClientTraffics": "Redefinir Tráfego de Todos os Clientes",
|
||||
"resetAllClientTrafficTitle": "Redefinir Tráfego de Todos os Clientes",
|
||||
"resetAllClientTrafficContent": "Tem certeza de que deseja redefinir o tráfego de todos os clientes?",
|
||||
"delDepletedClients": "Excluir Clientes Esgotados",
|
||||
"delDepletedClientsTitle": "Excluir Clientes Esgotados",
|
||||
"delDepletedClientsContent": "Tem certeza de que deseja excluir todos os clientes esgotados?",
|
||||
"email": "Email",
|
||||
"emailDesc": "Por favor, forneça um endereço de e-mail único.",
|
||||
"IPLimit": "Limite de IP",
|
||||
"IPLimitDesc": "Desativa o inbound se o número ultrapassar o valor definido. (0 = desativar)",
|
||||
"IPLimitlog": "Log de IP",
|
||||
"IPLimitlogDesc": "O histórico de IPs. (para ativar o inbound após a desativação, limpe o log)",
|
||||
"IPLimitlogclear": "Limpar o Log",
|
||||
"setDefaultCert": "Definir Certificado pelo Painel",
|
||||
"setDefaultCertEmpty": "Nenhum certificado configurado para o painel. Configure um em Configurações primeiro.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Wrapper do bloco sniffing do Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Wrapper do bloco stream do Xray:",
|
||||
"jsonErrorPrefix": "JSON avançado"
|
||||
"streamHelp": "Wrapper do bloco stream do Xray:"
|
||||
},
|
||||
"telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.",
|
||||
"subSortIndex": "Ordem sub",
|
||||
"same": "Igual",
|
||||
"inboundInfo": "Informações do Inbound",
|
||||
"exportInbound": "Exportar Inbound",
|
||||
"import": "Importar",
|
||||
"importInbound": "Importar um Inbound",
|
||||
"periodicTrafficResetTitle": "Reset de Tráfego",
|
||||
"periodicTrafficResetDesc": "Reinicia automaticamente o contador de tráfego em intervalos especificados",
|
||||
"lastReset": "Último Reset",
|
||||
"periodicTrafficResetDay": "Dia da redefinição mensal",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Nunca",
|
||||
"daily": "Diariamente",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Obter",
|
||||
"updateSuccess": "A atualização foi bem-sucedida",
|
||||
"logCleanSuccess": "O log foi limpo",
|
||||
"inboundsUpdateSuccess": "Entradas atualizadas com sucesso",
|
||||
"inboundUpdateSuccess": "Entrada atualizada com sucesso",
|
||||
"inboundCreateSuccess": "Entrada criada com sucesso",
|
||||
"bulkDeleted": "{count} inbounds excluídos",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Cliente de entrada excluído",
|
||||
"inboundClientUpdateSuccess": "Cliente de entrada atualizado",
|
||||
"savedNodeOfflineWillSync": "Salvo localmente. Um nó de apoio está offline ou desativado — a alteração será sincronizada assim que reconectar.",
|
||||
"delDepletedClientsSuccess": "Todos os clientes esgotados foram excluídos",
|
||||
"resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado",
|
||||
"resetAllTrafficSuccess": "Todo o tráfego foi reiniciado",
|
||||
"resetInboundClientTrafficSuccess": "O tráfego foi reiniciado",
|
||||
@@ -657,6 +599,10 @@
|
||||
"maxTimeDiff": "Máx. diferença de tempo (ms)",
|
||||
"minClientVer": "Mín. versão cliente",
|
||||
"maxClientVer": "Máx. versão cliente",
|
||||
"minClientVerHint": "Vazio não significa sem restrição: o Xray-core aplica o mínimo embutido da build do núcleo em uso (26.3.27 nas versões atuais) e rejeita clientes que reportam uma versão mais antiga — incluindo núcleos de terceiros como Mihomo e sing-box. Definir 1.0.0 os aceita, ao custo de admitir impressões digitais TLS desatualizadas.",
|
||||
"maxClientVerHint": "Vazio significa sem limite superior. Se definido, não deve ser menor que o mínimo efetivo — a versão mínima do cliente ou, se aquele campo estiver vazio, o mínimo embutido do Xray-core — ou todos os clientes serão rejeitados.",
|
||||
"clientVerInvalid": "A versão do cliente deve ter até três números separados por pontos, cada um 0-255 (ex.: 26.3.27)",
|
||||
"maxClientVerBelowMin": "A versão máxima do cliente não deve ser menor que a versão mínima",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Obrigatório. Deve incluir uma porta (ex.: example.com:443). Sem porta, o Xray-core não inicia.",
|
||||
"realityTargetRequired": "O alvo REALITY é obrigatório",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Config Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "Requisição",
|
||||
"response": "Resposta",
|
||||
"name": "Nome",
|
||||
"value": "Valor"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Versão",
|
||||
"method": "Método",
|
||||
"path": "Caminho",
|
||||
"status": "Status",
|
||||
"statusDescription": "Descrição do Status",
|
||||
"requestHeader": "Cabeçalho da Requisição",
|
||||
"responseHeader": "Cabeçalho da Resposta"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Substituição de destino"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Adicionar assinatura externa",
|
||||
"noExternalLinks": "Ainda não há links externos.",
|
||||
"noExternalSubscriptions": "Ainda não há assinaturas externas.",
|
||||
"add": "Adicionar cliente",
|
||||
"edit": "Editar cliente",
|
||||
"submitAdd": "Adicionar cliente",
|
||||
"submitEdit": "Salvar alterações",
|
||||
"clientCount": "Número de clientes",
|
||||
"bulk": "Adicionar em lote",
|
||||
"copyFromInbound": "Copiar clientes do inbound",
|
||||
"copyToInbound": "Copiar clientes para",
|
||||
"copySelected": "Copiar selecionados",
|
||||
"copySource": "Origem",
|
||||
"copyEmailPreview": "Prévia do e-mail resultante",
|
||||
"copySelectSourceFirst": "Selecione primeiro um inbound de origem.",
|
||||
"copyResult": "Resultado da cópia",
|
||||
"copyResultSuccess": "Copiado com sucesso",
|
||||
"copyResultNone": "Nada a copiar: nenhum cliente selecionado ou a origem está vazia",
|
||||
"copyResultErrors": "Erros de cópia",
|
||||
"copyFlowLabel": "Flow para os novos clientes (VLESS)",
|
||||
"copyFlowHint": "Aplicado a todos os clientes copiados. Deixe em branco para ignorar.",
|
||||
"selectAll": "Selecionar tudo",
|
||||
"clearAll": "Limpar tudo",
|
||||
"method": "Método",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Sufixo",
|
||||
"delayedStart": "Iniciar após o primeiro uso",
|
||||
"expireDays": "Duração (dias)",
|
||||
"days": "Dia(s)",
|
||||
"renew": "Renovação automática",
|
||||
"renewDesc": "Renovação automática após a expiração. (0 = desativar) (unidade: dia)",
|
||||
"renewDays": "Renovação automática (dias)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Expira em breve",
|
||||
"has": "Tem",
|
||||
"hasNot": "Não tem",
|
||||
"title": "Clientes",
|
||||
"actions": "Ações",
|
||||
"totalGB": "Limite de tráfego (GB)",
|
||||
"totalGBDesc": "Cota de dados para este cliente. 0 = ilimitado.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Adicionar cliente",
|
||||
"qrCode": "Código QR",
|
||||
"clientInfo": "Informações do cliente",
|
||||
"delete": "Excluir",
|
||||
"reset": "Redefinir tráfego",
|
||||
"editClient": "Editar cliente",
|
||||
"client": "Cliente",
|
||||
"enabled": "Habilitado",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "Sem links compartilháveis — associe primeiro este cliente a um inbound compatível com o protocolo.",
|
||||
"link": "Link",
|
||||
"resetNotPossible": "Associe primeiro este cliente a um inbound.",
|
||||
"general": "Geral",
|
||||
"resetAllTraffics": "Redefinir o tráfego de todos os clientes",
|
||||
"resetAllTrafficsTitle": "Redefinir o tráfego de todos os clientes?",
|
||||
"resetAllTrafficsContent": "Os contadores de envio/recebimento de cada cliente vão a zero. Cota e expiração não são afetadas. Não é possível desfazer.",
|
||||
"deleteConfirmTitle": "Excluir o cliente {email}?",
|
||||
"deleteConfirmContent": "Isto remove o cliente de cada inbound associado e descarta o registro de tráfego. Não é possível desfazer.",
|
||||
"deleteSelected": "Excluir ({count})",
|
||||
"adjustSelected": "Ajustar ({count})",
|
||||
"subLinksSelected": "Links sub ({count})",
|
||||
"addToGroupTitle": "Adicionar {count} cliente(s) a um grupo",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "Desativar {count} clientes?",
|
||||
"bulkDisableConfirmContent": "Desativa cada cliente selecionado em todos os inbounds associados. Eles perdem o acesso imediatamente, mas seus registros e tráfego são mantidos.",
|
||||
"selectedCount": "{count} selecionado(s)",
|
||||
"attachSelected": "Associar ({count})",
|
||||
"attachToInboundsTitle": "Associar {count} cliente(s) a entrada(s)",
|
||||
"attachToInboundsDesc": "Associa os {count} cliente(s) selecionados (mesmo UUID/senha e tráfego compartilhado) às entradas escolhidas. Mantêm suas associações existentes.",
|
||||
"attachToInboundsTargets": "Entradas de destino",
|
||||
"attachToInboundsNoTargets": "Não há entradas multiusuário disponíveis para associação.",
|
||||
"detachSelected": "Desassociar ({count})",
|
||||
"detach": "Desassociar",
|
||||
"detachFromInboundsTitle": "Desassociar {count} cliente(s) de entrada(s)",
|
||||
"detachFromInboundsDesc": "Remove os {count} cliente(s) selecionados das entradas escolhidas. Pares onde o cliente não estava associado são ignorados silenciosamente. Os registros dos clientes são mantidos (use Delete para remover completamente).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||
"telegramId": "ID de usuário do Telegram",
|
||||
"telegramIdPlaceholder": "ID numérico de usuário do Telegram (0 = nenhum)",
|
||||
"created": "Criado",
|
||||
"updated": "Atualizado",
|
||||
"ipLimit": "Limite de IP",
|
||||
"toasts": {
|
||||
"deleted": "Cliente excluído",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Grupos",
|
||||
"name": "Nome",
|
||||
"clientCount": "Clientes",
|
||||
"totalGroups": "Total de grupos",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "Removidos {count} cliente(s) de {name}."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Nós",
|
||||
"addNode": "Adicionar nó",
|
||||
"editNode": "Editar nó",
|
||||
"totalNodes": "Total de nós",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "Token da página de Configurações do painel remoto",
|
||||
"apiTokenHint": "O painel remoto exibe o token da API em Autenticação → Token da API.",
|
||||
"apiTokenKeepHint": "Deixe em branco para manter o token atual",
|
||||
"regenerate": "Regenerar token",
|
||||
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
|
||||
"allowPrivateAddress": "Permitir endereço privado",
|
||||
"allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
|
||||
"outboundTag": "Outbound de conexão",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "Atualizar painel",
|
||||
"updateSelected": "Atualizar selecionados ({count})",
|
||||
"updateAvailable": "Atualização disponível",
|
||||
"upToDate": "Atualizado",
|
||||
"updateConfirmTitle": "Atualizar {count} nó(s) para a versão mais recente?",
|
||||
"updateConfirmContent": "Cada nó selecionado baixa a versão mais recente e reinicia nela. Apenas nós ativos e online são atualizados.",
|
||||
"updateDevChannel": "Atualizar para o canal de desenvolvimento (último commit)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "Não foi possível obter o certificado"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Padrão",
|
||||
"title": "Configurações do Painel",
|
||||
"save": "Salvar",
|
||||
"infoDesc": "Toda alteração feita aqui precisa ser salva. Reinicie o painel para aplicar as alterações.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "Uso de memória alto (%)",
|
||||
"remarkTemplate": "Modelo de Observação",
|
||||
"remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
|
||||
"subShowIdentityOnAllLinks": "Mostrar identidade em todos os links",
|
||||
"subShowIdentityOnAllLinksDesc": "Quando ativado, {{EMAIL}} e {{USERNAME}} permanecem na observação de cada link do corpo da assinatura. Tokens de uso continuam só no primeiro link.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "O caminho deve começar com /"
|
||||
},
|
||||
@@ -1448,7 +1350,6 @@
|
||||
"secretClearUndo": "Desfazer limpeza"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Configurações Xray",
|
||||
"importRules": "Importar regras",
|
||||
"exportRules": "Exportar regras",
|
||||
"importOutbounds": "Importar saídas",
|
||||
@@ -1458,11 +1359,7 @@
|
||||
"metricsListenDesc": "Expõe as métricas no estilo Prometheus do Xray neste endereço:porta (por exemplo, 127.0.0.1:11111). Deixe vazio para desativar. Vincule ao localhost e use um proxy reverso — ele não é autenticado.",
|
||||
"metricsTag": "Tag de métricas",
|
||||
"save": "Salvar",
|
||||
"restart": "Reiniciar Xray",
|
||||
"restartSuccess": "Xray foi reiniciado com sucesso",
|
||||
"restartOutputTitle": "Saída do reinício do Xray",
|
||||
"restartConfirmTitle": "Reiniciar xray?",
|
||||
"restartConfirmContent": "Recarrega o serviço xray com a configuração salva.",
|
||||
"stopSuccess": "Xray foi interrompido com sucesso",
|
||||
"restartError": "Ocorreu um erro ao reiniciar o Xray.",
|
||||
"stopError": "Ocorreu um erro ao parar o Xray.",
|
||||
@@ -1472,7 +1369,6 @@
|
||||
"generalConfigsDesc": "Essas opções determinam ajustes gerais.",
|
||||
"logConfigs": "Log",
|
||||
"logConfigsDesc": "Os logs podem afetar a eficiência do servidor. É recomendável habilitá-los com sabedoria apenas se necessário.",
|
||||
"blockConfigsDesc": "Essas opções bloqueiam tráfego com base em protocolos e sites específicos solicitados.",
|
||||
"basicRouting": "Roteamento Básico",
|
||||
"blockConnectionsConfigsDesc": "Essas opções bloquearão o tráfego com base no país solicitado.",
|
||||
"directConnectionsConfigsDesc": "Uma conexão direta garante que o tráfego específico não seja roteado por outro servidor.",
|
||||
@@ -1482,10 +1378,6 @@
|
||||
"directdomains": "Domínios Diretos",
|
||||
"ipv4Routing": "Roteamento IPv4",
|
||||
"ipv4RoutingDesc": "Essas opções roteam o tráfego para um destino específico via IPv4.",
|
||||
"warpRouting": "Roteamento WARP",
|
||||
"warpRoutingDesc": "Essas opções roteam o tráfego para um destino específico via WARP.",
|
||||
"nordRouting": "Roteamento NordVPN",
|
||||
"nordRoutingDesc": "Essas opções roteiam o tráfego para um destino específico via NordVPN.",
|
||||
"Template": "Modelo de Configuração Avançada do Xray",
|
||||
"TemplateDesc": "O arquivo final de configuração do Xray será gerado com base neste modelo.",
|
||||
"FreedomStrategy": "Estratégia do Protocolo Freedom",
|
||||
@@ -1499,10 +1391,7 @@
|
||||
"outboundTestUrlDesc": "URL usada ao testar conectividade do outbound",
|
||||
"Torrent": "Bloquear Protocolo BitTorrent",
|
||||
"Inbounds": "Entradas",
|
||||
"InboundsDesc": "Aceitar clientes específicos.",
|
||||
"Outbounds": "Saídas",
|
||||
"OutboundSubscriptions": "Assinaturas de Saída",
|
||||
"OutboundSubscriptionsDesc": "Importe saídas a partir de URLs de assinatura remotas (vmess/vless/trojan/ss/...). As tags são mantidas estáveis para uso em balanceadores e regras de roteamento. As atualizações são automáticas.",
|
||||
"Balancers": "Balanceadores",
|
||||
"balancerTagRequired": "A tag é obrigatória",
|
||||
"balancerSelectorRequired": "Selecione pelo menos uma saída",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "Saída correspondente",
|
||||
"routeTesterViaBalancer": "via balanceador",
|
||||
"routeTesterDefaultOutbound": "Nenhuma regra de roteamento correspondeu — o tráfego vai para a saída padrão (primeira).",
|
||||
"OutboundsDesc": "Definir o caminho de saída do tráfego.",
|
||||
"Routings": "Regras de Roteamento",
|
||||
"RoutingsDesc": "A prioridade de cada regra é importante!",
|
||||
"completeTemplate": "Tudo",
|
||||
"logLevel": "Nível de Log",
|
||||
"logLevelDesc": "O nível de log para erros, indicando a informação que precisa ser registrada.",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "Máscara de endereço IP, quando ativado, substitui automaticamente o endereço IP que aparece no log.",
|
||||
"statistics": "Estatísticas",
|
||||
"statsInboundUplink": "Estatísticas de Upload de Entrada",
|
||||
"statsInboundUplinkDesc": "Habilita a coleta de estatísticas para o tráfego de upload de todos os proxies de entrada.",
|
||||
"statsInboundDownlink": "Estatísticas de Download de Entrada",
|
||||
"statsInboundDownlinkDesc": "Habilita a coleta de estatísticas para o tráfego de download de todos os proxies de entrada.",
|
||||
"statsOutboundUplink": "Estatísticas de Upload de Saída",
|
||||
"statsOutboundUplinkDesc": "Habilita a coleta de estatísticas para o tráfego de upload de todos os proxies de saída.",
|
||||
"statsOutboundDownlink": "Estatísticas de Download de Saída",
|
||||
"statsOutboundDownlinkDesc": "Habilita a coleta de estatísticas para o tráfego de download de todos os proxies de saída.",
|
||||
"connectionLimits": "Limites de conexão",
|
||||
"connectionLimitsDesc": "Políticas em nível de conexão para o nível de usuário 0. Deixe um campo vazio para usar o padrão do Xray.",
|
||||
"connIdle": "Tempo limite de inatividade",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "automático",
|
||||
"seconds": "segundos",
|
||||
"rules": {
|
||||
"first": "Primeiro",
|
||||
"last": "Último",
|
||||
"up": "Cima",
|
||||
"down": "Baixo",
|
||||
"source": "Fonte",
|
||||
"dest": "Destino",
|
||||
"inbound": "Entrada",
|
||||
"outbound": "Saída",
|
||||
"balancer": "Balanceador",
|
||||
"info": "Info",
|
||||
"add": "Adicionar Regra",
|
||||
"edit": "Editar Regra",
|
||||
"useComma": "Itens separados por vírgula"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "Regra {n}",
|
||||
"action": "Ação",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Regras finais",
|
||||
"overrideXrayPrivateIp": "Sobrescrever o bloqueio de IP privado padrão do Xray",
|
||||
"blockDelay": "Atraso do bloqueio (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "Intervalo keep alive",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Interface",
|
||||
"ipv6Only": "Apenas IPv6",
|
||||
"acceptProxyProtocol": "Aceitar proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Adicionar Saída",
|
||||
"addReverse": "Adicionar Reverso",
|
||||
"editOutbound": "Editar Saída",
|
||||
"editReverse": "Editar Reverso",
|
||||
"reverseTag": "Tag de Reverso",
|
||||
"reverseTagDesc": "Tag de saída do proxy reverso simples VLESS. Deixe vazio para desabilitar.",
|
||||
"reverseTagPlaceholder": "tag de saída (vazio para desabilitar)",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Tag Única",
|
||||
"address": "Endereço",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Reverso",
|
||||
"domain": "Domínio",
|
||||
"type": "Tipo",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Link",
|
||||
"intercon": "Interconexão",
|
||||
"settings": "Configurações",
|
||||
"accountInfo": "Informações da Conta",
|
||||
"outboundStatus": "Status de Saída",
|
||||
"sendThrough": "Enviar Através de",
|
||||
"targetStrategy": "Estratégia de destino",
|
||||
"test": "Testar",
|
||||
"testResult": "Resultado do teste",
|
||||
"testing": "Testando conexão...",
|
||||
"testSuccess": "Teste bem-sucedido",
|
||||
"testFailed": "Teste falhou",
|
||||
"testError": "Falha ao testar saída",
|
||||
"modeRealDelay": "Latência real",
|
||||
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray. Latência real: tempo total incluindo o estabelecimento da conexão.",
|
||||
"testAll": "Testar todos",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "Conexão do proxy",
|
||||
"breakdownTls": "TLS via saída",
|
||||
"breakdownTtfb": "Primeiro byte",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Token de Acesso",
|
||||
"country": "País",
|
||||
"server": "Servidor",
|
||||
"city": "Cidade",
|
||||
"allCities": "Todas as Cidades",
|
||||
"privateKey": "Chave Privada",
|
||||
"load": "Carga",
|
||||
"moveToTop": "Mover para o topo"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "Assinaturas ativas",
|
||||
"empty": "Nenhuma assinatura ainda. Adicione uma acima.",
|
||||
"colRemark": "Observação",
|
||||
"colPrefix": "Prefixo",
|
||||
"colInterval": "Intervalo",
|
||||
"colLastFetch": "Última busca",
|
||||
"colEnabled": "Ativado",
|
||||
"auto": "auto",
|
||||
"never": "nunca",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"refreshNow": "Atualizar agora",
|
||||
"lastError": "Último erro",
|
||||
"deleteConfirm": "Excluir esta assinatura?",
|
||||
"restartHint": "Após adicionar ou atualizar, reinicie o Xray (ou aguarde o próximo recarregamento automático) para ativar as saídas.",
|
||||
"fromSubsTitle": "De assinaturas de saída (somente leitura)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "Configurações do balanceador",
|
||||
"tabObservatory": "Observatório",
|
||||
"observatory": {
|
||||
"title": "Observatório",
|
||||
"burstTitle": "Observatório Burst",
|
||||
"autoManaged": "Os observadores são gerenciados automaticamente a partir dos seus balanceadores. Ajuste abaixo como eles sondam; as saídas monitoradas seguem os seletores do balanceador.",
|
||||
"emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que balanceadores com observador possam verificar a saúde das saídas antes de escolher um destino.",
|
||||
"mixedLegacy": "Esta configuração contém Observatory e Burst Observatory ao mesmo tempo. O Xray usa um único observador global, então esse estado misto legado não é suportado; ao salvar os balanceadores ele será normalizado para um único observador.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "Balanceador {tag} — removido (sem destinos restantes)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Adicionar Balanceador",
|
||||
"editBalancer": "Editar Balanceador",
|
||||
"balancerStrategy": "Estratégia",
|
||||
"balancerSelectors": "Seletores",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Tag Única",
|
||||
"tagDuplicate": "Tag já usada por outro balanceador",
|
||||
"tagPlaceholder": "tag única do balanceador",
|
||||
"selector": "Seletor",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "Tolerância",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.",
|
||||
"costMatch": "Padrão de tag",
|
||||
"costValue": "Peso",
|
||||
"costRegexp": "Correspondência por expressão regular",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "Chave Pública",
|
||||
"allowedIPs": "IPs Permitidos",
|
||||
"endpoint": "Ponto Final",
|
||||
"psk": "Chave Pré-Compartilhada",
|
||||
"domainStrategy": "Estratégia de Domínio"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "Somente para AmneziaWG 2.0. Deixe vazio para compatibilidade com 1.x."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "O nome da interface TUN. O padrão é 'xray0'",
|
||||
"mtuDesc": "Unidade Máxima de Transmissão. O tamanho máximo dos pacotes de dados. O padrão é 1500",
|
||||
"userLevel": "Nível do Usuário",
|
||||
"userLevelDesc": "Todas as conexões feitas através deste inbound usarão este nível de usuário. O padrão é 0"
|
||||
"userLevel": "Nível do Usuário"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Adicionar Fake DNS",
|
||||
"edit": "Editar Fake DNS",
|
||||
"ipPool": "Sub-rede do Pool de IP",
|
||||
"poolSize": "Tamanho do Pool"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Adicionar",
|
||||
"month": "Mês",
|
||||
"months": "Meses",
|
||||
"day": "Dia",
|
||||
"days": "Dias",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Usuário do Telegram salvo.",
|
||||
"loginSuccess": "✅ Conectado ao painel com sucesso.\r\n",
|
||||
"loginFailed": "❗️Tentativa de login no painel falhou.\r\n",
|
||||
"2faFailed": "Falha no 2FA",
|
||||
"report": "🕰 Relatórios agendados: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Data&Hora: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 Download: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Usuário do Telegram: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 {{ .Type }} esgotado:\r\n",
|
||||
"exhaustedCount": "🚨 Contagem de {{ .Type }} esgotado:\r\n",
|
||||
"onlinesCount": "🌐 Clientes online: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Desativado: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Atualizado em: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Sim",
|
||||
"no": "❌ Não",
|
||||
"received_id": "🔑📥 ID atualizado.",
|
||||
"received_password": "🔑📥 Senha atualizada.",
|
||||
"received_email": "📧📥 E-mail atualizado.",
|
||||
"received_comment": "💬📥 Comentário atualizado.",
|
||||
"id_prompt": "🔑 ID Padrão: {{ .ClientId }}\n\nDigite seu ID.",
|
||||
"pass_prompt": "🔑 Senha Padrão: {{ .ClientPassword }}\n\nDigite sua senha.",
|
||||
"email_prompt": "📧 E-mail Padrão: {{ .ClientEmail }}\n\nDigite seu e-mail.",
|
||||
"comment_prompt": "💬 Comentário Padrão: {{ .ClientComment }}\n\nDigite seu comentário.",
|
||||
"inbound_client_data_id": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Tráfego: {{ .ClientTraffic }}\n📅 Data de expiração: {{ .ClientExp }}\n🌐 Limite de IP: {{ .IpLimit }}\n💬 Comentário: {{ .ClientComment }}\n\nAgora você pode adicionar o cliente à entrada!",
|
||||
"inbound_client_data_pass": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 Senha: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Tráfego: {{ .ClientTraffic }}\n📅 Data de expiração: {{ .ClientExp }}\n🌐 Limite de IP: {{ .IpLimit }}\n💬 Comentário: {{ .ClientComment }}\n\nAgora você pode adicionar o cliente à entrada!",
|
||||
"cancel": "❌ Processo Cancelado! \n\nVocê pode iniciar novamente a qualquer momento com /start. 🔄",
|
||||
"error_add_client": "⚠️ Erro:\n\n {{ .error }}",
|
||||
"using_default_value": "Tudo bem, vou manter o valor padrão. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Erro: {{ .Error }}",
|
||||
"eventNodeDown": "O nó {{ .Name }} está INATIVO",
|
||||
"eventNodeUp": "O nó {{ .Name }} está ATIVO",
|
||||
"eventCPUHigh": "CPU alta",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Falha de login a partir de {{ .Source }}",
|
||||
"memoryThreshold": "Uso de memória {{ .Percent }}% excede o limite de {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Enviar como Desativado ☑️",
|
||||
"submitEnable": "Enviar como Ativado ✅",
|
||||
"use_default": "🏷️ Usar padrão",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 Senha",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Comentário",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Redefinir Todo o Tráfego",
|
||||
"SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "O outbound {{ .Tag }} está INATIVO",
|
||||
"subjectOutboundUp": "O outbound {{ .Tag }} está ATIVO",
|
||||
"subjectXrayCrash": "O Xray FALHOU",
|
||||
"subjectCPUHigh": "CPU alta",
|
||||
"subjectLoginSuccess": "Login bem-sucedido",
|
||||
"subjectLoginFailed": "Falha de login",
|
||||
"titleOutboundDown": "Outbound INATIVO",
|
||||
"titleOutboundUp": "Outbound ATIVO",
|
||||
"titleXrayCrash": "O Xray FALHOU",
|
||||
"titleCPUHigh": "CPU alta",
|
||||
"titleLoginSuccess": "Login bem-sucedido",
|
||||
"titleLoginFailed": "Falha de login",
|
||||
"labelStatus": "Status",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelNode": "Nó",
|
||||
"labelError": "Erro",
|
||||
"labelDelay": "Latência",
|
||||
"labelDetail": "Detalhe",
|
||||
"labelUsername": "Nome de usuário",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Motivo",
|
||||
"labelSource": "Origem",
|
||||
"labelTime": "Horário",
|
||||
"statusCrashed": "FALHOU",
|
||||
"statusRunning": "Em execução",
|
||||
"statusHigh": "ALTA",
|
||||
"statusSuccess": "SUCESSO",
|
||||
"statusFailed": "FALHOU",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Скопировано",
|
||||
"sure": "Да",
|
||||
"encryption": "Шифрование",
|
||||
"useIPv4ForHost": "Использовать IPv4 для подключения к хосту",
|
||||
"transmission": "Транспорт",
|
||||
"host": "Хост",
|
||||
"path": "Путь",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Код 2FA",
|
||||
"remained": "Остаток",
|
||||
"security": "Безопасность",
|
||||
"secAlertTitle": "Предупреждение системы безопасности",
|
||||
"secAlertSsl": "Соединение не защищено. Не вводите конфиденциальные данные до установки SSL-сертификата.",
|
||||
"secAlertConf": "Некоторые настройки уязвимы. Рекомендуется усилить защиту для предотвращения атак.",
|
||||
"secAlertSSL": "Подключение к панели не защищено. Установите SSL-сертификат для защиты данных.",
|
||||
"secAlertPanelPort": "Порт панели по умолчанию небезопасен. Установите нестандартный или случайный порт.",
|
||||
"secAlertPanelURI": "Адрес панели по умолчанию небезопасен. Настройте уникальный и сложный URI.",
|
||||
"secAlertSubURI": "URI подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес.",
|
||||
"secAlertSubJsonURI": "URI JSON-подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес.",
|
||||
"emptyDnsDesc": "Нет добавленных DNS-серверов.",
|
||||
"emptyFakeDnsDesc": "Нет добавленных Fake DNS-серверов.",
|
||||
"emptyBalancersDesc": "Нет добавленных балансировщиков.",
|
||||
"emptyReverseDesc": "Нет добавленных реверс-прокси.",
|
||||
"somethingWentWrong": "Что-то пошло не так",
|
||||
"subscription": {
|
||||
"title": "Информация о подписке",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Тема",
|
||||
"dark": "Темная",
|
||||
"ultraDark": "Очень темная",
|
||||
"dashboard": "Дашборд",
|
||||
"inbounds": "Входящие",
|
||||
"clients": "Клиенты",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "Маршрутизация",
|
||||
"outbounds": "Исходящие",
|
||||
"apiDocs": "Документация API",
|
||||
"logout": "Выход",
|
||||
"link": "Управление",
|
||||
"donate": "Поддержать",
|
||||
"hosts": "Хосты",
|
||||
"docs": "Документация",
|
||||
"openMenu": "Открыть меню"
|
||||
"openMenu": "Открыть меню",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Дашборд",
|
||||
"cpu": "ЦП",
|
||||
"logicalProcessors": "Логические процессоры",
|
||||
"frequency": "Частота",
|
||||
"swap": "Подкачка",
|
||||
"storage": "Диск",
|
||||
"memory": "Память",
|
||||
"threads": "Потоки",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Стоп",
|
||||
"restartXray": "Перезапуск",
|
||||
"xraySwitch": "Выбор версии",
|
||||
"xrayUpdates": "Обновления Xray",
|
||||
"xraySwitchClick": "Выберите нужную версию",
|
||||
"xraySwitchClickDesk": "Важно: старые версии могут не поддерживать текущие настройки",
|
||||
"updatePanel": "Обновить панель",
|
||||
"panelUpdateDesc": "Это обновит 3X-UI до последнего релиза и перезапустит сервис панели.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Текущий коммит",
|
||||
"latestCommit": "Последний коммит",
|
||||
"updateChannelChanged": "Канал обновления изменён",
|
||||
"upToDate": "Обновлено",
|
||||
"xrayStatusUnknown": "Неизвестно",
|
||||
"xrayStatusRunning": "Запущен",
|
||||
"xrayStatusStop": "Остановлен",
|
||||
"xrayStatusError": "Ошибка",
|
||||
"xrayErrorPopoverTitle": "Ошибка при запуске Xray",
|
||||
"operationHours": "Время работы системы",
|
||||
"systemHistoryTitle": "История системы",
|
||||
"historyTitleCpu": "Загрузка ЦП",
|
||||
"historyTitleMem": "Использование памяти",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Нагрузка",
|
||||
"historyTabConnections": "Соединения",
|
||||
"historyTabDiskUsage": "Использование диска",
|
||||
"charts": "Графики",
|
||||
"xrayMetricsTitle": "Метрики Xray",
|
||||
"xrayTitleHeap": "Выделенная память кучи",
|
||||
"xrayTitleSys": "Память, зарезервированная у ОС",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Недоступен",
|
||||
"xrayObservatoryLastSeen": "Последняя активность",
|
||||
"xrayObservatoryLastTry": "Последняя попытка",
|
||||
"trendLast2Min": "Последние 2 минуты",
|
||||
"systemLoad": "Нагрузка на систему",
|
||||
"systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут",
|
||||
"connectionCount": "Количество соединений",
|
||||
"ipAddresses": "IP-адреса сервера",
|
||||
"toggleIpVisibility": "Скрыть или показать IP-адреса сервера",
|
||||
"overallSpeed": "Общая скорость передачи трафика",
|
||||
"upload": "Загрузка",
|
||||
"download": "Скачать",
|
||||
"totalData": "Общий объем трафика",
|
||||
"sent": "Отправлено",
|
||||
"received": "Получено",
|
||||
"documentation": "Документация",
|
||||
"xraySwitchVersionDialog": "Переключить версию Xray",
|
||||
"xraySwitchVersionDialogDesc": "Вы точно хотите сменить версию Xray?",
|
||||
"xraySwitchVersionPopover": "Xray успешно обновлён",
|
||||
"panelUpdateDialog": "Вы действительно хотите обновить панель?",
|
||||
"panelUpdateDialogDesc": "Это обновит 3X-UI до версии #version# и перезапустит сервис панели.",
|
||||
"panelUpdateCheckPopover": "Проверка обновления панели не удалась",
|
||||
"panelUpdateStartedPopover": "Обновление панели началось",
|
||||
"panelUpdateFailedTitle": "Не удалось обновить панель",
|
||||
"panelUpdateFailedDesc": "Обновление не завершилось успешно. Проверьте журналы сервера или выполните 'x-ui update' в командной строке.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Логи доступа",
|
||||
"autoUpdate": "Автообновление",
|
||||
"config": "Конфигурация",
|
||||
"backup": "Резервная копия",
|
||||
"backupTitle": "Бэкап и восстановление",
|
||||
"exportDatabase": "Экспорт базы данных",
|
||||
"exportDatabaseDesc": "Нажмите, чтобы скачать файл .db, содержащий резервную копию вашей текущей базы данных на ваше устройство. Этот же файл можно восстановить на панели, работающей на PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.",
|
||||
"importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить резервную копию PostgreSQL (.dump), базу данных SQLite (.db) или миграционный дамп SQLite для восстановления базы данных. Это заменит все текущие данные.",
|
||||
"migrationDownload": "Скачать файл миграции",
|
||||
"migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite."
|
||||
"migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite.",
|
||||
"avg": "среднее",
|
||||
"peak": "пик",
|
||||
"free": "свободно",
|
||||
"openSockets": "открытых сокетов",
|
||||
"throughputSub": "Всего по интерфейсу",
|
||||
"avgWindow": "Среднее за период",
|
||||
"healthWarm": "{list} — повышенная нагрузка",
|
||||
"healthCritical": "{list} — критический уровень",
|
||||
"panel": "Панель",
|
||||
"threads": "Потоки",
|
||||
"uptime": "Время работы"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Входящие",
|
||||
"totalDownUp": "Отправлено/получено",
|
||||
"totalUsage": "Всего трафика",
|
||||
"inboundCount": "Всего подключений",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Локальная панель",
|
||||
"fallbacks": {
|
||||
"title": "Fallback'и",
|
||||
"help": "Когда соединение на этом инбаунде не совпадает ни с одним клиентом, оно перенаправляется в другое место. Выберите дочерний инбаунд ниже, чтобы поля маршрутизации (SNI / ALPN / Path / xver) заполнились автоматически из его транспорта, либо оставьте выбор пустым и задайте Dest напрямую (например, 8080 или 127.0.0.1:8080), чтобы перенаправить на внешний сервер, такой как Nginx. Каждый дочерний инбаунд должен слушать на 127.0.0.1 с security=none.",
|
||||
"empty": "Фолбэков пока нет",
|
||||
"add": "Добавить фолбэк",
|
||||
"pickInbound": "Выберите инбаунд",
|
||||
"matchAny": "любой",
|
||||
"destPlaceholder": "авто (listen:порт дочернего)",
|
||||
"rederive": "Заполнить из дочернего",
|
||||
"rederived": "Заполнено из дочернего",
|
||||
"editAdvanced": "Изменить поля маршрутизации",
|
||||
"hideAdvanced": "Скрыть расширенные",
|
||||
"quickAddAll": "Быстро добавить все подходящие",
|
||||
"quickAdded": "Добавлено {n} фолбэк(ов)",
|
||||
"quickAddedNone": "Нет новых подходящих инбаундов",
|
||||
"routesWhen": "Маршрутизирует, когда",
|
||||
"defaultCatchAll": "По умолчанию — ловит всё остальное",
|
||||
"needsTls": "Fallbacks станут доступны после выбора TLS или Reality на вкладке «Безопасность» (только VLESS/Trojan поверх RAW)."
|
||||
},
|
||||
"protocol": "Протокол",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Сопоставление портов",
|
||||
"traffic": "Трафик",
|
||||
"speed": "Скорость",
|
||||
"details": "Подробнее",
|
||||
"transportConfig": "Транспорт",
|
||||
"expireDate": "Дата окончания",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Создать подключение",
|
||||
"generalActions": "Общие действия",
|
||||
"modifyInbound": "Изменить подключение",
|
||||
"deleteInbound": "Удалить подключение",
|
||||
"deleteInboundContent": "Вы уверены, что хотите удалить подключение?",
|
||||
"deleteConfirmTitle": "Удалить подключение \"{remark}\"?",
|
||||
"deleteConfirmContent": "Подключение и все его клиенты будут удалены. Это действие нельзя отменить.",
|
||||
"resetConfirmTitle": "Сбросить трафик \"{remark}\"?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "Все-входящие",
|
||||
"exportAllSubsFileName": "Все-входящие-Subs",
|
||||
"inboundJsonTitle": "JSON входящего",
|
||||
"deleteClient": "Удалить клиента",
|
||||
"deleteClientContent": "Вы уверены, что хотите удалить клиента?",
|
||||
"resetTrafficContent": "Вы уверены, что хотите сбросить трафик?",
|
||||
"copyLink": "Копировать ссылку",
|
||||
"address": "Адрес",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Безлимит. (единица: ГБ)",
|
||||
"totalFlow": "Общий расход",
|
||||
"leaveBlankToNeverExpire": "Оставьте пустым, чтобы было бесконечным",
|
||||
"noRecommendKeepDefault": "Рекомендуется оставить настройки по умолчанию",
|
||||
"certificatePath": "Путь к сертификату",
|
||||
"certificateContent": "Содержимое сертификата",
|
||||
"publicKey": "Публичный ключ",
|
||||
"privatekey": "Приватный ключ",
|
||||
"clickOnQRcode": "Нажмите на QR-код, чтобы скопировать",
|
||||
"client": "Клиент",
|
||||
"export": "Экспорт ссылок",
|
||||
"clone": "Клонировать",
|
||||
"cloneInbound": "Клонировать",
|
||||
"cloneInboundContent": "Будут клонированы все настройки подключений, кроме списка клиентов, порта и IP-адреса прослушивания",
|
||||
"cloneInboundOk": "Клонировано",
|
||||
"resetAllTraffic": "Сброс трафика всех подключений",
|
||||
"resetAllTrafficTitle": "Сброс трафика всех подключений",
|
||||
"resetAllTrafficContent": "Вы уверены, что хотите сбросить трафик всех подключений?",
|
||||
"resetInboundClientTraffics": "Сброс трафика клиента",
|
||||
"resetInboundClientTrafficTitle": "Сброс трафика клиентов",
|
||||
"resetInboundClientTrafficContent": "Вы уверены, что хотите сбросить трафик для этих клиентов?",
|
||||
"resetAllClientTraffics": "Сброс трафика всех клиентов",
|
||||
"resetAllClientTrafficTitle": "Сброс трафика всех клиентов",
|
||||
"resetAllClientTrafficContent": "Вы уверены, что хотите сбросить трафик всех клиентов?",
|
||||
"delDepletedClients": "Удалить отключенных клиентов",
|
||||
"delDepletedClientsTitle": "Удаление отключенных клиентов",
|
||||
"delDepletedClientsContent": "Вы уверены, что хотите удалить всех отключенных клиентов?",
|
||||
"email": "Email",
|
||||
"emailDesc": "Пожалуйста, укажите уникальный Email",
|
||||
"IPLimit": "Лимит по количеству IP",
|
||||
"IPLimitDesc": "Ограничение числа одновременных подключений с разных IP (0 – отключить)",
|
||||
"IPLimitlog": "Лог IP-адресов",
|
||||
"IPLimitlogDesc": "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)",
|
||||
"IPLimitlogclear": "Очистить лог",
|
||||
"setDefaultCert": "Установить сертификат панели",
|
||||
"setDefaultCertEmpty": "Для панели не настроен сертификат. Сначала установите его в Настройках.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Обёртка блока sniffing Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Обёртка блока stream Xray:",
|
||||
"jsonErrorPrefix": "Расширенный JSON"
|
||||
"streamHelp": "Обёртка блока stream Xray:"
|
||||
},
|
||||
"telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'",
|
||||
"subSortIndex": "Порядок",
|
||||
"same": "Тот же",
|
||||
"inboundInfo": "Информация о подключении",
|
||||
"exportInbound": "Экспорт подключений",
|
||||
"import": "Импортировать",
|
||||
"importInbound": "Импорт подключений",
|
||||
"periodicTrafficResetTitle": "Сброс трафика",
|
||||
"periodicTrafficResetDesc": "Автоматический сброс счетчика трафика через указанные интервалы",
|
||||
"lastReset": "Последний сброс",
|
||||
"periodicTrafficResetDay": "День ежемесячного сброса",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Никогда",
|
||||
"daily": "Ежедневно",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Получить",
|
||||
"updateSuccess": "Обновление прошло успешно",
|
||||
"logCleanSuccess": "Лог был очищен",
|
||||
"inboundsUpdateSuccess": "Подключения успешно обновлены",
|
||||
"inboundUpdateSuccess": "Подключение успешно обновлено",
|
||||
"inboundCreateSuccess": "Подключение успешно создано",
|
||||
"bulkDeleted": "Удалено подключений: {count}",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Клиент подключения удалён",
|
||||
"inboundClientUpdateSuccess": "Клиент подключения обновлён",
|
||||
"savedNodeOfflineWillSync": "Сохранено локально. Опорный узел отключён или недоступен — изменение синхронизируется после повторного подключения.",
|
||||
"delDepletedClientsSuccess": "Все исчерпанные клиенты удалены",
|
||||
"resetAllClientTrafficSuccess": "Весь трафик клиента сброшен",
|
||||
"resetAllTrafficSuccess": "Весь трафик сброшен",
|
||||
"resetInboundClientTrafficSuccess": "Трафик сброшен",
|
||||
@@ -657,6 +599,10 @@
|
||||
"maxTimeDiff": "Макс. разница во времени (мс)",
|
||||
"minClientVer": "Мин. версия клиента",
|
||||
"maxClientVer": "Макс. версия клиента",
|
||||
"minClientVerHint": "Пустое поле не означает «без ограничений»: Xray-core применит встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах) и отклонит клиентов, сообщающих более старую версию, — включая сторонние ядра, такие как Mihomo и sing-box. Значение 1.0.0 разрешит их, но допустит устаревшие TLS-отпечатки.",
|
||||
"maxClientVerHint": "Пустое поле — без верхнего предела. Если задано, значение не должно быть ниже действующего минимума — «Мин. версия клиента», а при пустом том поле — встроенного минимума Xray-core, иначе все клиенты будут отклонены.",
|
||||
"clientVerInvalid": "Версия клиента — до трёх чисел через точку, каждое 0-255 (например 26.3.27)",
|
||||
"maxClientVerBelowMin": "Макс. версия клиента не должна быть ниже минимальной версии клиента",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Обязательно. Должно содержать порт (например, example.com:443). Без порта Xray-core не запускается.",
|
||||
"realityTargetRequired": "Цель REALITY обязательна",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Конфиг Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "Запрос",
|
||||
"response": "Ответ",
|
||||
"name": "Имя",
|
||||
"value": "Значение"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Версия",
|
||||
"method": "Метод",
|
||||
"path": "Путь",
|
||||
"status": "Статус",
|
||||
"statusDescription": "Описание статуса",
|
||||
"requestHeader": "Заголовок запроса",
|
||||
"responseHeader": "Заголовок ответа"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Переопределение назначения"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Добавить внешнюю подписку",
|
||||
"noExternalLinks": "Пока нет внешних ссылок.",
|
||||
"noExternalSubscriptions": "Пока нет внешних подписок.",
|
||||
"add": "Добавить клиента",
|
||||
"edit": "Изменить клиента",
|
||||
"submitAdd": "Добавить клиента",
|
||||
"submitEdit": "Сохранить изменения",
|
||||
"clientCount": "Количество клиентов",
|
||||
"bulk": "Массовое добавление",
|
||||
"copyFromInbound": "Скопировать клиентов из входящего",
|
||||
"copyToInbound": "Скопировать клиентов в",
|
||||
"copySelected": "Скопировать выбранное",
|
||||
"copySource": "Источник",
|
||||
"copyEmailPreview": "Предпросмотр результирующего email",
|
||||
"copySelectSourceFirst": "Сначала выберите исходный входящий.",
|
||||
"copyResult": "Результат копирования",
|
||||
"copyResultSuccess": "Скопировано успешно",
|
||||
"copyResultNone": "Нечего копировать: клиенты не выбраны или источник пуст",
|
||||
"copyResultErrors": "Ошибки копирования",
|
||||
"copyFlowLabel": "Flow для новых клиентов (VLESS)",
|
||||
"copyFlowHint": "Применяется ко всем скопированным клиентам. Оставьте пустым, чтобы пропустить.",
|
||||
"selectAll": "Выбрать всё",
|
||||
"clearAll": "Очистить всё",
|
||||
"method": "Метод",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Постфикс",
|
||||
"delayedStart": "Старт после первого использования",
|
||||
"expireDays": "Длительность (дней)",
|
||||
"days": "Дни",
|
||||
"renew": "Автопродление",
|
||||
"renewDesc": "Автоматическое продление после окончания. (0 = отключено) (единица: день)",
|
||||
"renewDays": "Автопродление (дней)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Скорее истекают",
|
||||
"has": "Есть",
|
||||
"hasNot": "Нет",
|
||||
"title": "Клиенты",
|
||||
"actions": "Действия",
|
||||
"totalGB": "Лимит трафика (ГБ)",
|
||||
"totalGBDesc": "Квота трафика для этого клиента. 0 = без ограничений.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Добавить клиента",
|
||||
"qrCode": "QR-код",
|
||||
"clientInfo": "Информация о клиенте",
|
||||
"delete": "Удалить",
|
||||
"reset": "Сбросить трафик",
|
||||
"editClient": "Изменить клиента",
|
||||
"client": "Клиент",
|
||||
"enabled": "Включён",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.",
|
||||
"link": "Ссылка",
|
||||
"resetNotPossible": "Сначала привяжите этого клиента к входящему.",
|
||||
"general": "Общие",
|
||||
"resetAllTraffics": "Сбросить трафик всех клиентов",
|
||||
"resetAllTrafficsTitle": "Сбросить трафик всех клиентов?",
|
||||
"resetAllTrafficsContent": "Счётчики отправки/приёма всех клиентов сбрасываются в ноль. Квоты и срок действия не затрагиваются. Это действие нельзя отменить.",
|
||||
"deleteConfirmTitle": "Удалить клиента {email}?",
|
||||
"deleteConfirmContent": "Клиент будет удалён из всех привязанных входящих, а его запись трафика будет уничтожена. Это действие нельзя отменить.",
|
||||
"deleteSelected": "Удалить ({count})",
|
||||
"adjustSelected": "Изменить ({count})",
|
||||
"subLinksSelected": "Sub-ссылки ({count})",
|
||||
"addToGroupTitle": "Добавить {count} клиент(ов) в группу",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "Отключить {count} клиентов?",
|
||||
"bulkDisableConfirmContent": "Отключает каждого выбранного клиента на всех привязанных подключениях. Они сразу теряют доступ, но их записи и трафик сохраняются.",
|
||||
"selectedCount": "{count} выбрано",
|
||||
"attachSelected": "Привязать ({count})",
|
||||
"attachToInboundsTitle": "Привязать {count} клиент(ов) к входящим",
|
||||
"attachToInboundsDesc": "Привязывает выбранных {count} клиент(ов) (тот же UUID/пароль и общий трафик) к выбранным входящим. Существующие привязки сохраняются.",
|
||||
"attachToInboundsTargets": "Целевые входящие",
|
||||
"attachToInboundsNoTargets": "Нет доступных многопользовательских входящих для привязки.",
|
||||
"detachSelected": "Отвязать ({count})",
|
||||
"detach": "Отвязать",
|
||||
"detachFromInboundsTitle": "Отвязать {count} клиент(ов) от входящих",
|
||||
"detachFromInboundsDesc": "Удаляет выбранных {count} клиент(ов) из выбранных входящих. Пары, где клиент не был привязан, тихо пропускаются. Записи клиентов сохраняются (используйте Delete для полного удаления).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Необязательный Reverse tag",
|
||||
"telegramId": "ID пользователя Telegram",
|
||||
"telegramIdPlaceholder": "Числовой ID пользователя Telegram (0 = нет)",
|
||||
"created": "Создан",
|
||||
"updated": "Обновлён",
|
||||
"ipLimit": "Лимит IP",
|
||||
"toasts": {
|
||||
"deleted": "Клиент удалён",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Группы",
|
||||
"name": "Имя",
|
||||
"clientCount": "Клиенты",
|
||||
"totalGroups": "Всего групп",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "Удалено {count} клиент(ов) из {name}."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Узлы",
|
||||
"addNode": "Добавить узел",
|
||||
"editNode": "Изменить узел",
|
||||
"totalNodes": "Всего узлов",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "Токен со страницы Настроек удалённой панели",
|
||||
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Учетная запись → Токен API.",
|
||||
"apiTokenKeepHint": "Оставьте пустым, чтобы сохранить текущий токен",
|
||||
"regenerate": "Сгенерировать токен заново",
|
||||
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
|
||||
"allowPrivateAddress": "Разрешить частный адрес",
|
||||
"allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
|
||||
"outboundTag": "Исходящее подключение",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "Обновить панель",
|
||||
"updateSelected": "Обновить выбранные ({count})",
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"upToDate": "Актуально",
|
||||
"updateConfirmTitle": "Обновить {count} узлов до последней версии?",
|
||||
"updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.",
|
||||
"updateDevChannel": "Обновить до канала разработки (последний коммит)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "Не удалось получить сертификат"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "По умолчанию",
|
||||
"title": "Настройки",
|
||||
"save": "Сохранить",
|
||||
"infoDesc": "Сохраните изменения и перезапустите панель для их применения.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "Превышение порога памяти (%)",
|
||||
"remarkTemplate": "Шаблон примечания",
|
||||
"remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
|
||||
"subShowIdentityOnAllLinks": "Показывать идентификатор на каждой ссылке",
|
||||
"subShowIdentityOnAllLinksDesc": "Если включено, {{EMAIL}} и {{USERNAME}} остаются в примечании каждой ссылки тела подписки. Токены использования по-прежнему только на первой ссылке.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Путь должен начинаться с /"
|
||||
},
|
||||
@@ -1456,13 +1358,8 @@
|
||||
"metricsListen": "Эндпоинт метрик",
|
||||
"metricsListenDesc": "Публикует метрики Xray в стиле Prometheus по этому адресу:порту (например, 127.0.0.1:11111). Оставьте пустым, чтобы отключить. Привяжите к localhost и проксируйте через reverse-proxy — он без аутентификации.",
|
||||
"metricsTag": "Тег метрик",
|
||||
"title": "Настройки Xray",
|
||||
"save": "Сохранить",
|
||||
"restart": "Перезапуск Xray",
|
||||
"restartSuccess": "Xray успешно перезапущен",
|
||||
"restartOutputTitle": "Вывод перезапуска Xray",
|
||||
"restartConfirmTitle": "Перезапустить xray?",
|
||||
"restartConfirmContent": "Перезагружает сервис xray с сохранённой конфигурацией.",
|
||||
"stopSuccess": "Xray успешно остановлен",
|
||||
"restartError": "Произошла ошибка при перезапуске Xray.",
|
||||
"stopError": "Произошла ошибка при остановке Xray.",
|
||||
@@ -1472,7 +1369,6 @@
|
||||
"generalConfigsDesc": "Эти параметры описывают общие настройки",
|
||||
"logConfigs": "Лог",
|
||||
"logConfigsDesc": "Логи могут замедлять работу сервера. Включайте только нужные вам виды логов при необходимости!",
|
||||
"blockConfigsDesc": "Настройте, чтобы клиенты не имели доступа к определенным протоколам",
|
||||
"basicRouting": "Базовые соединения",
|
||||
"blockConnectionsConfigsDesc": "Эти параметры будут блокировать трафик в зависимости от страны назначения.",
|
||||
"directConnectionsConfigsDesc": "Прямое соединение означает, что определенный трафик не будет перенаправлен через другой сервер.",
|
||||
@@ -1482,10 +1378,6 @@
|
||||
"directdomains": "Прямые домены",
|
||||
"ipv4Routing": "Правила IPv4",
|
||||
"ipv4RoutingDesc": "Эти параметры позволят клиентам маршрутизироваться к целевым доменам только через IPv4",
|
||||
"warpRouting": "Правила WARP",
|
||||
"warpRoutingDesc": " Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через WARP.",
|
||||
"nordRouting": "Маршрутизация NordVPN",
|
||||
"nordRoutingDesc": "Эти опции будут направлять трафик в зависимости от конкретного пункта назначения через NordVPN.",
|
||||
"Template": "Шаблон конфигурации Xray",
|
||||
"TemplateDesc": "На основе шаблона создаётся конфигурационный файл Xray.",
|
||||
"FreedomStrategy": "Настройка стратегии протокола Freedom",
|
||||
@@ -1499,10 +1391,7 @@
|
||||
"outboundTestUrlDesc": "URL для проверки подключения исходящего",
|
||||
"Torrent": "Заблокировать BitTorrent",
|
||||
"Inbounds": "Входящие",
|
||||
"InboundsDesc": "Изменение шаблона конфигурации для подключения определенных клиентов",
|
||||
"Outbounds": "Исходящие",
|
||||
"OutboundSubscriptions": "Подписки исходящих",
|
||||
"OutboundSubscriptionsDesc": "Импорт исходящих из удалённых URL подписок (vmess/vless/trojan/ss/...). Теги остаются неизменными для использования в балансировщиках и правилах маршрутизации. Обновление выполняется автоматически.",
|
||||
"Balancers": "Балансировщик",
|
||||
"balancerTagRequired": "Тег обязателен",
|
||||
"balancerSelectorRequired": "Выберите хотя бы одно исходящее",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "Совпавший исходящий",
|
||||
"routeTesterViaBalancer": "через балансировщик",
|
||||
"routeTesterDefaultOutbound": "Ни одно правило маршрутизации не совпало — трафик направляется в исходящий по умолчанию (первый).",
|
||||
"OutboundsDesc": "Изменение шаблона конфигурации, чтобы определить исходящие подключения для этого сервера",
|
||||
"Routings": "Маршрутизация",
|
||||
"RoutingsDesc": "Важен приоритет каждого правила!",
|
||||
"completeTemplate": "Все",
|
||||
"logLevel": "Уровень логов",
|
||||
"logLevelDesc": "Уровень журнала для журналов ошибок, указывающий информацию, которую необходимо записать.",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "При активации реальный IP-адрес заменяется на маскировочный в логах.",
|
||||
"statistics": "Статистика",
|
||||
"statsInboundUplink": "Статистика входящего аплинка",
|
||||
"statsInboundUplinkDesc": "Включает сбор статистики для исходящего трафика всех входящих прокси.",
|
||||
"statsInboundDownlink": "Статистика входящего даунлинка",
|
||||
"statsInboundDownlinkDesc": "Включает сбор статистики для входящего трафика всех входящих прокси.",
|
||||
"statsOutboundUplink": "Статистика исходящего аплинка",
|
||||
"statsOutboundUplinkDesc": "Включает сбор статистики для исходящего трафика всех исходящих прокси.",
|
||||
"statsOutboundDownlink": "Статистика исходящего даунлинка",
|
||||
"statsOutboundDownlinkDesc": "Включает сбор статистики для входящего трафика всех исходящих прокси.",
|
||||
"connectionLimits": "Ограничения соединения",
|
||||
"connectionLimitsDesc": "Политики уровня соединения для пользователей уровня 0. Оставьте поле пустым, чтобы использовать значение Xray по умолчанию.",
|
||||
"connIdle": "Тайм-аут простоя",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "авто",
|
||||
"seconds": "секунд",
|
||||
"rules": {
|
||||
"first": "Первый",
|
||||
"last": "Последний",
|
||||
"up": "Поднять вверх",
|
||||
"down": "Опустить вниз",
|
||||
"source": "Источник",
|
||||
"dest": "Пункт назначения",
|
||||
"inbound": "Входящее подключение",
|
||||
"outbound": "Исходящее подключение",
|
||||
"balancer": "Балансировщик",
|
||||
"info": "Инфо",
|
||||
"add": "Создать правило",
|
||||
"edit": "Редактировать правило",
|
||||
"useComma": "Элементы, разделённые запятыми"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "Правило {n}",
|
||||
"action": "Действие",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Финальные правила",
|
||||
"overrideXrayPrivateIp": "Переопределить дефолтный блок частных IP в Xray",
|
||||
"blockDelay": "Задержка блока (мс)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "Интервал keep alive",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Интерфейс",
|
||||
"ipv6Only": "Только IPv6",
|
||||
"acceptProxyProtocol": "Принимать proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (мс)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (с)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Создать исходящее подключение",
|
||||
"addReverse": "Создать реверс-прокси",
|
||||
"editOutbound": "Изменить исходящее подключение",
|
||||
"editReverse": "Редактировать реверс-прокси",
|
||||
"reverseTag": "Тег реверс-прокси",
|
||||
"reverseTagDesc": "Тег исходящего подключения для простого реверс-прокси VLESS. Оставьте пустым для отключения.",
|
||||
"reverseTagPlaceholder": "тег исходящего (пусто = отключено)",
|
||||
"tag": "Тег",
|
||||
"tagDesc": "Уникальный тег",
|
||||
"address": "Адрес",
|
||||
"egress": "Выход",
|
||||
"egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.",
|
||||
"reverse": "Реверс-прокси",
|
||||
"domain": "Домен",
|
||||
"type": "Тип",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Ссылка",
|
||||
"intercon": "Соединение",
|
||||
"settings": "Настройки",
|
||||
"accountInfo": "Информация об учетной записи",
|
||||
"outboundStatus": "Статус исходящего подключения",
|
||||
"sendThrough": "Отправить через",
|
||||
"targetStrategy": "Стратегия назначения",
|
||||
"test": "Тест",
|
||||
"testResult": "Результат теста",
|
||||
"testing": "Тестирование соединения...",
|
||||
"testSuccess": "Тест успешен",
|
||||
"testFailed": "Тест не пройден",
|
||||
"testError": "Не удалось протестировать исходящее подключение",
|
||||
"modeRealDelay": "Реальная задержка",
|
||||
"testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray. Реальная задержка: полное время с установлением соединения.",
|
||||
"testAll": "Тестировать все",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "Подключение к прокси",
|
||||
"breakdownTls": "TLS через исходящий",
|
||||
"breakdownTtfb": "Первый байт",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Токен доступа",
|
||||
"country": "Страна",
|
||||
"server": "Сервер",
|
||||
"city": "Город",
|
||||
"allCities": "Все города",
|
||||
"privateKey": "Приватный ключ",
|
||||
"load": "Нагрузка",
|
||||
"moveToTop": "Переместить наверх"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "Активные подписки",
|
||||
"empty": "Подписок пока нет. Добавьте одну выше.",
|
||||
"colRemark": "Примечание",
|
||||
"colPrefix": "Префикс",
|
||||
"colInterval": "Интервал",
|
||||
"colLastFetch": "Последнее обновление",
|
||||
"colEnabled": "Включено",
|
||||
"auto": "авто",
|
||||
"never": "никогда",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"refreshNow": "Обновить сейчас",
|
||||
"lastError": "Последняя ошибка",
|
||||
"deleteConfirm": "Удалить эту подписку?",
|
||||
"restartHint": "После добавления или обновления перезапустите Xray (или дождитесь следующей автоперезагрузки), чтобы исходящие стали активными.",
|
||||
"fromSubsTitle": "Из подписок исходящих (только для чтения)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "Настройки балансировщика",
|
||||
"tabObservatory": "Обсерватория",
|
||||
"observatory": {
|
||||
"title": "Обсерватория",
|
||||
"burstTitle": "Burst-обсерватория",
|
||||
"autoManaged": "Наблюдатели управляются автоматически на основе ваших балансировщиков. Ниже можно настроить, как они опрашивают; отслеживаемые исходящие следуют за селекторами балансировщика.",
|
||||
"emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщики с наблюдателем могли проверять состояние исходящих перед выбором цели.",
|
||||
"mixedLegacy": "В этой конфигурации одновременно есть Observatory и Burst Observatory. Xray использует один глобальный наблюдатель, поэтому такое устаревшее смешанное состояние не поддерживается; сохранение балансировщиков нормализует его до одного наблюдателя.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "Балансировщик {tag} — удалён (не осталось целей)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Создать балансировщик",
|
||||
"editBalancer": "Редактировать балансировщик",
|
||||
"balancerStrategy": "Стратегия",
|
||||
"balancerSelectors": "Селекторы",
|
||||
"tag": "Тег",
|
||||
"tagDesc": "Уникальный тег",
|
||||
"tagDuplicate": "Тег уже используется другим балансировщиком",
|
||||
"tagPlaceholder": "уникальный тег балансировщика",
|
||||
"selector": "Селектор",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "Допуск",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.",
|
||||
"costMatch": "Шаблон тега",
|
||||
"costValue": "Вес",
|
||||
"costRegexp": "Совпадение по регулярному выражению",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "Публичный ключ",
|
||||
"allowedIPs": "Разрешенные IP-адреса",
|
||||
"endpoint": "Конечная точка",
|
||||
"psk": "Общий ключ",
|
||||
"domainStrategy": "Стратегия домена"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "Только для AmneziaWG 2.0. Оставьте пустым для совместимости с 1.x."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "Имя интерфейса TUN. Значение по умолчанию - 'xray0'",
|
||||
"mtuDesc": "Максимальная единица передачи. Максимальный размер пакетов данных. Значение по умолчанию - 1500",
|
||||
"userLevel": "Уровень пользователя",
|
||||
"userLevelDesc": "Все соединения, установленные через этот входящий поток, будут использовать этот уровень пользователя. Значение по умолчанию - 0"
|
||||
"userLevel": "Уровень пользователя"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Создать Fake DNS",
|
||||
"edit": "Редактировать Fake DNS",
|
||||
"ipPool": "Подсеть пула IP",
|
||||
"poolSize": "Размер пула"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Добавить",
|
||||
"month": "Месяц",
|
||||
"months": "Месяцев",
|
||||
"day": "День",
|
||||
"days": "Дней",
|
||||
"hours": "Часов",
|
||||
"minutes": "Минуты",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Пользователь Telegram сохранен.",
|
||||
"loginSuccess": "✅ Успешный вход в панель.\r\n",
|
||||
"loginFailed": "❗️ Ошибка входа в панель.\r\n",
|
||||
"2faFailed": "Ошибка 2FA",
|
||||
"report": "🕰 Запланированные отчеты: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Дата и время: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Хост: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 Загрузка: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Всего: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Telegram User ID: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 Исчерпаны {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 Количество исчерпанных {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Клиентов онлайн: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Отключено: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Обновлено: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Да",
|
||||
"no": "❌ Нет",
|
||||
"received_id": "🔑📥 ID обновлён.",
|
||||
"received_password": "🔑📥 Пароль обновлён.",
|
||||
"received_email": "📧📥 Email обновлен.",
|
||||
"received_comment": "💬📥 Комментарий обновлён.",
|
||||
"id_prompt": "🔑 Стандартный ID: {{ .ClientId }}\n\nВведите ваш ID.",
|
||||
"pass_prompt": "🔑 Стандартный пароль: {{ .ClientPassword }}\n\nВведите ваш пароль.",
|
||||
"email_prompt": "📧 Стандартный email: {{ .ClientEmail }}\n\nВведите ваш email.",
|
||||
"comment_prompt": "💬 Стандартный комментарий: {{ .ClientComment }}\n\nВведите ваш комментарий.",
|
||||
"inbound_client_data_id": "🔄 Входящие подключения: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Срок действия: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в входящее подключение!",
|
||||
"inbound_client_data_pass": "🔄 Входящие подключения: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Срок действия: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в входящее подключение!",
|
||||
"cancel": "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄",
|
||||
"error_add_client": "⚠️ Ошибка:\n\n {{ .error }}",
|
||||
"using_default_value": "Используется значение по умолчанию👌",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Ошибка: {{ .Error }}",
|
||||
"eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН",
|
||||
"eventNodeUp": "Узел {{ .Name }} В СЕТИ",
|
||||
"eventCPUHigh": "Высокая загрузка CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Неудачный вход с {{ .Source }}",
|
||||
"memoryThreshold": "🔴 Использование памяти {{ .Percent }}% превышает пороговое значение {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Добавить отключенным ☑️",
|
||||
"submitEnable": "Добавить включенным ✅",
|
||||
"use_default": "🏷️ Использовать по умолчанию",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 Пароль",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Комментарий",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Сбросить весь трафик",
|
||||
"SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО",
|
||||
"subjectOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ",
|
||||
"subjectXrayCrash": "Сбой Xray",
|
||||
"subjectCPUHigh": "Высокая загрузка CPU",
|
||||
"subjectLoginSuccess": "Успешный вход",
|
||||
"subjectLoginFailed": "Неудачный вход",
|
||||
"titleOutboundDown": "Исходящее подключение НЕДОСТУПНО",
|
||||
"titleOutboundUp": "Исходящее подключение РАБОТАЕТ",
|
||||
"titleXrayCrash": "Сбой Xray",
|
||||
"titleCPUHigh": "Высокая загрузка CPU",
|
||||
"titleLoginSuccess": "Успешный вход",
|
||||
"titleLoginFailed": "Неудачный вход",
|
||||
"labelStatus": "Статус",
|
||||
"labelOutbound": "Исходящее подключение",
|
||||
"labelNode": "Узел",
|
||||
"labelError": "Ошибка",
|
||||
"labelDelay": "Задержка",
|
||||
"labelDetail": "Подробности",
|
||||
"labelUsername": "Имя пользователя",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Причина",
|
||||
"labelSource": "Источник",
|
||||
"labelTime": "Время",
|
||||
"statusCrashed": "СБОЙ",
|
||||
"statusRunning": "Работает",
|
||||
"statusHigh": "ВЫСОКАЯ",
|
||||
"statusSuccess": "УСПЕШНО",
|
||||
"statusFailed": "НЕУДАЧНО",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Başarıyla kopyalandı",
|
||||
"sure": "Emin misiniz?",
|
||||
"encryption": "Şifreleme",
|
||||
"useIPv4ForHost": "Ana bilgisayar için IPv4 kullan",
|
||||
"transmission": "İletim",
|
||||
"host": "Host",
|
||||
"path": "Yol",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Kod",
|
||||
"remained": "Kalan",
|
||||
"security": "Güvenlik",
|
||||
"secAlertTitle": "Güvenlik Uyarısı",
|
||||
"secAlertSsl": "Bu bağlantı güvenli değil. Verilerin korunması için TLS etkinleştirilene kadar hassas bilgi girmekten kaçının.",
|
||||
"secAlertConf": "Bazı ayarlar saldırıya açıktır. Olası ihlalleri önlemek için güvenlik protokollerini güçlendirmeniz önerilir.",
|
||||
"secAlertSSL": "Panelde güvenli bağlantı yok. Verilerin korunması için TLS sertifikası yükleyin.",
|
||||
"secAlertPanelPort": "Panelin varsayılan portu savunmasız. Rastgele veya belirli bir port yapılandırın.",
|
||||
"secAlertPanelURI": "Panelin varsayılan URI yolu güvensiz. Karmaşık bir URI yolu yapılandırın.",
|
||||
"secAlertSubURI": "Aboneliğin varsayılan URI yolu güvensiz. Karmaşık bir URI yolu yapılandırın.",
|
||||
"secAlertSubJsonURI": "Abonelik JSON dosyasının varsayılan URI yolu güvensiz. Karmaşık bir URI yolu yapılandırın.",
|
||||
"emptyDnsDesc": "Eklenmiş DNS sunucusu yok.",
|
||||
"emptyFakeDnsDesc": "Eklenmiş Fake DNS sunucusu yok.",
|
||||
"emptyBalancersDesc": "Eklenmiş dengeleyici yok.",
|
||||
"emptyReverseDesc": "Eklenmiş ters proxy yok.",
|
||||
"somethingWentWrong": "Bir hata oluştu",
|
||||
"subscription": {
|
||||
"title": "Abonelik Bilgisi",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Tema",
|
||||
"dark": "Koyu",
|
||||
"ultraDark": "Ultra Koyu",
|
||||
"dashboard": "Genel Bakış",
|
||||
"inbounds": "Gelen Bağlantılar",
|
||||
"clients": "Kullanıcılar",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "Yönlendirme",
|
||||
"outbounds": "Giden Bağlantılar",
|
||||
"apiDocs": "API Belgeleri",
|
||||
"logout": "Çıkış Yap",
|
||||
"link": "Yönet",
|
||||
"donate": "Bağış Yap",
|
||||
"hosts": "Host'lar",
|
||||
"docs": "Belgeler",
|
||||
"openMenu": "Menüyü aç"
|
||||
"openMenu": "Menüyü aç",
|
||||
"pinSidebar": "Kenar çubuğunu sabitle",
|
||||
"unpinSidebar": "Kenar çubuğu sabitlemesini kaldır"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Genel Bakış",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "Mantıksal İşlemciler",
|
||||
"frequency": "Frekans",
|
||||
"swap": "Takas",
|
||||
"storage": "Depolama",
|
||||
"memory": "RAM",
|
||||
"threads": "İş Parçacığı",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Durdur",
|
||||
"restartXray": "Yeniden Başlat",
|
||||
"xraySwitch": "Sürüm",
|
||||
"xrayUpdates": "Xray Güncellemeleri",
|
||||
"xraySwitchClick": "Geçiş yapmak istediğiniz sürümü seçin.",
|
||||
"xraySwitchClickDesk": "Dikkatli seçin, eski sürümler mevcut yapılandırmalarla uyumlu olmayabilir.",
|
||||
"updatePanel": "Paneli Güncelle",
|
||||
"panelUpdateDesc": "Bu işlem 3X-UI'yi en son sürüme güncelleyecek ve panel servisini yeniden başlatacaktır.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Geçerli commit",
|
||||
"latestCommit": "Son commit",
|
||||
"updateChannelChanged": "Güncelleme kanalı değiştirildi",
|
||||
"upToDate": "Güncel",
|
||||
"xrayStatusUnknown": "Bilinmiyor",
|
||||
"xrayStatusRunning": "Çalışıyor",
|
||||
"xrayStatusStop": "Durduruldu",
|
||||
"xrayStatusError": "Hata",
|
||||
"xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu",
|
||||
"operationHours": "Çalışma Süresi",
|
||||
"systemHistoryTitle": "Sistem Geçmişi",
|
||||
"historyTitleCpu": "CPU Kullanımı",
|
||||
"historyTitleMem": "Bellek Kullanımı",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Yük",
|
||||
"historyTabConnections": "Bağlantılar",
|
||||
"historyTabDiskUsage": "Disk Kullanımı",
|
||||
"charts": "Grafikler",
|
||||
"xrayMetricsTitle": "Xray Metrikleri",
|
||||
"xrayTitleHeap": "Ayrılan Yığın Belleği",
|
||||
"xrayTitleSys": "İşletim Sisteminden Ayrılan Bellek",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Kapalı",
|
||||
"xrayObservatoryLastSeen": "Son görülme",
|
||||
"xrayObservatoryLastTry": "Son deneme",
|
||||
"trendLast2Min": "Son 2 dakika",
|
||||
"systemLoad": "Sistem Yükü",
|
||||
"systemLoadDesc": "Son 1, 5 ve 15 dakikanın sistem yükü ortalaması",
|
||||
"connectionCount": "Bağlantı İstatistikleri",
|
||||
"ipAddresses": "IP Adresleri",
|
||||
"toggleIpVisibility": "IP görünürlüğünü değiştir",
|
||||
"overallSpeed": "Genel Hız",
|
||||
"upload": "Yükleme",
|
||||
"download": "İndirme",
|
||||
"totalData": "Toplam Veri",
|
||||
"sent": "Gönderilen",
|
||||
"received": "Alınan",
|
||||
"documentation": "Dokümantasyon",
|
||||
"xraySwitchVersionDialog": "Xray sürümünü gerçekten değiştirmek istiyor musunuz?",
|
||||
"xraySwitchVersionDialogDesc": "Bu işlem Xray sürümünü #version# olarak değiştirecektir.",
|
||||
"xraySwitchVersionPopover": "Xray başarıyla güncellendi",
|
||||
"panelUpdateDialog": "Gerçekten paneli güncellemek istiyor musunuz?",
|
||||
"panelUpdateDialogDesc": "Bu işlem 3X-UI'yi #version# sürümüne güncelleyecek ve panel servisini yeniden başlatacaktır.",
|
||||
"panelUpdateCheckPopover": "Panel güncelleme kontrolü başarısız oldu",
|
||||
"panelUpdateStartedPopover": "Panel güncellemesi başlatıldı",
|
||||
"panelUpdateFailedTitle": "Panel güncellemesi başarısız oldu",
|
||||
"panelUpdateFailedDesc": "Güncelleme başarıyla tamamlanamadı. Sunucu günlüklerini kontrol edin veya komut satırından 'x-ui update' çalıştırın.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Erişim Günlükleri",
|
||||
"autoUpdate": "Otomatik Güncelleme",
|
||||
"config": "Yapılandırma",
|
||||
"backup": "Yedek",
|
||||
"backupTitle": "Yedekleme ve Geri Yükleme",
|
||||
"exportDatabase": "Yedekle",
|
||||
"exportDatabaseDesc": "Mevcut veritabanınızın yedeğini içeren bir .db dosyasını cihazınıza indirmek için tıklayın. Aynı dosya PostgreSQL üzerinde çalışan bir panele de geri yüklenebilir.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.",
|
||||
"importDatabasePgDesc": "Veritabanınızı geri yüklemek için bir PostgreSQL yedeği (.dump), SQLite veritabanı (.db) veya SQLite taşıma dökümü seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
|
||||
"migrationDownload": "Geçiş Dosyasını İndir",
|
||||
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın."
|
||||
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın.",
|
||||
"avg": "ort.",
|
||||
"peak": "tepe",
|
||||
"free": "boş",
|
||||
"openSockets": "açık soket",
|
||||
"throughputSub": "Arayüz toplamı",
|
||||
"avgWindow": "Dönem ortalaması",
|
||||
"healthWarm": "{list} — yükseliyor",
|
||||
"healthCritical": "{list} — kritik",
|
||||
"panel": "Panel",
|
||||
"threads": "İş parçacıkları",
|
||||
"uptime": "Çalışma süresi"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Gelen Bağlantılar",
|
||||
"totalDownUp": "Toplam Gönderilen/Alınan",
|
||||
"totalUsage": "Toplam Kullanım",
|
||||
"inboundCount": "Toplam Gelen Bağlantı",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Yerel Panel",
|
||||
"fallbacks": {
|
||||
"title": "Fallback'ler",
|
||||
"help": "Bu gelen bağlantı üzerindeki bir istek hiçbir istemci ile eşleşmediğinde, başka bir yere yönlendirilir. Aşağıdan bir alt (child) gelen bağlantı seçerek yönlendirme alanlarını (SNI / ALPN / Path / xver) aktarım (transport) ayarlarından otomatik doldurun ya da seçimi boş bırakıp Hedef (Dest) değerini doğrudan girin (örn. 8080 veya 127.0.0.1:8080); böylece Nginx gibi harici bir sunucuya yönlendirebilirsiniz. Her alt gelen bağlantı 127.0.0.1 üzerinde security=none (güvenlik=yok) ile dinlemelidir.",
|
||||
"empty": "Henüz fallback yok",
|
||||
"add": "Fallback Ekle",
|
||||
"pickInbound": "Bir Gelen Bağlantı Seç",
|
||||
"matchAny": "herhangi",
|
||||
"destPlaceholder": "otomatik (child listen:port)",
|
||||
"rederive": "Child'dan Yeniden Doldur",
|
||||
"rederived": "Child'dan yeniden dolduruldu",
|
||||
"editAdvanced": "Yönlendirme Alanlarını Düzenle",
|
||||
"hideAdvanced": "Gelişmişi Gizle",
|
||||
"quickAddAll": "Uygun Olan Tümünü Hızlı Ekle",
|
||||
"quickAdded": "{n} fallback eklendi",
|
||||
"quickAddedNone": "Eklenecek yeni uygun gelen bağlantı yok",
|
||||
"routesWhen": "Şu Durumda Yönlendirir",
|
||||
"defaultCatchAll": "Varsayılan — başka her şeyi yakalar",
|
||||
"needsTls": "Geri düşüşler (fallback), Güvenlik sekmesinde TLS veya Reality seçildiğinde kullanılabilir olur (yalnızca RAW üzerinde VLESS/Trojan)."
|
||||
},
|
||||
"protocol": "Protokol",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Port Eşlemesi",
|
||||
"traffic": "Trafik",
|
||||
"speed": "Hız",
|
||||
"details": "Detaylar",
|
||||
"transportConfig": "Aktarım",
|
||||
"expireDate": "Süre",
|
||||
"createdAt": "Oluşturuldu",
|
||||
"updatedAt": "Güncellendi",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Gelen Bağlantı Ekle",
|
||||
"generalActions": "Genel İşlemler",
|
||||
"modifyInbound": "Gelen Bağlantını Düzenle",
|
||||
"deleteInbound": "Gelen Bağlantını Sil",
|
||||
"deleteInboundContent": "Bu gelen bağlantıyı silmek istediğinizden emin misiniz?",
|
||||
"deleteConfirmTitle": "\"{remark}\" gelen bağlantı silinsin mi?",
|
||||
"deleteConfirmContent": "Bu işlem gelen bağlantıyı ve tüm kullanıcılarını siler. Geri alınamaz.",
|
||||
"resetConfirmTitle": "\"{remark}\" trafiği sıfırlansın mı?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "Tüm-BağlantıNoktaları",
|
||||
"exportAllSubsFileName": "Tüm-BağlantıNoktaları-Subs",
|
||||
"inboundJsonTitle": "Gelen Bağlantı JSON",
|
||||
"deleteClient": "Kullanıcıyı Sil",
|
||||
"deleteClientContent": "Kullanıcıyı silmek istediğinizden emin misiniz?",
|
||||
"resetTrafficContent": "Trafiği sıfırlamak istediğinizden emin misiniz?",
|
||||
"copyLink": "URL'yi Kopyala",
|
||||
"address": "Adres",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Sınırsız. (birim: GB)",
|
||||
"totalFlow": "Toplam Akış",
|
||||
"leaveBlankToNeverExpire": "Süresiz olması için boş bırakın",
|
||||
"noRecommendKeepDefault": "Varsayılan ayarda bırakılması önerilir",
|
||||
"certificatePath": "Dosya Yolu",
|
||||
"certificateContent": "Dosya İçeriği",
|
||||
"publicKey": "Genel Anahtar",
|
||||
"privatekey": "Özel Anahtar",
|
||||
"clickOnQRcode": "Kopyalamak İçin QR Koda Tıklayın",
|
||||
"client": "Kullanıcı",
|
||||
"export": "Tüm URL'leri Dışa Aktar",
|
||||
"clone": "Klonla",
|
||||
"cloneInbound": "Klonla",
|
||||
"cloneInboundContent": "Bu gelen bağlantıyın tüm ayarları, Port, Dinleme IP ve Kullanıcılar hariç, klona uygulanacaktır.",
|
||||
"cloneInboundOk": "Klonla",
|
||||
"resetAllTraffic": "Tüm Gelen Trafiği Sıfırla",
|
||||
"resetAllTrafficTitle": "Tüm Gelen Trafiği Sıfırla",
|
||||
"resetAllTrafficContent": "Tüm gelen bağlantılarnın trafiğini sıfırlamak istediğinizden emin misiniz?",
|
||||
"resetInboundClientTraffics": "Kullanıcıların Trafiğini Sıfırla",
|
||||
"resetInboundClientTrafficTitle": "Kullanıcı Trafiklerini Sıfırla",
|
||||
"resetInboundClientTrafficContent": "Bu gelen bağlantıya ait kullanıcıların trafiğini sıfırlamak istediğinizden emin misiniz?",
|
||||
"resetAllClientTraffics": "Tüm Kullanıcıların Trafiğini Sıfırla",
|
||||
"resetAllClientTrafficTitle": "Tüm Kullanıcı Trafiklerini Sıfırla",
|
||||
"resetAllClientTrafficContent": "Tüm kullanıcıların trafiğini sıfırlamak istediğinizden emin misiniz?",
|
||||
"delDepletedClients": "Kotası Dolan Kullanıcıları Sil",
|
||||
"delDepletedClientsTitle": "Kotası Dolan Kullanıcıları Sil",
|
||||
"delDepletedClientsContent": "Kotası dolan veya süresi biten tüm kullanıcıları silmek istediğinizden emin misiniz?",
|
||||
"email": "E-posta",
|
||||
"emailDesc": "Lütfen benzersiz bir e-posta adresi sağlayın.",
|
||||
"IPLimit": "IP Limiti",
|
||||
"IPLimitDesc": "Sayının aşılması durumunda gelen bağlantı devre dışı bırakılır. (0 = devre dışı)",
|
||||
"IPLimitlog": "IP Günlüğü",
|
||||
"IPLimitlogDesc": "IP geçmiş günlüğü. (devre dışı bırakıldıktan sonra yeniden etkinleştirmek için günlüğü temizleyin)",
|
||||
"IPLimitlogclear": "Günlüğü Temizle",
|
||||
"setDefaultCert": "Panelden Sertifikayı Ayarla",
|
||||
"setDefaultCertEmpty": "Panel için sertifika yapılandırılmamış. Önce Ayarlar'dan ayarlayın.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Xray sniffing bloğunun sarmalayıcısı:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Xray stream bloğunun sarmalayıcısı:",
|
||||
"jsonErrorPrefix": "Gelişmiş JSON"
|
||||
"streamHelp": "Xray stream bloğunun sarmalayıcısı:"
|
||||
},
|
||||
"telegramDesc": "Lütfen Telegram Sohbet Kimliği (Chat ID) sağlayın. ({'@'}userinfobot'tan öğrenebilir veya botta '/id' komutunu kullanabilirsiniz.)",
|
||||
"subscriptionDesc": "Abonelik URL'nizi bulmak için 'Detaylar'a gidin. Aynı adı birden fazla kullanıcı için kullanabilirsiniz.",
|
||||
"subSortIndex": "Sıralama",
|
||||
"same": "Aynı",
|
||||
"inboundInfo": "Gelen Bağlantı Bilgileri",
|
||||
"exportInbound": "Gelen Bağlantını Dışa Aktar",
|
||||
"import": "İçe Aktar",
|
||||
"importInbound": "Gelen Bağlantı İçe Aktar",
|
||||
"periodicTrafficResetTitle": "Trafik Sıfırlama",
|
||||
"periodicTrafficResetDesc": "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla",
|
||||
"lastReset": "Son Sıfırlama",
|
||||
"periodicTrafficResetDay": "Aylık sıfırlama günü",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Asla",
|
||||
"daily": "Günlük",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Al",
|
||||
"updateSuccess": "Güncelleme başarılı oldu.",
|
||||
"logCleanSuccess": "Günlük temizlendi.",
|
||||
"inboundsUpdateSuccess": "Gelen bağlantılar başarıyla güncellendi.",
|
||||
"inboundUpdateSuccess": "Gelen bağlantı başarıyla güncellendi.",
|
||||
"inboundCreateSuccess": "Gelen bağlantı başarıyla oluşturuldu.",
|
||||
"bulkDeleted": "{count} gelen bağlantı silindi",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Gelen bağlantı kullanıcısı silindi.",
|
||||
"inboundClientUpdateSuccess": "Gelen bağlantı kullanıcısı güncellendi.",
|
||||
"savedNodeOfflineWillSync": "Yerel olarak kaydedildi. Destekleyen bir düğüm çevrimdışı veya devre dışı — değişiklik yeniden bağlandığında senkronize edilecek.",
|
||||
"delDepletedClientsSuccess": "Tüm tükenmiş kullanıcılar silindi.",
|
||||
"resetAllClientTrafficSuccess": "Tüm kullanıcıların trafiği sıfırlandı.",
|
||||
"resetAllTrafficSuccess": "Tüm trafik sıfırlandı.",
|
||||
"resetInboundClientTrafficSuccess": "Trafik sıfırlandı.",
|
||||
@@ -636,6 +578,10 @@
|
||||
"maxTimeDiff": "Maks. Zaman Farkı (ms)",
|
||||
"minClientVer": "Min. Kullanıcı Sürümü",
|
||||
"maxClientVer": "Maks. Kullanıcı Sürümü",
|
||||
"minClientVerHint": "Boş bırakmak sınırsız demek değildir: Xray-core, çalıştırdığınız çekirdek sürümünün yerleşik alt sınırını (güncel sürümlerde 26.3.27) uygular ve daha eski sürüm bildiren istemcileri reddeder — Mihomo ve sing-box gibi üçüncü taraf çekirdekler dahil. 1.0.0 girmek onları kabul eder; bedeli eski TLS parmak izlerine izin vermektir.",
|
||||
"maxClientVerHint": "Boş, üst sınır yok demektir. Ayarlanırsa geçerli alt sınırın — Min. Kullanıcı Sürümü, o alan boşsa Xray-core'un yerleşik alt sınırı — altında olmamalıdır, aksi halde tüm istemciler reddedilir.",
|
||||
"clientVerInvalid": "İstemci sürümü noktayla ayrılmış en fazla üç sayıdan oluşmalıdır, her biri 0-255 (örn. 26.3.27)",
|
||||
"maxClientVerBelowMin": "Maks. istemci sürümü, en düşük istemci sürümünün altında olamaz",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Zorunlu. Bir port içermelidir (ör. example.com:443). Port belirtilmezse Xray-core başlamaz.",
|
||||
"realityTargetRequired": "REALITY hedefi zorunludur",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Peer {n} Yapılandırması"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "İstek",
|
||||
"response": "Yanıt",
|
||||
"name": "Ad",
|
||||
"value": "Değer"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Sürüm",
|
||||
"method": "Yöntem",
|
||||
"path": "Yol",
|
||||
"status": "Durum",
|
||||
"statusDescription": "Durum Açıklaması",
|
||||
"requestHeader": "İstek Başlığı",
|
||||
"responseHeader": "Yanıt Başlığı"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Hedef geçersiz kılma"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Harici Abonelik Ekle",
|
||||
"noExternalLinks": "Henüz harici bağlantı yok.",
|
||||
"noExternalSubscriptions": "Henüz harici abonelik yok.",
|
||||
"add": "Kullanıcı Ekle",
|
||||
"edit": "Kullanıcıyı Düzenle",
|
||||
"submitAdd": "Kullanıcı Ekle",
|
||||
"submitEdit": "Değişiklikleri Kaydet",
|
||||
"clientCount": "Kullanıcı Sayısı",
|
||||
"bulk": "Toplu Ekle",
|
||||
"copyFromInbound": "Gelen Bağlantından Kullanıcıları Kopyala",
|
||||
"copyToInbound": "Kullanıcıların Kopyalanacağı Yer",
|
||||
"copySelected": "Seçileni Kopyala",
|
||||
"copySource": "Kaynak",
|
||||
"copyEmailPreview": "Oluşacak E-posta Önizlemesi",
|
||||
"copySelectSourceFirst": "Önce bir kaynak gelen bağlantı seçin.",
|
||||
"copyResult": "Kopya Sonucu",
|
||||
"copyResultSuccess": "Başarıyla kopyalandı",
|
||||
"copyResultNone": "Kopyalanacak bir şey yok: kullanıcı seçilmemiş veya kaynak boş.",
|
||||
"copyResultErrors": "Kopyalama Hataları",
|
||||
"copyFlowLabel": "Yeni Kullanıcılar İçin Flow (VLESS)",
|
||||
"copyFlowHint": "Kopyalanan tüm kullanıcılara uygulanır. Atlamak için boş bırakın.",
|
||||
"selectAll": "Tümünü Seç",
|
||||
"clearAll": "Tümünü Temizle",
|
||||
"method": "Yöntem",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Sonek",
|
||||
"delayedStart": "İlk Kullanımdan Sonra Başla",
|
||||
"expireDays": "Süre (gün)",
|
||||
"days": "Gün(ler)",
|
||||
"renew": "Otomatik Yenileme",
|
||||
"renewDesc": "Süre dolduktan sonra otomatik yeniler. (0 = devre dışı) (birim: gün)",
|
||||
"renewDays": "Otomatik Yenileme (gün)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Yakında Biten",
|
||||
"has": "Var",
|
||||
"hasNot": "Yok",
|
||||
"title": "Kullanıcılar",
|
||||
"actions": "İşlemler",
|
||||
"totalGB": "Trafik Limiti (GB)",
|
||||
"totalGBDesc": "Bu kullanıcı için veri kotası. 0 = sınırsız.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Kullanıcı Ekle",
|
||||
"qrCode": "QR Kodu",
|
||||
"clientInfo": "Kullanıcı Bilgileri",
|
||||
"delete": "Sil",
|
||||
"reset": "Trafiği Sıfırla",
|
||||
"editClient": "Kullanıcıyı Düzenle",
|
||||
"client": "Kullanıcı",
|
||||
"enabled": "Etkin",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "Paylaşılabilir bağlantı yok — önce bu kullanıcıyı bir protokole sahip olan gelen bağlantıya bağlayın.",
|
||||
"link": "Bağlantı",
|
||||
"resetNotPossible": "Önce bu kullanıcıyı bir gelen bağlantıya bağlayın.",
|
||||
"general": "Genel",
|
||||
"resetAllTraffics": "Tüm Kullanıcıların Trafiğini Sıfırla",
|
||||
"resetAllTrafficsTitle": "Tüm Kullanıcıların Trafiği Sıfırlansın Mı?",
|
||||
"resetAllTrafficsContent": "Her kullanıcının yükleme/indirme sayaçları sıfırlanır. Kotalar ve son kullanma tarihleri etkilenmez. Geri alınamaz.",
|
||||
"deleteConfirmTitle": "{email} Kullanıcısı Silinsin Mi?",
|
||||
"deleteConfirmContent": "Bu işlem kullanıcıyı bağlı tüm gelen bağlantılarndan kaldırır ve trafik kaydını siler. Geri alınamaz.",
|
||||
"deleteSelected": "Sil ({count})",
|
||||
"adjustSelected": "Ayarla ({count})",
|
||||
"subLinksSelected": "Abonelik Bağlantıları ({count})",
|
||||
"addToGroupTitle": "{count} Kullanıcıyı Bir Gruba Ekle",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "{count} kullanıcı devre dışı bırakılsın mı?",
|
||||
"bulkDisableConfirmContent": "Seçili her kullanıcıyı bağlı olduğu tüm gelen bağlantılarda devre dışı bırakır. Erişimlerini hemen kaybederler ancak kayıtları ve trafikleri korunur.",
|
||||
"selectedCount": "{count} Seçildi",
|
||||
"attachSelected": "Bağla ({count})",
|
||||
"attachToInboundsTitle": "{count} Kullanıcıyı Gelen Bağlantına Bağla",
|
||||
"attachToInboundsDesc": "Seçilen {count} kullanıcıyı (aynı UUID/şifre ve paylaşılan trafikle) seçilen gelen bağlantıya bağlar. Mevcut bağlantıları da korunur.",
|
||||
"attachToInboundsTargets": "Hedef Gelen Bağlantılar",
|
||||
"attachToInboundsNoTargets": "Bağlanacak çoklu kullanıcılı gelen bağlantı yok.",
|
||||
"detachSelected": "Ayır ({count})",
|
||||
"detach": "Ayır",
|
||||
"detachFromInboundsTitle": "{count} Kullanıcıyı Gelen Bağlantından Ayır",
|
||||
"detachFromInboundsDesc": "Seçilen {count} kullanıcıyı seçilen gelen bağlantıdan kaldırır. Kullanıcının zaten bağlı olmadığı gelen bağlantılar atlanır. Kullanıcı kayıtları korunur (tamamen kaldırmak için Sil'i kullanın).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
|
||||
"telegramId": "Telegram Kullanıcı ID'si",
|
||||
"telegramIdPlaceholder": "Sayısal Telegram kullanıcı ID'si (0 = yok)",
|
||||
"created": "Oluşturuldu",
|
||||
"updated": "Güncellendi",
|
||||
"ipLimit": "IP Limiti",
|
||||
"toasts": {
|
||||
"deleted": "Kullanıcı silindi",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gruplar",
|
||||
"name": "İsim",
|
||||
"clientCount": "Kullanıcılar",
|
||||
"totalGroups": "Toplam grup",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "{name} grubundan {count} kullanıcı çıkarıldı."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Düğümler",
|
||||
"addNode": "Düğüm Ekle",
|
||||
"editNode": "Düğümü Düzenle",
|
||||
"totalNodes": "Toplam Düğüm",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "Uzak panelin Ayarlar sayfasındaki token",
|
||||
"apiTokenHint": "Uzak panel API token'ını Kimlik Doğrulama → API Token altında gösterir.",
|
||||
"apiTokenKeepHint": "Mevcut token'ı korumak için boş bırakın",
|
||||
"regenerate": "Token'ı Yeniden Oluştur",
|
||||
"regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
|
||||
"allowPrivateAddress": "Özel Adrese İzin Ver",
|
||||
"allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştirin.",
|
||||
"outboundTag": "Bağlantı gideni",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "Paneli Güncelle",
|
||||
"updateSelected": "Seçilenleri Güncelle ({count})",
|
||||
"updateAvailable": "Güncelleme mevcut",
|
||||
"upToDate": "Güncel",
|
||||
"updateConfirmTitle": "{count} düğüm en son sürüme güncellensin mi?",
|
||||
"updateConfirmContent": "Seçilen her düğüm en son sürümü indirir ve yeniden başlatılır. Yalnızca etkin ve çevrimiçi düğümler güncellenir.",
|
||||
"updateDevChannel": "Dev kanalına güncelle (son commit)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "Sertifika alınamadı"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Varsayılan",
|
||||
"title": "Panel Ayarları",
|
||||
"save": "Kaydet",
|
||||
"infoDesc": "Burada yapılan her değişikliğin kaydedilmesi gerekir. Değişikliklerin uygulanması için paneli yeniden başlatın.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "Bellek kullanımı yüksek (%)",
|
||||
"remarkTemplate": "Açıklama Şablonu",
|
||||
"remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
|
||||
"subShowIdentityOnAllLinks": "Kimliği her bağlantıda göster",
|
||||
"subShowIdentityOnAllLinksDesc": "Etkinleştirildiğinde {{EMAIL}} ve {{USERNAME}} abonelik gövdesindeki her bağlantı notunda kalır. Kullanım jetonları yine yalnızca ilk bağlantıda görünür.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Yol / ile başlamalıdır"
|
||||
},
|
||||
@@ -1448,13 +1350,8 @@
|
||||
"secretClearUndo": "Temizlemeyi geri al"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray Yapılandırmaları",
|
||||
"save": "Kaydet",
|
||||
"restart": "Xray'i Yeniden Başlat",
|
||||
"restartSuccess": "Xray başarıyla yeniden başlatıldı.",
|
||||
"restartOutputTitle": "Xray yeniden başlatma çıktısı",
|
||||
"restartConfirmTitle": "Xray'i Yeniden Başlat?",
|
||||
"restartConfirmContent": "Xray hizmeti kaydedilmiş yapılandırma ile yeniden yüklenir.",
|
||||
"stopSuccess": "Xray başarıyla durduruldu.",
|
||||
"restartError": "Xray yeniden başlatılırken bir hata oluştu.",
|
||||
"stopError": "Xray durdurulurken bir hata oluştu.",
|
||||
@@ -1464,7 +1361,6 @@
|
||||
"generalConfigsDesc": "Bu seçenekler genel ayarlamaları belirler.",
|
||||
"logConfigs": "Günlük",
|
||||
"logConfigsDesc": "Günlükler sunucunuzun verimliliğini etkileyebilir. Yalnızca ihtiyaç durumunda akıllıca etkinleştirmeniz önerilir.",
|
||||
"blockConfigsDesc": "Bu seçenekler belirli istek protokolleri ve web siteleri temelinde trafiği engeller.",
|
||||
"basicRouting": "Temel Yönlendirme",
|
||||
"blockConnectionsConfigsDesc": "Bu seçenekler, istenen belirli ülkelere göre trafiği engelleyecektir.",
|
||||
"directConnectionsConfigsDesc": "Doğrudan bağlantı, belirli bir trafiğin başka bir sunucu üzerinden yönlendirilmeden doğrudan hedefe gitmesini sağlar.",
|
||||
@@ -1474,10 +1370,6 @@
|
||||
"directdomains": "Doğrudan Alan Adları",
|
||||
"ipv4Routing": "IPv4 Yönlendirme",
|
||||
"ipv4RoutingDesc": "Bu seçenekler belirli bir varış yerine IPv4 üzerinden trafiği yönlendirir.",
|
||||
"warpRouting": "WARP Yönlendirme",
|
||||
"warpRoutingDesc": "Bu seçenekler belirli bir varış yerine WARP üzerinden trafiği yönlendirir.",
|
||||
"nordRouting": "NordVPN Yönlendirme",
|
||||
"nordRoutingDesc": "Bu seçenekler belirli bir varış yerine NordVPN üzerinden trafiği yönlendirir.",
|
||||
"Template": "Gelişmiş Xray Yapılandırma Şablonu",
|
||||
"TemplateDesc": "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır.",
|
||||
"FreedomStrategy": "Freedom Protokol Stratejisi",
|
||||
@@ -1499,10 +1391,7 @@
|
||||
"metricsTag": "Metrik Etiketi",
|
||||
"Torrent": "BitTorrent Protokolünü Engelle",
|
||||
"Inbounds": "Gelen Bağlantılar",
|
||||
"InboundsDesc": "Belirtilen istemcileri (clients) kabul eder.",
|
||||
"Outbounds": "Giden Bağlantılar",
|
||||
"OutboundSubscriptions": "Giden Bağlantı Abonelikleri",
|
||||
"OutboundSubscriptionsDesc": "Uzak abonelik URL'lerinden (vmess/vless/trojan/ss/...) giden bağlantılarnı içe aktarın. Etiketler dengeleyicilerde ve yönlendirme kurallarında kullanılabilmek için sabit tutulur. Güncellemeler otomatiktir.",
|
||||
"Balancers": "Dengeleyiciler",
|
||||
"balancerTagRequired": "Etiket zorunludur",
|
||||
"balancerSelectorRequired": "En az bir giden bağlantı seçin",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "Eşleşen giden",
|
||||
"routeTesterViaBalancer": "dengeleyici aracılığıyla",
|
||||
"routeTesterDefaultOutbound": "Hiçbir yönlendirme kuralı eşleşmedi — trafik varsayılan (ilk) giden bağlantıya yönlendirilir.",
|
||||
"OutboundsDesc": "Giden trafiğin yolunu ayarlayın.",
|
||||
"Routings": "Yönlendirme Kuralları",
|
||||
"RoutingsDesc": "Her kuralın önceliği önemlidir!",
|
||||
"completeTemplate": "Tümü",
|
||||
"logLevel": "Günlük Seviyesi",
|
||||
"logLevelDesc": "Hata günlükleri için kayıt seviyesi; hangi detayda bilginin kaydedileceğini belirler.",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "IP adresi maskesi, etkinleştirildiğinde günlükte görünen IP adresini otomatik olarak değiştirecektir.",
|
||||
"statistics": "İstatistikler",
|
||||
"statsInboundUplink": "Gelen Yükleme İstatistikleri",
|
||||
"statsInboundUplinkDesc": "Tüm gelen proxy'lerin yükleme trafiği için istatistik toplamayı etkinleştirir.",
|
||||
"statsInboundDownlink": "Gelen İndirme İstatistikleri",
|
||||
"statsInboundDownlinkDesc": "Tüm gelen proxy'lerin indirme trafiği için istatistik toplamayı etkinleştirir.",
|
||||
"statsOutboundUplink": "Giden Yükleme İstatistikleri",
|
||||
"statsOutboundUplinkDesc": "Tüm giden proxy'lerin yükleme trafiği için istatistik toplamayı etkinleştirir.",
|
||||
"statsOutboundDownlink": "Giden İndirme İstatistikleri",
|
||||
"statsOutboundDownlinkDesc": "Tüm giden proxy'lerin indirme trafiği için istatistik toplamayı etkinleştirir.",
|
||||
"connectionLimits": "Bağlantı Sınırları",
|
||||
"connectionLimitsDesc": "Kullanıcı seviyesi 0 için bağlantı düzeyi politikaları. Xray'in varsayılanını kullanmak için alanı boş bırakın.",
|
||||
"connIdle": "Boşta Kalma Zaman Aşımı",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "otomatik",
|
||||
"seconds": "saniye",
|
||||
"rules": {
|
||||
"first": "İlk",
|
||||
"last": "Son",
|
||||
"up": "Yukarı",
|
||||
"down": "Aşağı",
|
||||
"source": "Kaynak",
|
||||
"dest": "Hedef",
|
||||
"inbound": "Gelen Bağlantı",
|
||||
"outbound": "Giden Bağlantı",
|
||||
"balancer": "Dengeleyici",
|
||||
"info": "Bilgi",
|
||||
"add": "Kural Ekle",
|
||||
"edit": "Kuralı Düzenle",
|
||||
"useComma": "Virgülle ayrılmış öğeler"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "Kural {n}",
|
||||
"action": "Eylem",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Nihai Kurallar",
|
||||
"overrideXrayPrivateIp": "Xray'in varsayılan özel IP bloğunu geçersiz kıl",
|
||||
"blockDelay": "Engelleme Gecikmesi (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "Keep Alive Aralığı",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Arabirim",
|
||||
"ipv6Only": "Yalnızca IPv6",
|
||||
"acceptProxyProtocol": "Proxy Protocol Kabul Et",
|
||||
"proxyProtocol": "Proxy Protocol",
|
||||
"tcpUserTimeoutMs": "TCP User Timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Giden Bağlantı Ekle",
|
||||
"addReverse": "Ters Ekle",
|
||||
"editOutbound": "Giden Bağlantını Düzenle",
|
||||
"editReverse": "Tersi Düzenle",
|
||||
"reverseTag": "Ters Etiket",
|
||||
"reverseTagDesc": "VLESS basit ters proxy giden bağlantı etiketi. Devre dışı bırakmak için boş bırakın.",
|
||||
"reverseTagPlaceholder": "çıkış etiketi (boş = devre dışı)",
|
||||
"tag": "Etiket",
|
||||
"tagDesc": "Benzersiz Etiket",
|
||||
"address": "Adres",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Ters",
|
||||
"domain": "Alan Adı",
|
||||
"type": "Tür",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Bağlantı",
|
||||
"intercon": "Bağlantı",
|
||||
"settings": "Ayarlar",
|
||||
"accountInfo": "Hesap Bilgileri",
|
||||
"outboundStatus": "Giden Bağlantı Durumu",
|
||||
"sendThrough": "Üzerinden Gönder",
|
||||
"targetStrategy": "Hedef Stratejisi",
|
||||
"test": "Test",
|
||||
"testResult": "Test Sonucu",
|
||||
"testing": "Bağlantı test ediliyor...",
|
||||
"testSuccess": "Test başarılı",
|
||||
"testFailed": "Test başarısız",
|
||||
"testError": "Giden bağlantı test edilemedi",
|
||||
"modeRealDelay": "Gerçek gecikme",
|
||||
"testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek. Gerçek gecikme: bağlantı kurulumu dahil toplam süre.",
|
||||
"testAll": "Tümünü Test Et",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "Proxy bağlantısı",
|
||||
"breakdownTls": "Giden üzerinden TLS",
|
||||
"breakdownTtfb": "İlk bayt",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Erişim Jetonu",
|
||||
"country": "Ülke",
|
||||
"server": "Sunucu",
|
||||
"city": "Şehir",
|
||||
"allCities": "Tüm Şehirler",
|
||||
"privateKey": "Özel Anahtar",
|
||||
"load": "Yükle",
|
||||
"moveToTop": "En üste taşı"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "Aktif abonelikler",
|
||||
"empty": "Henüz hiç abonelik yok. Yukarıdan bir tane ekleyin.",
|
||||
"colRemark": "Açıklama",
|
||||
"colPrefix": "Önek",
|
||||
"colInterval": "Aralık",
|
||||
"colLastFetch": "Son getirme",
|
||||
"colEnabled": "Etkin",
|
||||
"auto": "otomatik",
|
||||
"never": "asla",
|
||||
"yes": "Evet",
|
||||
"no": "Hayır",
|
||||
"refreshNow": "Şimdi yenile",
|
||||
"lastError": "Son hata",
|
||||
"deleteConfirm": "Bu aboneliği silmek istiyor musunuz?",
|
||||
"restartHint": "Ekledikten veya yeniledikten sonra giden bağlantılarnı aktif hale getirmek için Xray'i yeniden başlatın (veya bir sonraki otomatik yeniden yüklemeyi bekleyin).",
|
||||
"fromSubsTitle": "Giden bağlantı aboneliklerinden (salt okunur)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "Dengeleyici Ayarları",
|
||||
"tabObservatory": "Gözlemci",
|
||||
"observatory": {
|
||||
"title": "Gözlemci",
|
||||
"burstTitle": "Burst Gözlemci",
|
||||
"autoManaged": "Gözlemciler dengeleyicilerinize göre otomatik yönetilir. Nasıl sınama yapacaklarını aşağıdan ayarlayın; izlenen çıkışlar dengeleyici seçicilerini izler.",
|
||||
"emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece gözlemci kullanan dengeleyiciler hedef seçmeden önce çıkış sağlığını kontrol edebilir.",
|
||||
"mixedLegacy": "Bu yapılandırmada hem Observatory hem de Burst Observatory var. Xray tek bir global gözlemci kullanır, bu nedenle bu eski karma durum desteklenmez; dengeleyiciler kaydedildiğinde tek bir gözlemciye normalleştirilir.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "Dengeleyici {tag} — kaldırıldı (hedef kalmadı)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Dengeleyici Ekle",
|
||||
"editBalancer": "Dengeleyiciyi Düzenle",
|
||||
"balancerStrategy": "Strateji",
|
||||
"balancerSelectors": "Seçiciler",
|
||||
"tag": "Etiket",
|
||||
"tagDesc": "Benzersiz Etiket",
|
||||
"tagDuplicate": "Etiket başka bir dengeleyici tarafından kullanılıyor",
|
||||
"tagPlaceholder": "benzersiz dengeleyici etiketi",
|
||||
"selector": "Seçici",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "Tolerans",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur.",
|
||||
"costMatch": "Etiket deseni",
|
||||
"costValue": "Ağırlık",
|
||||
"costRegexp": "Düzenli ifade eşleşmesi",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "Genel Anahtar",
|
||||
"allowedIPs": "İzin Verilen IP'ler",
|
||||
"endpoint": "Uç Nokta",
|
||||
"psk": "Ön Paylaşılan Anahtar",
|
||||
"domainStrategy": "Alan Adı Stratejisi"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "Yalnızca AmneziaWG 2.0 için. 1.x uyumluluğu için boş bırakın."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "TUN arabiriminin adı. Varsayılan değer 'xray0'dır.",
|
||||
"mtuDesc": "Maksimum İletim Birimi. Veri paketlerinin maksimum boyutu. Varsayılan değer 1500'dür.",
|
||||
"userLevel": "Kullanıcı Seviyesi",
|
||||
"userLevelDesc": "Bu gelen bağlantı üzerinden yapılan tüm bağlantılar bu kullanıcı seviyesini kullanacaktır. Varsayılan değer 0'dır."
|
||||
"userLevel": "Kullanıcı Seviyesi"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access Token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Sahte DNS Ekle",
|
||||
"edit": "Sahte DNS'i Düzenle",
|
||||
"ipPool": "IP Havuzu Alt Ağı",
|
||||
"poolSize": "Havuz Boyutu"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Ekle",
|
||||
"month": "Ay",
|
||||
"months": "Aylar",
|
||||
"day": "Gün",
|
||||
"days": "Günler",
|
||||
"hours": "Saatler",
|
||||
"minutes": "Dakikalar",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Telegram Kullanıcısı kaydedildi.",
|
||||
"loginSuccess": "✅ Panele başarıyla giriş yapıldı.\r\n",
|
||||
"loginFailed": "❗️Panele giriş denemesi başarısız oldu.\r\n",
|
||||
"2faFailed": "2FA Hatası",
|
||||
"report": "🕰 Planlanmış Raporlar: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Tarih ve Saat: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 İndirme: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Toplam: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Telegram Kullanıcısı: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 Limiti Dolanlar ({{ .Type }}):\r\n",
|
||||
"exhaustedCount": "🚨 Limiti Dolan {{ .Type }} sayısı:\r\n",
|
||||
"onlinesCount": "🌐 Çevrimiçi Kullanıcılar: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Devre Dışı: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Yenilendi: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Evet",
|
||||
"no": "❌ Hayır",
|
||||
"received_id": "🔑📥 Kimlik (ID) güncellendi.",
|
||||
"received_password": "🔑📥 Şifre güncellendi.",
|
||||
"received_email": "📧📥 E-posta güncellendi.",
|
||||
"received_comment": "💬📥 Yorum güncellendi.",
|
||||
"id_prompt": "🔑 Mevcut Kimlik (ID): {{ .ClientId }}\n\nYeni kimliğinizi (ID) girin.",
|
||||
"pass_prompt": "🔑 Varsayılan Şifre: {{ .ClientPassword }}\n\nŞifrenizi girin.",
|
||||
"email_prompt": "📧 Varsayılan E-posta: {{ .ClientEmail }}\n\nE-postanızı girin.",
|
||||
"comment_prompt": "💬 Varsayılan Yorum: {{ .ClientComment }}\n\nYorumunuzu girin.",
|
||||
"inbound_client_data_id": "🔄 Gelen Bağlantı: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Kota: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Açıklama: {{ .ClientComment }}\n\nArtık bu kullanıcıyı gelen bağlantıya ekleyebilirsiniz!",
|
||||
"inbound_client_data_pass": "🔄 Gelen Bağlantı: {{ .InboundRemark }}\n\n🔑 Şifre: {{ .ClientPass }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Kota: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Açıklama: {{ .ClientComment }}\n\nArtık bu kullanıcıyı gelen bağlantıya ekleyebilirsiniz!",
|
||||
"cancel": "❌ İşlem iptal edildi! \n\nİstediğiniz zaman /start ile yeniden başlayabilirsiniz. 🔄",
|
||||
"error_add_client": "⚠️ Hata:\n\n {{ .error }}",
|
||||
"using_default_value": "Tamam, varsayılan değeri kullanacağım. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Hata: {{ .Error }}",
|
||||
"eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI",
|
||||
"eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ",
|
||||
"eventCPUHigh": "Yüksek CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız",
|
||||
"memoryThreshold": "Bellek kullanımı {{ .Percent }}% eşiği {{ .Threshold }}% aşıyor"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Devre Dışı Olarak Gönder ☑️",
|
||||
"submitEnable": "Etkin Olarak Gönder ✅",
|
||||
"use_default": "🏷️ Varsayılanı Kullan",
|
||||
"change_id": "⚙️🔑 Kimlik",
|
||||
"change_password": "⚙️🔑 Şifre",
|
||||
"change_email": "⚙️📧 E-posta",
|
||||
"change_comment": "⚙️💬 Yorum",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Tüm Trafikleri Sıfırla",
|
||||
"SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
|
||||
"subjectOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
|
||||
"subjectXrayCrash": "Xray ÇÖKTÜ",
|
||||
"subjectCPUHigh": "Yüksek CPU",
|
||||
"subjectLoginSuccess": "Oturum açma başarılı",
|
||||
"subjectLoginFailed": "Oturum açma başarısız",
|
||||
"titleOutboundDown": "Giden Bağlantı ÇEVRİMDIŞI",
|
||||
"titleOutboundUp": "Giden Bağlantı ÇEVRİMİÇİ",
|
||||
"titleXrayCrash": "Xray ÇÖKTÜ",
|
||||
"titleCPUHigh": "Yüksek CPU",
|
||||
"titleLoginSuccess": "Oturum açma başarılı",
|
||||
"titleLoginFailed": "Oturum açma başarısız",
|
||||
"labelStatus": "Durum",
|
||||
"labelOutbound": "Giden Bağlantı",
|
||||
"labelNode": "Düğüm",
|
||||
"labelError": "Hata",
|
||||
"labelDelay": "Gecikme",
|
||||
"labelDetail": "Ayrıntı",
|
||||
"labelUsername": "Kullanıcı Adı",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Neden",
|
||||
"labelSource": "Kaynak",
|
||||
"labelTime": "Zaman",
|
||||
"statusCrashed": "ÇÖKTÜ",
|
||||
"statusRunning": "Çalışıyor",
|
||||
"statusHigh": "YÜKSEK",
|
||||
"statusSuccess": "BAŞARILI",
|
||||
"statusFailed": "BAŞARISIZ",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Скопійовано успішно",
|
||||
"sure": "Звичайно",
|
||||
"encryption": "Шифрування",
|
||||
"useIPv4ForHost": "Використовувати IPv4 для хоста",
|
||||
"transmission": "Протокол передачи",
|
||||
"host": "Хост",
|
||||
"path": "Шлях",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Код",
|
||||
"remained": "Залишилося",
|
||||
"security": "Беспека",
|
||||
"secAlertTitle": "Попередження системи безпеки",
|
||||
"secAlertSsl": "Це з'єднання не є безпечним. Будь ласка, уникайте введення конфіденційної інформації, поки TLS не буде активовано для захисту даних.",
|
||||
"secAlertConf": "Деякі налаштування вразливі до атак. Рекомендується посилити протоколи безпеки, щоб запобігти можливим порушенням.",
|
||||
"secAlertSSL": "Панель не має безпечного з'єднання. Будь ласка, встановіть сертифікат TLS для захисту даних.",
|
||||
"secAlertPanelPort": "Стандартний порт панелі вразливий. Будь ласка, сконфігуруйте випадковий або конкретний порт.",
|
||||
"secAlertPanelURI": "Стандартний URI-шлях панелі небезпечний. Будь ласка, сконфігуруйте складний URI-шлях.",
|
||||
"secAlertSubURI": "Стандартний URI-шлях підписки небезпечний. Будь ласка, сконфігуруйте складний URI-шлях.",
|
||||
"secAlertSubJsonURI": "Стандартний URI-шлях JSON підписки небезпечний. Будь ласка, сконфігуруйте складний URI-шлях.",
|
||||
"emptyDnsDesc": "Немає доданих DNS-серверів.",
|
||||
"emptyFakeDnsDesc": "Немає доданих Fake DNS-серверів.",
|
||||
"emptyBalancersDesc": "Немає доданих балансувальників.",
|
||||
"emptyReverseDesc": "Немає доданих зворотних проксі.",
|
||||
"somethingWentWrong": "Щось пішло не так",
|
||||
"subscription": {
|
||||
"title": "Інформація про підписку",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Тема",
|
||||
"dark": "Темна",
|
||||
"ultraDark": "Ультра темна",
|
||||
"dashboard": "Огляд",
|
||||
"inbounds": "Вхідні",
|
||||
"clients": "Клієнти",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "Маршрутизація",
|
||||
"outbounds": "Вихідні",
|
||||
"apiDocs": "Документація API",
|
||||
"logout": "Вийти",
|
||||
"link": "Керувати",
|
||||
"donate": "Підтримати",
|
||||
"hosts": "Хости",
|
||||
"docs": "Документація",
|
||||
"openMenu": "Відкрити меню"
|
||||
"openMenu": "Відкрити меню",
|
||||
"pinSidebar": "Закріпити бічну панель",
|
||||
"unpinSidebar": "Відкріпити бічну панель"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Огляд",
|
||||
"cpu": "ЦП",
|
||||
"logicalProcessors": "Логічні процесори",
|
||||
"frequency": "Частота",
|
||||
"swap": "Підкачка",
|
||||
"storage": "Сховище",
|
||||
"memory": "Пам’ять",
|
||||
"threads": "Потоки",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Стоп",
|
||||
"restartXray": "Перезапуск",
|
||||
"xraySwitch": "Версія",
|
||||
"xrayUpdates": "Оновлення Xray",
|
||||
"xraySwitchClick": "Виберіть версію, на яку ви хочете перейти.",
|
||||
"xraySwitchClickDesk": "Вибирайте уважно, оскільки старіші версії можуть бути несумісними з поточними конфігураціями.",
|
||||
"updatePanel": "Оновити панель",
|
||||
"panelUpdateDesc": "Це оновить 3X-UI до останнього релізу та перезапустить сервіс панелі.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Поточний коміт",
|
||||
"latestCommit": "Останній коміт",
|
||||
"updateChannelChanged": "Канал оновлення змінено",
|
||||
"upToDate": "Оновлено",
|
||||
"xrayStatusUnknown": "Невідомо",
|
||||
"xrayStatusRunning": "Запущено",
|
||||
"xrayStatusStop": "Зупинено",
|
||||
"xrayStatusError": "Помилка",
|
||||
"xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка",
|
||||
"operationHours": "Час роботи",
|
||||
"systemHistoryTitle": "Історія системи",
|
||||
"historyTitleCpu": "Завантаження ЦП",
|
||||
"historyTitleMem": "Використання пам’яті",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Навантаження",
|
||||
"historyTabConnections": "З’єднання",
|
||||
"historyTabDiskUsage": "Використання диска",
|
||||
"charts": "Графіки",
|
||||
"xrayMetricsTitle": "Метрики Xray",
|
||||
"xrayTitleHeap": "Виділена пам’ять купи",
|
||||
"xrayTitleSys": "Пам’ять, зарезервована в ОС",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Недоступний",
|
||||
"xrayObservatoryLastSeen": "Остання активність",
|
||||
"xrayObservatoryLastTry": "Остання спроба",
|
||||
"trendLast2Min": "Останні 2 хвилини",
|
||||
"systemLoad": "Завантаження системи",
|
||||
"systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин",
|
||||
"connectionCount": "Статистика з'єднання",
|
||||
"ipAddresses": "IP-адреси",
|
||||
"toggleIpVisibility": "Перемкнути видимість IP",
|
||||
"overallSpeed": "Загальна швидкість",
|
||||
"upload": "Завантаження",
|
||||
"download": "Завантажити",
|
||||
"totalData": "Загальний обсяг даних",
|
||||
"sent": "Відправлено",
|
||||
"received": "Отримано",
|
||||
"documentation": "Документація",
|
||||
"xraySwitchVersionDialog": "Ви дійсно хочете змінити версію Xray?",
|
||||
"xraySwitchVersionDialogDesc": "Це змінить версію Xray на #version#.",
|
||||
"xraySwitchVersionPopover": "Xray успішно оновлено",
|
||||
"panelUpdateDialog": "Ви дійсно хочете оновити панель?",
|
||||
"panelUpdateDialogDesc": "Це оновить 3X-UI до #version# та перезапустить сервіс панелі.",
|
||||
"panelUpdateCheckPopover": "Перевірка оновлення панелі не вдалася",
|
||||
"panelUpdateStartedPopover": "Розпочато оновлення панелі",
|
||||
"panelUpdateFailedTitle": "Не вдалося оновити панель",
|
||||
"panelUpdateFailedDesc": "Оновлення не завершилося успішно. Перевірте журнали сервера або виконайте 'x-ui update' у командному рядку.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Логи доступу",
|
||||
"autoUpdate": "Автооновлення",
|
||||
"config": "Конфігурація",
|
||||
"backup": "Резервна копія",
|
||||
"backupTitle": "Резервне копіювання та відновлення",
|
||||
"exportDatabase": "Резервна копія",
|
||||
"exportDatabaseDesc": "Натисніть, щоб завантажити файл .db, що містить резервну копію вашої поточної бази даних на ваш пристрій. Цей самий файл можна відновити на панелі, що працює на PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.",
|
||||
"importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити резервну копію PostgreSQL (.dump), базу даних SQLite (.db) або міграційний дамп SQLite для відновлення бази даних. Це замінить усі поточні дані.",
|
||||
"migrationDownload": "Завантажити файл міграції",
|
||||
"migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite."
|
||||
"migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite.",
|
||||
"avg": "середнє",
|
||||
"peak": "пік",
|
||||
"free": "вільно",
|
||||
"openSockets": "відкритих сокетів",
|
||||
"throughputSub": "Разом за інтерфейсом",
|
||||
"avgWindow": "Середнє за період",
|
||||
"healthWarm": "{list} — підвищене навантаження",
|
||||
"healthCritical": "{list} — критичний рівень",
|
||||
"panel": "Панель",
|
||||
"threads": "Потоки",
|
||||
"uptime": "Час роботи"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Вхідні",
|
||||
"totalDownUp": "Всього надісланих/отриманих",
|
||||
"totalUsage": "Всього використанно",
|
||||
"inboundCount": "Загальна кількість вхідних",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Локальна панель",
|
||||
"fallbacks": {
|
||||
"title": "Fallback'и",
|
||||
"help": "Коли з'єднання на цьому інбаунді не збігається з жодним клієнтом, воно перенаправляється в інше місце. Оберіть дочірній інбаунд нижче, щоб поля маршрутизації (SNI / ALPN / Path / xver) заповнилися автоматично з його транспорту, або залиште вибір порожнім і задайте Dest напряму (наприклад, 8080 або 127.0.0.1:8080), щоб перенаправити на зовнішній сервер, такий як Nginx. Кожен дочірній інбаунд має слухати на 127.0.0.1 з security=none.",
|
||||
"empty": "Фолбеків поки немає",
|
||||
"add": "Додати фолбек",
|
||||
"pickInbound": "Оберіть інбаунд",
|
||||
"matchAny": "будь-який",
|
||||
"destPlaceholder": "авто (listen:порт дочірнього)",
|
||||
"rederive": "Заповнити з дочірнього",
|
||||
"rederived": "Заповнено з дочірнього",
|
||||
"editAdvanced": "Редагувати поля маршрутизації",
|
||||
"hideAdvanced": "Сховати розширені",
|
||||
"quickAddAll": "Швидко додати всі придатні",
|
||||
"quickAdded": "Додано {n} фолбек(ів)",
|
||||
"quickAddedNone": "Немає нових придатних інбаундів",
|
||||
"routesWhen": "Маршрутизує, коли",
|
||||
"defaultCatchAll": "За замовчуванням — ловить усе інше",
|
||||
"needsTls": "Fallbacks стануть доступні після вибору TLS або Reality на вкладці «Безпека» (лише VLESS/Trojan поверх RAW)."
|
||||
},
|
||||
"protocol": "Протокол",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Відображення портів",
|
||||
"traffic": "Трафік",
|
||||
"speed": "Швидкість",
|
||||
"details": "Деталі",
|
||||
"transportConfig": "Транспорт",
|
||||
"expireDate": "Тривалість",
|
||||
"createdAt": "Створено",
|
||||
"updatedAt": "Оновлено",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Додати вхідний",
|
||||
"generalActions": "Загальні дії",
|
||||
"modifyInbound": "Змінити вхідний",
|
||||
"deleteInbound": "Видалити вхідні",
|
||||
"deleteInboundContent": "Ви впевнені, що хочете видалити вхідні?",
|
||||
"deleteConfirmTitle": "Видалити вхідні \"{remark}\"?",
|
||||
"deleteConfirmContent": "Це видалить вхідні та всіх його клієнтів. Цю дію неможливо скасувати.",
|
||||
"resetConfirmTitle": "Скинути трафік \"{remark}\"?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "Усі-вхідні",
|
||||
"exportAllSubsFileName": "Усі-вхідні-Subs",
|
||||
"inboundJsonTitle": "JSON вхідного",
|
||||
"deleteClient": "Видалити клієнта",
|
||||
"deleteClientContent": "Ви впевнені, що хочете видалити клієнт?",
|
||||
"resetTrafficContent": "Ви впевнені, що хочете скинути трафік?",
|
||||
"copyLink": "Копіювати URL",
|
||||
"address": "Адреса",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Без обмежень. (одиниця: ГБ)",
|
||||
"totalFlow": "Загальна витрата",
|
||||
"leaveBlankToNeverExpire": "Залиште порожнім, щоб ніколи не закінчувався",
|
||||
"noRecommendKeepDefault": "Рекомендується зберегти значення за замовчуванням",
|
||||
"certificatePath": "Шлях до файлу",
|
||||
"certificateContent": "Вміст файлу",
|
||||
"publicKey": "Публічний ключ",
|
||||
"privatekey": "Закритий ключ",
|
||||
"clickOnQRcode": "Натисніть QR-код, щоб скопіювати",
|
||||
"client": "Клієнт",
|
||||
"export": "Експортувати всі URL-адреси",
|
||||
"clone": "Клон",
|
||||
"cloneInbound": "Клонувати",
|
||||
"cloneInboundContent": "Усі налаштування цього вхідного потоку, крім порту, IP-адреси прослуховування та клієнтів, будуть застосовані до клону.",
|
||||
"cloneInboundOk": "Клонувати",
|
||||
"resetAllTraffic": "Скинути весь вхідний трафік",
|
||||
"resetAllTrafficTitle": "Скинути весь вхідний трафік",
|
||||
"resetAllTrafficContent": "Ви впевнені, що бажаєте скинути трафік усіх вхідних?",
|
||||
"resetInboundClientTraffics": "Скинути трафік клієнтів",
|
||||
"resetInboundClientTrafficTitle": "Скинути трафік клієнтів",
|
||||
"resetInboundClientTrafficContent": "Ви впевнені, що бажаєте скинути трафік клієнтів цього вхідного потоку?",
|
||||
"resetAllClientTraffics": "Скинути весь трафік клієнтів",
|
||||
"resetAllClientTrafficTitle": "Скинути весь трафік клієнтів",
|
||||
"resetAllClientTrafficContent": "Ви впевнені, що бажаєте скинути трафік усіх клієнтів?",
|
||||
"delDepletedClients": "Видалити вичерпані клієнти",
|
||||
"delDepletedClientsTitle": "Видалити вичерпані клієнти",
|
||||
"delDepletedClientsContent": "Ви впевнені, що хочете видалити всі вичерпані клієнти?",
|
||||
"email": "Email",
|
||||
"emailDesc": "Будь ласка, надайте унікальну адресу електронної пошти.",
|
||||
"IPLimit": "Обмеження IP",
|
||||
"IPLimitDesc": "Вимикає вхідний, якщо кількість перевищує встановлене значення. (0 = вимкнено)",
|
||||
"IPLimitlog": "Журнал IP",
|
||||
"IPLimitlogDesc": "Журнал історії IP-адрес. (щоб увімкнути вхідну після вимкнення, очистіть журнал)",
|
||||
"IPLimitlogclear": "Очистити журнал",
|
||||
"setDefaultCert": "Установити сертифікат з панелі",
|
||||
"setDefaultCertEmpty": "Для панелі не налаштовано сертифікат. Спочатку встановіть його в Налаштуваннях.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Обгортка блоку sniffing Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Обгортка блоку stream Xray:",
|
||||
"jsonErrorPrefix": "Розширений JSON"
|
||||
"streamHelp": "Обгортка блоку stream Xray:"
|
||||
},
|
||||
"telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.",
|
||||
"subSortIndex": "Порядок",
|
||||
"same": "Те саме",
|
||||
"inboundInfo": "Інформація про підключення",
|
||||
"exportInbound": "Експортувати вхідні",
|
||||
"import": "Імпорт",
|
||||
"importInbound": "Імпортувати вхідний",
|
||||
"periodicTrafficResetTitle": "Скидання трафіку",
|
||||
"periodicTrafficResetDesc": "Автоматично скидати лічильник трафіку через певні проміжки часу",
|
||||
"lastReset": "Останнє скидання",
|
||||
"periodicTrafficResetDay": "День щомісячного скидання",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Ніколи",
|
||||
"daily": "Щодня",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Отримати",
|
||||
"updateSuccess": "Оновлення пройшло успішно",
|
||||
"logCleanSuccess": "Журнал очищено",
|
||||
"inboundsUpdateSuccess": "Вхідні підключення успішно оновлено",
|
||||
"inboundUpdateSuccess": "Вхідне підключення успішно оновлено",
|
||||
"inboundCreateSuccess": "Вхідне підключення успішно створено",
|
||||
"bulkDeleted": "Видалено підключень: {count}",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Клієнта вхідного підключення видалено",
|
||||
"inboundClientUpdateSuccess": "Клієнта вхідного підключення оновлено",
|
||||
"savedNodeOfflineWillSync": "Збережено локально. Опорний вузол вимкнено або недоступний — зміни синхронізуються після повторного підключення.",
|
||||
"delDepletedClientsSuccess": "Усі вичерпані клієнти видалені",
|
||||
"resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто",
|
||||
"resetAllTrafficSuccess": "Весь трафік скинуто",
|
||||
"resetInboundClientTrafficSuccess": "Трафік скинуто",
|
||||
@@ -636,6 +578,10 @@
|
||||
"maxTimeDiff": "Макс. різниця в часі (мс)",
|
||||
"minClientVer": "Мін. версія клієнта",
|
||||
"maxClientVer": "Макс. версія клієнта",
|
||||
"minClientVerHint": "Порожнє поле не означає «без обмежень»: Xray-core застосує вбудований мінімум використовуваної збірки ядра (26.3.27 у поточних релізах) і відхилятиме клієнтів зі старішою версією — зокрема сторонні ядра, як-от Mihomo та sing-box. Значення 1.0.0 дозволить їх, але допустить застарілі TLS-відбитки.",
|
||||
"maxClientVerHint": "Порожнє поле — без верхньої межі. Якщо задано, значення не має бути нижчим за чинний мінімум — «Мін. версія клієнта», а коли те поле порожнє — вбудований мінімум Xray-core, інакше всіх клієнтів буде відхилено.",
|
||||
"clientVerInvalid": "Версія клієнта — до трьох чисел через крапку, кожне 0-255 (наприклад 26.3.27)",
|
||||
"maxClientVerBelowMin": "Макс. версія клієнта не має бути нижчою за мінімальну версію клієнта",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Обов'язково. Має містити порт (напр., example.com:443). Без порту Xray-core не запускається.",
|
||||
"realityTargetRequired": "Ціль REALITY обов'язкова",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Конфіг Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "Запит",
|
||||
"response": "Відповідь",
|
||||
"name": "Ім'я",
|
||||
"value": "Значення"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Версія",
|
||||
"method": "Метод",
|
||||
"path": "Шлях",
|
||||
"status": "Статус",
|
||||
"statusDescription": "Опис стану",
|
||||
"requestHeader": "Заголовок запиту",
|
||||
"responseHeader": "Заголовок відповіді"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Перевизначення призначення"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Додати зовнішню підписку",
|
||||
"noExternalLinks": "Зовнішніх посилань ще немає.",
|
||||
"noExternalSubscriptions": "Зовнішніх підписок ще немає.",
|
||||
"add": "Додати клієнта",
|
||||
"edit": "Редагувати клієнта",
|
||||
"submitAdd": "Додати клієнта",
|
||||
"submitEdit": "Зберегти зміни",
|
||||
"clientCount": "Кількість клієнтів",
|
||||
"bulk": "Масове додавання",
|
||||
"copyFromInbound": "Скопіювати клієнтів із вхідного",
|
||||
"copyToInbound": "Скопіювати клієнтів у",
|
||||
"copySelected": "Скопіювати вибране",
|
||||
"copySource": "Джерело",
|
||||
"copyEmailPreview": "Перегляд email, що буде створено",
|
||||
"copySelectSourceFirst": "Спочатку виберіть вхідний-джерело.",
|
||||
"copyResult": "Результат копіювання",
|
||||
"copyResultSuccess": "Скопійовано успішно",
|
||||
"copyResultNone": "Нічого копіювати: не вибрано клієнтів або джерело порожнє",
|
||||
"copyResultErrors": "Помилки копіювання",
|
||||
"copyFlowLabel": "Flow для нових клієнтів (VLESS)",
|
||||
"copyFlowHint": "Застосовується до всіх скопійованих клієнтів. Залишіть порожнім, щоб пропустити.",
|
||||
"selectAll": "Вибрати все",
|
||||
"clearAll": "Очистити все",
|
||||
"method": "Метод",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Постфікс",
|
||||
"delayedStart": "Запуск після першого використання",
|
||||
"expireDays": "Тривалість (днів)",
|
||||
"days": "Дні",
|
||||
"renew": "Авто-продовження",
|
||||
"renewDesc": "Автоматичне продовження після закінчення. (0 = вимкнено) (одиниця: день)",
|
||||
"renewDays": "Авто-продовження (днів)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Швидше закінчуються",
|
||||
"has": "Має",
|
||||
"hasNot": "Не має",
|
||||
"title": "Клієнти",
|
||||
"actions": "Дії",
|
||||
"totalGB": "Ліміт трафіку (ГБ)",
|
||||
"totalGBDesc": "Квота трафіку для цього клієнта. 0 = без обмежень.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Додати клієнта",
|
||||
"qrCode": "QR-код",
|
||||
"clientInfo": "Інформація про клієнта",
|
||||
"delete": "Видалити",
|
||||
"reset": "Скинути трафік",
|
||||
"editClient": "Редагувати клієнта",
|
||||
"client": "Клієнт",
|
||||
"enabled": "Увімкнено",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.",
|
||||
"link": "Посилання",
|
||||
"resetNotPossible": "Спочатку прив'яжіть цього клієнта до вхідного.",
|
||||
"general": "Загальне",
|
||||
"resetAllTraffics": "Скинути трафік усіх клієнтів",
|
||||
"resetAllTrafficsTitle": "Скинути трафік усіх клієнтів?",
|
||||
"resetAllTrafficsContent": "Лічильники відправлення/отримання кожного клієнта обнулюються. Квоти й термін дії не змінюються. Цю дію неможливо скасувати.",
|
||||
"deleteConfirmTitle": "Видалити клієнта {email}?",
|
||||
"deleteConfirmContent": "Клієнт буде вилучений з усіх прив'язаних вхідних, його запис трафіку буде знищено. Цю дію неможливо скасувати.",
|
||||
"deleteSelected": "Видалити ({count})",
|
||||
"adjustSelected": "Змінити ({count})",
|
||||
"subLinksSelected": "Sub-посилання ({count})",
|
||||
"addToGroupTitle": "Додати {count} клієнт(ів) до групи",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "Вимкнути {count} клієнтів?",
|
||||
"bulkDisableConfirmContent": "Вимикає кожного вибраного клієнта на всіх прив'язаних підключеннях. Вони одразу втрачають доступ, але їхні записи та трафік зберігаються.",
|
||||
"selectedCount": "Обрано {count}",
|
||||
"attachSelected": "Прив'язати ({count})",
|
||||
"attachToInboundsTitle": "Прив'язати {count} клієнт(ів) до вхідних",
|
||||
"attachToInboundsDesc": "Прив'язує обрані {count} клієнт(ів) (той самий UUID/пароль і спільний трафік) до обраних вхідних. Існуючі прив'язки зберігаються.",
|
||||
"attachToInboundsTargets": "Цільові вхідні",
|
||||
"attachToInboundsNoTargets": "Немає доступних багатокористувацьких вхідних для прив'язки.",
|
||||
"detachSelected": "Від'єднати ({count})",
|
||||
"detach": "Від'єднати",
|
||||
"detachFromInboundsTitle": "Від'єднати {count} клієнт(ів) від вхідних",
|
||||
"detachFromInboundsDesc": "Видаляє обраних {count} клієнт(ів) з обраних вхідних. Пари, де клієнт не був прив'язаний, тихо пропускаються. Записи клієнтів зберігаються (використовуйте Delete для повного видалення).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
|
||||
"telegramId": "ID користувача Telegram",
|
||||
"telegramIdPlaceholder": "Числовий ID користувача Telegram (0 = немає)",
|
||||
"created": "Створено",
|
||||
"updated": "Оновлено",
|
||||
"ipLimit": "Ліміт IP",
|
||||
"toasts": {
|
||||
"deleted": "Клієнта видалено",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Групи",
|
||||
"name": "Назва",
|
||||
"clientCount": "Клієнти",
|
||||
"totalGroups": "Всього груп",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "Видалено {count} клієнт(ів) з {name}."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Вузли",
|
||||
"addNode": "Додати вузол",
|
||||
"editNode": "Змінити вузол",
|
||||
"totalNodes": "Усього вузлів",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "Токен зі сторінки Налаштувань віддаленої панелі",
|
||||
"apiTokenHint": "Віддалена панель показує свій токен API в Автентифікація → Токен API.",
|
||||
"apiTokenKeepHint": "Залиште порожнім, щоб зберегти поточний токен",
|
||||
"regenerate": "Перегенерувати токен",
|
||||
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
|
||||
"allowPrivateAddress": "Дозволити приватну адресу",
|
||||
"allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
|
||||
"outboundTag": "Вихідне з'єднання",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "Оновити панель",
|
||||
"updateSelected": "Оновити вибрані ({count})",
|
||||
"updateAvailable": "Доступне оновлення",
|
||||
"upToDate": "Актуально",
|
||||
"updateConfirmTitle": "Оновити {count} вузлів до останньої версії?",
|
||||
"updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.",
|
||||
"updateDevChannel": "Оновити до каналу розробки (останній коміт)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "Не вдалося отримати сертифікат"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Типово",
|
||||
"title": "Параметри панелі",
|
||||
"save": "Зберегти",
|
||||
"infoDesc": "Кожна внесена тут зміна повинна бути збережена. Перезапустіть панель, щоб застосувати зміни.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "Високе використання пам'яті (%)",
|
||||
"remarkTemplate": "Шаблон примітки",
|
||||
"remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
|
||||
"subShowIdentityOnAllLinks": "Показувати ідентичність на кожному посиланні",
|
||||
"subShowIdentityOnAllLinksDesc": "Якщо увімкнено, {{EMAIL}} і {{USERNAME}} залишаються в примітці кожного посилання тіла підписки. Токени використання й надалі лише на першому посиланні.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Шлях має починатися з /"
|
||||
},
|
||||
@@ -1448,13 +1350,8 @@
|
||||
"secretClearUndo": "Скасувати очищення"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray конфігурації",
|
||||
"save": "Зберегти",
|
||||
"restart": "Перезапуск Xray",
|
||||
"restartSuccess": "Xray успішно перезапущено",
|
||||
"restartOutputTitle": "Вивід перезапуску Xray",
|
||||
"restartConfirmTitle": "Перезапустити xray?",
|
||||
"restartConfirmContent": "Перезавантажує сервіс xray зі збереженою конфігурацією.",
|
||||
"stopSuccess": "Xray успішно зупинено",
|
||||
"restartError": "Виникла помилка під час перезапуску Xray.",
|
||||
"stopError": "Виникла помилка під час зупинки Xray.",
|
||||
@@ -1464,7 +1361,6 @@
|
||||
"generalConfigsDesc": "Ці параметри визначатимуть загальні налаштування.",
|
||||
"logConfigs": "Лог",
|
||||
"logConfigsDesc": "Журнали можуть вплинути на ефективність вашого сервера. Рекомендується вмикати його з розумом лише у випадку ваших потреб",
|
||||
"blockConfigsDesc": "Ці параметри блокуватимуть трафік на основі конкретних запитуваних протоколів і веб-сайтів.",
|
||||
"basicRouting": "Основна Маршрутизація",
|
||||
"blockConnectionsConfigsDesc": "Ці параметри блокуватимуть трафік на основі запитаних країн.",
|
||||
"directConnectionsConfigsDesc": "Пряме з'єднання гарантує, що певний трафік не буде маршрутизовано через інший сервер.",
|
||||
@@ -1474,10 +1370,6 @@
|
||||
"directdomains": "Прямі домени",
|
||||
"ipv4Routing": "Маршрутизація IPv4",
|
||||
"ipv4RoutingDesc": "Ці параметри спрямовуватимуть трафік на основі певного призначення через IPv4.",
|
||||
"warpRouting": "WARP Маршрутизація",
|
||||
"warpRoutingDesc": "Ці параметри маршрутизуватимуть трафік на основі певного пункту призначення через WARP.",
|
||||
"nordRouting": "Маршрутизація NordVPN",
|
||||
"nordRoutingDesc": "Ці параметри маршрутизуватимуть трафік на основі певного пункту призначення через NordVPN.",
|
||||
"Template": "Шаблон розширеної конфігурації Xray",
|
||||
"TemplateDesc": "Остаточний конфігураційний файл Xray буде створено на основі цього шаблону.",
|
||||
"FreedomStrategy": "Стратегія протоколу свободи",
|
||||
@@ -1491,10 +1383,7 @@
|
||||
"outboundTestUrlDesc": "URL для перевірки з'єднання outbound",
|
||||
"Torrent": "Блокувати протокол BitTorrent",
|
||||
"Inbounds": "Вхідні",
|
||||
"InboundsDesc": "Прийняття певних клієнтів.",
|
||||
"Outbounds": "Вихідні",
|
||||
"OutboundSubscriptions": "Підписки вихідних",
|
||||
"OutboundSubscriptionsDesc": "Імпортуйте вихідні з віддалених URL підписок (vmess/vless/trojan/ss/...). Теги залишаються стабільними для використання в балансувальниках і правилах маршрутизації. Оновлення відбувається автоматично.",
|
||||
"Balancers": "Балансери",
|
||||
"balancerTagRequired": "Тег обов'язковий",
|
||||
"balancerSelectorRequired": "Виберіть принаймні один вихідний",
|
||||
@@ -1513,9 +1402,7 @@
|
||||
"routeTesterMatchedOutbound": "Відповідний вихідний",
|
||||
"routeTesterViaBalancer": "через балансувальник",
|
||||
"routeTesterDefaultOutbound": "Жодне правило маршрутизації не збіглося — трафік надходить до вихідного за замовчуванням (першого).",
|
||||
"OutboundsDesc": "Встановити шлях вихідного трафіку.",
|
||||
"Routings": "Правила маршрутизації",
|
||||
"RoutingsDesc": "Пріоритет кожного правила важливий!",
|
||||
"importRules": "Імпортувати правила",
|
||||
"exportRules": "Експортувати правила",
|
||||
"importOutbounds": "Імпортувати вихідні",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "Маска IP-адреси, при активації автоматично замінює IP-адресу, яка з'являється у журналі.",
|
||||
"statistics": "Статистика",
|
||||
"statsInboundUplink": "Статистика вхідного аплінку",
|
||||
"statsInboundUplinkDesc": "Увімкнення збору статистики для вхідного трафіку всіх вхідних проксі.",
|
||||
"statsInboundDownlink": "Статистика вхідного даунлінку",
|
||||
"statsInboundDownlinkDesc": "Увімкнення збору статистики для вихідного трафіку всіх вхідних проксі.",
|
||||
"statsOutboundUplink": "Статистика вихідного аплінку",
|
||||
"statsOutboundUplinkDesc": "Увімкнення збору статистики для вхідного трафіку всіх вихідних проксі.",
|
||||
"statsOutboundDownlink": "Статистика вихідного даунлінку",
|
||||
"statsOutboundDownlinkDesc": "Увімкнення збору статистики для вихідного трафіку всіх вихідних проксі.",
|
||||
"connectionLimits": "Обмеження з'єднання",
|
||||
"connectionLimitsDesc": "Політики рівня з'єднання для користувачів рівня 0. Залиште поле порожнім, щоб використовувати значення Xray за замовчуванням.",
|
||||
"connIdle": "Тайм-аут простою",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "авто",
|
||||
"seconds": "секунд",
|
||||
"rules": {
|
||||
"first": "Перший",
|
||||
"last": "Останній",
|
||||
"up": "Вгору",
|
||||
"down": "Вниз",
|
||||
"source": "Джерело",
|
||||
"dest": "Пункт призначення",
|
||||
"inbound": "Вхідний",
|
||||
"outbound": "Вихідний",
|
||||
"balancer": "Балансувальник",
|
||||
"info": "Інфо",
|
||||
"add": "Додати правило",
|
||||
"edit": "Редагувати правило",
|
||||
"useComma": "Елементи, розділені комами"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "Правило {n}",
|
||||
"action": "Дія",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Фінальні правила",
|
||||
"overrideXrayPrivateIp": "Перевизначити дефолтний блок приватних IP у Xray",
|
||||
"blockDelay": "Затримка блоку (мс)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "Інтервал keep alive",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Інтерфейс",
|
||||
"ipv6Only": "Лише IPv6",
|
||||
"acceptProxyProtocol": "Приймати proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (мс)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (с)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Додати вихідний",
|
||||
"addReverse": "Додати реверс",
|
||||
"editOutbound": "Редагувати вихідні",
|
||||
"editReverse": "Редагувати реверс",
|
||||
"reverseTag": "Тег реверс-проксі",
|
||||
"reverseTagDesc": "Тег вихідного з'єднання для простого реверс-проксі VLESS. Залиште порожнім для вимкнення.",
|
||||
"reverseTagPlaceholder": "тег вихідного (порожнє = вимкнено)",
|
||||
"tag": "Тег",
|
||||
"tagDesc": "Унікальний тег",
|
||||
"address": "Адреса",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Зворотний",
|
||||
"domain": "Домен",
|
||||
"type": "Тип",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Посилання",
|
||||
"intercon": "Взаємозв'язок",
|
||||
"settings": "Налаштування",
|
||||
"accountInfo": "Інформація про обліковий запис",
|
||||
"outboundStatus": "Статус виходу",
|
||||
"sendThrough": "Надіслати через",
|
||||
"targetStrategy": "Стратегія призначення",
|
||||
"test": "Тест",
|
||||
"testResult": "Результат тесту",
|
||||
"testing": "Тестування з'єднання...",
|
||||
"testSuccess": "Тест успішний",
|
||||
"testFailed": "Тест не пройдено",
|
||||
"testError": "Не вдалося протестувати вихідне з'єднання",
|
||||
"modeRealDelay": "Реальна затримка",
|
||||
"testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray. Реальна затримка: повний час із встановленням з'єднання.",
|
||||
"testAll": "Тестувати всі",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "Підключення до проксі",
|
||||
"breakdownTls": "TLS через вихідний",
|
||||
"breakdownTtfb": "Перший байт",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Токен доступу",
|
||||
"country": "Країна",
|
||||
"server": "Сервер",
|
||||
"city": "Місто",
|
||||
"allCities": "Усі міста",
|
||||
"privateKey": "Приватний ключ",
|
||||
"load": "Навантаження",
|
||||
"moveToTop": "Перемістити вгору"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "Активні підписки",
|
||||
"empty": "Підписок поки немає. Додайте одну вище.",
|
||||
"colRemark": "Примітка",
|
||||
"colPrefix": "Префікс",
|
||||
"colInterval": "Інтервал",
|
||||
"colLastFetch": "Останнє завантаження",
|
||||
"colEnabled": "Увімкнено",
|
||||
"auto": "авто",
|
||||
"never": "ніколи",
|
||||
"yes": "Так",
|
||||
"no": "Ні",
|
||||
"refreshNow": "Оновити зараз",
|
||||
"lastError": "Остання помилка",
|
||||
"deleteConfirm": "Видалити цю підписку?",
|
||||
"restartHint": "Після додавання або оновлення перезапустіть Xray (або зачекайте наступного автоматичного перезавантаження), щоб вихідні стали активними.",
|
||||
"fromSubsTitle": "З підписок вихідних (лише для читання)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "Налаштування балансувальника",
|
||||
"tabObservatory": "Обсерваторія",
|
||||
"observatory": {
|
||||
"title": "Обсерваторія",
|
||||
"burstTitle": "Burst-обсерваторія",
|
||||
"autoManaged": "Спостерігачі керуються автоматично на основі ваших балансувальників. Нижче можна налаштувати, як вони опитують; відстежувані вихідні слідують за селекторами балансувальника.",
|
||||
"emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальники зі спостерігачем могли перевіряти стан вихідних перед вибором цілі.",
|
||||
"mixedLegacy": "Ця конфігурація містить і Observatory, і Burst Observatory. Xray використовує одного глобального спостерігача, тому такий застарілий змішаний стан не підтримується; збереження балансувальників нормалізує його до одного спостерігача.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "Балансувальник {tag} — видалено (не залишилося цілей)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Додати балансир",
|
||||
"editBalancer": "Редагувати балансир",
|
||||
"balancerStrategy": "Стратегія",
|
||||
"balancerSelectors": "Селектори",
|
||||
"tag": "Тег",
|
||||
"tagDesc": "Унікальний тег",
|
||||
"tagDuplicate": "Тег уже використовується іншим балансувальником",
|
||||
"tagPlaceholder": "унікальний тег балансувальника",
|
||||
"selector": "Селектор",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "Допуск",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.",
|
||||
"costMatch": "Шаблон тегу",
|
||||
"costValue": "Вага",
|
||||
"costRegexp": "Збіг за регулярним виразом",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "Публічний ключ",
|
||||
"allowedIPs": "Дозволені IP-адреси",
|
||||
"endpoint": "Кінцева точка",
|
||||
"psk": "Спільний ключ",
|
||||
"domainStrategy": "Стратегія домену"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "Лише для AmneziaWG 2.0. Залиште порожнім для сумісності з 1.x."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "Назва інтерфейсу TUN. Значення за замовчуванням - 'xray0'",
|
||||
"mtuDesc": "Максимальна одиниця передачі. Максимальний розмір пакетів даних. Значення за замовчуванням - 1500",
|
||||
"userLevel": "Рівень користувача",
|
||||
"userLevelDesc": "Всі з'єднання, встановлені через цей вхід, використовуватимуть цей рівень користувача. Значення за замовчуванням - 0"
|
||||
"userLevel": "Рівень користувача"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Додати підроблений DNS",
|
||||
"edit": "Редагувати підроблений DNS",
|
||||
"ipPool": "Підмережа IP-пулу",
|
||||
"poolSize": "Розмір пулу"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Додати",
|
||||
"month": "Місяць",
|
||||
"months": "Місяці",
|
||||
"day": "День",
|
||||
"days": "Дні",
|
||||
"hours": "Години",
|
||||
"minutes": "Хвилини",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Користувача Telegram збережено.",
|
||||
"loginSuccess": "✅ Успішно ввійшли в панель\r\n",
|
||||
"loginFailed": "❗️ Помилка входу в панель.\r\n",
|
||||
"2faFailed": "Помилка 2FA",
|
||||
"report": "🕰 Заплановані звіти: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Дата й час: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Хост: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 Завантаження: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Усього: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Користувач Telegram: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 Вичерпано {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 Вичерпано кількість {{ .Type }} count:\r\n",
|
||||
"onlinesCount": "🌐 Онлайн-клієнти: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Вимкнено: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Оновлено: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Так",
|
||||
"no": "❌ Ні",
|
||||
"received_id": "🔑📥 ID оновлено.",
|
||||
"received_password": "🔑📥 Пароль оновлено.",
|
||||
"received_email": "📧📥 Електронна пошта оновлена.",
|
||||
"received_comment": "💬📥 Коментар оновлено.",
|
||||
"id_prompt": "🔑 Стандартний ID: {{ .ClientId }}\n\nВведіть ваш ID.",
|
||||
"pass_prompt": "🔑 Стандартний пароль: {{ .ClientPassword }}\n\nВведіть ваш пароль.",
|
||||
"email_prompt": "📧 Стандартний email: {{ .ClientEmail }}\n\nВведіть ваш email.",
|
||||
"comment_prompt": "💬 Стандартний коментар: {{ .ClientComment }}\n\nВведіть ваш коментар.",
|
||||
"inbound_client_data_id": "🔄 Вхід: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Електронна пошта: {{ .ClientEmail }}\n📊 Трафік: {{ .ClientTraffic }}\n📅 Дата завершення: {{ .ClientExp }}\n🌐 Обмеження IP: {{ .IpLimit }}\n💬 Коментар: {{ .ClientComment }}\n\nТепер ви можете додати клієнта до вхідного з'єднання!",
|
||||
"inbound_client_data_pass": "🔄 Вхід: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Електронна пошта: {{ .ClientEmail }}\n📊 Трафік: {{ .ClientTraffic }}\n📅 Дата завершення: {{ .ClientExp }}\n🌐 Обмеження IP: {{ .IpLimit }}\n💬 Коментар: {{ .ClientComment }}\n\nТепер ви можете додати клієнта до вхідного з'єднання!",
|
||||
"cancel": "❌ Процес скасовано! \n\nВи можете знову розпочати, використовуючи /start у будь-який час. 🔄",
|
||||
"error_add_client": "⚠️ Помилка:\n\n {{ .error }}",
|
||||
"using_default_value": "Гаразд, залишу значення за замовчуванням. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Помилка: {{ .Error }}",
|
||||
"eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ",
|
||||
"eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ",
|
||||
"eventCPUHigh": "Високе навантаження на CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Невдала спроба входу з {{ .Source }}",
|
||||
"memoryThreshold": "Використання пам'яті {{ .Percent }}% перевищує порогове значення {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Надіслати як вимкнено ☑️",
|
||||
"submitEnable": "Надіслати як увімкнено ✅",
|
||||
"use_default": "🏷️ Використати типове",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 Пароль",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Коментар",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Скинути весь трафік",
|
||||
"SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
|
||||
"subjectOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
|
||||
"subjectXrayCrash": "Стався збій Xray",
|
||||
"subjectCPUHigh": "Високе навантаження на CPU",
|
||||
"subjectLoginSuccess": "Успішний вхід",
|
||||
"subjectLoginFailed": "Невдалий вхід",
|
||||
"titleOutboundDown": "Вихідне з'єднання НЕДОСТУПНЕ",
|
||||
"titleOutboundUp": "Вихідне з'єднання ДОСТУПНЕ",
|
||||
"titleXrayCrash": "Стався збій Xray",
|
||||
"titleCPUHigh": "Високе навантаження на CPU",
|
||||
"titleLoginSuccess": "Успішний вхід",
|
||||
"titleLoginFailed": "Невдалий вхід",
|
||||
"labelStatus": "Статус",
|
||||
"labelOutbound": "Вихідне з'єднання",
|
||||
"labelNode": "Вузол",
|
||||
"labelError": "Помилка",
|
||||
"labelDelay": "Затримка",
|
||||
"labelDetail": "Деталі",
|
||||
"labelUsername": "Ім'я користувача",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Причина",
|
||||
"labelSource": "Джерело",
|
||||
"labelTime": "Час",
|
||||
"statusCrashed": "ЗБІЙ",
|
||||
"statusRunning": "Працює",
|
||||
"statusHigh": "ВИСОКЕ",
|
||||
"statusSuccess": "УСПІШНО",
|
||||
"statusFailed": "НЕВДАЛО",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "Đã sao chép thành công",
|
||||
"sure": "Chắc chắn",
|
||||
"encryption": "Mã hóa",
|
||||
"useIPv4ForHost": "Sử dụng IPv4 cho máy chủ",
|
||||
"transmission": "Truyền tải",
|
||||
"host": "Host",
|
||||
"path": "Đường dẫn",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "Mã",
|
||||
"remained": "Còn lại",
|
||||
"security": "Bảo vệ",
|
||||
"secAlertTitle": "Cảnh báo an ninh-Tiếng Việt by Ohoang7",
|
||||
"secAlertSsl": "Kết nối này không an toàn; Vui lòng không nhập thông tin nhạy cảm cho đến khi TLS được kích hoạt để bảo vệ dữ liệu của Bạn",
|
||||
"secAlertConf": "Một số cài đặt có thể dễ bị tấn công. Đề xuất tăng cường các giao thức bảo mật để ngăn chặn các vi phạm tiềm ẩn.",
|
||||
"secAlertSSL": "Bảng điều khiển thiếu kết nối an toàn. Vui lòng cài đặt chứng chỉ TLS để bảo vệ dữ liệu.",
|
||||
"secAlertPanelPort": "Cổng mặc định của bảng điều khiển có thể dễ bị tấn công. Vui lòng cấu hình một cổng ngẫu nhiên hoặc cụ thể.",
|
||||
"secAlertPanelURI": "Đường dẫn URI mặc định của bảng điều khiển không an toàn. Vui lòng cấu hình một đường dẫn URI phức tạp.",
|
||||
"secAlertSubURI": "Đường dẫn URI mặc định của đăng ký không an toàn. Vui lòng cấu hình một đường dẫn URI phức tạp.",
|
||||
"secAlertSubJsonURI": "Đường dẫn URI JSON mặc định của đăng ký không an toàn. Vui lòng cấu hình một đường dẫn URI phức tạp.",
|
||||
"emptyDnsDesc": "Không có máy chủ DNS nào được thêm.",
|
||||
"emptyFakeDnsDesc": "Không có máy chủ Fake DNS nào được thêm.",
|
||||
"emptyBalancersDesc": "Không có bộ cân bằng tải nào được thêm.",
|
||||
"emptyReverseDesc": "Không có proxy ngược nào được thêm.",
|
||||
"somethingWentWrong": "Đã xảy ra lỗi",
|
||||
"subscription": {
|
||||
"title": "Thông tin đăng ký",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "Chủ đề",
|
||||
"dark": "Tối",
|
||||
"ultraDark": "Siêu tối",
|
||||
"dashboard": "Trạng thái hệ thống",
|
||||
"inbounds": "Inbound",
|
||||
"clients": "Khách hàng",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "Định tuyến",
|
||||
"outbounds": "Outbound",
|
||||
"apiDocs": "Tài liệu API",
|
||||
"logout": "Đăng xuất",
|
||||
"link": "Quản lý",
|
||||
"donate": "Quyên góp",
|
||||
"hosts": "Hosts",
|
||||
"docs": "Tài liệu",
|
||||
"openMenu": "Mở menu"
|
||||
"openMenu": "Mở menu",
|
||||
"pinSidebar": "Ghim thanh bên",
|
||||
"unpinSidebar": "Bỏ ghim thanh bên"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "Trạng thái hệ thống",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "Bộ xử lý logic",
|
||||
"frequency": "Tần số",
|
||||
"swap": "Swap",
|
||||
"storage": "Lưu trữ",
|
||||
"memory": "Bộ nhớ",
|
||||
"threads": "Luồng",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "Dừng",
|
||||
"restartXray": "Khởi động lại",
|
||||
"xraySwitch": "Phiên bản",
|
||||
"xrayUpdates": "Cập nhật Xray",
|
||||
"xraySwitchClick": "Chọn phiên bản mà bạn muốn chuyển đổi sang.",
|
||||
"xraySwitchClickDesk": "Hãy lựa chọn thận trọng, vì các phiên bản cũ có thể không tương thích với các cấu hình hiện tại.",
|
||||
"updatePanel": "Cập nhật Panel",
|
||||
"panelUpdateDesc": "Điều này sẽ cập nhật 3X-UI lên bản phát hành mới nhất và khởi động lại dịch vụ panel.",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "Commit hiện tại",
|
||||
"latestCommit": "Commit mới nhất",
|
||||
"updateChannelChanged": "Đã đổi kênh cập nhật",
|
||||
"upToDate": "Đã cập nhật",
|
||||
"xrayStatusUnknown": "Không xác định",
|
||||
"xrayStatusRunning": "Đang chạy",
|
||||
"xrayStatusStop": "Dừng",
|
||||
"xrayStatusError": "Lỗi",
|
||||
"xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray",
|
||||
"operationHours": "Thời gian hoạt động",
|
||||
"systemHistoryTitle": "Lịch sử hệ thống",
|
||||
"historyTitleCpu": "Mức sử dụng CPU",
|
||||
"historyTitleMem": "Mức sử dụng bộ nhớ",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "Tải",
|
||||
"historyTabConnections": "Kết nối",
|
||||
"historyTabDiskUsage": "Sử dụng đĩa",
|
||||
"charts": "Biểu đồ",
|
||||
"xrayMetricsTitle": "Chỉ số Xray",
|
||||
"xrayTitleHeap": "Bộ nhớ Heap đã cấp phát",
|
||||
"xrayTitleSys": "Bộ nhớ dành riêng từ HĐH",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "Ngừng",
|
||||
"xrayObservatoryLastSeen": "Lần cuối thấy",
|
||||
"xrayObservatoryLastTry": "Lần thử cuối",
|
||||
"trendLast2Min": "2 phút gần nhất",
|
||||
"systemLoad": "Tải hệ thống",
|
||||
"systemLoadDesc": "trung bình tải hệ thống trong 1, 5 và 15 phút qua",
|
||||
"connectionCount": "Số lượng kết nối",
|
||||
"ipAddresses": "Địa chỉ IP",
|
||||
"toggleIpVisibility": "Chuyển đổi hiển thị IP",
|
||||
"overallSpeed": "Tốc độ tổng thể",
|
||||
"upload": "Tải lên",
|
||||
"download": "Tải xuống",
|
||||
"totalData": "Tổng dữ liệu",
|
||||
"sent": "Đã gửi",
|
||||
"received": "Đã nhận",
|
||||
"documentation": "Tài liệu",
|
||||
"xraySwitchVersionDialog": "Bạn có chắc chắn muốn thay đổi phiên bản Xray không?",
|
||||
"xraySwitchVersionDialogDesc": "Hành động này sẽ thay đổi phiên bản Xray thành #version#.",
|
||||
"xraySwitchVersionPopover": "Xray đã được cập nhật thành công",
|
||||
"panelUpdateDialog": "Bạn có chắc muốn cập nhật panel không?",
|
||||
"panelUpdateDialogDesc": "Điều này sẽ cập nhật 3X-UI lên #version# và khởi động lại dịch vụ panel.",
|
||||
"panelUpdateCheckPopover": "Kiểm tra cập nhật panel thất bại",
|
||||
"panelUpdateStartedPopover": "Bắt đầu cập nhật panel",
|
||||
"panelUpdateFailedTitle": "Cập nhật panel thất bại",
|
||||
"panelUpdateFailedDesc": "Bản cập nhật không hoàn tất thành công. Hãy kiểm tra nhật ký máy chủ, hoặc chạy 'x-ui update' từ dòng lệnh.",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "Nhật ký truy cập",
|
||||
"autoUpdate": "Tự động cập nhật",
|
||||
"config": "Cấu hình",
|
||||
"backup": "Sao lưu",
|
||||
"backupTitle": "Sao lưu & Khôi phục",
|
||||
"exportDatabase": "Sao lưu",
|
||||
"exportDatabaseDesc": "Nhấp để tải xuống tệp .db chứa bản sao lưu cơ sở dữ liệu hiện tại của bạn vào thiết bị. Tệp này cũng có thể được khôi phục vào bảng điều khiển chạy PostgreSQL.",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.",
|
||||
"importDatabasePgDesc": "Nhấn để chọn và tải lên bản sao lưu PostgreSQL (.dump), cơ sở dữ liệu SQLite (.db) hoặc tệp kết xuất di trú SQLite nhằm khôi phục cơ sở dữ liệu của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
|
||||
"migrationDownload": "Tải tệp di trú",
|
||||
"migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite."
|
||||
"migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite.",
|
||||
"avg": "TB",
|
||||
"peak": "đỉnh",
|
||||
"free": "trống",
|
||||
"openSockets": "socket đang mở",
|
||||
"throughputSub": "Tổng theo giao diện mạng",
|
||||
"avgWindow": "Trung bình trong khoảng",
|
||||
"healthWarm": "{list} — đang cao",
|
||||
"healthCritical": "{list} — nguy cấp",
|
||||
"panel": "Panel",
|
||||
"threads": "Luồng",
|
||||
"uptime": "Thời gian chạy"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "Inbound",
|
||||
"totalDownUp": "Tổng tải lên/tải xuống",
|
||||
"totalUsage": "Tổng sử dụng",
|
||||
"inboundCount": "Số lượng điểm vào",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "Panel cục bộ",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "Khi một kết nối trên inbound này không khớp với client nào, hãy chuyển hướng nó tới nơi khác. Chọn một inbound con bên dưới để tự động điền các trường định tuyến (SNI / ALPN / Path / xver) từ transport của nó, hoặc để trống ô chọn và đặt Dest trực tiếp (ví dụ 8080 hoặc 127.0.0.1:8080) để chuyển hướng tới một máy chủ bên ngoài như Nginx. Mỗi inbound con nên lắng nghe trên 127.0.0.1 với security=none.",
|
||||
"empty": "Chưa có fallback nào",
|
||||
"add": "Thêm fallback",
|
||||
"pickInbound": "Chọn một inbound",
|
||||
"matchAny": "bất kỳ",
|
||||
"destPlaceholder": "tự động (listen:port của child)",
|
||||
"rederive": "Điền lại từ child",
|
||||
"rederived": "Đã điền lại từ child",
|
||||
"editAdvanced": "Sửa trường định tuyến",
|
||||
"hideAdvanced": "Ẩn nâng cao",
|
||||
"quickAddAll": "Thêm nhanh tất cả các inbound đủ điều kiện",
|
||||
"quickAdded": "Đã thêm {n} fallback",
|
||||
"quickAddedNone": "Không có inbound mới nào đủ điều kiện",
|
||||
"routesWhen": "Định tuyến khi",
|
||||
"defaultCatchAll": "Mặc định — bắt mọi thứ khác",
|
||||
"needsTls": "Fallback khả dụng sau khi chọn TLS hoặc Reality trong thẻ Bảo mật (chỉ VLESS/Trojan trên RAW)."
|
||||
},
|
||||
"protocol": "Giao thức",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "Ánh xạ cổng",
|
||||
"traffic": "Lưu lượng",
|
||||
"speed": "Tốc độ",
|
||||
"details": "Chi tiết",
|
||||
"transportConfig": "Truyền dẫn",
|
||||
"expireDate": "Ngày hết hạn",
|
||||
"createdAt": "Tạo lúc",
|
||||
"updatedAt": "Cập nhật",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "Thêm điểm vào",
|
||||
"generalActions": "Hành động chung",
|
||||
"modifyInbound": "Chỉnh sửa điểm vào (Inbound)",
|
||||
"deleteInbound": "Xóa điểm vào (Inbound)",
|
||||
"deleteInboundContent": "Xác nhận xóa điểm vào? (Inbound)",
|
||||
"deleteConfirmTitle": "Xóa inbound \"{remark}\"?",
|
||||
"deleteConfirmContent": "Hành động này xóa inbound và toàn bộ khách hàng của nó. Không thể hoàn tác.",
|
||||
"resetConfirmTitle": "Đặt lại lưu lượng của \"{remark}\"?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "Tat-ca-Inbound",
|
||||
"exportAllSubsFileName": "Tat-ca-Inbound-Subs",
|
||||
"inboundJsonTitle": "JSON inbound",
|
||||
"deleteClient": "Xóa người dùng",
|
||||
"deleteClientContent": "Bạn có chắc chắn muốn xóa người dùng không?",
|
||||
"resetTrafficContent": "Xác nhận đặt lại lưu lượng?",
|
||||
"copyLink": "Sao chép liên kết",
|
||||
"address": "Địa chỉ",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= Không giới hạn. (đơn vị: GB)",
|
||||
"totalFlow": "Tổng lưu lượng",
|
||||
"leaveBlankToNeverExpire": "Để trống để không bao giờ hết hạn",
|
||||
"noRecommendKeepDefault": "Không yêu cầu đặc biệt để giữ nguyên cài đặt mặc định",
|
||||
"certificatePath": "Đường dẫn tập",
|
||||
"certificateContent": "Nội dung tập",
|
||||
"publicKey": "Khóa công khai",
|
||||
"privatekey": "Khóa cá nhân",
|
||||
"clickOnQRcode": "Nhấn vào Mã QR để sao chép",
|
||||
"client": "Người dùng",
|
||||
"export": "Xuất liên kết",
|
||||
"clone": "Sao chép",
|
||||
"cloneInbound": "Sao chép điểm vào (Inbound)",
|
||||
"cloneInboundContent": "Tất cả cài đặt của điểm vào này, trừ Cổng, IP nghe và máy khách, sẽ được áp dụng cho bản sao.",
|
||||
"cloneInboundOk": "Sao chép",
|
||||
"resetAllTraffic": "Đặt lại lưu lượng cho tất cả điểm vào",
|
||||
"resetAllTrafficTitle": "Đặt lại lưu lượng cho tất cả điểm vào",
|
||||
"resetAllTrafficContent": "Bạn có chắc chắn muốn đặt lại lưu lượng cho tất cả điểm vào không?",
|
||||
"resetInboundClientTraffics": "Đặt lại lưu lượng toàn bộ người dùng của điểm vào",
|
||||
"resetInboundClientTrafficTitle": "Đặt lại lưu lượng cho toàn bộ người dùng của điểm vào",
|
||||
"resetInboundClientTrafficContent": "Bạn có chắc chắn muốn đặt lại tất cả lưu lượng cho các người dùng của điểm vào này không?",
|
||||
"resetAllClientTraffics": "Đặt lại lưu lượng cho toàn bộ người dùng",
|
||||
"resetAllClientTrafficTitle": "Đặt lại lưu lượng cho toàn bộ người dùng",
|
||||
"resetAllClientTrafficContent": "Bạn có chắc chắn muốn đặt lại tất cả lưu lượng cho toàn bộ người dùng không?",
|
||||
"delDepletedClients": "Xóa các người dùng đã cạn kiệt",
|
||||
"delDepletedClientsTitle": "Xóa các người dùng đã cạn kiệt",
|
||||
"delDepletedClientsContent": "Bạn có chắc chắn muốn xóa toàn bộ người dùng đã cạn kiệt không?",
|
||||
"email": "Email",
|
||||
"emailDesc": "Vui lòng cung cấp một địa chỉ email duy nhất.",
|
||||
"IPLimit": "Giới hạn IP",
|
||||
"IPLimitDesc": "Vô hiệu hóa điểm vào nếu số lượng vượt quá giá trị đã nhập (nhập 0 để vô hiệu hóa giới hạn IP).",
|
||||
"IPLimitlog": "Lịch sử IP",
|
||||
"IPLimitlogDesc": "Lịch sử đăng nhập IP (trước khi kích hoạt điểm vào sau khi bị vô hiệu hóa bởi giới hạn IP, bạn nên xóa lịch sử).",
|
||||
"IPLimitlogclear": "Xóa Lịch sử",
|
||||
"setDefaultCert": "Đặt chứng chỉ từ bảng điều khiển",
|
||||
"setDefaultCertEmpty": "Không có chứng chỉ nào được cấu hình cho bảng điều khiển. Hãy đặt một chứng chỉ trong Cài đặt trước.",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Bao đóng khối sniffing của Xray:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Bao đóng khối stream của Xray:",
|
||||
"jsonErrorPrefix": "JSON nâng cao"
|
||||
"streamHelp": "Bao đóng khối stream của Xray:"
|
||||
},
|
||||
"telegramDesc": "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc ({'@'}userinfobot)",
|
||||
"subscriptionDesc": "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau",
|
||||
"subSortIndex": "Thứ tự sub",
|
||||
"same": "Giống nhau",
|
||||
"inboundInfo": "Thông tin Inbound",
|
||||
"exportInbound": "Xuất nhập khẩu",
|
||||
"import": "Nhập",
|
||||
"importInbound": "Nhập inbound",
|
||||
"periodicTrafficResetTitle": "Đặt lại lưu lượng",
|
||||
"periodicTrafficResetDesc": "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định",
|
||||
"lastReset": "Đặt lại lần cuối",
|
||||
"periodicTrafficResetDay": "Ngày đặt lại hàng tháng",
|
||||
"periodicTrafficReset": {
|
||||
"never": "Không bao giờ",
|
||||
"daily": "Hàng ngày",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "Nhận",
|
||||
"updateSuccess": "Cập nhật thành công",
|
||||
"logCleanSuccess": "Đã xóa nhật ký",
|
||||
"inboundsUpdateSuccess": "Đã cập nhật thành công các kết nối inbound",
|
||||
"inboundUpdateSuccess": "Đã cập nhật thành công kết nối inbound",
|
||||
"inboundCreateSuccess": "Đã tạo thành công kết nối inbound",
|
||||
"bulkDeleted": "Đã xóa {count} inbound",
|
||||
@@ -473,7 +416,6 @@
|
||||
"inboundClientDeleteSuccess": "Đã xóa client inbound",
|
||||
"inboundClientUpdateSuccess": "Đã cập nhật client inbound",
|
||||
"savedNodeOfflineWillSync": "Đã lưu cục bộ. Một nút hỗ trợ đang ngoại tuyến hoặc bị tắt — thay đổi sẽ được đồng bộ khi kết nối lại.",
|
||||
"delDepletedClientsSuccess": "Đã xóa tất cả client hết hạn",
|
||||
"resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client",
|
||||
"resetAllTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng",
|
||||
"resetInboundClientTrafficSuccess": "Đã đặt lại lưu lượng",
|
||||
@@ -657,6 +599,10 @@
|
||||
"maxTimeDiff": "Chênh lệch thời gian tối đa (ms)",
|
||||
"minClientVer": "Phiên bản client tối thiểu",
|
||||
"maxClientVer": "Phiên bản client tối đa",
|
||||
"minClientVerHint": "Để trống không có nghĩa là không giới hạn: Xray-core sẽ áp dụng mức tối thiểu tích hợp của bản core đang chạy (26.3.27 ở các bản phát hành hiện tại) và từ chối các client khai báo phiên bản cũ hơn — bao gồm các core bên thứ ba như Mihomo và sing-box. Đặt 1.0.0 để chấp nhận chúng, đổi lại là cho phép các dấu vân tay TLS lỗi thời.",
|
||||
"maxClientVerHint": "Để trống nghĩa là không có giới hạn trên. Nếu đặt, không được thấp hơn mức tối thiểu đang có hiệu lực — phiên bản client tối thiểu, hoặc mức tối thiểu tích hợp của Xray-core khi ô đó để trống — nếu không mọi client đều bị từ chối.",
|
||||
"clientVerInvalid": "Phiên bản client phải gồm tối đa ba số cách nhau bằng dấu chấm, mỗi số 0-255 (ví dụ 26.3.27)",
|
||||
"maxClientVerBelowMin": "Phiên bản client tối đa không được thấp hơn phiên bản client tối thiểu",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "Bắt buộc. Phải bao gồm cổng (ví dụ example.com:443). Không có cổng, Xray-core sẽ không khởi động.",
|
||||
"realityTargetRequired": "Mục tiêu REALITY là bắt buộc",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Cấu hình Peer {n}"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "Lời yêu cầu",
|
||||
"response": "Phản ứng",
|
||||
"name": "Tên",
|
||||
"value": "Giá trị"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "Phiên bản",
|
||||
"method": "Phương pháp",
|
||||
"path": "Đường dẫn",
|
||||
"status": "Trạng thái",
|
||||
"statusDescription": "Tình trạng Mô tả",
|
||||
"requestHeader": "Header yêu cầu",
|
||||
"responseHeader": "Header phản hồi"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "Ghi đè đích"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "Thêm đăng ký ngoài",
|
||||
"noExternalLinks": "Chưa có liên kết ngoài.",
|
||||
"noExternalSubscriptions": "Chưa có đăng ký ngoài.",
|
||||
"add": "Thêm khách hàng",
|
||||
"edit": "Chỉnh sửa khách hàng",
|
||||
"submitAdd": "Thêm khách hàng",
|
||||
"submitEdit": "Lưu thay đổi",
|
||||
"clientCount": "Số lượng khách hàng",
|
||||
"bulk": "Thêm hàng loạt",
|
||||
"copyFromInbound": "Sao chép khách hàng từ inbound",
|
||||
"copyToInbound": "Sao chép khách hàng đến",
|
||||
"copySelected": "Sao chép đã chọn",
|
||||
"copySource": "Nguồn",
|
||||
"copyEmailPreview": "Xem trước email kết quả",
|
||||
"copySelectSourceFirst": "Hãy chọn inbound nguồn trước.",
|
||||
"copyResult": "Kết quả sao chép",
|
||||
"copyResultSuccess": "Đã sao chép thành công",
|
||||
"copyResultNone": "Không có gì để sao chép: chưa chọn khách hàng hoặc nguồn rỗng",
|
||||
"copyResultErrors": "Lỗi sao chép",
|
||||
"copyFlowLabel": "Flow cho khách hàng mới (VLESS)",
|
||||
"copyFlowHint": "Áp dụng cho tất cả khách hàng được sao chép. Để trống để bỏ qua.",
|
||||
"selectAll": "Chọn tất cả",
|
||||
"clearAll": "Xóa tất cả",
|
||||
"method": "Phương thức",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "Hậu tố",
|
||||
"delayedStart": "Bắt đầu sau lần dùng đầu",
|
||||
"expireDays": "Thời hạn (ngày)",
|
||||
"days": "Ngày",
|
||||
"renew": "Tự động gia hạn",
|
||||
"renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt) (đơn vị: ngày)",
|
||||
"renewDays": "Tự động gia hạn (ngày)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "Sắp hết hạn",
|
||||
"has": "Có",
|
||||
"hasNot": "Không có",
|
||||
"title": "Khách hàng",
|
||||
"actions": "Hành động",
|
||||
"totalGB": "Giới hạn lưu lượng (GB)",
|
||||
"totalGBDesc": "Hạn mức dữ liệu cho khách hàng này. 0 = không giới hạn.",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "Thêm khách hàng",
|
||||
"qrCode": "Mã QR",
|
||||
"clientInfo": "Thông tin khách hàng",
|
||||
"delete": "Xóa",
|
||||
"reset": "Đặt lại lưu lượng",
|
||||
"editClient": "Chỉnh sửa khách hàng",
|
||||
"client": "Khách hàng",
|
||||
"enabled": "Đã bật",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "Không có liên kết chia sẻ — hãy gắn khách hàng này vào một inbound có giao thức tương thích trước.",
|
||||
"link": "Liên kết",
|
||||
"resetNotPossible": "Hãy gắn khách hàng này vào một inbound trước.",
|
||||
"general": "Chung",
|
||||
"resetAllTraffics": "Đặt lại lưu lượng của tất cả khách hàng",
|
||||
"resetAllTrafficsTitle": "Đặt lại lưu lượng của tất cả khách hàng?",
|
||||
"resetAllTrafficsContent": "Bộ đếm gửi/nhận của mỗi khách hàng về 0. Hạn mức và thời hạn không bị ảnh hưởng. Không thể hoàn tác.",
|
||||
"deleteConfirmTitle": "Xóa khách hàng {email}?",
|
||||
"deleteConfirmContent": "Hành động này gỡ khách hàng khỏi mọi inbound đã gắn và xóa bản ghi lưu lượng. Không thể hoàn tác.",
|
||||
"deleteSelected": "Xóa ({count})",
|
||||
"adjustSelected": "Điều chỉnh ({count})",
|
||||
"subLinksSelected": "Liên kết sub ({count})",
|
||||
"addToGroupTitle": "Thêm {count} client vào một nhóm",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "Tắt {count} khách hàng?",
|
||||
"bulkDisableConfirmContent": "Tắt từng khách hàng đã chọn trên mọi inbound được gắn. Họ mất quyền truy cập ngay lập tức nhưng hồ sơ và lưu lượng của họ vẫn được giữ lại.",
|
||||
"selectedCount": "Đã chọn {count}",
|
||||
"attachSelected": "Gắn ({count})",
|
||||
"attachToInboundsTitle": "Gắn {count} client vào inbound",
|
||||
"attachToInboundsDesc": "Gắn {count} client đã chọn (cùng UUID/mật khẩu và lưu lượng chung) vào các inbound đã chọn. Các gắn kết hiện tại được giữ nguyên.",
|
||||
"attachToInboundsTargets": "Inbound đích",
|
||||
"attachToInboundsNoTargets": "Không có inbound đa người dùng nào để gắn.",
|
||||
"detachSelected": "Tách ({count})",
|
||||
"detach": "Tách",
|
||||
"detachFromInboundsTitle": "Tách {count} client khỏi inbound",
|
||||
"detachFromInboundsDesc": "Xóa {count} client đã chọn khỏi các inbound đã chọn. Các cặp client chưa gắn sẽ được bỏ qua. Hồ sơ client được giữ lại (dùng Delete để xóa hoàn toàn).",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "Reverse tag tùy chọn",
|
||||
"telegramId": "ID người dùng Telegram",
|
||||
"telegramIdPlaceholder": "ID người dùng Telegram dạng số (0 = không có)",
|
||||
"created": "Tạo",
|
||||
"updated": "Cập nhật",
|
||||
"ipLimit": "Giới hạn IP",
|
||||
"toasts": {
|
||||
"deleted": "Đã xóa khách hàng",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Nhóm",
|
||||
"name": "Tên",
|
||||
"clientCount": "Client",
|
||||
"totalGroups": "Tổng số nhóm",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "Đã xóa {count} client khỏi {name}."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Nút",
|
||||
"addNode": "Thêm nút",
|
||||
"editNode": "Sửa node",
|
||||
"totalNodes": "Tổng số nút",
|
||||
@@ -1016,8 +918,6 @@
|
||||
"apiTokenPlaceholder": "Token từ trang Cài đặt của panel từ xa",
|
||||
"apiTokenHint": "Panel từ xa hiển thị token API tại Bảo mật → Token API.",
|
||||
"apiTokenKeepHint": "Để trống để giữ token hiện tại",
|
||||
"regenerate": "Tạo lại token",
|
||||
"regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
|
||||
"allowPrivateAddress": "Cho phép địa chỉ riêng",
|
||||
"allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
|
||||
"outboundTag": "Outbound kết nối",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "Cập nhật bảng điều khiển",
|
||||
"updateSelected": "Cập nhật đã chọn ({count})",
|
||||
"updateAvailable": "Có bản cập nhật",
|
||||
"upToDate": "Mới nhất",
|
||||
"updateConfirmTitle": "Cập nhật {count} node lên phiên bản mới nhất?",
|
||||
"updateConfirmContent": "Mỗi node đã chọn sẽ tải bản phát hành mới nhất và khởi động lại. Chỉ các node đang bật và trực tuyến được cập nhật.",
|
||||
"updateDevChannel": "Cập nhật lên kênh phát triển (commit mới nhất)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "Không thể lấy chứng chỉ"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Mặc định",
|
||||
"title": "Cài đặt",
|
||||
"save": "Lưu",
|
||||
"infoDesc": "Mọi thay đổi được thực hiện ở đây cần phải được lưu. Vui lòng khởi động lại bảng điều khiển để áp dụng các thay đổi.",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
|
||||
"remarkTemplate": "Mẫu ghi chú",
|
||||
"remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
|
||||
"subShowIdentityOnAllLinks": "Hiện danh tính trên mọi liên kết",
|
||||
"subShowIdentityOnAllLinksDesc": "Khi bật, {{EMAIL}} và {{USERNAME}} vẫn có trong ghi chú mọi liên kết phần thân đăng ký. Token dung lượng vẫn chỉ ở liên kết đầu tiên.",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
|
||||
},
|
||||
@@ -1448,7 +1350,6 @@
|
||||
"secretClearUndo": "Hoàn tác xóa"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Cài đặt Xray",
|
||||
"importRules": "Nhập quy tắc",
|
||||
"exportRules": "Xuất quy tắc",
|
||||
"importOutbounds": "Nhập outbound",
|
||||
@@ -1458,11 +1359,7 @@
|
||||
"metricsListenDesc": "Hiển thị các chỉ số kiểu Prometheus của Xray tại địa chỉ:cổng này (ví dụ 127.0.0.1:11111). Để trống để tắt. Hãy gắn vào localhost và reverse-proxy nó — vì nó không có xác thực.",
|
||||
"metricsTag": "Metrics Tag",
|
||||
"save": "Lưu cài đặt",
|
||||
"restart": "Khởi động lại Xray",
|
||||
"restartSuccess": "Đã khởi động lại Xray thành công",
|
||||
"restartOutputTitle": "Đầu ra khởi động lại Xray",
|
||||
"restartConfirmTitle": "Khởi động lại xray?",
|
||||
"restartConfirmContent": "Tải lại dịch vụ xray với cấu hình đã lưu.",
|
||||
"stopSuccess": "Xray đã được dừng thành công",
|
||||
"restartError": "Đã xảy ra lỗi khi khởi động lại Xray.",
|
||||
"stopError": "Đã xảy ra lỗi khi dừng Xray.",
|
||||
@@ -1472,7 +1369,6 @@
|
||||
"generalConfigsDesc": "Những tùy chọn này sẽ cung cấp điều chỉnh tổng quát.",
|
||||
"logConfigs": "Nhật ký",
|
||||
"logConfigsDesc": "Nhật ký có thể ảnh hưởng đến hiệu suất máy chủ của bạn. Bạn chỉ nên kích hoạt nó một cách khôn ngoan trong trường hợp bạn cần",
|
||||
"blockConfigsDesc": "Những tùy chọn này sẽ ngăn người dùng kết nối đến các giao thức và trang web cụ thể.",
|
||||
"basicRouting": "Định tuyến Cơ bản",
|
||||
"blockConnectionsConfigsDesc": "Các tùy chọn này sẽ chặn lưu lượng truy cập dựa trên quốc gia được yêu cầu cụ thể.",
|
||||
"directConnectionsConfigsDesc": "Kết nối trực tiếp đảm bảo rằng lưu lượng truy cập cụ thể không được định tuyến qua máy chủ khác.",
|
||||
@@ -1482,10 +1378,6 @@
|
||||
"directdomains": "Tên Miền Trực Tiếp",
|
||||
"ipv4Routing": "Định tuyến IPv4",
|
||||
"ipv4RoutingDesc": "Những tùy chọn này sẽ chỉ định kết nối đến các tên miền mục tiêu qua IPv4.",
|
||||
"warpRouting": "Định tuyến WARP",
|
||||
"warpRoutingDesc": "Cảnh báo: Trước khi sử dụng những tùy chọn này, hãy cài đặt WARP ở chế độ proxy socks5 trên máy chủ của bạn bằng cách làm theo các bước trên GitHub của bảng điều khiển. WARP sẽ định tuyến lưu lượng đến các trang web qua máy chủ Cloudflare.",
|
||||
"nordRouting": "Định tuyến NordVPN",
|
||||
"nordRoutingDesc": "Các tùy chọn này sẽ định tuyến lưu lượng dựa trên đích cụ thể qua NordVPN.",
|
||||
"Template": "Mẫu Cấu hình Xray",
|
||||
"TemplateDesc": "Tạo tệp cấu hình Xray cuối cùng dựa trên mẫu này.",
|
||||
"FreedomStrategy": "Cấu hình Chiến lược cho Giao thức Freedom",
|
||||
@@ -1499,10 +1391,7 @@
|
||||
"outboundTestUrlDesc": "URL dùng khi kiểm tra kết nối outbound",
|
||||
"Torrent": "Cấu hình sử dụng BitTorrent",
|
||||
"Inbounds": "Inbound",
|
||||
"InboundsDesc": "Thay đổi mẫu cấu hình để chấp nhận các máy khách cụ thể.",
|
||||
"Outbounds": "Outbound",
|
||||
"OutboundSubscriptions": "Đăng ký Outbound",
|
||||
"OutboundSubscriptionsDesc": "Nhập các outbound từ URL đăng ký từ xa (vmess/vless/trojan/ss/...). Tag được giữ ổn định để dùng trong bộ cân bằng tải và quy tắc định tuyến. Cập nhật diễn ra tự động.",
|
||||
"Balancers": "Cân bằng",
|
||||
"balancerTagRequired": "Tag là bắt buộc",
|
||||
"balancerSelectorRequired": "Chọn ít nhất một outbound",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "Outbound phù hợp",
|
||||
"routeTesterViaBalancer": "qua bộ cân bằng tải",
|
||||
"routeTesterDefaultOutbound": "Không có quy tắc định tuyến nào khớp — lưu lượng đến outbound mặc định (đầu tiên).",
|
||||
"OutboundsDesc": "Thay đổi mẫu cấu hình để xác định các cách ra đi cho máy chủ này.",
|
||||
"Routings": "Quy tắc định tuyến",
|
||||
"RoutingsDesc": "Mức độ ưu tiên của mỗi quy tắc đều quan trọng!",
|
||||
"completeTemplate": "Tất cả",
|
||||
"logLevel": "Mức đăng nhập",
|
||||
"logLevelDesc": "Cấp độ nhật ký cho nhật ký lỗi, cho biết thông tin cần được ghi lại.",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "Mặt nạ địa chỉ IP, khi được bật, sẽ tự động thay thế địa chỉ IP xuất hiện trong nhật ký.",
|
||||
"statistics": "Thống kê",
|
||||
"statsInboundUplink": "Thống kê tải lên đầu vào",
|
||||
"statsInboundUplinkDesc": "Kích hoạt thu thập thống kê cho lưu lượng tải lên của tất cả các proxy đầu vào.",
|
||||
"statsInboundDownlink": "Thống kê tải xuống đầu vào",
|
||||
"statsInboundDownlinkDesc": "Kích hoạt thu thập thống kê cho lưu lượng tải xuống của tất cả các proxy đầu vào.",
|
||||
"statsOutboundUplink": "Thống kê tải lên đầu ra",
|
||||
"statsOutboundUplinkDesc": "Kích hoạt thu thập thống kê cho lưu lượng tải lên của tất cả các proxy đầu ra.",
|
||||
"statsOutboundDownlink": "Thống kê tải xuống đầu ra",
|
||||
"statsOutboundDownlinkDesc": "Kích hoạt thu thập thống kê cho lưu lượng tải xuống của tất cả các proxy đầu ra.",
|
||||
"connectionLimits": "Giới hạn kết nối",
|
||||
"connectionLimitsDesc": "Chính sách cấp kết nối cho người dùng cấp 0. Để trống một trường để sử dụng giá trị mặc định của Xray.",
|
||||
"connIdle": "Thời gian chờ nhàn rỗi",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "tự động",
|
||||
"seconds": "giây",
|
||||
"rules": {
|
||||
"first": "Đầu tiên",
|
||||
"last": "Cuối cùng",
|
||||
"up": "Lên",
|
||||
"down": "Xuống",
|
||||
"source": "Nguồn",
|
||||
"dest": "Đích",
|
||||
"inbound": "Vào",
|
||||
"outbound": "Ra",
|
||||
"balancer": "Cân bằng",
|
||||
"info": "Thông tin",
|
||||
"add": "Thêm quy tắc",
|
||||
"edit": "Chỉnh sửa quy tắc",
|
||||
"useComma": "Các mục được phân tách bằng dấu phẩy"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "Quy tắc {n}",
|
||||
"action": "Hành động",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "Quy tắc cuối",
|
||||
"overrideXrayPrivateIp": "Ghi đè chặn IP riêng mặc định của Xray",
|
||||
"blockDelay": "Trễ chặn (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "Khoảng keep alive",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "Giao diện",
|
||||
"ipv6Only": "Chỉ IPv6",
|
||||
"acceptProxyProtocol": "Chấp nhận proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "Thêm thư đi",
|
||||
"addReverse": "Thêm đảo ngược",
|
||||
"editOutbound": "Chỉnh sửa gửi đi",
|
||||
"editReverse": "Chỉnh sửa ngược lại",
|
||||
"reverseTag": "Thẻ Ngược",
|
||||
"reverseTagDesc": "Thẻ outbound của proxy ngược đơn giản VLESS. Để trống để vô hiệu hóa.",
|
||||
"reverseTagPlaceholder": "thẻ outbound (để trống để vô hiệu hóa)",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "thẻ duy nhất",
|
||||
"address": "Địa chỉ",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Đảo ngược",
|
||||
"domain": "Tên miền",
|
||||
"type": "Loại",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "Liên kết",
|
||||
"intercon": "Kết nối",
|
||||
"settings": "cài đặt",
|
||||
"accountInfo": "Thông tin tài khoản",
|
||||
"outboundStatus": "Trạng thái đầu ra",
|
||||
"sendThrough": "Gửi qua",
|
||||
"targetStrategy": "Chiến lược đích",
|
||||
"test": "Kiểm tra",
|
||||
"testResult": "Kết quả kiểm tra",
|
||||
"testing": "Đang kiểm tra kết nối...",
|
||||
"testSuccess": "Kiểm tra thành công",
|
||||
"testFailed": "Kiểm tra thất bại",
|
||||
"testError": "Không thể kiểm tra đầu ra",
|
||||
"modeRealDelay": "Độ trễ thực",
|
||||
"testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray. Độ trễ thực: tổng thời gian gồm cả thiết lập kết nối.",
|
||||
"testAll": "Kiểm tra tất cả",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "Kết nối proxy",
|
||||
"breakdownTls": "TLS qua outbound",
|
||||
"breakdownTtfb": "Byte đầu tiên",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "Mã truy cập",
|
||||
"country": "Quốc gia",
|
||||
"server": "Máy chủ",
|
||||
"city": "Thành phố",
|
||||
"allCities": "Tất cả thành phố",
|
||||
"privateKey": "Khóa riêng",
|
||||
"load": "Tải",
|
||||
"moveToTop": "Chuyển lên đầu"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "Đăng ký đang hoạt động",
|
||||
"empty": "Chưa có đăng ký nào. Hãy thêm một mục ở trên.",
|
||||
"colRemark": "Ghi chú",
|
||||
"colPrefix": "Tiền tố",
|
||||
"colInterval": "Khoảng",
|
||||
"colLastFetch": "Lần tải gần nhất",
|
||||
"colEnabled": "Đã kích hoạt",
|
||||
"auto": "tự động",
|
||||
"never": "không bao giờ",
|
||||
"yes": "Có",
|
||||
"no": "Không",
|
||||
"refreshNow": "Cập nhật ngay",
|
||||
"lastError": "Lỗi gần nhất",
|
||||
"deleteConfirm": "Xóa đăng ký này?",
|
||||
"restartHint": "Sau khi thêm hoặc cập nhật, hãy khởi động lại Xray (hoặc chờ lần tự động tải lại tiếp theo) để kích hoạt các outbound.",
|
||||
"fromSubsTitle": "Từ đăng ký outbound (chỉ đọc)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "Cài đặt Balancer",
|
||||
"tabObservatory": "Observatory",
|
||||
"observatory": {
|
||||
"title": "Observatory",
|
||||
"burstTitle": "Burst Observatory",
|
||||
"autoManaged": "Observer được quản lý tự động từ các balancer của bạn. Điều chỉnh cách chúng dò ở bên dưới; các outbound được theo dõi sẽ tuân theo selector của balancer.",
|
||||
"emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để các balancer dùng observer có thể kiểm tra sức khỏe outbound trước khi chọn mục tiêu.",
|
||||
"mixedLegacy": "Cấu hình này có cả Observatory và Burst Observatory. Xray chỉ dùng một observer toàn cục, nên trạng thái hỗn hợp cũ này không được hỗ trợ; khi lưu balancer, nó sẽ được chuẩn hóa về một observer.",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "Balancer {tag} — đã xóa (không còn mục tiêu)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "Thêm cân bằng",
|
||||
"editBalancer": "Chỉnh sửa cân bằng",
|
||||
"balancerStrategy": "Chiến lược",
|
||||
"balancerSelectors": "Bộ chọn",
|
||||
"tag": "Tag",
|
||||
"tagDesc": "thẻ duy nhất",
|
||||
"tagDuplicate": "Tag đã được dùng bởi balancer khác",
|
||||
"tagPlaceholder": "tag balancer duy nhất",
|
||||
"selector": "Selector",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "Dung sai",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động.",
|
||||
"costMatch": "Mẫu thẻ",
|
||||
"costValue": "Trọng số",
|
||||
"costRegexp": "Khớp biểu thức chính quy",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "Khóa công khai",
|
||||
"allowedIPs": "IP được phép",
|
||||
"endpoint": "Điểm cuối",
|
||||
"psk": "Khóa chia sẻ",
|
||||
"domainStrategy": "Chiến lược tên miền"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "Chỉ dành cho AmneziaWG 2.0. Để trống để tương thích với 1.x."
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "Tên của giao diện TUN. Giá trị mặc định là 'xray0'",
|
||||
"mtuDesc": "Đơn vị Truyền Tối đa. Kích thước tối đa của các gói dữ liệu. Giá trị mặc định là 1500",
|
||||
"userLevel": "Mức Người Dùng",
|
||||
"userLevelDesc": "Tất cả các kết nối được thực hiện thông qua inbound này sẽ sử dụng mức người dùng này. Giá trị mặc định là 0"
|
||||
"userLevel": "Mức Người Dùng"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "Thêm DNS giả",
|
||||
"edit": "Chỉnh sửa DNS giả",
|
||||
"ipPool": "Mạng con nhóm IP",
|
||||
"poolSize": "Kích thước bể bơi"
|
||||
},
|
||||
@@ -2067,7 +1894,6 @@
|
||||
"add": "Thêm",
|
||||
"month": "Tháng",
|
||||
"months": "Tháng",
|
||||
"day": "Ngày",
|
||||
"days": "Ngày",
|
||||
"hours": "Giờ",
|
||||
"minutes": "Phút",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ Người dùng Telegram đã được lưu.",
|
||||
"loginSuccess": "✅ Đăng nhập thành công vào bảng điều khiển.\r\n",
|
||||
"loginFailed": "❗️ Đăng nhập vào bảng điều khiển thất bại.\r\n",
|
||||
"2faFailed": "Lỗi 2FA",
|
||||
"report": "🕰 Báo cáo định kỳ: {{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ Ngày-Giờ: {{ .DateTime }}\r\n",
|
||||
"hostname": "💻 Host: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 Tải xuống: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 Tổng: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 Người dùng Telegram: {{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 Sự cạn kiệt {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 Số lần cạn kiệt {{ .Type }}:\r\n",
|
||||
"onlinesCount": "🌐 Khách hàng trực tuyến: {{ .Count }}\r\n",
|
||||
"disabled": "🛑 Vô hiệu hóa: {{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 Đã cập nhật lần cuối vào: {{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ Có",
|
||||
"no": "❌ Không",
|
||||
"received_id": "🔑📥 ID đã được cập nhật.",
|
||||
"received_password": "🔑📥 Mật khẩu đã được cập nhật.",
|
||||
"received_email": "📧📥 Email đã được cập nhật.",
|
||||
"received_comment": "💬📥 Bình luận đã được cập nhật.",
|
||||
"id_prompt": "🔑 ID mặc định: {{ .ClientId }}\n\nVui lòng nhập ID của bạn.",
|
||||
"pass_prompt": "🔑 Mật khẩu mặc định: {{ .ClientPassword }}\n\nVui lòng nhập mật khẩu của bạn.",
|
||||
"email_prompt": "📧 Email mặc định: {{ .ClientEmail }}\n\nVui lòng nhập email của bạn.",
|
||||
"comment_prompt": "💬 Bình luận mặc định: {{ .ClientComment }}\n\nVui lòng nhập bình luận của bạn.",
|
||||
"inbound_client_data_id": "🔄 Kết nối vào: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Dung lượng: {{ .ClientTraffic }}\n📅 Ngày hết hạn: {{ .ClientExp }}\n🌐 Giới hạn IP: {{ .IpLimit }}\n💬 Ghi chú: {{ .ClientComment }}\n\nBây giờ bạn có thể thêm khách hàng vào inbound!",
|
||||
"inbound_client_data_pass": "🔄 Kết nối vào: {{ .InboundRemark }}\n\n🔑 Mật khẩu: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Dung lượng: {{ .ClientTraffic }}\n📅 Ngày hết hạn: {{ .ClientExp }}\n🌐 Giới hạn IP: {{ .IpLimit }}\n💬 Ghi chú: {{ .ClientComment }}\n\nBây giờ bạn có thể thêm khách hàng vào inbound!",
|
||||
"cancel": "❌ Quá trình đã bị hủy! \n\nBạn có thể bắt đầu lại bất cứ lúc nào bằng cách nhập /start. 🔄",
|
||||
"error_add_client": "⚠️ Lỗi:\n\n {{ .error }}",
|
||||
"using_default_value": "Được rồi, tôi sẽ sử dụng giá trị mặc định. 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "Lỗi: {{ .Error }}",
|
||||
"eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG",
|
||||
"eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG",
|
||||
"eventCPUHigh": "CPU cao",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}",
|
||||
"memoryThreshold": "Sử dụng bộ nhớ {{ .Percent }}% vượt quá ngưỡng {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "Gửi Dưới Dạng Vô Hiệu ☑️",
|
||||
"submitEnable": "Gửi Dưới Dạng Kích Hoạt ✅",
|
||||
"use_default": "🏷️ Sử Dụng Mặc Định",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 Mật Khẩu",
|
||||
"change_email": "⚙️📧 Email",
|
||||
"change_comment": "⚙️💬 Bình Luận",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "Đặt lại tất cả lưu lượng",
|
||||
"SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
|
||||
"subjectOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
|
||||
"subjectXrayCrash": "Xray GẶP SỰ CỐ",
|
||||
"subjectCPUHigh": "CPU cao",
|
||||
"subjectLoginSuccess": "Đăng nhập thành công",
|
||||
"subjectLoginFailed": "Đăng nhập thất bại",
|
||||
"titleOutboundDown": "Outbound NGỪNG HOẠT ĐỘNG",
|
||||
"titleOutboundUp": "Outbound HOẠT ĐỘNG",
|
||||
"titleXrayCrash": "Xray GẶP SỰ CỐ",
|
||||
"titleCPUHigh": "CPU cao",
|
||||
"titleLoginSuccess": "Đăng nhập thành công",
|
||||
"titleLoginFailed": "Đăng nhập thất bại",
|
||||
"labelStatus": "Trạng thái",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelNode": "Node",
|
||||
"labelError": "Lỗi",
|
||||
"labelDelay": "Độ trễ",
|
||||
"labelDetail": "Chi tiết",
|
||||
"labelUsername": "Tên đăng nhập",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Lý do",
|
||||
"labelSource": "Nguồn",
|
||||
"labelTime": "Thời gian",
|
||||
"statusCrashed": "GẶP SỰ CỐ",
|
||||
"statusRunning": "Đang chạy",
|
||||
"statusHigh": "CAO",
|
||||
"statusSuccess": "THÀNH CÔNG",
|
||||
"statusFailed": "THẤT BẠI",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "复制成功",
|
||||
"sure": "确定",
|
||||
"encryption": "加密",
|
||||
"useIPv4ForHost": "使用 IPv4 连接主机",
|
||||
"transmission": "传输",
|
||||
"host": "主机",
|
||||
"path": "路径",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "代码",
|
||||
"remained": "剩余",
|
||||
"security": "安全",
|
||||
"secAlertTitle": "安全警报",
|
||||
"secAlertSsl": "此连接不安全。在激活 TLS 进行数据保护之前,请勿输入敏感信息。",
|
||||
"secAlertConf": "某些设置易受攻击。建议加强安全协议以防止潜在漏洞。",
|
||||
"secAlertSSL": "面板缺少安全连接。请安装 TLS 证书以保护数据安全。",
|
||||
"secAlertPanelPort": "面板默认端口存在安全风险。请配置随机端口或特定端口。",
|
||||
"secAlertPanelURI": "面板默认 URI 路径不安全。请配置复杂的 URI 路径。",
|
||||
"secAlertSubURI": "订阅默认 URI 路径不安全。请配置复杂的 URI 路径。",
|
||||
"secAlertSubJsonURI": "订阅 JSON 默认 URI 路径不安全。请配置复杂的 URI 路径。",
|
||||
"emptyDnsDesc": "未添加DNS服务器。",
|
||||
"emptyFakeDnsDesc": "未添加Fake DNS服务器。",
|
||||
"emptyDnsDesc": "未添加 DNS 服务器。",
|
||||
"emptyFakeDnsDesc": "未添加 Fake DNS 服务器。",
|
||||
"emptyBalancersDesc": "未添加负载均衡器。",
|
||||
"emptyReverseDesc": "未添加反向代理。",
|
||||
"somethingWentWrong": "出了点问题",
|
||||
"subscription": {
|
||||
"title": "订阅信息",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "主题",
|
||||
"dark": "暗色",
|
||||
"ultraDark": "超暗色",
|
||||
"dashboard": "系统状态",
|
||||
"inbounds": "入站",
|
||||
"clients": "客户端",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "路由",
|
||||
"outbounds": "出站",
|
||||
"apiDocs": "API 文档",
|
||||
"logout": "退出登录",
|
||||
"link": "管理",
|
||||
"donate": "捐赠",
|
||||
"hosts": "主机",
|
||||
"docs": "文档",
|
||||
"openMenu": "打开菜单"
|
||||
"openMenu": "打开菜单",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "系统状态",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "逻辑处理器",
|
||||
"frequency": "频率",
|
||||
"swap": "交换空间",
|
||||
"storage": "存储",
|
||||
"memory": "内存",
|
||||
"threads": "线程",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "停止",
|
||||
"restartXray": "重启",
|
||||
"xraySwitch": "版本",
|
||||
"xrayUpdates": "Xray 更新",
|
||||
"xraySwitchClick": "选择你要切换到的版本",
|
||||
"xraySwitchClickDesk": "请谨慎选择,因为较旧版本可能与当前配置不兼容",
|
||||
"updatePanel": "更新面板",
|
||||
"panelUpdateDesc": "这将把 3X-UI 更新到最新版本并重启面板服务。",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "当前提交",
|
||||
"latestCommit": "最新提交",
|
||||
"updateChannelChanged": "更新通道已切换",
|
||||
"upToDate": "已是最新",
|
||||
"xrayStatusUnknown": "未知",
|
||||
"xrayStatusRunning": "运行中",
|
||||
"xrayStatusStop": "停止",
|
||||
"xrayStatusError": "错误",
|
||||
"xrayErrorPopoverTitle": "运行Xray时发生错误",
|
||||
"operationHours": "系统正常运行时间",
|
||||
"systemHistoryTitle": "系统历史",
|
||||
"historyTitleCpu": "CPU 使用率",
|
||||
"historyTitleMem": "内存使用率",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "负载",
|
||||
"historyTabConnections": "连接数",
|
||||
"historyTabDiskUsage": "磁盘使用量",
|
||||
"charts": "图表",
|
||||
"xrayMetricsTitle": "Xray 指标",
|
||||
"xrayTitleHeap": "已分配的堆内存",
|
||||
"xrayTitleSys": "向操作系统保留的内存",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "离线",
|
||||
"xrayObservatoryLastSeen": "最后在线",
|
||||
"xrayObservatoryLastTry": "最后尝试",
|
||||
"trendLast2Min": "最近 2 分钟",
|
||||
"systemLoad": "系统负载",
|
||||
"systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载",
|
||||
"connectionCount": "连接数",
|
||||
"ipAddresses": "IP地址",
|
||||
"toggleIpVisibility": "切换IP可见性",
|
||||
"ipAddresses": "IP 地址",
|
||||
"toggleIpVisibility": "切换 IP 可见性",
|
||||
"overallSpeed": "整体速度",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"totalData": "总数据",
|
||||
"sent": "已发送",
|
||||
"received": "已接收",
|
||||
"documentation": "文档",
|
||||
"xraySwitchVersionDialog": "您确定要更改Xray版本吗?",
|
||||
"xraySwitchVersionDialogDesc": "这将把Xray版本更改为#version#。",
|
||||
"xraySwitchVersionDialog": "您确定要更改 Xray 版本吗?",
|
||||
"xraySwitchVersionDialogDesc": "这将把 Xray 版本更改为 #version#。",
|
||||
"xraySwitchVersionPopover": "Xray 更新成功",
|
||||
"panelUpdateDialog": "您确定要更新面板吗?",
|
||||
"panelUpdateDialogDesc": "这将把 3X-UI 更新到 #version# 并重启面板服务。",
|
||||
"panelUpdateCheckPopover": "面板更新检查失败",
|
||||
"panelUpdateStartedPopover": "已开始更新面板",
|
||||
"panelUpdateFailedTitle": "面板更新失败",
|
||||
"panelUpdateFailedDesc": "更新未成功完成。请检查服务器日志,或在命令行运行「x-ui update」。",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "访问日志",
|
||||
"autoUpdate": "自动更新",
|
||||
"config": "配置",
|
||||
"backup": "备份",
|
||||
"backupTitle": "备份和恢复",
|
||||
"exportDatabase": "备份",
|
||||
"exportDatabaseDesc": "点击下载包含当前数据库备份的 .db 文件到您的设备。同一文件也可以恢复到运行 PostgreSQL 的面板中。",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump)下载到您的设备。",
|
||||
"importDatabasePgDesc": "点击选择并上传 PostgreSQL 备份(.dump)、SQLite 数据库(.db)或 SQLite 迁移导出文件以恢复您的数据库。此操作将替换所有当前数据。",
|
||||
"migrationDownload": "下载迁移文件",
|
||||
"migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。"
|
||||
"migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。",
|
||||
"avg": "均值",
|
||||
"peak": "峰值",
|
||||
"free": "剩余",
|
||||
"openSockets": "打开的套接字",
|
||||
"throughputSub": "网卡总计",
|
||||
"avgWindow": "窗口均值",
|
||||
"healthWarm": "{list} — 偏高",
|
||||
"healthCritical": "{list} — 危险",
|
||||
"panel": "面板",
|
||||
"threads": "线程",
|
||||
"uptime": "运行时间"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "入站",
|
||||
"totalDownUp": "总上传 / 下载",
|
||||
"totalUsage": "总用量",
|
||||
"inboundCount": "入站数量",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "本地面板",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "当此入站的连接未匹配任何客户端时,将其路由到其他位置。在下方选择一个子入站,可从其传输方式自动填充路由字段(SNI / ALPN / Path / xver);或将选择框留空并直接设置 Dest(例如 8080 或 127.0.0.1:8080),以路由到 Nginx 等外部服务器。每个子入站应监听 127.0.0.1,security=none。",
|
||||
"empty": "暂无回落",
|
||||
"add": "添加回落",
|
||||
"pickInbound": "选择一个入站",
|
||||
"matchAny": "任意",
|
||||
"destPlaceholder": "自动(子入站 listen:port)",
|
||||
"rederive": "从子入站重新填充",
|
||||
"rederived": "已从子入站重新填充",
|
||||
"editAdvanced": "编辑路由字段",
|
||||
"hideAdvanced": "隐藏高级",
|
||||
"quickAddAll": "一键添加所有可用入站",
|
||||
"quickAdded": "已添加 {n} 条回落",
|
||||
"quickAddedNone": "没有可添加的新入站",
|
||||
"routesWhen": "当满足条件时路由",
|
||||
"defaultCatchAll": "默认 — 兜底匹配其他所有",
|
||||
"needsTls": "在“安全”标签页选择 TLS 或 Reality 后即可配置回落(仅限 RAW 上的 VLESS/Trojan)。"
|
||||
},
|
||||
"protocol": "协议",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "端口映射",
|
||||
"traffic": "流量",
|
||||
"speed": "速度",
|
||||
"details": "详细信息",
|
||||
"transportConfig": "传输",
|
||||
"expireDate": "到期时间",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "添加入站",
|
||||
"generalActions": "通用操作",
|
||||
"modifyInbound": "修改入站",
|
||||
"deleteInbound": "删除入站",
|
||||
"deleteInboundContent": "确定要删除入站吗?",
|
||||
"deleteConfirmTitle": "删除入站 \"{remark}\"?",
|
||||
"deleteConfirmContent": "将删除此入站及其所有客户端。该操作不可撤销。",
|
||||
"resetConfirmTitle": "重置 \"{remark}\" 的流量?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "所有入站",
|
||||
"exportAllSubsFileName": "所有入站-Subs",
|
||||
"inboundJsonTitle": "入站 JSON",
|
||||
"deleteClient": "删除客户端",
|
||||
"deleteClientContent": "确定要删除客户端吗?",
|
||||
"resetTrafficContent": "确定要重置流量吗?",
|
||||
"copyLink": "复制链接",
|
||||
"address": "地址",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= 无限制。(单位: GB)",
|
||||
"totalFlow": "总流量",
|
||||
"leaveBlankToNeverExpire": "留空表示永不过期",
|
||||
"noRecommendKeepDefault": "建议保留默认值",
|
||||
"certificatePath": "文件路径",
|
||||
"certificateContent": "文件内容",
|
||||
"publicKey": "公钥",
|
||||
"privatekey": "私钥",
|
||||
"clickOnQRcode": "点击二维码复制",
|
||||
"client": "客户",
|
||||
"export": "导出链接",
|
||||
"clone": "克隆",
|
||||
"cloneInbound": "克隆",
|
||||
"cloneInboundContent": "此入站规则除端口(Port)、监听 IP(Listening IP)和客户端(Clients)以外的所有配置都将应用于克隆",
|
||||
"cloneInboundOk": "创建克隆",
|
||||
"resetAllTraffic": "重置所有入站流量",
|
||||
"resetAllTrafficTitle": "重置所有入站流量",
|
||||
"resetAllTrafficContent": "确定要重置所有入站流量吗?",
|
||||
"resetInboundClientTraffics": "重置客户端流量",
|
||||
"resetInboundClientTrafficTitle": "重置所有客户端流量",
|
||||
"resetInboundClientTrafficContent": "确定要重置此入站客户端的所有流量吗?",
|
||||
"resetAllClientTraffics": "重置所有客户端流量",
|
||||
"resetAllClientTrafficTitle": "重置所有客户端流量",
|
||||
"resetAllClientTrafficContent": "确定要重置所有客户端的所有流量吗?",
|
||||
"delDepletedClients": "删除流量耗尽的客户端",
|
||||
"delDepletedClientsTitle": "删除流量耗尽的客户端",
|
||||
"delDepletedClientsContent": "确定要删除所有流量耗尽的客户端吗?",
|
||||
"email": "邮箱",
|
||||
"emailDesc": "电子邮件必须完全唯一",
|
||||
"IPLimit": "IP 限制",
|
||||
"IPLimitDesc": "如果数量超过设置值,则禁用入站流量。(0 = 禁用)",
|
||||
"IPLimitlog": "IP 日志",
|
||||
"IPLimitlogDesc": "IP 历史日志(要启用被禁用的入站流量,请清除日志)",
|
||||
"IPLimitlogclear": "清除日志",
|
||||
"setDefaultCert": "从面板设置证书",
|
||||
"setDefaultCertEmpty": "面板尚未配置证书。请先在“设置”中设置。",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Xray sniffing 块包装:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Xray stream 块包装:",
|
||||
"jsonErrorPrefix": "高级 JSON"
|
||||
"streamHelp": "Xray stream 块包装:"
|
||||
},
|
||||
"telegramDesc": "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"subscriptionDesc": "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。",
|
||||
"subSortIndex": "订阅排序",
|
||||
"same": "相同",
|
||||
"inboundInfo": "入站信息",
|
||||
"exportInbound": "导出入站规则",
|
||||
"import": "导入",
|
||||
"importInbound": "导入入站规则",
|
||||
"periodicTrafficResetTitle": "流量重置",
|
||||
"periodicTrafficResetDesc": "按指定间隔自动重置流量计数器",
|
||||
"lastReset": "上次重置",
|
||||
"periodicTrafficResetDay": "每月重置日",
|
||||
"periodicTrafficReset": {
|
||||
"never": "从不",
|
||||
"daily": "每日",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "获取",
|
||||
"updateSuccess": "更新成功",
|
||||
"logCleanSuccess": "日志已清除",
|
||||
"inboundsUpdateSuccess": "入站连接已成功更新",
|
||||
"inboundUpdateSuccess": "入站连接已成功更新",
|
||||
"inboundCreateSuccess": "入站连接已成功创建",
|
||||
"bulkDeleted": "已删除 {count} 个入站",
|
||||
@@ -473,15 +416,14 @@
|
||||
"inboundClientDeleteSuccess": "入站客户端已删除",
|
||||
"inboundClientUpdateSuccess": "入站客户端已更新",
|
||||
"savedNodeOfflineWillSync": "已在本地保存。某个支撑节点离线或已禁用——重新连接后将同步此更改。",
|
||||
"delDepletedClientsSuccess": "所有耗尽客户端已删除",
|
||||
"resetAllClientTrafficSuccess": "客户端所有流量已重置",
|
||||
"resetAllTrafficSuccess": "所有流量已重置",
|
||||
"resetInboundClientTrafficSuccess": "流量已重置",
|
||||
"resetInboundTrafficSuccess": "入站流量已重置",
|
||||
"trafficGetError": "获取流量数据时出错",
|
||||
"getNewX25519CertError": "获取X25519证书时出错。",
|
||||
"getNewmldsa65Error": "获取mldsa65证书时出错。",
|
||||
"getNewVlessEncError": "获取VlessEnc证书时出错。",
|
||||
"getNewX25519CertError": "获取 X25519 证书时出错。",
|
||||
"getNewmldsa65Error": "获取 mldsa65 证书时出错。",
|
||||
"getNewVlessEncError": "获取 VlessEnc 证书时出错。",
|
||||
"scanRealityTargetError": "扫描 REALITY 目标失败。",
|
||||
"scanRealityTargetFeasible": "目标可用 — 已填入目标和 SNI。",
|
||||
"scanRealityTargetNotFeasible": "目标可达,但不适用于 REALITY。",
|
||||
@@ -656,6 +598,10 @@
|
||||
"maxTimeDiff": "最大时间差 (ms)",
|
||||
"minClientVer": "最小客户端版本",
|
||||
"maxClientVer": "最大客户端版本",
|
||||
"minClientVerHint": "留空不等于不限制:Xray-core 会改用所运行内核版本的内置最低值(当前版本为 26.3.27),拒绝自报版本更低的客户端——包括 Mihomo、sing-box 等第三方内核。填 1.0.0 可放行它们,代价是允许过时的 TLS 指纹。",
|
||||
"maxClientVerHint": "留空表示无上限。若填写,不得低于实际生效的下限——最小客户端版本,该字段留空时则为 Xray-core 的内置最低值——否则所有客户端都会被拒绝。",
|
||||
"clientVerInvalid": "客户端版本须为最多三段以点分隔的数字,每段 0-255(例如 26.3.27)",
|
||||
"maxClientVerBelowMin": "最大客户端版本不得低于最小客户端版本",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "必填。必须包含端口(例如 example.com:443)。没有端口时 Xray-core 将无法启动。",
|
||||
"realityTargetRequired": "REALITY 目标为必填项",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Peer {n} 配置"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "请求",
|
||||
"response": "响应",
|
||||
"name": "名称",
|
||||
"value": "值"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "版本",
|
||||
"method": "方法",
|
||||
"path": "路径",
|
||||
"status": "状态",
|
||||
"statusDescription": "状态说明",
|
||||
"requestHeader": "请求头",
|
||||
"responseHeader": "响应头"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "目标覆盖"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "添加外部订阅",
|
||||
"noExternalLinks": "暂无外部链接。",
|
||||
"noExternalSubscriptions": "暂无外部订阅。",
|
||||
"add": "添加客户端",
|
||||
"edit": "编辑客户端",
|
||||
"submitAdd": "添加客户端",
|
||||
"submitEdit": "保存更改",
|
||||
"clientCount": "客户端数量",
|
||||
"bulk": "批量添加",
|
||||
"copyFromInbound": "从入站复制客户端",
|
||||
"copyToInbound": "复制客户端到",
|
||||
"copySelected": "复制所选",
|
||||
"copySource": "来源",
|
||||
"copyEmailPreview": "生成的邮箱预览",
|
||||
"copySelectSourceFirst": "请先选择一个来源入站。",
|
||||
"copyResult": "复制结果",
|
||||
"copyResultSuccess": "复制成功",
|
||||
"copyResultNone": "没有内容可复制:未选中客户端或来源为空",
|
||||
"copyResultErrors": "复制错误",
|
||||
"copyFlowLabel": "新客户端的 Flow (VLESS)",
|
||||
"copyFlowHint": "应用于所有被复制的客户端。留空则跳过。",
|
||||
"selectAll": "全选",
|
||||
"clearAll": "全部清除",
|
||||
"method": "方式",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "后缀",
|
||||
"delayedStart": "首次使用后开始",
|
||||
"expireDays": "时长 (天)",
|
||||
"days": "天",
|
||||
"renew": "自动续期",
|
||||
"renewDesc": "到期后自动续期。(0 = 禁用) (单位: 天)",
|
||||
"renewDays": "自动续期 (天)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "即将过期",
|
||||
"has": "拥有",
|
||||
"hasNot": "不拥有",
|
||||
"title": "客户端",
|
||||
"actions": "操作",
|
||||
"totalGB": "流量上限 (GB)",
|
||||
"totalGBDesc": "该客户端的流量配额。0 = 不限制。",
|
||||
@@ -810,7 +722,7 @@
|
||||
"online": "在线",
|
||||
"email": "邮箱",
|
||||
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
||||
"subIdInvalidChars": "订阅ID不能包含空格、'/'、'\\' 或控制字符",
|
||||
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
||||
"group": "分组",
|
||||
"groupDesc": "用于对相关客户端进行分桶的逻辑标签(如团队、客户、地区)。可从工具栏筛选。",
|
||||
"groupPlaceholder": "如 customer-a",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "添加客户端",
|
||||
"qrCode": "二维码",
|
||||
"clientInfo": "客户端信息",
|
||||
"delete": "删除",
|
||||
"reset": "重置流量",
|
||||
"editClient": "编辑客户端",
|
||||
"client": "客户端",
|
||||
"enabled": "已启用",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。",
|
||||
"link": "链接",
|
||||
"resetNotPossible": "请先将此客户端关联到入站。",
|
||||
"general": "常规",
|
||||
"resetAllTraffics": "重置所有客户端流量",
|
||||
"resetAllTrafficsTitle": "重置所有客户端流量?",
|
||||
"resetAllTrafficsContent": "所有客户端的上下行计数器将归零。配额与过期时间不受影响。该操作不可撤销。",
|
||||
"deleteConfirmTitle": "删除客户端 {email}?",
|
||||
"deleteConfirmContent": "将从所有关联入站中移除该客户端并删除其流量记录。该操作不可撤销。",
|
||||
"deleteSelected": "删除 ({count})",
|
||||
"adjustSelected": "调整 ({count})",
|
||||
"subLinksSelected": "订阅链接 ({count})",
|
||||
"addToGroupTitle": "将 {count} 个客户端添加到分组",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "禁用 {count} 个客户端?",
|
||||
"bulkDisableConfirmContent": "在每个已附加的入站上禁用所选的客户端。他们将立即失去访问权限,但其记录和流量将被保留。",
|
||||
"selectedCount": "已选 {count} 项",
|
||||
"attachSelected": "附加 ({count})",
|
||||
"attachToInboundsTitle": "将 {count} 个客户端附加到入站",
|
||||
"attachToInboundsDesc": "将选中的 {count} 个客户端(相同 UUID/密码和共享流量)附加到选定的入站。它们保留现有的附加关系。",
|
||||
"attachToInboundsTargets": "目标入站",
|
||||
"attachToInboundsNoTargets": "没有可用于附加的多用户入站。",
|
||||
"detachSelected": "分离 ({count})",
|
||||
"detach": "分离",
|
||||
"detachFromInboundsTitle": "从入站分离 {count} 个客户端",
|
||||
"detachFromInboundsDesc": "从选定的入站中移除选中的 {count} 个客户端。客户端未附加的配对将被静默跳过。客户端记录保留(使用 Delete 完全移除)。",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "可选 Reverse tag",
|
||||
"telegramId": "Telegram 用户 ID",
|
||||
"telegramIdPlaceholder": "数字形式的 Telegram 用户 ID (0 = 无)",
|
||||
"created": "创建时间",
|
||||
"updated": "更新时间",
|
||||
"ipLimit": "IP 限制",
|
||||
"toasts": {
|
||||
"deleted": "客户端已删除",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组",
|
||||
"name": "名称",
|
||||
"clientCount": "客户端",
|
||||
"totalGroups": "分组总数",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "已从 {name} 移除 {count} 个客户端。"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "节点",
|
||||
"addNode": "添加节点",
|
||||
"editNode": "编辑节点",
|
||||
"totalNodes": "节点总数",
|
||||
@@ -1016,10 +918,8 @@
|
||||
"apiTokenPlaceholder": "远程面板设置页中的令牌",
|
||||
"apiTokenHint": "远程面板在 安全设定 → API 令牌 中显示其 API 令牌。",
|
||||
"apiTokenKeepHint": "留空以保留当前令牌",
|
||||
"regenerate": "重新生成令牌",
|
||||
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
|
||||
"allowPrivateAddress": "允许私有地址",
|
||||
"allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
|
||||
"allowPrivateAddressHint": "仅对私有网络或 VPN 上的节点启用。",
|
||||
"outboundTag": "连接出站",
|
||||
"outboundTagHint": "通过选定的 Xray 出站路由此节点的面板 API 流量。系统会自动将回环桥接入站添加到运行配置并实时应用。留空表示直接连接。",
|
||||
"outboundTagPlaceholder": "直接连接",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "更新面板",
|
||||
"updateSelected": "更新所选 ({count})",
|
||||
"updateAvailable": "有可用更新",
|
||||
"upToDate": "已是最新",
|
||||
"updateConfirmTitle": "将 {count} 个节点更新到最新版本?",
|
||||
"updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。",
|
||||
"updateDevChannel": "更新到开发通道(最新提交)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "无法获取证书"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "默认",
|
||||
"title": "面板设置",
|
||||
"save": "保存",
|
||||
"infoDesc": "此处的所有更改都需要保存并重启面板才能生效",
|
||||
@@ -1214,7 +1114,7 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
|
||||
"subClashUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上识别 Clash/Mihomo 客户端的 Go RE2 正则表达式。留空则使用默认规则。更改后请重启面板。",
|
||||
"subTitle": "订阅标题",
|
||||
"subTitleDesc": "在VPN客户端中显示的标题",
|
||||
"subTitleDesc": "在 VPN 客户端中显示的标题",
|
||||
"subSupportUrl": "支持链接",
|
||||
"subSupportUrlDesc": "VPN 客户端中显示的技术支持链接",
|
||||
"subProfileUrl": "个人资料链接",
|
||||
@@ -1323,7 +1223,7 @@
|
||||
"muxDesc": "在已建立的数据流内传输多个独立的数据流",
|
||||
"muxSett": "复用器设置",
|
||||
"direct": "直接连接",
|
||||
"directDesc": "直接与特定国家的域或IP范围建立连接",
|
||||
"directDesc": "直接与特定国家的域或 IP 范围建立连接",
|
||||
"notifications": "通知",
|
||||
"certs": "证书",
|
||||
"externalTraffic": "外部流量",
|
||||
@@ -1337,12 +1237,12 @@
|
||||
"security": {
|
||||
"admin": "管理员凭据",
|
||||
"twoFactor": "双重验证",
|
||||
"twoFactorEnable": "启用2FA",
|
||||
"twoFactorEnable": "启用 2FA",
|
||||
"twoFactorEnableDesc": "增加额外的验证层以提高安全性。",
|
||||
"twoFactorModalSetTitle": "启用双重认证",
|
||||
"twoFactorModalDeleteTitle": "停用双重认证",
|
||||
"twoFactorModalSteps": "要设定双重认证,请执行以下步骤:",
|
||||
"twoFactorModalFirstStep": "1. 在认证应用程序中扫描此QR码,或复制QR码附近的令牌并粘贴到应用程序中",
|
||||
"twoFactorModalFirstStep": "1. 在认证应用程序中扫描此 QR 码,或复制 QR 码附近的令牌并粘贴到应用程序中",
|
||||
"twoFactorModalSecondStep": "2. 输入应用程序中的验证码",
|
||||
"twoFactorModalRemoveStep": "输入应用程序中的验证码以移除双重认证。",
|
||||
"twoFactorModalChangeCredentialsTitle": "更改凭据",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "内存使用率高 (%)",
|
||||
"remarkTemplate": "备注模板",
|
||||
"remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
|
||||
"subShowIdentityOnAllLinks": "在每个链接上显示身份",
|
||||
"subShowIdentityOnAllLinksDesc": "启用后,{{EMAIL}} 和 {{USERNAME}} 会保留在每条订阅正文备注中。用量相关变量仍仅出现在第一条链接。",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "路径必须以 / 开头"
|
||||
},
|
||||
@@ -1456,36 +1358,26 @@
|
||||
"metricsListen": "指标端点",
|
||||
"metricsListenDesc": "在此 address:port 上暴露 Xray 的 Prometheus 风格指标(例如 127.0.0.1:11111)。留空则禁用。请绑定到本地回环并通过反向代理转发——它没有身份验证。",
|
||||
"metricsTag": "指标标签",
|
||||
"title": "Xray 配置",
|
||||
"save": "保存",
|
||||
"restart": "重启 Xray",
|
||||
"restartSuccess": "Xray 已成功重新启动",
|
||||
"restartOutputTitle": "Xray 重启输出",
|
||||
"restartConfirmTitle": "重启 xray?",
|
||||
"restartConfirmContent": "使用已保存的配置重新加载 xray 服务。",
|
||||
"stopSuccess": "Xray 已成功停止",
|
||||
"restartError": "重启Xray时发生错误。",
|
||||
"stopError": "停止Xray时发生错误。",
|
||||
"restartError": "重启 Xray 时发生错误。",
|
||||
"stopError": "停止 Xray 时发生错误。",
|
||||
"basicTemplate": "基础配置",
|
||||
"advancedTemplate": "高级配置",
|
||||
"generalConfigs": "常规配置",
|
||||
"generalConfigsDesc": "这些选项将决定常规配置",
|
||||
"logConfigs": "日志",
|
||||
"logConfigsDesc": "日志可能会影响服务器的性能,建议仅在需要时启用",
|
||||
"blockConfigsDesc": "这些选项将阻止用户连接到特定协议和网站",
|
||||
"basicRouting": "基本路由",
|
||||
"blockConnectionsConfigsDesc": "这些选项将根据特定的请求国家阻止流量。",
|
||||
"directConnectionsConfigsDesc": "直接连接确保特定的流量不会通过其他服务器路由。",
|
||||
"blockips": "阻止IP",
|
||||
"blockips": "阻止 IP",
|
||||
"blockdomains": "阻止域名",
|
||||
"directips": "直接IP",
|
||||
"directips": "直接 IP",
|
||||
"directdomains": "直接域名",
|
||||
"ipv4Routing": "IPv4 路由",
|
||||
"ipv4RoutingDesc": "此选项将仅通过 IPv4 路由到目标域",
|
||||
"warpRouting": "WARP 路由",
|
||||
"warpRoutingDesc": "注意:在使用这些选项之前,请按照面板 GitHub 上的步骤在你的服务器上以 socks5 代理模式安装 WARP。WARP 将通过 Cloudflare 服务器将流量路由到网站。",
|
||||
"nordRouting": "NordVPN 路由",
|
||||
"nordRoutingDesc": "这些选项将根据特定目的地通过 NordVPN 路由流量。",
|
||||
"Template": "高级 Xray 配置模板",
|
||||
"TemplateDesc": "最终的 Xray 配置文件将基于此模板生成",
|
||||
"FreedomStrategy": "Freedom 协议策略",
|
||||
@@ -1499,10 +1391,7 @@
|
||||
"outboundTestUrlDesc": "测试出站连接时使用的 URL",
|
||||
"Torrent": "屏蔽 BitTorrent 协议",
|
||||
"Inbounds": "入站",
|
||||
"InboundsDesc": "接受来自特定客户端的流量",
|
||||
"Outbounds": "出站",
|
||||
"OutboundSubscriptions": "出站订阅",
|
||||
"OutboundSubscriptionsDesc": "从远程订阅 URL(vmess/vless/trojan/ss/…)导入出站。标签保持稳定,可用于负载均衡器和路由规则。更新会自动进行。",
|
||||
"Balancers": "负载均衡",
|
||||
"balancerTagRequired": "标签为必填项",
|
||||
"balancerSelectorRequired": "至少选择一个出站",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "匹配出站",
|
||||
"routeTesterViaBalancer": "经由负载均衡器",
|
||||
"routeTesterDefaultOutbound": "无路由规则匹配 — 流量将发往默认(第一个)出站。",
|
||||
"OutboundsDesc": "设置出站流量传出方式",
|
||||
"Routings": "路由规则",
|
||||
"RoutingsDesc": "每条规则的优先级都很重要",
|
||||
"completeTemplate": "全部",
|
||||
"logLevel": "日志级别",
|
||||
"logLevelDesc": "错误日志的日志级别,用于指示需要记录的信息",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "IP 地址掩码,启用时会自动替换日志中出现的 IP 地址。",
|
||||
"statistics": "统计",
|
||||
"statsInboundUplink": "入站上传统计",
|
||||
"statsInboundUplinkDesc": "启用所有入站代理的上行流量统计收集。",
|
||||
"statsInboundDownlink": "入站下载统计",
|
||||
"statsInboundDownlinkDesc": "启用所有入站代理的下行流量统计收集。",
|
||||
"statsOutboundUplink": "出站上传统计",
|
||||
"statsOutboundUplinkDesc": "启用所有出站代理的上行流量统计收集。",
|
||||
"statsOutboundDownlink": "出站下载统计",
|
||||
"statsOutboundDownlinkDesc": "启用所有出站代理的下行流量统计收集。",
|
||||
"connectionLimits": "连接限制",
|
||||
"connectionLimitsDesc": "用户等级 0 的连接级策略。留空则使用 Xray 的默认值。",
|
||||
"connIdle": "空闲超时",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "自动",
|
||||
"seconds": "秒",
|
||||
"rules": {
|
||||
"first": "置顶",
|
||||
"last": "置底",
|
||||
"up": "向上",
|
||||
"down": "向下",
|
||||
"source": "来源",
|
||||
"dest": "目的地址",
|
||||
"inbound": "入站",
|
||||
"outbound": "出站",
|
||||
"balancer": "负载均衡",
|
||||
"info": "信息",
|
||||
"add": "添加规则",
|
||||
"edit": "编辑规则",
|
||||
"useComma": "逗号分隔的项目"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "规则 {n}",
|
||||
"action": "操作",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "最终规则",
|
||||
"overrideXrayPrivateIp": "覆盖 Xray 默认的私有 IP 阻止",
|
||||
"blockDelay": "阻塞延迟 (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "keep alive 间隔",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "接口",
|
||||
"ipv6Only": "仅 IPv6",
|
||||
"acceptProxyProtocol": "接受 proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "添加出站",
|
||||
"addReverse": "添加反向",
|
||||
"editOutbound": "编辑出站",
|
||||
"editReverse": "编辑反向",
|
||||
"reverseTag": "反向标签",
|
||||
"reverseTagDesc": "VLESS 简易反向代理出站标签。留空则禁用。设置后,此客户端的连接可用作反向代理隧道。",
|
||||
"reverseTagPlaceholder": "出站标签(留空则禁用)",
|
||||
"tag": "标签",
|
||||
"tagDesc": "唯一标签",
|
||||
"address": "地址",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "反向",
|
||||
"domain": "域名",
|
||||
"type": "类型",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "链接",
|
||||
"intercon": "互连",
|
||||
"settings": "设置",
|
||||
"accountInfo": "帐户信息",
|
||||
"outboundStatus": "出站状态",
|
||||
"sendThrough": "发送通过",
|
||||
"targetStrategy": "目标解析策略",
|
||||
"test": "测试",
|
||||
"testResult": "测试结果",
|
||||
"testing": "正在测试连接...",
|
||||
"testSuccess": "测试成功",
|
||||
"testFailed": "测试失败",
|
||||
"testError": "测试出站失败",
|
||||
"modeRealDelay": "真实延迟",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。真实延迟: 含建立连接的总耗时。",
|
||||
"testAll": "全部测试",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "代理连接",
|
||||
"breakdownTls": "经由出站的 TLS",
|
||||
"breakdownTtfb": "首字节",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "访问令牌",
|
||||
"country": "国家",
|
||||
"server": "服务器",
|
||||
"city": "城市",
|
||||
"allCities": "所有城市",
|
||||
"privateKey": "私钥",
|
||||
"load": "负载",
|
||||
"moveToTop": "移到顶部"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "已启用的订阅",
|
||||
"empty": "暂无订阅。请在上方添加。",
|
||||
"colRemark": "备注",
|
||||
"colPrefix": "前缀",
|
||||
"colInterval": "间隔",
|
||||
"colLastFetch": "上次拉取",
|
||||
"colEnabled": "启用",
|
||||
"auto": "自动",
|
||||
"never": "从未",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"refreshNow": "立即刷新",
|
||||
"lastError": "上次错误",
|
||||
"deleteConfirm": "删除此订阅?",
|
||||
"restartHint": "添加或刷新后,请重启 Xray(或等待下一次自动重载)以使出站生效。",
|
||||
"fromSubsTitle": "来自出站订阅(只读)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "负载均衡设置",
|
||||
"tabObservatory": "观测器",
|
||||
"observatory": {
|
||||
"title": "观测器",
|
||||
"burstTitle": "突发观测器",
|
||||
"autoManaged": "观测器会根据你的负载均衡器自动管理。可在下方调整探测方式;被观测的出站会跟随负载均衡器的选择器。",
|
||||
"emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便依赖观测器的负载均衡器在选择目标前检查出站健康状态。",
|
||||
"mixedLegacy": "此配置同时包含 Observatory 和 Burst Observatory。Xray 只使用一个全局观测器,因此不支持这种旧式混合状态;保存负载均衡器时会将其规范化为单个观测器。",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "负载均衡器 {tag} — 已移除(没有剩余目标)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "添加负载均衡",
|
||||
"editBalancer": "编辑负载均衡",
|
||||
"balancerStrategy": "策略",
|
||||
"balancerSelectors": "选择器",
|
||||
"tag": "标签",
|
||||
"tagDesc": "唯一标签",
|
||||
"tagDuplicate": "该标签已被其他均衡器使用",
|
||||
"tagPlaceholder": "唯一均衡器标签",
|
||||
"selector": "选择器",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "容差",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。",
|
||||
"costMatch": "标签匹配模式",
|
||||
"costValue": "权重",
|
||||
"costRegexp": "正则表达式匹配",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "公钥",
|
||||
"allowedIPs": "允许的 IP",
|
||||
"endpoint": "端点",
|
||||
"psk": "共享密钥",
|
||||
"domainStrategy": "域策略"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "仅适用于 AmneziaWG 2.0。留空则兼容 1.x。"
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "TUN 接口的名称。默认值为 'xray0'",
|
||||
"mtuDesc": "最大传输单元。数据包的最大大小。默认值为 1500",
|
||||
"userLevel": "用户级别",
|
||||
"userLevelDesc": "通过此入站的所有连接都将使用此用户级别。默认值为 0"
|
||||
"userLevel": "用户级别"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1890,28 +1718,28 @@
|
||||
"enableDesc": "启用内置 DNS 服务器",
|
||||
"tag": "DNS 入站标签",
|
||||
"tagDesc": "此标签将在路由规则中可用作入站标签",
|
||||
"clientIp": "客户端IP",
|
||||
"clientIpDesc": "用于在DNS查询期间通知服务器指定的IP位置",
|
||||
"clientIp": "客户端 IP",
|
||||
"clientIpDesc": "用于在 DNS 查询期间通知服务器指定的 IP 位置",
|
||||
"disableCache": "禁用缓存",
|
||||
"disableCacheDesc": "禁用DNS缓存",
|
||||
"disableCacheDesc": "禁用 DNS 缓存",
|
||||
"disableFallback": "禁用回退",
|
||||
"disableFallbackDesc": "禁用回退DNS查询",
|
||||
"disableFallbackDesc": "禁用回退 DNS 查询",
|
||||
"disableFallbackIfMatch": "匹配时禁用回退",
|
||||
"disableFallbackIfMatchDesc": "当DNS服务器的匹配域名列表命中时,禁用回退DNS查询",
|
||||
"disableFallbackIfMatchDesc": "当 DNS 服务器的匹配域名列表命中时,禁用回退 DNS 查询",
|
||||
"enableParallelQuery": "启用并行查询",
|
||||
"enableParallelQueryDesc": "启用并行DNS查询到多个服务器以实现更快的解析",
|
||||
"enableParallelQueryDesc": "启用并行 DNS 查询到多个服务器以实现更快的解析",
|
||||
"strategy": "查询策略",
|
||||
"strategyDesc": "解析域名的总体策略",
|
||||
"add": "添加服务器",
|
||||
"edit": "编辑服务器",
|
||||
"domains": "域名",
|
||||
"expectIPs": "预期 IP",
|
||||
"unexpectIPs": "意外IP",
|
||||
"useSystemHosts": "使用系统Hosts",
|
||||
"useSystemHostsDesc": "使用已安装系统的hosts文件",
|
||||
"unexpectIPs": "意外 IP",
|
||||
"useSystemHosts": "使用系统 Hosts",
|
||||
"useSystemHostsDesc": "使用已安装系统的 hosts 文件",
|
||||
"serveStale": "提供过期结果",
|
||||
"serveStaleDesc": "在后台刷新时返回过期的缓存结果",
|
||||
"serveExpiredTTL": "过期TTL",
|
||||
"serveExpiredTTL": "过期 TTL",
|
||||
"serveExpiredTTLDesc": "过期缓存条目的有效期(秒);0 = 永不过期",
|
||||
"timeoutMs": "超时 (毫秒)",
|
||||
"skipFallback": "跳过回退",
|
||||
@@ -1922,7 +1750,7 @@
|
||||
"hostsDomain": "域名 (例如 domain:example.com)",
|
||||
"hostsValues": "IP 或域名 — 输入后按 Enter",
|
||||
"usePreset": "使用模板",
|
||||
"dnsPresetTitle": "DNS模板",
|
||||
"dnsPresetTitle": "DNS 模板",
|
||||
"dnsPresetFamily": "家庭",
|
||||
"clearAll": "删除全部",
|
||||
"clearAllTitle": "删除所有 DNS 服务器?",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "添加假 DNS",
|
||||
"edit": "编辑假 DNS",
|
||||
"ipPool": "IP 池子网",
|
||||
"poolSize": "池大小"
|
||||
},
|
||||
@@ -2061,13 +1888,12 @@
|
||||
"noResult": "❗ 没有结果!",
|
||||
"noQuery": "❌ 未找到查询!请再次使用该命令!",
|
||||
"wentWrong": "❌ 出了点问题!",
|
||||
"noIpRecord": "❗ 没有IP记录!",
|
||||
"noIpRecord": "❗ 没有 IP 记录!",
|
||||
"noInbounds": "❗ 未找到入站!",
|
||||
"unlimited": "♾ 无限(重置)",
|
||||
"add": "添加",
|
||||
"month": "月",
|
||||
"months": "月",
|
||||
"day": "天",
|
||||
"days": "天",
|
||||
"hours": "小时",
|
||||
"minutes": "分钟",
|
||||
@@ -2085,8 +1911,8 @@
|
||||
"status": "✅ 机器人正常运行!",
|
||||
"usage": "❗ 请输入要搜索的文本!",
|
||||
"getID": "🆔 您的 ID 为:<code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "要重新启动 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜索客户电子邮件:\r\n<code>/usage [电子邮件]</code>\r\n\r\n要搜索入站(带有客户统计数据):\r\n<code>/inbound [备注]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n<code>/usage [电子邮件]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpAdminCommands": "要重新启动 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜索客户电子邮件:\r\n<code>/usage [电子邮件]</code>\r\n\r\n要搜索入站(带有客户统计数据):\r\n<code>/inbound [备注]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜索统计数据,请使用以下命令:\r\n<code>/usage [电子邮件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"restartUsage": "\r\n\r\n<code>/restart</code>",
|
||||
"restartSuccess": "✅ 操作成功!",
|
||||
"restartFailed": "❗ 操作错误。\r\n\r\n<code>错误: {{ .Error }}</code>.",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ 电报用户已保存。",
|
||||
"loginSuccess": "✅ 成功登录到面板。\r\n",
|
||||
"loginFailed": "❗️ 面板登录失败。\r\n",
|
||||
"2faFailed": "2FA 失败",
|
||||
"report": "🕰 定时报告:{{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ 日期时间:{{ .DateTime }}\r\n",
|
||||
"hostname": "💻 主机: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 下载: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 总计: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 电报用户:{{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 耗尽的 {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 耗尽的 {{ .Type }} 数量:\r\n",
|
||||
"onlinesCount": "🌐 在线客户:{{ .Count }}\r\n",
|
||||
"disabled": "🛑 禁用:{{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 刷新时间:{{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ 是的",
|
||||
"no": "❌ 否",
|
||||
"received_id": "🔑📥 ID 已更新。",
|
||||
"received_password": "🔑📥 密码已更新。",
|
||||
"received_email": "📧📥 邮箱已更新。",
|
||||
"received_comment": "💬📥 评论已更新。",
|
||||
"id_prompt": "🔑 默认 ID: {{ .ClientId }}\n\n请输入您的 ID。",
|
||||
"pass_prompt": "🔑 默认密码: {{ .ClientPassword }}\n\n请输入您的密码。",
|
||||
"email_prompt": "📧 默认邮箱: {{ .ClientEmail }}\n\n请输入您的邮箱。",
|
||||
"comment_prompt": "💬 默认评论: {{ .ClientComment }}\n\n请输入您的评论。",
|
||||
"inbound_client_data_id": "🔄 入站: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 邮箱: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日期: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 备注: {{ .ClientComment }}\n\n你现在可以将客户添加到入站了!",
|
||||
"inbound_client_data_pass": "🔄 入站: {{ .InboundRemark }}\n\n🔑 密码: {{ .ClientPass }}\n📧 邮箱: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日期: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 备注: {{ .ClientComment }}\n\n你现在可以将客户添加到入站了!",
|
||||
"cancel": "❌ 进程已取消!\n\n您可以随时使用 /start 重新开始。 🔄",
|
||||
"error_add_client": "⚠️ 错误:\n\n {{ .error }}",
|
||||
"using_default_value": "好的,我会使用默认值。 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "错误:{{ .Error }}",
|
||||
"eventNodeDown": "节点 {{ .Name }} 已离线",
|
||||
"eventNodeUp": "节点 {{ .Name }} 已上线",
|
||||
"eventCPUHigh": "CPU 占用过高",
|
||||
"eventCPUHighDetail": "CPU:{{ .Detail }}",
|
||||
"eventLoginFallback": "来自 {{ .Source }} 的登录失败",
|
||||
"memoryThreshold": "内存使用率 {{ .Percent }}% 超过阈值 {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "提交为禁用 ☑️",
|
||||
"submitEnable": "提交为启用 ✅",
|
||||
"use_default": "🏷️ 使用默认",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 密码",
|
||||
"change_email": "⚙️📧 邮箱",
|
||||
"change_comment": "⚙️💬 评论",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "重置所有流量",
|
||||
"SortedTrafficUsageReport": "排序的流量使用报告"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "出站 {{ .Tag }} 已断开",
|
||||
"subjectOutboundUp": "出站 {{ .Tag }} 已恢复",
|
||||
"subjectXrayCrash": "Xray 已崩溃",
|
||||
"subjectCPUHigh": "CPU 占用过高",
|
||||
"subjectLoginSuccess": "登录成功",
|
||||
"subjectLoginFailed": "登录失败",
|
||||
"titleOutboundDown": "出站断开",
|
||||
"titleOutboundUp": "出站恢复",
|
||||
"titleXrayCrash": "Xray 已崩溃",
|
||||
"titleCPUHigh": "CPU 占用过高",
|
||||
"titleLoginSuccess": "登录成功",
|
||||
"titleLoginFailed": "登录失败",
|
||||
"labelStatus": "状态",
|
||||
"labelOutbound": "出站",
|
||||
"labelNode": "节点",
|
||||
"labelError": "错误",
|
||||
"labelDelay": "延迟",
|
||||
"labelDetail": "详情",
|
||||
"labelUsername": "用户名",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "原因",
|
||||
"labelSource": "来源",
|
||||
"labelTime": "时间",
|
||||
"statusCrashed": "已崩溃",
|
||||
"statusRunning": "运行中",
|
||||
"statusHigh": "过高",
|
||||
"statusSuccess": "成功",
|
||||
"statusFailed": "失败",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"copySuccess": "複製成功",
|
||||
"sure": "確定",
|
||||
"encryption": "加密",
|
||||
"useIPv4ForHost": "使用 IPv4 連接主機",
|
||||
"transmission": "傳輸",
|
||||
"host": "主機",
|
||||
"path": "路徑",
|
||||
@@ -74,18 +73,9 @@
|
||||
"twoFactorCode": "代碼",
|
||||
"remained": "剩餘",
|
||||
"security": "安全",
|
||||
"secAlertTitle": "安全警報",
|
||||
"secAlertSsl": "此連線不安全。在啟用 TLS 進行資料保護之前,請勿輸入敏感資訊。",
|
||||
"secAlertConf": "某些設定易受攻擊。建議加強安全協議以防止潛在漏洞。",
|
||||
"secAlertSSL": "面板缺少安全連線。請安裝 TLS 證書以保護資料安全。",
|
||||
"secAlertPanelPort": "面板預設埠存在安全風險。請配置隨機埠或特定埠。",
|
||||
"secAlertPanelURI": "面板預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
|
||||
"secAlertSubURI": "訂閱預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
|
||||
"secAlertSubJsonURI": "訂閱 JSON 預設 URI 路徑不安全。請配置複雜的 URI 路徑。",
|
||||
"emptyDnsDesc": "未添加DNS伺服器。",
|
||||
"emptyFakeDnsDesc": "未添加Fake DNS伺服器。",
|
||||
"emptyDnsDesc": "未添加 DNS 伺服器。",
|
||||
"emptyFakeDnsDesc": "未添加 Fake DNS 伺服器。",
|
||||
"emptyBalancersDesc": "未添加負載平衡器。",
|
||||
"emptyReverseDesc": "未添加反向代理。",
|
||||
"somethingWentWrong": "發生錯誤",
|
||||
"subscription": {
|
||||
"title": "訂閱資訊",
|
||||
@@ -106,8 +96,6 @@
|
||||
},
|
||||
"menu": {
|
||||
"theme": "主題",
|
||||
"dark": "深色",
|
||||
"ultraDark": "超深色",
|
||||
"dashboard": "系統狀態",
|
||||
"inbounds": "入站",
|
||||
"clients": "客戶端",
|
||||
@@ -118,12 +106,12 @@
|
||||
"routing": "路由",
|
||||
"outbounds": "出站",
|
||||
"apiDocs": "API 文件",
|
||||
"logout": "退出登入",
|
||||
"link": "管理",
|
||||
"donate": "捐贈",
|
||||
"hosts": "Hosts",
|
||||
"docs": "文件",
|
||||
"openMenu": "開啟選單"
|
||||
"openMenu": "開啟選單",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
@@ -139,20 +127,15 @@
|
||||
}
|
||||
},
|
||||
"index": {
|
||||
"title": "系統狀態",
|
||||
"cpu": "CPU",
|
||||
"logicalProcessors": "邏輯處理器",
|
||||
"frequency": "頻率",
|
||||
"swap": "交換空間",
|
||||
"storage": "儲存",
|
||||
"memory": "記憶體",
|
||||
"threads": "執行緒",
|
||||
"xrayStatus": "Xray",
|
||||
"stopXray": "停止",
|
||||
"restartXray": "重新啟動",
|
||||
"xraySwitch": "版本",
|
||||
"xrayUpdates": "Xray 更新",
|
||||
"xraySwitchClick": "選擇你要切換到的版本",
|
||||
"xraySwitchClickDesk": "請謹慎選擇,因為較舊版本可能與當前配置不相容",
|
||||
"updatePanel": "更新面板",
|
||||
"panelUpdateDesc": "這將把 3X-UI 更新到最新版本並重新啟動面板服務。",
|
||||
@@ -164,13 +147,10 @@
|
||||
"currentCommit": "目前提交",
|
||||
"latestCommit": "最新提交",
|
||||
"updateChannelChanged": "更新通道已切換",
|
||||
"upToDate": "已是最新",
|
||||
"xrayStatusUnknown": "未知",
|
||||
"xrayStatusRunning": "運行中",
|
||||
"xrayStatusStop": "停止",
|
||||
"xrayStatusError": "錯誤",
|
||||
"xrayErrorPopoverTitle": "執行Xray時發生錯誤",
|
||||
"operationHours": "系統正常執行時間",
|
||||
"systemHistoryTitle": "系統歷史",
|
||||
"historyTitleCpu": "CPU 使用率",
|
||||
"historyTitleMem": "記憶體使用率",
|
||||
@@ -188,7 +168,6 @@
|
||||
"historyTabLoad": "負載",
|
||||
"historyTabConnections": "連線數",
|
||||
"historyTabDiskUsage": "磁碟使用量",
|
||||
"charts": "圖表",
|
||||
"xrayMetricsTitle": "Xray 指標",
|
||||
"xrayTitleHeap": "已配置的堆積記憶體",
|
||||
"xrayTitleSys": "向作業系統保留的記憶體",
|
||||
@@ -211,25 +190,19 @@
|
||||
"xrayObservatoryDead": "離線",
|
||||
"xrayObservatoryLastSeen": "最後在線",
|
||||
"xrayObservatoryLastTry": "最後嘗試",
|
||||
"trendLast2Min": "最近 2 分鐘",
|
||||
"systemLoad": "系統負載",
|
||||
"systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載",
|
||||
"connectionCount": "連線數",
|
||||
"ipAddresses": "IP地址",
|
||||
"toggleIpVisibility": "切換IP可見性",
|
||||
"ipAddresses": "IP 地址",
|
||||
"toggleIpVisibility": "切換 IP 可見性",
|
||||
"overallSpeed": "整體速度",
|
||||
"upload": "上傳",
|
||||
"download": "下載",
|
||||
"totalData": "總數據",
|
||||
"sent": "已發送",
|
||||
"received": "已接收",
|
||||
"documentation": "文件",
|
||||
"xraySwitchVersionDialog": "您確定要變更Xray版本嗎?",
|
||||
"xraySwitchVersionDialogDesc": "這將會把Xray版本變更為#version#。",
|
||||
"xraySwitchVersionDialog": "您確定要變更 Xray 版本嗎?",
|
||||
"xraySwitchVersionDialogDesc": "這將會把 Xray 版本變更為 #version#。",
|
||||
"xraySwitchVersionPopover": "Xray 更新成功",
|
||||
"panelUpdateDialog": "您確定要更新面板嗎?",
|
||||
"panelUpdateDialogDesc": "這將把 3X-UI 更新到 #version# 並重新啟動面板服務。",
|
||||
"panelUpdateCheckPopover": "面板更新檢查失敗",
|
||||
"panelUpdateStartedPopover": "面板更新已開始",
|
||||
"panelUpdateFailedTitle": "面板更新失敗",
|
||||
"panelUpdateFailedDesc": "更新未成功完成。請檢查伺服器日誌,或在命令列執行「x-ui update」。",
|
||||
@@ -258,7 +231,6 @@
|
||||
"accessLogs": "存取記錄",
|
||||
"autoUpdate": "自動更新",
|
||||
"config": "配置",
|
||||
"backup": "備份",
|
||||
"backupTitle": "備份和恢復",
|
||||
"exportDatabase": "備份",
|
||||
"exportDatabaseDesc": "點擊下載包含當前資料庫備份的 .db 文件到您的設備。同一檔案也可以還原到執行 PostgreSQL 的面板中。",
|
||||
@@ -273,10 +245,20 @@
|
||||
"exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump)下載到您的裝置。",
|
||||
"importDatabasePgDesc": "點擊選擇並上傳 PostgreSQL 備份(.dump)、SQLite 資料庫(.db)或 SQLite 遷移匯出檔以還原您的資料庫。此操作將取代所有目前的資料。",
|
||||
"migrationDownload": "下載遷移檔案",
|
||||
"migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。"
|
||||
"migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。",
|
||||
"avg": "均值",
|
||||
"peak": "峰值",
|
||||
"free": "剩餘",
|
||||
"openSockets": "開啟的通訊端",
|
||||
"throughputSub": "網路介面總計",
|
||||
"avgWindow": "視窗均值",
|
||||
"healthWarm": "{list} — 偏高",
|
||||
"healthCritical": "{list} — 危險",
|
||||
"panel": "面板",
|
||||
"threads": "執行緒",
|
||||
"uptime": "執行時間"
|
||||
},
|
||||
"inbounds": {
|
||||
"title": "入站",
|
||||
"totalDownUp": "總上傳 / 下載",
|
||||
"totalUsage": "總用量",
|
||||
"inboundCount": "入站數量",
|
||||
@@ -288,21 +270,11 @@
|
||||
"localPanel": "本機面板",
|
||||
"fallbacks": {
|
||||
"title": "Fallbacks",
|
||||
"help": "當此入站的連線未匹配任何用戶時,將其路由到其他位置。在下方選擇一個子入站,可從其傳輸方式自動填入路由欄位(SNI / ALPN / Path / xver);或將選擇框留空並直接設定 Dest(例如 8080 或 127.0.0.1:8080),以路由到 Nginx 等外部伺服器。每個子入站應監聽 127.0.0.1,security=none。",
|
||||
"empty": "尚未新增回落",
|
||||
"add": "新增回落",
|
||||
"pickInbound": "選擇一個入站",
|
||||
"matchAny": "任何",
|
||||
"destPlaceholder": "自動(子入站 listen:port)",
|
||||
"rederive": "從子入站重新填入",
|
||||
"rederived": "已從子入站重新填入",
|
||||
"editAdvanced": "編輯路由欄位",
|
||||
"hideAdvanced": "隱藏進階",
|
||||
"quickAddAll": "一鍵新增所有符合的入站",
|
||||
"quickAdded": "已新增 {n} 個回落",
|
||||
"quickAddedNone": "沒有可新增的新入站",
|
||||
"routesWhen": "當條件成立時路由",
|
||||
"defaultCatchAll": "預設 — 兜底匹配其餘",
|
||||
"needsTls": "在「安全」分頁選擇 TLS 或 Reality 後即可設定回落(僅限 RAW 上的 VLESS/Trojan)。"
|
||||
},
|
||||
"protocol": "協議",
|
||||
@@ -310,8 +282,6 @@
|
||||
"portMap": "連接埠對應",
|
||||
"traffic": "流量",
|
||||
"speed": "速度",
|
||||
"details": "詳細資訊",
|
||||
"transportConfig": "傳輸",
|
||||
"expireDate": "到期時間",
|
||||
"createdAt": "建立時間",
|
||||
"updatedAt": "更新時間",
|
||||
@@ -319,8 +289,6 @@
|
||||
"addInbound": "新增入站",
|
||||
"generalActions": "通用操作",
|
||||
"modifyInbound": "修改入站",
|
||||
"deleteInbound": "刪除入站",
|
||||
"deleteInboundContent": "確定要刪除入站嗎?",
|
||||
"deleteConfirmTitle": "刪除入站「{remark}」?",
|
||||
"deleteConfirmContent": "將刪除此入站及其所有客戶端。此操作無法復原。",
|
||||
"resetConfirmTitle": "重置「{remark}」的流量?",
|
||||
@@ -364,8 +332,6 @@
|
||||
"exportAllLinksFileName": "所有入站",
|
||||
"exportAllSubsFileName": "所有入站-Subs",
|
||||
"inboundJsonTitle": "入站 JSON",
|
||||
"deleteClient": "刪除客戶端",
|
||||
"deleteClientContent": "確定要刪除客戶端嗎?",
|
||||
"resetTrafficContent": "確定要重置流量嗎?",
|
||||
"copyLink": "複製連結",
|
||||
"address": "地址",
|
||||
@@ -376,36 +342,19 @@
|
||||
"meansNoLimit": "= 無限制。(單位: GB)",
|
||||
"totalFlow": "總流量",
|
||||
"leaveBlankToNeverExpire": "留空表示永不過期",
|
||||
"noRecommendKeepDefault": "建議保留預設值",
|
||||
"certificatePath": "檔案路徑",
|
||||
"certificateContent": "檔案內容",
|
||||
"publicKey": "公鑰",
|
||||
"privatekey": "私鑰",
|
||||
"clickOnQRcode": "點選二維碼複製",
|
||||
"client": "客戶",
|
||||
"export": "匯出連結",
|
||||
"clone": "複製",
|
||||
"cloneInbound": "複製",
|
||||
"cloneInboundContent": "此入站規則除埠(Port)、監聽 IP(Listening IP)和客戶端(Clients)以外的所有配置都將應用於克隆",
|
||||
"cloneInboundOk": "建立克隆",
|
||||
"resetAllTraffic": "重置所有入站流量",
|
||||
"resetAllTrafficTitle": "重置所有入站流量",
|
||||
"resetAllTrafficContent": "確定要重置所有入站流量嗎?",
|
||||
"resetInboundClientTraffics": "重置客戶端流量",
|
||||
"resetInboundClientTrafficTitle": "重置所有客戶端流量",
|
||||
"resetInboundClientTrafficContent": "確定要重置此入站客戶端的所有流量嗎?",
|
||||
"resetAllClientTraffics": "重置所有客戶端流量",
|
||||
"resetAllClientTrafficTitle": "重置所有客戶端流量",
|
||||
"resetAllClientTrafficContent": "確定要重置所有客戶端的所有流量嗎?",
|
||||
"delDepletedClients": "刪除流量耗盡的客戶端",
|
||||
"delDepletedClientsTitle": "刪除流量耗盡的客戶端",
|
||||
"delDepletedClientsContent": "確定要刪除所有流量耗盡的客戶端嗎?",
|
||||
"email": "電子郵件",
|
||||
"emailDesc": "電子郵件必須完全唯一",
|
||||
"IPLimit": "IP 限制",
|
||||
"IPLimitDesc": "如果數量超過設定值,則禁用入站流量。(0 = 禁用)",
|
||||
"IPLimitlog": "IP 日誌",
|
||||
"IPLimitlogDesc": "IP 歷史日誌(要啟用被禁用的入站流量,請清除日誌)",
|
||||
"IPLimitlogclear": "清除日誌",
|
||||
"setDefaultCert": "從面板設定證書",
|
||||
"setDefaultCertEmpty": "面板尚未設定憑證。請先在「設定」中設定。",
|
||||
@@ -438,20 +387,15 @@
|
||||
"sniffing": "Sniffing",
|
||||
"sniffingHelp": "Xray sniffing 區塊包裝:",
|
||||
"stream": "Stream",
|
||||
"streamHelp": "Xray stream 區塊包裝:",
|
||||
"jsonErrorPrefix": "進階 JSON"
|
||||
"streamHelp": "Xray stream 區塊包裝:"
|
||||
},
|
||||
"telegramDesc": "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或({'@'}userinfobot",
|
||||
"subscriptionDesc": "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。",
|
||||
"subSortIndex": "訂閱排序",
|
||||
"same": "相同",
|
||||
"inboundInfo": "入站資訊",
|
||||
"exportInbound": "匯出入站規則",
|
||||
"import": "匯入",
|
||||
"importInbound": "匯入入站規則",
|
||||
"periodicTrafficResetTitle": "流量重置",
|
||||
"periodicTrafficResetDesc": "按指定間隔自動重置流量計數器",
|
||||
"lastReset": "上次重置",
|
||||
"periodicTrafficResetDay": "每月重置日",
|
||||
"periodicTrafficReset": {
|
||||
"never": "從不",
|
||||
"daily": "每日",
|
||||
@@ -463,7 +407,6 @@
|
||||
"obtain": "獲取",
|
||||
"updateSuccess": "更新成功",
|
||||
"logCleanSuccess": "日誌已清除",
|
||||
"inboundsUpdateSuccess": "入站連接已成功更新",
|
||||
"inboundUpdateSuccess": "入站連接已成功更新",
|
||||
"inboundCreateSuccess": "入站連接已成功建立",
|
||||
"bulkDeleted": "已刪除 {count} 個入站",
|
||||
@@ -473,15 +416,14 @@
|
||||
"inboundClientDeleteSuccess": "入站客戶端已刪除",
|
||||
"inboundClientUpdateSuccess": "入站客戶端已更新",
|
||||
"savedNodeOfflineWillSync": "已在本機儲存。某個支撐節點離線或已停用——重新連線後將同步此變更。",
|
||||
"delDepletedClientsSuccess": "所有耗盡客戶端已刪除",
|
||||
"resetAllClientTrafficSuccess": "客戶端所有流量已重置",
|
||||
"resetAllTrafficSuccess": "所有流量已重置",
|
||||
"resetInboundClientTrafficSuccess": "流量已重置",
|
||||
"resetInboundTrafficSuccess": "入站流量已重置",
|
||||
"trafficGetError": "取得流量資料時發生錯誤",
|
||||
"getNewX25519CertError": "取得X25519憑證時發生錯誤。",
|
||||
"getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
|
||||
"getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。",
|
||||
"getNewX25519CertError": "取得 X25519 憑證時發生錯誤。",
|
||||
"getNewmldsa65Error": "取得 mldsa65 憑證時發生錯誤。",
|
||||
"getNewVlessEncError": "取得 VlessEnc 憑證時發生錯誤。",
|
||||
"scanRealityTargetError": "掃描 REALITY 目標失敗。",
|
||||
"scanRealityTargetFeasible": "目標可用 — 已填入目標與 SNI。",
|
||||
"scanRealityTargetNotFeasible": "目標可達,但不適用於 REALITY。",
|
||||
@@ -636,6 +578,10 @@
|
||||
"maxTimeDiff": "最大時間差 (ms)",
|
||||
"minClientVer": "最小客戶端版本",
|
||||
"maxClientVer": "最大客戶端版本",
|
||||
"minClientVerHint": "留空不等於不限制:Xray-core 會改用所執行核心版本的內建最低值(目前版本為 26.3.27),拒絕自報版本較低的客戶端——包括 Mihomo、sing-box 等第三方核心。填 1.0.0 可放行它們,代價是允許過時的 TLS 指紋。",
|
||||
"maxClientVerHint": "留空表示無上限。若填寫,不得低於實際生效的下限——最小客戶端版本,該欄位留空時則為 Xray-core 的內建最低值——否則所有客戶端都會被拒絕。",
|
||||
"clientVerInvalid": "客戶端版本須為最多三段以點分隔的數字,每段 0-255(例如 26.3.27)",
|
||||
"maxClientVerBelowMin": "最大客戶端版本不得低於最小客戶端版本",
|
||||
"shortIds": "Short IDs",
|
||||
"realityTargetHint": "必填。必須包含連接埠(例如 example.com:443)。沒有連接埠時 Xray-core 將無法啟動。",
|
||||
"realityTargetRequired": "REALITY 目標為必填項",
|
||||
@@ -712,23 +658,6 @@
|
||||
"peerNumber": "Peer {n}",
|
||||
"peerNumberConfig": "Peer {n} 設定"
|
||||
},
|
||||
"stream": {
|
||||
"general": {
|
||||
"request": "請求",
|
||||
"response": "響應",
|
||||
"name": "名稱",
|
||||
"value": "值"
|
||||
},
|
||||
"tcp": {
|
||||
"version": "版本",
|
||||
"method": "方法",
|
||||
"path": "路徑",
|
||||
"status": "狀態",
|
||||
"statusDescription": "狀態說明",
|
||||
"requestHeader": "請求頭",
|
||||
"responseHeader": "響應頭"
|
||||
}
|
||||
},
|
||||
"sniffingDestOverride": "目標覆寫"
|
||||
},
|
||||
"clients": {
|
||||
@@ -742,24 +671,9 @@
|
||||
"addExternalSubscription": "新增外部訂閱",
|
||||
"noExternalLinks": "尚無外部連結。",
|
||||
"noExternalSubscriptions": "尚無外部訂閱。",
|
||||
"add": "新增客戶端",
|
||||
"edit": "編輯客戶端",
|
||||
"submitAdd": "新增客戶端",
|
||||
"submitEdit": "儲存變更",
|
||||
"clientCount": "客戶端數量",
|
||||
"bulk": "批次新增",
|
||||
"copyFromInbound": "從入站複製客戶端",
|
||||
"copyToInbound": "複製客戶端至",
|
||||
"copySelected": "複製所選",
|
||||
"copySource": "來源",
|
||||
"copyEmailPreview": "產生的信箱預覽",
|
||||
"copySelectSourceFirst": "請先選擇一個來源入站。",
|
||||
"copyResult": "複製結果",
|
||||
"copyResultSuccess": "複製成功",
|
||||
"copyResultNone": "沒有內容可複製:未選取客戶端或來源為空",
|
||||
"copyResultErrors": "複製錯誤",
|
||||
"copyFlowLabel": "新客戶端的 Flow (VLESS)",
|
||||
"copyFlowHint": "套用至所有被複製的客戶端。留空則略過。",
|
||||
"selectAll": "全選",
|
||||
"clearAll": "全部清除",
|
||||
"method": "方法",
|
||||
@@ -770,7 +684,6 @@
|
||||
"postfix": "後綴",
|
||||
"delayedStart": "首次使用後開始",
|
||||
"expireDays": "時長 (天)",
|
||||
"days": "天",
|
||||
"renew": "自動續期",
|
||||
"renewDesc": "到期後自動續期。(0 = 停用) (單位: 天)",
|
||||
"renewDays": "自動續期 (天)",
|
||||
@@ -793,7 +706,6 @@
|
||||
"sortExpiringSoonest": "即將到期",
|
||||
"has": "擁有",
|
||||
"hasNot": "不擁有",
|
||||
"title": "客戶端",
|
||||
"actions": "操作",
|
||||
"totalGB": "流量上限 (GB)",
|
||||
"totalGBDesc": "該客戶端的流量配額。0 = 不限制。",
|
||||
@@ -810,7 +722,7 @@
|
||||
"online": "上線",
|
||||
"email": "電子郵件",
|
||||
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
|
||||
"subIdInvalidChars": "訂閱ID不能包含空格、'/'、'\\' 或控制字元",
|
||||
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
|
||||
"group": "群組",
|
||||
"groupDesc": "用於將相關客戶端歸類的邏輯標籤(如團隊、客戶、地區)。可從工具列篩選。",
|
||||
"groupPlaceholder": "如 customer-a",
|
||||
@@ -821,8 +733,6 @@
|
||||
"addClient": "新增客戶端",
|
||||
"qrCode": "QR 碼",
|
||||
"clientInfo": "客戶端資訊",
|
||||
"delete": "刪除",
|
||||
"reset": "重設流量",
|
||||
"editClient": "編輯客戶端",
|
||||
"client": "客戶端",
|
||||
"enabled": "已啟用",
|
||||
@@ -836,13 +746,11 @@
|
||||
"noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。",
|
||||
"link": "連結",
|
||||
"resetNotPossible": "請先將此客戶端關聯至入站。",
|
||||
"general": "一般",
|
||||
"resetAllTraffics": "重設所有客戶端流量",
|
||||
"resetAllTrafficsTitle": "重設所有客戶端流量?",
|
||||
"resetAllTrafficsContent": "所有客戶端的上下行計數器將歸零。配額與到期時間不受影響。此操作無法復原。",
|
||||
"deleteConfirmTitle": "刪除客戶端 {email}?",
|
||||
"deleteConfirmContent": "將從所有關聯入站中移除該客戶端並刪除其流量紀錄。此操作無法復原。",
|
||||
"deleteSelected": "刪除 ({count})",
|
||||
"adjustSelected": "調整 ({count})",
|
||||
"subLinksSelected": "訂閱連結 ({count})",
|
||||
"addToGroupTitle": "將 {count} 個客戶端加入群組",
|
||||
@@ -864,12 +772,10 @@
|
||||
"bulkDisableConfirmTitle": "停用 {count} 個客戶端?",
|
||||
"bulkDisableConfirmContent": "在每個已附加的入站上停用所選的客戶端。他們將立即失去存取權限,但其記錄與流量將被保留。",
|
||||
"selectedCount": "已選 {count} 項",
|
||||
"attachSelected": "附加 ({count})",
|
||||
"attachToInboundsTitle": "將 {count} 個客戶端附加到入站",
|
||||
"attachToInboundsDesc": "將選取的 {count} 個客戶端(相同 UUID/密碼與共享流量)附加到選定入站。它們保留現有附加關係。",
|
||||
"attachToInboundsTargets": "目標入站",
|
||||
"attachToInboundsNoTargets": "沒有可供附加的多用戶入站。",
|
||||
"detachSelected": "分離 ({count})",
|
||||
"detach": "分離",
|
||||
"detachFromInboundsTitle": "從入站分離 {count} 個客戶端",
|
||||
"detachFromInboundsDesc": "從選定入站中移除選取的 {count} 個客戶端。客戶端未附加的配對會被靜默略過。客戶端記錄保留(用 Delete 完全移除)。",
|
||||
@@ -931,8 +837,6 @@
|
||||
"reverseTagPlaceholder": "選用 Reverse tag",
|
||||
"telegramId": "Telegram 使用者 ID",
|
||||
"telegramIdPlaceholder": "數字形式的 Telegram 使用者 ID (0 = 無)",
|
||||
"created": "建立時間",
|
||||
"updated": "更新時間",
|
||||
"ipLimit": "IP 限制",
|
||||
"toasts": {
|
||||
"deleted": "客戶端已刪除",
|
||||
@@ -955,7 +859,6 @@
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "群組",
|
||||
"name": "名稱",
|
||||
"clientCount": "客戶端",
|
||||
"totalGroups": "群組總數",
|
||||
@@ -997,7 +900,6 @@
|
||||
"removeFromGroupResult": "已從 {name} 移除 {count} 個客戶端。"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "節點",
|
||||
"addNode": "新增節點",
|
||||
"editNode": "編輯節點",
|
||||
"totalNodes": "節點總數",
|
||||
@@ -1016,10 +918,8 @@
|
||||
"apiTokenPlaceholder": "遠端面板設定頁中的權杖",
|
||||
"apiTokenHint": "遠端面板在 安全設定 → API 權杖 中顯示其 API 權杖。",
|
||||
"apiTokenKeepHint": "留空以保留目前的權杖",
|
||||
"regenerate": "重新產生權杖",
|
||||
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
|
||||
"allowPrivateAddress": "允許私有地址",
|
||||
"allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
|
||||
"allowPrivateAddressHint": "僅對私有網路或 VPN 上的節點啟用。",
|
||||
"outboundTag": "連線出站",
|
||||
"outboundTagHint": "透過選定的 Xray 出站路由此節點的面板 API 流量。系統會自動將迴環橋接入站加入執行中的設定並即時套用。留空表示直接連線。",
|
||||
"outboundTagPlaceholder": "直接連線",
|
||||
@@ -1049,7 +949,6 @@
|
||||
"updatePanel": "更新面板",
|
||||
"updateSelected": "更新所選 ({count})",
|
||||
"updateAvailable": "有可用更新",
|
||||
"upToDate": "已是最新",
|
||||
"updateConfirmTitle": "將 {count} 個節點更新到最新版本?",
|
||||
"updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。",
|
||||
"updateDevChannel": "更新到開發通道(最新提交)",
|
||||
@@ -1113,6 +1012,7 @@
|
||||
"pinFetchFailed": "無法取得憑證"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "預設",
|
||||
"title": "面板設定",
|
||||
"save": "儲存",
|
||||
"infoDesc": "此處的所有更改都需要儲存並重啟面板才能生效",
|
||||
@@ -1214,7 +1114,7 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
|
||||
"subClashUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上識別 Clash/Mihomo 用戶端的 Go RE2 正規表示式。留空則使用預設規則。變更後請重新啟動面板。",
|
||||
"subTitle": "訂閱標題",
|
||||
"subTitleDesc": "在VPN客戶端中顯示的標題",
|
||||
"subTitleDesc": "在 VPN 客戶端中顯示的標題",
|
||||
"subSupportUrl": "支援連結",
|
||||
"subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結",
|
||||
"subProfileUrl": "個人資料連結",
|
||||
@@ -1323,7 +1223,7 @@
|
||||
"muxDesc": "在已建立的資料流內傳輸多個獨立的資料流",
|
||||
"muxSett": "複用器設定",
|
||||
"direct": "直接連線",
|
||||
"directDesc": "直接與特定國家的域或IP範圍建立連線",
|
||||
"directDesc": "直接與特定國家的域或 IP 範圍建立連線",
|
||||
"notifications": "通知",
|
||||
"certs": "證書",
|
||||
"externalTraffic": "外部流量",
|
||||
@@ -1337,12 +1237,12 @@
|
||||
"security": {
|
||||
"admin": "管理員憑證",
|
||||
"twoFactor": "雙重驗證",
|
||||
"twoFactorEnable": "啟用2FA",
|
||||
"twoFactorEnable": "啟用 2FA",
|
||||
"twoFactorEnableDesc": "增加額外的驗證層以提高安全性。",
|
||||
"twoFactorModalSetTitle": "啟用雙重認證",
|
||||
"twoFactorModalDeleteTitle": "停用雙重認證",
|
||||
"twoFactorModalSteps": "要設定雙重認證,請執行以下步驟:",
|
||||
"twoFactorModalFirstStep": "1. 在認證應用程式中掃描此QR碼,或複製QR碼附近的令牌並貼到應用程式中",
|
||||
"twoFactorModalFirstStep": "1. 在認證應用程式中掃描此 QR 碼,或複製 QR 碼附近的令牌並貼到應用程式中",
|
||||
"twoFactorModalSecondStep": "2. 輸入應用程式中的驗證碼",
|
||||
"twoFactorModalRemoveStep": "輸入應用程式中的驗證碼以移除雙重認證。",
|
||||
"twoFactorModalChangeCredentialsTitle": "更改憑證",
|
||||
@@ -1441,6 +1341,8 @@
|
||||
"eventMemoryHigh": "記憶體使用率高 (%)",
|
||||
"remarkTemplate": "備註範本",
|
||||
"remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
|
||||
"subShowIdentityOnAllLinks": "在每個連結上顯示身分",
|
||||
"subShowIdentityOnAllLinksDesc": "啟用後,{{EMAIL}} 與 {{USERNAME}} 會保留在每條訂閱正文備註中。用量相關變數仍僅出現在第一條連結。",
|
||||
"validation": {
|
||||
"pathLeadingSlash": "路徑必須以 / 開頭"
|
||||
},
|
||||
@@ -1448,16 +1350,11 @@
|
||||
"secretClearUndo": "復原清除"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray 配置",
|
||||
"save": "儲存",
|
||||
"restart": "重新啟動 Xray",
|
||||
"restartSuccess": "Xray 已成功重新啟動",
|
||||
"restartOutputTitle": "Xray 重新啟動輸出",
|
||||
"restartConfirmTitle": "重新啟動 xray?",
|
||||
"restartConfirmContent": "使用已儲存的設定重新載入 xray 服務。",
|
||||
"stopSuccess": "Xray 已成功停止",
|
||||
"restartError": "重新啟動Xray時發生錯誤。",
|
||||
"stopError": "停止Xray時發生錯誤。",
|
||||
"restartError": "重新啟動 Xray 時發生錯誤。",
|
||||
"stopError": "停止 Xray 時發生錯誤。",
|
||||
"importRules": "匯入規則",
|
||||
"exportRules": "匯出規則",
|
||||
"importOutbounds": "匯入出站",
|
||||
@@ -1472,20 +1369,15 @@
|
||||
"generalConfigsDesc": "這些選項將決定常規配置",
|
||||
"logConfigs": "記錄",
|
||||
"logConfigsDesc": "日誌可能會影響伺服器的效能,建議僅在需要時啟用",
|
||||
"blockConfigsDesc": "這些選項將阻止使用者連線到特定協議和網站",
|
||||
"basicRouting": "基本路由",
|
||||
"blockConnectionsConfigsDesc": "這些選項將根據特定的請求國家阻止流量。",
|
||||
"directConnectionsConfigsDesc": "直接連線確保特定的流量不會通過其他伺服器路由。",
|
||||
"blockips": "阻止IP",
|
||||
"blockips": "阻止 IP",
|
||||
"blockdomains": "阻止域名",
|
||||
"directips": "直接IP",
|
||||
"directips": "直接 IP",
|
||||
"directdomains": "直接域名",
|
||||
"ipv4Routing": "IPv4 路由",
|
||||
"ipv4RoutingDesc": "此選項將僅通過 IPv4 路由到目標域",
|
||||
"warpRouting": "WARP 路由",
|
||||
"warpRoutingDesc": "注意:在使用這些選項之前,請按照面板 GitHub 上的步驟在你的伺服器上以 socks5 代理模式安裝 WARP。WARP 將通過 Cloudflare 伺服器將流量路由到網站。",
|
||||
"nordRouting": "NordVPN 路由",
|
||||
"nordRoutingDesc": "這些選項將根據特定目的地通過 NordVPN 路由流量。",
|
||||
"Template": "高階 Xray 配置模板",
|
||||
"TemplateDesc": "最終的 Xray 配置檔案將基於此模板生成",
|
||||
"FreedomStrategy": "Freedom 協議策略",
|
||||
@@ -1499,10 +1391,7 @@
|
||||
"outboundTestUrlDesc": "測試出站連線時使用的 URL",
|
||||
"Torrent": "遮蔽 BitTorrent 協議",
|
||||
"Inbounds": "入站",
|
||||
"InboundsDesc": "接受來自特定客戶端的流量",
|
||||
"Outbounds": "出站",
|
||||
"OutboundSubscriptions": "出站訂閱",
|
||||
"OutboundSubscriptionsDesc": "從遠端訂閱 URL(vmess/vless/trojan/ss/...)匯入出站。標籤會保持穩定,以便在負載均衡與路由規則中使用。系統會自動更新。",
|
||||
"Balancers": "負載均衡",
|
||||
"balancerTagRequired": "標籤為必填",
|
||||
"balancerSelectorRequired": "至少選擇一個出站",
|
||||
@@ -1521,9 +1410,7 @@
|
||||
"routeTesterMatchedOutbound": "匹配出站",
|
||||
"routeTesterViaBalancer": "經由負載均衡器",
|
||||
"routeTesterDefaultOutbound": "無路由規則匹配 — 流量將導向預設(第一個)出站。",
|
||||
"OutboundsDesc": "設定出站流量傳出方式",
|
||||
"Routings": "路由規則",
|
||||
"RoutingsDesc": "每條規則的優先順序都很重要",
|
||||
"completeTemplate": "全部",
|
||||
"logLevel": "日誌級別",
|
||||
"logLevelDesc": "錯誤日誌的日誌級別,用於指示需要記錄的資訊",
|
||||
@@ -1537,13 +1424,9 @@
|
||||
"maskAddressDesc": "IP 地址掩碼,啟用時會自動替換日誌中出現的 IP 地址。",
|
||||
"statistics": "統計",
|
||||
"statsInboundUplink": "入站上傳統計",
|
||||
"statsInboundUplinkDesc": "啟用所有入站代理的上行流量統計收集。",
|
||||
"statsInboundDownlink": "入站下載統計",
|
||||
"statsInboundDownlinkDesc": "啟用所有入站代理的下行流量統計收集。",
|
||||
"statsOutboundUplink": "出站上傳統計",
|
||||
"statsOutboundUplinkDesc": "啟用所有出站代理的上行流量統計收集。",
|
||||
"statsOutboundDownlink": "出站下載統計",
|
||||
"statsOutboundDownlinkDesc": "啟用所有出站代理的下行流量統計收集。",
|
||||
"connectionLimits": "連線限制",
|
||||
"connectionLimitsDesc": "使用者等級 0 的連線層級原則。留空則使用 Xray 的預設值。",
|
||||
"connIdle": "閒置逾時",
|
||||
@@ -1553,18 +1436,10 @@
|
||||
"bufferSizePlaceholder": "自動",
|
||||
"seconds": "秒",
|
||||
"rules": {
|
||||
"first": "置頂",
|
||||
"last": "置底",
|
||||
"up": "向上",
|
||||
"down": "向下",
|
||||
"source": "來源",
|
||||
"dest": "目的地址",
|
||||
"inbound": "入站",
|
||||
"outbound": "出站",
|
||||
"balancer": "負載均衡",
|
||||
"info": "資訊",
|
||||
"add": "新增規則",
|
||||
"edit": "編輯規則",
|
||||
"useComma": "逗號分隔的項目"
|
||||
},
|
||||
"routing": {
|
||||
@@ -1605,7 +1480,6 @@
|
||||
"ruleN": "規則 {n}",
|
||||
"action": "動作",
|
||||
"redirect": "Redirect",
|
||||
"fragment": "Fragment",
|
||||
"finalRules": "最終規則",
|
||||
"overrideXrayPrivateIp": "覆寫 Xray 預設的私有 IP 封鎖",
|
||||
"blockDelay": "阻斷延遲 (ms)",
|
||||
@@ -1631,43 +1505,17 @@
|
||||
"keepAliveInterval": "keep alive 間隔",
|
||||
"markFwmark": "Mark (fwmark)",
|
||||
"interface": "介面",
|
||||
"ipv6Only": "僅 IPv6",
|
||||
"acceptProxyProtocol": "接受 proxy protocol",
|
||||
"proxyProtocol": "Proxy protocol",
|
||||
"tcpUserTimeoutMs": "TCP user timeout (ms)",
|
||||
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
|
||||
},
|
||||
"outbound": {
|
||||
"addOutbound": "新增出站",
|
||||
"addReverse": "新增反向",
|
||||
"editOutbound": "編輯出站",
|
||||
"editReverse": "編輯反向",
|
||||
"reverseTag": "反向標籤",
|
||||
"reverseTagDesc": "VLESS 簡易反向代理出站標籤。留空則停用。設定後,此客戶端的連線可作為反向代理隧道。",
|
||||
"reverseTagPlaceholder": "出站標籤(留空則停用)",
|
||||
"tag": "標籤",
|
||||
"tagDesc": "唯一標籤",
|
||||
"address": "地址",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "反向",
|
||||
"domain": "網域",
|
||||
"type": "類型",
|
||||
"bridge": "Bridge",
|
||||
"portal": "Portal",
|
||||
"link": "連結",
|
||||
"intercon": "互連",
|
||||
"settings": "設定",
|
||||
"accountInfo": "帳戶資訊",
|
||||
"outboundStatus": "出站狀態",
|
||||
"sendThrough": "傳送通過",
|
||||
"targetStrategy": "目標解析策略",
|
||||
"test": "測試",
|
||||
"testResult": "測試結果",
|
||||
"testing": "正在測試連接...",
|
||||
"testSuccess": "測試成功",
|
||||
"testFailed": "測試失敗",
|
||||
"testError": "測試出站失敗",
|
||||
"modeRealDelay": "真實延遲",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。真實延遲: 含建立連線的總耗時。",
|
||||
"testAll": "全部測試",
|
||||
@@ -1675,14 +1523,10 @@
|
||||
"breakdownConnect": "代理連線",
|
||||
"breakdownTls": "經由出站的 TLS",
|
||||
"breakdownTtfb": "首位元組",
|
||||
"nordvpn": "NordVPN",
|
||||
"accessToken": "訪問令牌",
|
||||
"country": "國家",
|
||||
"server": "伺服器",
|
||||
"city": "城市",
|
||||
"allCities": "所有城市",
|
||||
"privateKey": "私密金鑰",
|
||||
"load": "負載",
|
||||
"moveToTop": "移到頂部"
|
||||
},
|
||||
"outboundSub": {
|
||||
@@ -1712,16 +1556,11 @@
|
||||
"active": "啟用中的訂閱",
|
||||
"empty": "尚無訂閱。請從上方新增。",
|
||||
"colRemark": "備註",
|
||||
"colPrefix": "前綴",
|
||||
"colInterval": "間隔",
|
||||
"colLastFetch": "上次抓取",
|
||||
"colEnabled": "啟用",
|
||||
"auto": "自動",
|
||||
"never": "從不",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"refreshNow": "立即重新整理",
|
||||
"lastError": "上次錯誤",
|
||||
"deleteConfirm": "確定要刪除此訂閱嗎?",
|
||||
"restartHint": "新增或重新整理後,請重新啟動 Xray(或等待下次自動重新載入),讓出站生效。",
|
||||
"fromSubsTitle": "來自出站訂閱(唯讀)",
|
||||
@@ -1738,8 +1577,6 @@
|
||||
"tabBalancerSettings": "負載平衡設定",
|
||||
"tabObservatory": "觀測器",
|
||||
"observatory": {
|
||||
"title": "觀測器",
|
||||
"burstTitle": "突發觀測器",
|
||||
"autoManaged": "觀測器會根據你的負載平衡器自動管理。可在下方調整探測方式;被觀測的出站會跟隨負載平衡器的選擇器。",
|
||||
"emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓依賴觀測器的負載平衡器能在選擇目標前檢查出站健康狀態。",
|
||||
"mixedLegacy": "此設定同時包含 Observatory 與 Burst Observatory。Xray 只使用一個全域觀測器,因此不支援這種舊式混合狀態;儲存負載平衡器時會將其正規化為單一觀測器。",
|
||||
@@ -1773,12 +1610,8 @@
|
||||
"balancerRemoved": "負載平衡器 {tag} — 已移除(沒有剩餘目標)"
|
||||
},
|
||||
"balancer": {
|
||||
"addBalancer": "新增負載均衡",
|
||||
"editBalancer": "編輯負載均衡",
|
||||
"balancerStrategy": "策略",
|
||||
"balancerSelectors": "選擇器",
|
||||
"tag": "標籤",
|
||||
"tagDesc": "唯一標籤",
|
||||
"tagDuplicate": "該標籤已被其他均衡器使用",
|
||||
"tagPlaceholder": "唯一均衡器標籤",
|
||||
"selector": "選擇器",
|
||||
@@ -1790,7 +1623,6 @@
|
||||
"tolerance": "容差",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。",
|
||||
"costMatch": "標籤比對模式",
|
||||
"costValue": "權重",
|
||||
"costRegexp": "正規表示式比對",
|
||||
@@ -1805,7 +1637,6 @@
|
||||
"publicKey": "公鑰",
|
||||
"allowedIPs": "允許的 IP",
|
||||
"endpoint": "端點",
|
||||
"psk": "共享金鑰",
|
||||
"domainStrategy": "域策略"
|
||||
},
|
||||
"amneziawg": {
|
||||
@@ -1843,10 +1674,7 @@
|
||||
"i1Hint": "僅適用於 AmneziaWG 2.0。留空則相容 1.x。"
|
||||
},
|
||||
"tun": {
|
||||
"nameDesc": "TUN 介面的名稱。預設值為 'xray0'",
|
||||
"mtuDesc": "最大傳輸單元。資料包的最大大小。預設值為 1500",
|
||||
"userLevel": "用戶級別",
|
||||
"userLevelDesc": "通過此入站的所有連接都將使用此用戶級別。預設值為 0"
|
||||
"userLevel": "用戶級別"
|
||||
},
|
||||
"nord": {
|
||||
"accessToken": "Access token",
|
||||
@@ -1890,28 +1718,28 @@
|
||||
"enableDesc": "啟用內建 DNS 伺服器",
|
||||
"tag": "DNS 入站標籤",
|
||||
"tagDesc": "此標籤將在路由規則中可用作入站標籤",
|
||||
"clientIp": "客戶端IP",
|
||||
"clientIpDesc": "用於在DNS查詢期間通知伺服器指定的IP位置",
|
||||
"clientIp": "客戶端 IP",
|
||||
"clientIpDesc": "用於在 DNS 查詢期間通知伺服器指定的 IP 位置",
|
||||
"disableCache": "禁用快取",
|
||||
"disableCacheDesc": "禁用DNS快取",
|
||||
"disableCacheDesc": "禁用 DNS 快取",
|
||||
"disableFallback": "禁用回退",
|
||||
"disableFallbackDesc": "禁用回退DNS查詢",
|
||||
"disableFallbackDesc": "禁用回退 DNS 查詢",
|
||||
"disableFallbackIfMatch": "匹配時禁用回退",
|
||||
"disableFallbackIfMatchDesc": "當DNS伺服器的匹配域名列表命中時,禁用回退DNS查詢",
|
||||
"disableFallbackIfMatchDesc": "當 DNS 伺服器的匹配域名列表命中時,禁用回退 DNS 查詢",
|
||||
"enableParallelQuery": "啟用並行查詢",
|
||||
"enableParallelQueryDesc": "啟用並行DNS查詢到多個伺服器以實現更快的解析",
|
||||
"enableParallelQueryDesc": "啟用並行 DNS 查詢到多個伺服器以實現更快的解析",
|
||||
"strategy": "查詢策略",
|
||||
"strategyDesc": "解析域名的總體策略",
|
||||
"add": "新增伺服器",
|
||||
"edit": "編輯伺服器",
|
||||
"domains": "網域",
|
||||
"expectIPs": "預期 IP",
|
||||
"unexpectIPs": "意外IP",
|
||||
"useSystemHosts": "使用系統Hosts",
|
||||
"useSystemHostsDesc": "使用已安裝系統的hosts檔案",
|
||||
"unexpectIPs": "意外 IP",
|
||||
"useSystemHosts": "使用系統 Hosts",
|
||||
"useSystemHostsDesc": "使用已安裝系統的 hosts 檔案",
|
||||
"serveStale": "提供過期結果",
|
||||
"serveStaleDesc": "在背景重新整理時傳回過期的快取結果",
|
||||
"serveExpiredTTL": "過期TTL",
|
||||
"serveExpiredTTL": "過期 TTL",
|
||||
"serveExpiredTTLDesc": "過期快取項目的有效期(秒);0 = 永不過期",
|
||||
"timeoutMs": "逾時 (毫秒)",
|
||||
"skipFallback": "跳過回退",
|
||||
@@ -1922,7 +1750,7 @@
|
||||
"hostsDomain": "網域 (例如 domain:example.com)",
|
||||
"hostsValues": "IP 或網域 — 輸入後按 Enter",
|
||||
"usePreset": "使用範本",
|
||||
"dnsPresetTitle": "DNS範本",
|
||||
"dnsPresetTitle": "DNS 範本",
|
||||
"dnsPresetFamily": "家庭",
|
||||
"clearAll": "全部刪除",
|
||||
"clearAllTitle": "刪除所有 DNS 伺服器?",
|
||||
@@ -1931,7 +1759,6 @@
|
||||
},
|
||||
"fakedns": {
|
||||
"add": "新增假 DNS",
|
||||
"edit": "編輯假 DNS",
|
||||
"ipPool": "IP 池子網",
|
||||
"poolSize": "池大小"
|
||||
},
|
||||
@@ -2061,13 +1888,12 @@
|
||||
"noResult": "❗ 沒有結果!",
|
||||
"noQuery": "❌ 未找到查詢!請再次使用該命令!",
|
||||
"wentWrong": "❌ 出了點問題!",
|
||||
"noIpRecord": "❗ 沒有IP記錄!",
|
||||
"noIpRecord": "❗ 沒有 IP 記錄!",
|
||||
"noInbounds": "❗ 未找到入站!",
|
||||
"unlimited": "♾ 無限(重置)",
|
||||
"add": "添加",
|
||||
"month": "月",
|
||||
"months": "月",
|
||||
"day": "天",
|
||||
"days": "天",
|
||||
"hours": "小時",
|
||||
"minutes": "分鐘",
|
||||
@@ -2085,8 +1911,8 @@
|
||||
"status": "✅ 機器人正常執行!",
|
||||
"usage": "❗ 請輸入要搜尋的文字!",
|
||||
"getID": "🆔 您的 ID 為:<code>{{ .ID }}</code>",
|
||||
"helpAdminCommands": "要重新啟動 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜尋客戶電子郵件:\r\n<code>/usage [電子郵件]</code>\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n<code>/inbound [備註]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n<code>/usage [電子郵件]</code>\r\n\r\nTelegram聊天ID:\r\n<code>/id</code>",
|
||||
"helpAdminCommands": "要重新啟動 Xray Core:\r\n<code>/restart</code>\r\n\r\n要搜尋客戶電子郵件:\r\n<code>/usage [電子郵件]</code>\r\n\r\n要搜尋入站(帶有客戶統計資料):\r\n<code>/inbound [備註]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"helpClientCommands": "要搜尋統計資料,請使用以下命令:\r\n<code>/usage [電子郵件]</code>\r\n\r\nTelegram 聊天 ID:\r\n<code>/id</code>",
|
||||
"restartUsage": "\r\n\r\n<code>/restart</code>",
|
||||
"restartSuccess": "✅ 操作成功!",
|
||||
"restartFailed": "❗ 操作錯誤。\r\n\r\n<code>錯誤: {{ .Error }}</code>.",
|
||||
@@ -2106,7 +1932,6 @@
|
||||
"userSaved": "✅ 電報使用者已儲存。",
|
||||
"loginSuccess": "✅ 成功登入到面板。\r\n",
|
||||
"loginFailed": "❗️ 面板登入失敗。\r\n",
|
||||
"2faFailed": "2FA 失敗",
|
||||
"report": "🕰 定時報告:{{ .RunTime }}\r\n",
|
||||
"datetime": "⏰ 日期時間:{{ .DateTime }}\r\n",
|
||||
"hostname": "💻 主機: {{ .Hostname }}\r\n",
|
||||
@@ -2139,7 +1964,6 @@
|
||||
"download": "🔽 下載: ↓{{ .Download }}\r\n",
|
||||
"total": "📊 總計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
|
||||
"TGUser": "👤 電報使用者:{{ .TelegramID }}\r\n",
|
||||
"exhaustedMsg": "🚨 耗盡的 {{ .Type }}:\r\n",
|
||||
"exhaustedCount": "🚨 耗盡的 {{ .Type }} 數量:\r\n",
|
||||
"onlinesCount": "🌐 線上客戶:{{ .Count }}\r\n",
|
||||
"disabled": "🛑 禁用:{{ .Disabled }}\r\n",
|
||||
@@ -2148,16 +1972,10 @@
|
||||
"refreshedOn": "\r\n📋🔄 重新整理時間:{{ .Time }}\r\n\r\n",
|
||||
"yes": "✅ 是的",
|
||||
"no": "❌ 否",
|
||||
"received_id": "🔑📥 ID 已更新。",
|
||||
"received_password": "🔑📥 密碼已更新。",
|
||||
"received_email": "📧📥 電子郵件已更新。",
|
||||
"received_comment": "💬📥 評論已更新。",
|
||||
"id_prompt": "🔑 預設 ID: {{ .ClientId }}\n\n請輸入您的 ID。",
|
||||
"pass_prompt": "🔑 預設密碼: {{ .ClientPassword }}\n\n請輸入您的密碼。",
|
||||
"email_prompt": "📧 預設電子郵件: {{ .ClientEmail }}\n\n請輸入您的電子郵件。",
|
||||
"comment_prompt": "💬 預設評論: {{ .ClientComment }}\n\n請輸入您的評論。",
|
||||
"inbound_client_data_id": "🔄 入站: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 電子郵件: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 備註: {{ .ClientComment }}\n\n你現在可以將客戶加入入站了!",
|
||||
"inbound_client_data_pass": "🔄 入站: {{ .InboundRemark }}\n\n🔑 密碼: {{ .ClientPass }}\n📧 電子郵件: {{ .ClientEmail }}\n📊 流量: {{ .ClientTraffic }}\n📅 到期日: {{ .ClientExp }}\n🌐 IP 限制: {{ .IpLimit }}\n💬 備註: {{ .ClientComment }}\n\n你現在可以將客戶加入入站了!",
|
||||
"cancel": "❌ 程序已取消!\n\n您可以隨時使用 /start 重新開始。 🔄",
|
||||
"error_add_client": "⚠️ 錯誤:\n\n {{ .error }}",
|
||||
"using_default_value": "好的,我會使用預設值。 😊",
|
||||
@@ -2174,8 +1992,6 @@
|
||||
"eventXrayCrashError": "錯誤:{{ .Error }}",
|
||||
"eventNodeDown": "節點 {{ .Name }} 已離線",
|
||||
"eventNodeUp": "節點 {{ .Name }} 已上線",
|
||||
"eventCPUHigh": "CPU 偏高",
|
||||
"eventCPUHighDetail": "CPU:{{ .Detail }}",
|
||||
"eventLoginFallback": "來自 {{ .Source }} 的登入失敗",
|
||||
"memoryThreshold": "記憶體使用率 {{ .Percent }}% 超過閾值 {{ .Threshold }}%"
|
||||
},
|
||||
@@ -2216,11 +2032,8 @@
|
||||
"submitDisable": "以停用方式送出 ☑️",
|
||||
"submitEnable": "以啟用方式送出 ✅",
|
||||
"use_default": "🏷️ 使用預設值",
|
||||
"change_id": "⚙️🔑 ID",
|
||||
"change_password": "⚙️🔑 密碼",
|
||||
"change_email": "⚙️📧 電子郵件",
|
||||
"change_comment": "⚙️💬 評論",
|
||||
"change_flow": "⚙️🚦 Flow",
|
||||
"ResetAllTraffics": "重設所有流量",
|
||||
"SortedTrafficUsageReport": "排序過的流量使用報告"
|
||||
},
|
||||
@@ -2249,31 +2062,16 @@
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "出站 {{ .Tag }} 已中斷",
|
||||
"subjectOutboundUp": "出站 {{ .Tag }} 已恢復",
|
||||
"subjectXrayCrash": "Xray 已當機",
|
||||
"subjectCPUHigh": "CPU 偏高",
|
||||
"subjectLoginSuccess": "登入成功",
|
||||
"subjectLoginFailed": "登入失敗",
|
||||
"titleOutboundDown": "出站中斷",
|
||||
"titleOutboundUp": "出站恢復",
|
||||
"titleXrayCrash": "Xray 已當機",
|
||||
"titleCPUHigh": "CPU 偏高",
|
||||
"titleLoginSuccess": "登入成功",
|
||||
"titleLoginFailed": "登入失敗",
|
||||
"labelStatus": "狀態",
|
||||
"labelOutbound": "出站",
|
||||
"labelNode": "節點",
|
||||
"labelError": "錯誤",
|
||||
"labelDelay": "延遲",
|
||||
"labelDetail": "詳細資訊",
|
||||
"labelUsername": "使用者名稱",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "原因",
|
||||
"labelSource": "來源",
|
||||
"labelTime": "時間",
|
||||
"statusCrashed": "已當機",
|
||||
"statusRunning": "執行中",
|
||||
"statusHigh": "偏高",
|
||||
"statusSuccess": "成功",
|
||||
"statusFailed": "失敗",
|
||||
|
||||
+10
-8
@@ -248,7 +248,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
|
||||
s.index = controller.NewIndexController(g)
|
||||
s.panel = controller.NewXUIController(g)
|
||||
g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec)
|
||||
s.api = controller.NewAPIController(g)
|
||||
|
||||
// Initialize WebSocket hub
|
||||
@@ -294,6 +293,7 @@ const (
|
||||
cadenceNodeHeartbeat = "@every 5s"
|
||||
cadenceNodeTraffic = "@every 5s"
|
||||
cadenceOutboundSub = "@every 5m"
|
||||
cadenceReapOrphans = "@every 5m"
|
||||
cadenceXrayLogPrune = "@every 10m"
|
||||
cadenceCheckHash = "@every 2m"
|
||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||
@@ -304,7 +304,7 @@ const (
|
||||
|
||||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||
// jobs) which the panel relies on for periodic maintenance and monitoring.
|
||||
func (s *Server) startTask(restartXray bool) {
|
||||
func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
if restartXray {
|
||||
err := s.xrayService.RestartXray(true)
|
||||
if err != nil {
|
||||
@@ -344,6 +344,8 @@ func (s *Server) startTask(restartXray bool) {
|
||||
// Outbound subscription auto-refresh (respects per-sub updateInterval)
|
||||
_, _ = s.cron.AddJob(cadenceOutboundSub, job.NewOutboundSubscriptionJob())
|
||||
|
||||
_, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
|
||||
|
||||
// check client ips from log file every day
|
||||
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||
_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
|
||||
@@ -351,13 +353,13 @@ func (s *Server) startTask(restartXray bool) {
|
||||
|
||||
// Inbound traffic reset jobs
|
||||
// Run every hour
|
||||
_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
|
||||
_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly", loc))
|
||||
// Run once a day, midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily", loc))
|
||||
// Run once a week, midnight between Sat/Sun
|
||||
_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
|
||||
// Run once a month, midnight, first of month
|
||||
_, _ = s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
|
||||
_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly", loc))
|
||||
// Check monthly reset days at midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
||||
|
||||
// LDAP sync scheduling
|
||||
if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
|
||||
@@ -658,7 +660,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
}
|
||||
})
|
||||
|
||||
s.startTask(restartXray)
|
||||
s.startTask(restartXray, loc)
|
||||
|
||||
if startTgBot {
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
|
||||
@@ -76,9 +76,6 @@ func BroadcastInbounds(inbounds any) {
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastNodes broadcasts the fresh node list to all connected clients.
|
||||
// Pushed by NodeHeartbeatJob at the end of each 10s tick so the Nodes page
|
||||
// reflects status / latency / cpu / mem updates without polling.
|
||||
func BroadcastNodes(nodes any) {
|
||||
if hub := GetHub(); hub != nil {
|
||||
hub.Broadcast(MessageTypeNodes, nodes)
|
||||
|
||||
Reference in New Issue
Block a user