mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 04:17:15 +00:00
feat(sub): client-side balancers for the JSON subscription (#6243)
* feat(sub): add SubBalancer model and migration Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up. * feat(sub): add SubBalancer service List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart. * feat(sub): add SubBalancer API controller and routes GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it. * feat(sub): emit client-side balancers in JSON subscription For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present. * test(sub): cover SubBalancer service and JSON output Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup. * feat(sub): add sub-balancers i18n keys pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales. * feat(sub): add SubBalancer schema and API queries Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key. * feat(sub): add subscription balancers settings tab SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled. * test(sub): add SubBalancer form modal test Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled). * feat(sub): register sub-balancers in API docs and OpenAPI Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it. * docs: sync openapi.json with frontend docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API. * docs: add subscription balancers API reference Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations. * feat(sub): accept roundRobin balancer strategy Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist. * feat(sub): add roundRobin strategy label pages.settings.subBalancers.strategyRoundRobin added to all 13 locales. * feat(sub): expose roundRobin in balancer form Zod strategy enum, form modal label key, and table strategy colour for roundRobin. * docs(sub): list roundRobin in strategy description The create/update strategy param description now mentions roundRobin alongside the other three. * feat(sub): add subJsonObservatory setting Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask. * feat(sub): wire observatory config through sub controller WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction. * feat(sub): emit observatory conditionally with configurable probes burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override. * feat(sub): add subJsonObservatory to AllSetting model Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string. * feat(sub): add balancer observatory config card New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults. * fix(sub): hide save/restart header on sub-balancers tab Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly. * feat(sub): add observatory config i18n keys pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales. * feat(sub): regenerate openapi for subJsonObservatory openapigen picks up the new AllSetting field; openapi.json synced into docs. * feat(sub): add observatory tab to sub-balancers Mirrors the Xray Balancers page: two tabs (Balancers + Observatory). Wires allSetting/updateSetting into the tab and adds tabBalancers / tabObservatory labels to all locales. The page Save header is shown again on this tab so the observatory config can be saved. * refactor(sub): drop observatory tab from sub-formats Now that the observatory config lives under sub-balancers, remove the duplicate tab plus its state and defaults from sub-formats. * fix(sub): add missing inboundsCount i18n key The sub-balancers table rendered the raw key path in the Inbounds column because pages.settings.subBalancers.inboundsCount was not defined. Added it to all 13 locales. * test(sub): pin disabled-inbound exclusion from balancer The balancer builds its members from the subscriber's already-filtered entry set, so an inbound disabled for that user can never surface as a member. Adds tests for both shapes (one of several disabled, and the only selected one disabled). * fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback Three coupled defects on the balancer observatory surface, flagged in PR review: - The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this. - Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value. - routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking. Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it. * fix(sub-balancer): localize controller toasts and reject malformed ids Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success. * fix(sub-balancer): enforce remark length cap server-side The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap. * fix(sub-balancer): exclude mtproto from balancer member picker SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment. * docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope - Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only). - Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX. - Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror. * style(model): trim SubBalancer comment to 2-line cap CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3. * fix(sub-balancer): parse enabled explicitly and preserve it on partial update parseSubBalancerForm treated any non-"false" value as true (so "bogus" silently enabled) and always overwrote Enabled on update, so a PATCH that omitted the toggle reset a disabled balancer back to enabled. Parse the field with strconv.ParseBool and return *bool: absent means "no change" on update and "true" on create; a malformed value is rejected as 400. Update keeps the stored Enabled when the pointer is nil. * fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds DelInbound cascaded hosts but left the deleted inbound id in every sub_balancers.InboundIds, so the balancer kept emitting a member no subscriber could resolve — a dangling outbound tag with no proxy behind it. Strip the id inside the existing delete transaction (same shape as the hosts cascade, #5648); with the last member gone the balancer stops emitting. * fix(sub-balancer): return not-found when deleting a missing balancer Delete returned the gorm result error only, which is nil when no row matched, so the controller reported success:true for an id that never existed — a stale UI row looked like a clean delete. Check RowsAffected and return a not-found error on 0 so the toast reflects reality. * style(sub): shorten leastPing/leastLoad observatory comments The observatory-emission guard comment and its test comment ran a few lines long; trim them to a couple of lines each without dropping the invariant that leastPing/leastLoad require a burst observatory. * fix(sub): validate observatory setting instead of silently dropping it SetObservatoryConfig applied whatever survived json.Unmarshal with no checks, so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even unparseable JSON was either silently applied or silently ignored. Validate each field: parse durations with time.ParseDuration, require http(s) URLs for destination/connectivity, and log a warning naming the field and the bad value on every fallback — including the unmarshal error, which was a quiet return. Bad values now keep the built-in defaults instead of leaking into the emitted burstObservatory. * fix(sub): deduplicate burst-observatory defaults across Go and frontend The burst-observatory ping defaults lived in three places that had drifted: Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity pre-check URL). Align them to one set: https probe destination, sampling 2, and empty connectivity (skip the direct pre-check). The settings tab now parses the stored JSON through PingConfigSchema and seeds its default from DEFAULT_BURST_OBSERVATORY instead of carrying its own literal. * refactor(sub): extract proxy outbounds once before the balancer loop buildBalancerConfig unmarshalled every inbound document and re-extracted its first outbound on each balancer, so with B balancers and N inbound docs the same document was parsed B*N times. Pull each doc's proxy outbound in a single pre-pass over the entries and cache it per entry; buildBalancerConfig now clones the cached map before retagging, so one parse serves every balancer. Output is byte-for-byte unchanged. * fix(sub): form balancer member tags from the inbound protocol, not tcp→vless balancerTransport derived the bal-N tag suffix from the outbound's transport network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was mislabelled "vless" in every client config — the tag lied about the proxy type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless, bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and dedup suffix are unchanged. Update the existing tag assertions and add a vmess case that fails under the old mapping. * fix(sub-balancer): default strategy to random in the create form The create-balancer form seeded strategy to 'leastLoad', but the service validate() defaults an empty strategy to 'random' and the API docs say the default is 'random' — so a freshly opened form showed leastLoad while saving without touching the field silently stored random. Align the form default to 'random' so what the admin sees is what gets persisted. * feat(api-docs): document the SubBalancer response schema The five sub-balancer endpoints carried no responseSchema, so the API docs page rendered them without a typed example. Add example: tags to every SubBalancer field, allow the struct through openapigen, and point the list (responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/. * style(sub-balancer): drop whitespace-only separator lines, add final newline subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as separators between statements and had no trailing newline. Replace them with clean empty blank lines and end each file with a newline. * fix(i18n): translate sub-balancer toasts and observatory note The sub-balancer toast messages (list/create/update/delete/invalidId) and the observatory note were left in English across 11 non-English locales (ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key in the subBalancers block was already translated. Translate them to match the meaning and terminology of the surrounding keys in each file; the JSON structure and keys are unchanged. * fix(sub-balancer): hide disabled inbounds from the member picker The picker offered every protocol-eligible inbound regardless of its enable flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled member is therefore dropped from every subscriber's entries, and when it was the balancer's only member the balancer document silently stops being emitted — with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll MembersDisabled already documents that backend behavior. Filter the way the sibling client picker has since #5645: hide disabled inbounds, but keep one that is already selected so editing an existing balancer cannot silently drop a member. Drop the `?? []` on the useWatch result so the new useMemo dependency stays referentially stable. * style(sub): trim the balancerMemberSuffix comment to the 2-line cap Comment blocks in committed Go are capped at 2 lines; the name already carries what the function picks, so keep only the why. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -201,6 +201,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
a.settingController = NewSettingController(api)
|
||||
a.xraySettingController = NewXraySettingController(api)
|
||||
|
||||
// Subscription balancers — client-side balancers for the JSON sub output
|
||||
NewSubBalancerController(api)
|
||||
|
||||
// Extra routes
|
||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// SubBalancerController manages client-side JSON-subscription balancers.
|
||||
type SubBalancerController struct {
|
||||
SubBalancerService service.SubBalancerService
|
||||
}
|
||||
|
||||
func NewSubBalancerController(g *gin.RouterGroup) *SubBalancerController {
|
||||
a := &SubBalancerController{}
|
||||
g = g.Group("/sub-balancers")
|
||||
g.GET("", a.list)
|
||||
g.POST("", a.create)
|
||||
g.POST("/:id", a.update)
|
||||
g.DELETE("/:id", a.del)
|
||||
g.POST("/:id/del", a.del)
|
||||
return a
|
||||
}
|
||||
|
||||
// parseSubBalancerForm reads the urlencoded form (HttpUtil default): scalars
|
||||
// via ShouldBind, inboundIds as repeated keys. enabled is returned as *bool so
|
||||
// Update can keep the stored value when the key is absent; a bad value is a 400.
|
||||
func parseSubBalancerForm(c *gin.Context) (*model.SubBalancer, *bool, error) {
|
||||
form := struct {
|
||||
Remark string `form:"remark"`
|
||||
Strategy string `form:"strategy"`
|
||||
SortOrder int `form:"sortOrder"`
|
||||
}{}
|
||||
if err := c.ShouldBind(&form); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var enabled *bool
|
||||
if raw, ok := c.GetPostForm("enabled"); ok {
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid enabled %q: %w", raw, err)
|
||||
}
|
||||
enabled = &v
|
||||
}
|
||||
balancer := &model.SubBalancer{
|
||||
Remark: form.Remark,
|
||||
Strategy: form.Strategy,
|
||||
SortOrder: form.SortOrder,
|
||||
}
|
||||
for _, raw := range c.PostFormArray("inboundIds") {
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid inbound id %q: %w", raw, err)
|
||||
}
|
||||
balancer.InboundIds = append(balancer.InboundIds, id)
|
||||
}
|
||||
return balancer, enabled, nil
|
||||
}
|
||||
|
||||
func (a *SubBalancerController) parseID(c *gin.Context) (int, error) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id < 1 {
|
||||
return 0, fmt.Errorf("invalid id %q", c.Param("id"))
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *SubBalancerController) list(c *gin.Context) {
|
||||
balancers, err := a.SubBalancerService.List()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.list"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, balancers, nil)
|
||||
}
|
||||
|
||||
func (a *SubBalancerController) create(c *gin.Context) {
|
||||
balancer, enabled, err := parseSubBalancerForm(c)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
|
||||
return
|
||||
}
|
||||
balancer.Enabled = enabled == nil || *enabled
|
||||
created, err := a.SubBalancerService.Create(balancer)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, created, nil)
|
||||
}
|
||||
|
||||
func (a *SubBalancerController) update(c *gin.Context) {
|
||||
id, err := a.parseID(c)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
|
||||
return
|
||||
}
|
||||
balancer, enabled, err := parseSubBalancerForm(c)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
updated, err := a.SubBalancerService.Update(id, balancer, enabled)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, updated, nil)
|
||||
}
|
||||
|
||||
func (a *SubBalancerController) del(c *gin.Context) {
|
||||
id, err := a.parseID(c)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
|
||||
return
|
||||
}
|
||||
if err := a.SubBalancerService.Delete(id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, "", nil)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
func setupSubBalancerRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
NewSubBalancerController(router.Group("/panel/api"))
|
||||
return router
|
||||
}
|
||||
|
||||
func subBalancerPost(t *testing.T, router *gin.Engine, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
return resp
|
||||
}
|
||||
|
||||
func responseObj(t *testing.T, body string) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", body, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// enabled absent on create defaults to true; "false" disables; a non-boolean
|
||||
// value is rejected so a malformed toggle can't silently flip the row.
|
||||
func TestSubBalancerController_EnabledParsing(t *testing.T) {
|
||||
router := setupSubBalancerRouter(t)
|
||||
base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
|
||||
|
||||
resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base)
|
||||
if !strings.Contains(resp.Body.String(), `"success":true`) {
|
||||
t.Fatalf("create no enabled: %s", resp.Body.String())
|
||||
}
|
||||
bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
|
||||
if bal["enabled"] != true {
|
||||
t.Fatalf("absent enabled = %v, want true", bal["enabled"])
|
||||
}
|
||||
|
||||
resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
|
||||
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
|
||||
if bal["enabled"] != false {
|
||||
t.Fatalf("enabled=false -> %v, want false", bal["enabled"])
|
||||
}
|
||||
|
||||
resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=bogus")
|
||||
if !strings.Contains(resp.Body.String(), `"success":false`) {
|
||||
t.Fatalf("enabled=bogus should be rejected: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An update omitting enabled preserves the stored value instead of resetting it
|
||||
// to the create default — a partial PATCH must not clobber the toggle.
|
||||
func TestSubBalancerController_UpdatePreservesEnabledWhenAbsent(t *testing.T) {
|
||||
router := setupSubBalancerRouter(t)
|
||||
base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
|
||||
|
||||
resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
|
||||
bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
|
||||
id := strconv.Itoa(int(bal["id"].(float64)))
|
||||
if bal["enabled"] != false {
|
||||
t.Fatalf("setup: enabled = %v, want false", bal["enabled"])
|
||||
}
|
||||
|
||||
resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1")
|
||||
if !strings.Contains(resp.Body.String(), `"success":true`) {
|
||||
t.Fatalf("update: %s", resp.Body.String())
|
||||
}
|
||||
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
|
||||
if bal["enabled"] != false {
|
||||
t.Fatalf("update without enabled = %v, want preserved false", bal["enabled"])
|
||||
}
|
||||
if bal["remark"] != "renamed" {
|
||||
t.Fatalf("remark = %v, want renamed", bal["remark"])
|
||||
}
|
||||
|
||||
resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1&enabled=true")
|
||||
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
|
||||
if bal["enabled"] != true {
|
||||
t.Fatalf("enabled=true -> %v, want true", bal["enabled"])
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ type AllSetting struct {
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
|
||||
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
|
||||
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -1188,6 +1189,22 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||
if err := tx.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Drop the deleted inbound from any sub-balancer that selects it; a
|
||||
// dangling id would emit a member no subscriber can resolve (#5648).
|
||||
var balancers []model.SubBalancer
|
||||
if err := tx.Find(&balancers).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range balancers {
|
||||
before := balancers[i].InboundIds
|
||||
balancers[i].InboundIds = slices.DeleteFunc(before, func(b int) bool { return b == id })
|
||||
if len(balancers[i].InboundIds) == len(before) {
|
||||
continue
|
||||
}
|
||||
if err := tx.Save(&balancers[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if loadErr == nil && ib.NodeID != nil {
|
||||
return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ var defaultValueMap = map[string]string{
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonObservatory": "",
|
||||
"subThemeDir": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
@@ -893,6 +894,10 @@ func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
||||
return s.getString("subJsonFinalMask")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonObservatory() (string, error) {
|
||||
return s.getString("subJsonObservatory")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubThemeDir() (string, error) {
|
||||
return s.getString("subThemeDir")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
)
|
||||
|
||||
var subBalancerStrategies = map[string]struct{}{
|
||||
"leastLoad": {},
|
||||
"leastPing": {},
|
||||
"random": {},
|
||||
"roundRobin": {},
|
||||
}
|
||||
|
||||
// SubBalancerService manages client-side JSON-subscription balancers; rows
|
||||
// are read per request by internal/sub, so mutations need no xray restart.
|
||||
type SubBalancerService struct{}
|
||||
|
||||
func (s *SubBalancerService) validate(b *model.SubBalancer) error {
|
||||
b.Remark = strings.TrimSpace(b.Remark)
|
||||
if b.Remark == "" {
|
||||
return common.NewError("balancer remark is required")
|
||||
}
|
||||
if len(b.Remark) > 256 {
|
||||
return common.NewError("balancer remark too long (max 256)")
|
||||
}
|
||||
if b.Strategy == "" {
|
||||
b.Strategy = "random"
|
||||
}
|
||||
if _, ok := subBalancerStrategies[b.Strategy]; !ok {
|
||||
return common.NewError("invalid balancer strategy:", b.Strategy)
|
||||
}
|
||||
if len(b.InboundIds) == 0 {
|
||||
return common.NewError("balancer must select at least one inbound")
|
||||
}
|
||||
if b.SortOrder < 1 {
|
||||
b.SortOrder = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all balancers in subscription order.
|
||||
func (s *SubBalancerService) List() ([]*model.SubBalancer, error) {
|
||||
var balancers []*model.SubBalancer
|
||||
err := database.GetDB().Model(&model.SubBalancer{}).
|
||||
Order("sort_order asc, id asc").Find(&balancers).Error
|
||||
return balancers, err
|
||||
}
|
||||
|
||||
func (s *SubBalancerService) Get(id int) (*model.SubBalancer, error) {
|
||||
var balancer model.SubBalancer
|
||||
if err := database.GetDB().First(&balancer, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &balancer, nil
|
||||
}
|
||||
|
||||
func (s *SubBalancerService) Create(balancer *model.SubBalancer) (*model.SubBalancer, error) {
|
||||
if err := s.validate(balancer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := database.GetDB().Create(balancer).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return balancer, nil
|
||||
}
|
||||
|
||||
func (s *SubBalancerService) Update(id int, balancer *model.SubBalancer, enabled *bool) (*model.SubBalancer, error) {
|
||||
if err := s.validate(balancer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := s.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current.Remark = balancer.Remark
|
||||
current.Strategy = balancer.Strategy
|
||||
current.InboundIds = balancer.InboundIds
|
||||
current.SortOrder = balancer.SortOrder
|
||||
if enabled != nil {
|
||||
current.Enabled = *enabled
|
||||
}
|
||||
if err := database.GetDB().Save(current).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *SubBalancerService) Delete(id int) error {
|
||||
res := database.GetDB().Delete(&model.SubBalancer{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return common.NewError("sub balancer not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Deleting an inbound that a sub-balancer selects must strip its id from
|
||||
// InboundIds, leaving no dangling member reference (#5648 mirrors the hosts
|
||||
// cascade). With the only member gone the balancer stops emitting a doc.
|
||||
func TestDelInboundClearsSubBalancerInboundIds(t *testing.T) {
|
||||
setupSubBalancerDB(t)
|
||||
ib := &model.Inbound{UserId: 1, Tag: "cleanup", Enable: false, Listen: "203.0.113.7", Port: 5001, Protocol: model.VLESS, Remark: "cleanup", Settings: `{}`, StreamSettings: `{}`}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
balSvc := &SubBalancerService{}
|
||||
bal, err := balSvc.Create(&model.SubBalancer{Remark: "bal", Strategy: "random", InboundIds: []int{ib.Id}, SortOrder: 1, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create balancer: %v", err)
|
||||
}
|
||||
|
||||
if _, err := (&InboundService{}).DelInbound(ib.Id); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
}
|
||||
|
||||
stored, err := balSvc.Get(bal.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get balancer: %v", err)
|
||||
}
|
||||
if len(stored.InboundIds) != 0 {
|
||||
t.Fatalf("InboundIds = %v, want empty (no dangling id)", stored.InboundIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
var subBalancerLoggerOnce sync.Once
|
||||
|
||||
func setupSubBalancerDB(t *testing.T) {
|
||||
t.Helper()
|
||||
subBalancerLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.CloseDB(); err != nil {
|
||||
t.Logf("CloseDB warning: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSubBalancerServiceCRUD(t *testing.T) {
|
||||
setupSubBalancerDB(t)
|
||||
svc := &SubBalancerService{}
|
||||
|
||||
created, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "auto", Strategy: "", InboundIds: []int{1, 2}, SortOrder: 0, Enabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Strategy != "random" {
|
||||
t.Fatalf("strategy = %q, want normalized random", created.Strategy)
|
||||
}
|
||||
if created.SortOrder != 1 {
|
||||
t.Fatalf("sortOrder = %d, want normalized 1", created.SortOrder)
|
||||
}
|
||||
stored, err := svc.Get(created.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if stored.Enabled {
|
||||
t.Fatal("explicit disabled balancer must be stored disabled")
|
||||
}
|
||||
|
||||
second, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "second", Strategy: "leastPing", InboundIds: []int{1}, SortOrder: 3, Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
|
||||
list, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 2 || list[0].Id != created.Id || list[1].Id != second.Id {
|
||||
t.Fatalf("list order = [%d %d], want [%d %d]", list[0].Id, list[1].Id, created.Id, second.Id)
|
||||
}
|
||||
|
||||
enabledFalse := false
|
||||
updated, err := svc.Update(second.Id, &model.SubBalancer{
|
||||
Remark: "renamed", Strategy: "leastLoad", InboundIds: []int{2}, SortOrder: 2,
|
||||
}, &enabledFalse)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.Remark != "renamed" || updated.Strategy != "leastLoad" || updated.SortOrder != 2 || updated.Enabled {
|
||||
t.Fatalf("update stored wrong row: %+v", updated)
|
||||
}
|
||||
after, err := svc.Get(second.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get after update: %v", err)
|
||||
}
|
||||
if after.Enabled || after.Strategy != "leastLoad" || len(after.InboundIds) != 1 || after.InboundIds[0] != 2 {
|
||||
t.Fatalf("update did not persist: %+v", after)
|
||||
}
|
||||
|
||||
if err := svc.Delete(created.Id); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
list, err = svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list after delete: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Id != second.Id {
|
||||
t.Fatalf("list after delete = %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// roundRobin is a valid xray routing strategy (selects outbounds in order) and
|
||||
// must pass the same validation as the other three.
|
||||
func TestSubBalancerServiceRoundRobin(t *testing.T) {
|
||||
setupSubBalancerDB(t)
|
||||
svc := &SubBalancerService{}
|
||||
|
||||
created, err := svc.Create(&model.SubBalancer{
|
||||
Remark: "rr", Strategy: "roundRobin", InboundIds: []int{1, 2}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create roundRobin: %v", err)
|
||||
}
|
||||
if created.Strategy != "roundRobin" {
|
||||
t.Fatalf("strategy = %q, want roundRobin", created.Strategy)
|
||||
}
|
||||
stored, err := svc.Get(created.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if stored.Strategy != "roundRobin" {
|
||||
t.Fatalf("stored strategy = %q, want roundRobin", stored.Strategy)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a missing balancer reports not-found instead of success:true, so
|
||||
// a stale UI row can't claim a delete that touched nothing.
|
||||
func TestSubBalancerServiceDeleteNotFound(t *testing.T) {
|
||||
setupSubBalancerDB(t)
|
||||
svc := &SubBalancerService{}
|
||||
if err := svc.Delete(999); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("Delete(999) = %v, want a not-found error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubBalancerServiceValidation(t *testing.T) {
|
||||
setupSubBalancerDB(t)
|
||||
svc := &SubBalancerService{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
row model.SubBalancer
|
||||
want string
|
||||
}{
|
||||
{"empty remark", model.SubBalancer{Strategy: "random", InboundIds: []int{1}}, "remark is required"},
|
||||
{"bad strategy", model.SubBalancer{Remark: "x", Strategy: "fastest", InboundIds: []int{1}}, "invalid balancer strategy"},
|
||||
{"no inbounds", model.SubBalancer{Remark: "x", Strategy: "random"}, "at least one inbound"},
|
||||
{"long remark", model.SubBalancer{Remark: strings.Repeat("x", 257), Strategy: "random", InboundIds: []int{1}}, "max 256"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.Create(&tc.row)
|
||||
if err == nil {
|
||||
t.Fatal("create must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "قائمة سماح حد IP",
|
||||
"ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل."
|
||||
"ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل.",
|
||||
"subBalancers": {
|
||||
"menu": "موزّعات الاشتراك",
|
||||
"title": "موزّع الاشتراك",
|
||||
"add": "إضافة موزّع",
|
||||
"desc": "كل موزّع مُفعّل يُضاف إلى اشتراك JSON كملف تعريف إضافي يختار تلقائيًا أفضل نقطة نهاية من الإينبوندات المحددة.",
|
||||
"remark": "ملاحظة",
|
||||
"remarkPlaceholder": "تلقائي · الأسرع",
|
||||
"strategy": "الاستراتيجية",
|
||||
"strategyLeastLoad": "أقل حمل",
|
||||
"strategyLeastPing": "أقل ping",
|
||||
"strategyRandom": "عشوائي",
|
||||
"strategyRoundRobin": "دوران",
|
||||
"sortOrder": "الترتيب",
|
||||
"sortOrderHelp": "الموضع في قائمة الاشتراك، متداخل مع ترتيب الإينبوندات؛ عند تساوي الرقم يأتي الموزّع بعد الإينباند.",
|
||||
"inbounds": "الإينبوندات",
|
||||
"inboundsCount": "{count} الإينبوندات",
|
||||
"enabled": "مُفعّل",
|
||||
"empty": "لا يوجد موزّعات بعد",
|
||||
"deleteConfirm": "حذف هذا الموزّع؟",
|
||||
"errRemarkRequired": "الملاحظة مطلوبة",
|
||||
"errInboundsRequired": "اختر إينبوندًا واحدًا على الأقل",
|
||||
"errSortOrder": "الترتيب يجب أن يكون عددًا صحيحًا ≥ 1",
|
||||
"toasts": {
|
||||
"list": "تعذّر عرض موزّعات الاشتراك",
|
||||
"create": "تعذّر إنشاء موزّع اشتراك",
|
||||
"update": "تعذّر تحديث موزّع اشتراك",
|
||||
"delete": "تعذّر حذف موزّع اشتراك",
|
||||
"invalidId": "معرّف غير صالح"
|
||||
},
|
||||
"tabBalancers": "موازنات التحميل",
|
||||
"tabObservatory": "المرصد",
|
||||
"observatory": {
|
||||
"title": "مرصد الموزّع",
|
||||
"desc": "معاملات probe لـ burstObservatory المُضمَّن في كل ملف leastPing/leastLoad. random/roundRobin بلا مرصد. يُحفظ كإعداد شامل لاشتراك JSON.",
|
||||
"destination": "عنوان probe",
|
||||
"destinationDesc": "العنوان الذي يقيس العميل به كل صادر عضو.",
|
||||
"connectivity": "عنوان الاتصالية",
|
||||
"connectivityDesc": "عنوان اختياري للتحقق مرة واحدة من وصول العضو للهدف. اتركه فارغًا للتخطي.",
|
||||
"interval": "فترة probe",
|
||||
"intervalDesc": "الزمن بين جولات probe، مثال 1m.",
|
||||
"timeout": "مهلة probe",
|
||||
"timeoutDesc": "مهلة probe واحدة، مثال 5s.",
|
||||
"sampling": "أخذ العينات",
|
||||
"samplingDesc": "عدد probe المتتالية لقياس الاستقرار.",
|
||||
"httpMethod": "أسلوب HTTP",
|
||||
"httpMethodDesc": "الأسلوب المستخدم في طلبات probe.",
|
||||
"note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "احفظ",
|
||||
|
||||
@@ -1504,7 +1504,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "IP limit allowlist",
|
||||
"ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR."
|
||||
"ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR.",
|
||||
"subBalancers": {
|
||||
"menu": "Sub Balancers",
|
||||
"title": "Subscription balancer",
|
||||
"add": "Add balancer",
|
||||
"desc": "Each enabled balancer is added to the JSON subscription as one extra profile that automatically picks the best of the selected inbounds' endpoints (routing.balancers + burstObservatory in the client config).",
|
||||
"remark": "Remark",
|
||||
"remarkPlaceholder": "Auto · fastest",
|
||||
"strategy": "Strategy",
|
||||
"strategyLeastLoad": "Least load",
|
||||
"strategyLeastPing": "Least ping",
|
||||
"strategyRandom": "Random",
|
||||
"strategyRoundRobin": "Round robin",
|
||||
"sortOrder": "Order",
|
||||
"sortOrderHelp": "Position in the subscription list, interleaved with the inbounds' own order; on equal numbers the balancer comes after the inbound.",
|
||||
"inbounds": "Inbounds",
|
||||
"inboundsCount": "{count} Inbounds",
|
||||
"enabled": "Enabled",
|
||||
"empty": "No balancers yet",
|
||||
"deleteConfirm": "Delete this balancer?",
|
||||
"errRemarkRequired": "Remark is required",
|
||||
"errInboundsRequired": "Select at least one inbound",
|
||||
"errSortOrder": "Order must be a whole number ≥ 1",
|
||||
"toasts": {
|
||||
"list": "Failed to list subscription balancers",
|
||||
"create": "Failed to create subscription balancer",
|
||||
"update": "Failed to update subscription balancer",
|
||||
"delete": "Failed to delete subscription balancer",
|
||||
"invalidId": "Invalid id"
|
||||
},
|
||||
"tabBalancers": "Balancers",
|
||||
"tabObservatory": "Observatory",
|
||||
"observatory": {
|
||||
"title": "Balancer observatory",
|
||||
"desc": "Probe parameters for the burst observatory emitted into each leastPing/leastLoad balancer profile. random/roundRobin balancers get no observatory. Stored as a panel-wide JSON-sub setting.",
|
||||
"destination": "Probe URL",
|
||||
"destinationDesc": "URL the client pings to measure each member outbound.",
|
||||
"connectivity": "Connectivity URL",
|
||||
"connectivityDesc": "Optional URL checked once to confirm the member can reach the probe destination. Leave empty to skip.",
|
||||
"interval": "Probe interval",
|
||||
"intervalDesc": "Time between probe rounds, e.g. 1m.",
|
||||
"timeout": "Probe timeout",
|
||||
"timeoutDesc": "Per-probe timeout, e.g. 5s.",
|
||||
"sampling": "Sampling",
|
||||
"samplingDesc": "Number of consecutive probes averaged for stability.",
|
||||
"httpMethod": "HTTP method",
|
||||
"httpMethodDesc": "Method used for probe requests.",
|
||||
"note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "Save",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "Lista de permitidos del límite de IP",
|
||||
"ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma."
|
||||
"ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma.",
|
||||
"subBalancers": {
|
||||
"menu": "Balanceadores de suscripción",
|
||||
"title": "Balanceador de suscripción",
|
||||
"add": "Añadir balanceador",
|
||||
"desc": "Cada balanceador activo se añade a la suscripción JSON como un perfil adicional que elige automáticamente el mejor de los endpoints de los inbounds seleccionados.",
|
||||
"remark": "Comentario",
|
||||
"remarkPlaceholder": "Auto · el más rápido",
|
||||
"strategy": "Estrategia",
|
||||
"strategyLeastLoad": "Menor carga",
|
||||
"strategyLeastPing": "Menor ping",
|
||||
"strategyRandom": "Aleatorio",
|
||||
"strategyRoundRobin": "Round robin",
|
||||
"sortOrder": "Orden",
|
||||
"sortOrderHelp": "Posición en la lista de la suscripción, intercalada con el orden de los inbounds; con el mismo número, el balanceador va después del inbound.",
|
||||
"inbounds": "Inbounds",
|
||||
"inboundsCount": "{count} Inbounds",
|
||||
"enabled": "Activado",
|
||||
"empty": "Aún no hay balanceadores",
|
||||
"deleteConfirm": "¿Eliminar este balanceador?",
|
||||
"errRemarkRequired": "El comentario es obligatorio",
|
||||
"errInboundsRequired": "Selecciona al menos un inbound",
|
||||
"errSortOrder": "El orden debe ser un número entero ≥ 1",
|
||||
"toasts": {
|
||||
"list": "No se pudieron listar los balanceadores de suscripción",
|
||||
"create": "No se pudo crear el balanceador de suscripción",
|
||||
"update": "No se pudo actualizar el balanceador de suscripción",
|
||||
"delete": "No se pudo eliminar el balanceador de suscripción",
|
||||
"invalidId": "Id no válido"
|
||||
},
|
||||
"tabBalancers": "Equilibradores",
|
||||
"tabObservatory": "Observatorio",
|
||||
"observatory": {
|
||||
"title": "Observatorio del balanceador",
|
||||
"desc": "Parámetros de probe para el burstObservatory incluido en cada perfil leastPing/leastLoad. random/roundRobin no generan observatorio. Se guarda como ajuste global de la suscripción JSON.",
|
||||
"destination": "URL de probe",
|
||||
"destinationDesc": "Dirección que el cliente sondea para medir cada salida miembro.",
|
||||
"connectivity": "URL de conectividad",
|
||||
"connectivityDesc": "Dirección opcional para verificar una vez que el miembro llega al destino. Vacío para omitir.",
|
||||
"interval": "Intervalo de probe",
|
||||
"intervalDesc": "Tiempo entre rondas de probe, p. ej. 1m.",
|
||||
"timeout": "Tiempo de espera de probe",
|
||||
"timeoutDesc": "Tiempo de espera de cada probe, p. ej. 5s.",
|
||||
"sampling": "Muestreo",
|
||||
"samplingDesc": "Número de probes consecutivos para promediar estabilidad.",
|
||||
"httpMethod": "Método HTTP",
|
||||
"httpMethodDesc": "Método usado para las solicitudes de probe.",
|
||||
"note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "Guardar configuración",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "فهرست مجاز محدودیت IP",
|
||||
"ipLimitAllowlistDesc": "نشانیها و شبکههایی که محدودیت IP هرگز آنها را نمیشمارد و مسدود نمیکند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما)."
|
||||
"ipLimitAllowlistDesc": "نشانیها و شبکههایی که محدودیت IP هرگز آنها را نمیشمارد و مسدود نمیکند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما).",
|
||||
"subBalancers": {
|
||||
"menu": "موزانکنندههای اشتراک",
|
||||
"title": "موزانکننده اشتراک",
|
||||
"add": "افزودن موزانکننده",
|
||||
"desc": "هر موزانکنندهٔ فعال بهعنوان یک پروفایل اضافه به اشتراک JSON اضافه میشود و بهطور خودکار بهترین نقطهٔ پایانیِ اینباندهای انتخابشده را برمیگزیند.",
|
||||
"remark": "توضیح",
|
||||
"remarkPlaceholder": "خودکار · سریعترین",
|
||||
"strategy": "استراتژی",
|
||||
"strategyLeastLoad": "کمترین بار",
|
||||
"strategyLeastPing": "کمترین پینگ",
|
||||
"strategyRandom": "تصادفی",
|
||||
"strategyRoundRobin": "گردشی",
|
||||
"sortOrder": "ترتیب",
|
||||
"sortOrderHelp": "جایگاه در فهرست اشتراک، درهمتنیده با ترتیب اینباندها؛ با شمارهٔ برابر، موزانکننده بعد از اینباند میآید.",
|
||||
"inbounds": "اینباندها",
|
||||
"inboundsCount": "{count} اینباندها",
|
||||
"enabled": "فعال",
|
||||
"empty": "هنوز موزانکنندهای وجود ندارد",
|
||||
"deleteConfirm": "این موزانکننده حذف شود؟",
|
||||
"errRemarkRequired": "توضیح الزامی است",
|
||||
"errInboundsRequired": "حداقل یک اینباند انتخاب کنید",
|
||||
"errSortOrder": "ترتیب باید عدد صحیح ≥ ۱ باشد",
|
||||
"toasts": {
|
||||
"list": "فهرستسازی موزانکنندههای اشتراک ناموفق بود",
|
||||
"create": "ایجاد موزانکننده اشتراک ناموفق بود",
|
||||
"update": "بهروزرسانی موزانکننده اشتراک ناموفق بود",
|
||||
"delete": "حذف موزانکننده اشتراک ناموفق بود",
|
||||
"invalidId": "شناسه نامعتبر"
|
||||
},
|
||||
"tabBalancers": "بالانسرها",
|
||||
"tabObservatory": "رصدخانه",
|
||||
"observatory": {
|
||||
"title": "رصدگر موزانکننده",
|
||||
"desc": "پارامترهای probe برای burstObservatory که در هر پروفایل leastPing/leastLoad نوشته میشود. random/roundRobin رصدگر ندارند. بهصورت تنظیم سراسری اشتراک JSON ذخیره میشود.",
|
||||
"destination": "آدرس probe",
|
||||
"destinationDesc": "آدرسی که کلاینت برای سنجش هر خروجی عضو آن را probe میکند.",
|
||||
"connectivity": "آدرس اتصال",
|
||||
"connectivityDesc": "آدرس اختیاری برای بررسی یکبارهٔ دسترسی به هدف. خالی بگذارید تا رد شود.",
|
||||
"interval": "بازه probe",
|
||||
"intervalDesc": "زمان بین دورهای probe، مثلاً 1m.",
|
||||
"timeout": "مهلت probe",
|
||||
"timeoutDesc": "مهلت هر probe، مثلاً 5s.",
|
||||
"sampling": "نمونهبرداری",
|
||||
"samplingDesc": "تعداد probe متوالی برای میانگین پایداری.",
|
||||
"httpMethod": "متد HTTP",
|
||||
"httpMethodDesc": "متد استفادهشده برای درخواستهای probe.",
|
||||
"note": "موزانکنندههای leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی میکند — آن را خاموش کنید تا از پیشفرضهای داخلی استفاده شود. تغییرات پس از راهاندازی مجدد پنل اعمال میشوند."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "ذخیره",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "Daftar izin batas IP",
|
||||
"ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma)."
|
||||
"ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma).",
|
||||
"subBalancers": {
|
||||
"menu": "Penyeimbang langganan",
|
||||
"title": "Penyeimbang langganan",
|
||||
"add": "Tambah penyeimbang",
|
||||
"desc": "Setiap penyeimbang yang aktif ditambahkan ke langganan JSON sebagai profil tambahan yang otomatis memilih titik akhir terbaik dari inbound terpilih.",
|
||||
"remark": "Keterangan",
|
||||
"remarkPlaceholder": "Otomatis · tercepat",
|
||||
"strategy": "Strategi",
|
||||
"strategyLeastLoad": "Beban terendah",
|
||||
"strategyLeastPing": "Ping terendah",
|
||||
"strategyRandom": "Acak",
|
||||
"strategyRoundRobin": "Round robin",
|
||||
"sortOrder": "Urutan",
|
||||
"sortOrderHelp": "Posisi dalam daftar langganan, berselang-seling dengan urutan inbound; jika sama, penyeimbang berada setelah inbound.",
|
||||
"inbounds": "Inbound",
|
||||
"inboundsCount": "{count} Inbound",
|
||||
"enabled": "Aktif",
|
||||
"empty": "Belum ada penyeimbang",
|
||||
"deleteConfirm": "Hapus penyeimbang ini?",
|
||||
"errRemarkRequired": "Keterangan wajib diisi",
|
||||
"errInboundsRequired": "Pilih minimal satu inbound",
|
||||
"errSortOrder": "Urutan harus bilangan bulat ≥ 1",
|
||||
"toasts": {
|
||||
"list": "Gagal menampilkan daftar penyeimbang langganan",
|
||||
"create": "Gagal membuat penyeimbang langganan",
|
||||
"update": "Gagal memperbarui penyeimbang langganan",
|
||||
"delete": "Gagal menghapus penyeimbang langganan",
|
||||
"invalidId": "Id tidak valid"
|
||||
},
|
||||
"tabBalancers": "Penyeimbang",
|
||||
"tabObservatory": "Observatory",
|
||||
"observatory": {
|
||||
"title": "Observatorium penyeimbang",
|
||||
"desc": "Parameter probe untuk burstObservatory yang disisipkan ke setiap profil leastPing/leastLoad. random/roundRobin tanpa observatorium. Disimpan sebagai pengaturan langganan JSON tingkat panel.",
|
||||
"destination": "URL probe",
|
||||
"destinationDesc": "Alamat yang di-probe klien untuk mengukur setiap outbound anggota.",
|
||||
"connectivity": "URL konektivitas",
|
||||
"connectivityDesc": "Alamat opsional untuk memeriksa sekali bahwa anggota menjangkau tujuan. Kosongkan untuk melewati.",
|
||||
"interval": "Interval probe",
|
||||
"intervalDesc": "Waktu antar ronde probe, mis. 1m.",
|
||||
"timeout": "Waktu habis probe",
|
||||
"timeoutDesc": "Waktu habis per probe, mis. 5s.",
|
||||
"sampling": "Pengambilan sampel",
|
||||
"samplingDesc": "Jumlah probe beruntun untuk merata-ratakan stabilitas.",
|
||||
"httpMethod": "Metode HTTP",
|
||||
"httpMethodDesc": "Metode yang dipakai untuk permintaan probe.",
|
||||
"note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "Simpan",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "IP 制限の許可リスト",
|
||||
"ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。"
|
||||
"ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。",
|
||||
"subBalancers": {
|
||||
"menu": "サブスクリプションバランサー",
|
||||
"title": "サブスクリプションバランサー",
|
||||
"add": "バランサーを追加",
|
||||
"desc": "有効なバランサーは JSON サブスクリプションに追加プロファイルとして加わり、選択したインバウンドのエンドポイントから最適なものを自動選択します。",
|
||||
"remark": "備考",
|
||||
"remarkPlaceholder": "自動 · 最速",
|
||||
"strategy": "方式",
|
||||
"strategyLeastLoad": "最小負荷",
|
||||
"strategyLeastPing": "最小 ping",
|
||||
"strategyRandom": "ランダム",
|
||||
"strategyRoundRobin": "ラウンドロビン",
|
||||
"sortOrder": "順序",
|
||||
"sortOrderHelp": "サブスクリプション一覧内の位置。インバウンドの順序と交互に並び、同番号の場合はインバウンドの後ろになります。",
|
||||
"inbounds": "インバウンド",
|
||||
"inboundsCount": "{count} インバウンド",
|
||||
"enabled": "有効",
|
||||
"empty": "バランサーはまだありません",
|
||||
"deleteConfirm": "このバランサーを削除しますか?",
|
||||
"errRemarkRequired": "備考を入力してください",
|
||||
"errInboundsRequired": "インバウンドを1つ以上選択してください",
|
||||
"errSortOrder": "順序は1以上の整数にしてください",
|
||||
"toasts": {
|
||||
"list": "サブスクリプションバランサーの一覧取得に失敗しました",
|
||||
"create": "サブスクリプションバランサーの作成に失敗しました",
|
||||
"update": "サブスクリプションバランサーの更新に失敗しました",
|
||||
"delete": "サブスクリプションバランサーの削除に失敗しました",
|
||||
"invalidId": "無効な id です"
|
||||
},
|
||||
"tabBalancers": "負荷分散",
|
||||
"tabObservatory": "オブザーバトリ",
|
||||
"observatory": {
|
||||
"title": "バランサー観測",
|
||||
"desc": "各 leastPing/leastLoad バランサープロファイルに埋め込む burstObservatory のプローブ設定。random/roundRobin には観測を入れません。パネル全体の JSON サブ設定として保存されます。",
|
||||
"destination": "プローブ URL",
|
||||
"destinationDesc": "クライアントが各メンバーアウトバウンドを計測するためのアドレス。",
|
||||
"connectivity": "接続確認 URL",
|
||||
"connectivityDesc": "メンバーがプローブ先へ到達できるか一度確認する任意のアドレス。空ならスキップ。",
|
||||
"interval": "プローブ間隔",
|
||||
"intervalDesc": "プローブ周期の間隔(例: 1m)。",
|
||||
"timeout": "プローブタイムアウト",
|
||||
"timeoutDesc": "1回のプローブのタイムアウト(例: 5s)。",
|
||||
"sampling": "サンプリング",
|
||||
"samplingDesc": "安定度を平均するための連続プローブ回数。",
|
||||
"httpMethod": "HTTP メソッド",
|
||||
"httpMethodDesc": "プローブ要求に使う HTTP メソッド。",
|
||||
"note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "ルールをインポート",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "Lista de permissões do limite de IP",
|
||||
"ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula."
|
||||
"ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula.",
|
||||
"subBalancers": {
|
||||
"menu": "Balanceadores de assinatura",
|
||||
"title": "Balanceador de assinatura",
|
||||
"add": "Adicionar balanceador",
|
||||
"desc": "Cada balanceador ativo é adicionado à assinatura JSON como um perfil extra que escolhe automaticamente o melhor endpoint entre os inbounds selecionados.",
|
||||
"remark": "Descrição",
|
||||
"remarkPlaceholder": "Auto · mais rápido",
|
||||
"strategy": "Estratégia",
|
||||
"strategyLeastLoad": "Menor carga",
|
||||
"strategyLeastPing": "Menor ping",
|
||||
"strategyRandom": "Aleatório",
|
||||
"strategyRoundRobin": "Round robin",
|
||||
"sortOrder": "Ordem",
|
||||
"sortOrderHelp": "Posição na lista da assinatura, intercalada com a ordem dos inbounds; em caso de empate, o balanceador vem depois do inbound.",
|
||||
"inbounds": "Inbounds",
|
||||
"inboundsCount": "{count} Inbounds",
|
||||
"enabled": "Ativado",
|
||||
"empty": "Ainda não há balanceadores",
|
||||
"deleteConfirm": "Excluir este balanceador?",
|
||||
"errRemarkRequired": "A descrição é obrigatória",
|
||||
"errInboundsRequired": "Selecione ao menos um inbound",
|
||||
"errSortOrder": "A ordem deve ser um inteiro ≥ 1",
|
||||
"toasts": {
|
||||
"list": "Falha ao listar os balanceadores de assinatura",
|
||||
"create": "Falha ao criar o balanceador de assinatura",
|
||||
"update": "Falha ao atualizar o balanceador de assinatura",
|
||||
"delete": "Falha ao excluir o balanceador de assinatura",
|
||||
"invalidId": "Id inválido"
|
||||
},
|
||||
"tabBalancers": "Balanceadores",
|
||||
"tabObservatory": "Observatório",
|
||||
"observatory": {
|
||||
"title": "Observatório do balanceador",
|
||||
"desc": "Parâmetros de probe para o burstObservatory embutido em cada perfil leastPing/leastLoad. random/roundRobin não geram observatório. Salvo como ajuste global da assinatura JSON.",
|
||||
"destination": "URL de probe",
|
||||
"destinationDesc": "Endereço que o cliente sonda para medir cada saída membro.",
|
||||
"connectivity": "URL de conectividade",
|
||||
"connectivityDesc": "Endereço opcional para verificar uma vez que o membro alcança o destino. Vazio para pular.",
|
||||
"interval": "Intervalo de probe",
|
||||
"intervalDesc": "Tempo entre rodadas de probe, p. ex. 1m.",
|
||||
"timeout": "Tempo limite de probe",
|
||||
"timeoutDesc": "Tempo limite de cada probe, p. ex. 5s.",
|
||||
"sampling": "Amostragem",
|
||||
"samplingDesc": "Número de probes consecutivos para média de estabilidade.",
|
||||
"httpMethod": "Método HTTP",
|
||||
"httpMethodDesc": "Método usado nas requisições de probe.",
|
||||
"note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "Importar regras",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Григорианский (обычный)",
|
||||
"calendarJalalian": "Джалали (شمسی)",
|
||||
"ipLimitAllowlist": "Доверенные адреса для лимита",
|
||||
"ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть."
|
||||
"ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть.",
|
||||
"subBalancers": {
|
||||
"menu": "Балансировщики подписки",
|
||||
"title": "Балансировщик подписки",
|
||||
"add": "Добавить балансировщик",
|
||||
"desc": "Каждый включённый балансировщик добавляется в JSON-подписку как отдельный профиль, автоматически выбирающий лучший из эндпоинтов выбранных инбаундов (routing.balancers + burstObservatory в клиентском конфиге).",
|
||||
"remark": "Примечание",
|
||||
"remarkPlaceholder": "Авто · самый быстрый",
|
||||
"strategy": "Стратегия",
|
||||
"strategyLeastLoad": "Минимальная нагрузка",
|
||||
"strategyLeastPing": "Минимальный пинг",
|
||||
"strategyRandom": "Случайный",
|
||||
"strategyRoundRobin": "По очереди",
|
||||
"sortOrder": "Порядок",
|
||||
"sortOrderHelp": "Позиция в списке подписки, чередуется с порядком инбаундов; при равных номерах балансировщик идёт после инбаунда.",
|
||||
"inbounds": "Инбаунды",
|
||||
"inboundsCount": "{count} Инбаунды",
|
||||
"enabled": "Включён",
|
||||
"empty": "Балансировщиков пока нет",
|
||||
"deleteConfirm": "Удалить этот балансировщик?",
|
||||
"errRemarkRequired": "Укажите примечание",
|
||||
"errInboundsRequired": "Выберите хотя бы один инбаунд",
|
||||
"errSortOrder": "Порядок — целое число ≥ 1",
|
||||
"toasts": {
|
||||
"list": "Не удалось получить список балансировщиков подписки",
|
||||
"create": "Не удалось создать балансировщик подписки",
|
||||
"update": "Не удалось обновить балансировщик подписки",
|
||||
"delete": "Не удалось удалить балансировщик подписки",
|
||||
"invalidId": "Некорректный id"
|
||||
},
|
||||
"tabBalancers": "Балансировщик",
|
||||
"tabObservatory": "Обсерватория",
|
||||
"observatory": {
|
||||
"title": "Обсерватория балансировщика",
|
||||
"desc": "Параметры probe-запросов для burstObservatory, добавляемого в профили leastPing/leastLoad. random/roundRobin обходятся без обсерватории. Хранится как общая настройка JSON-подписки.",
|
||||
"destination": "URL проверки",
|
||||
"destinationDesc": "Адрес, по которому клиент проверяет доступность каждого участника.",
|
||||
"connectivity": "URL связности",
|
||||
"connectivityDesc": "Необязательный адрес для однократной проверки доступности цели. Оставьте пустым, чтобы пропустить.",
|
||||
"interval": "Интервал проверок",
|
||||
"intervalDesc": "Время между раундами проверок, например 1m.",
|
||||
"timeout": "Тайм-аут проверки",
|
||||
"timeoutDesc": "Тайм-аут одной проверки, например 5s.",
|
||||
"sampling": "Выборка",
|
||||
"samplingDesc": "Число подряд проверок для усреднения стабильности.",
|
||||
"httpMethod": "HTTP-метод",
|
||||
"httpMethodDesc": "Метод запросов при проверках.",
|
||||
"note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "Импорт правил",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "IP limiti izin listesi",
|
||||
"ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış)."
|
||||
"ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış).",
|
||||
"subBalancers": {
|
||||
"menu": "Abonelik dengeleyicileri",
|
||||
"title": "Abonelik dengeleyici",
|
||||
"add": "Dengeleyici ekle",
|
||||
"desc": "Etkin her dengeleyici, seçilen inbound'ların uç noktalarından en iyisini otomatik seçen ek bir profil olarak JSON aboneliğine eklenir.",
|
||||
"remark": "Açıklama",
|
||||
"remarkPlaceholder": "Otomatik · en hızlı",
|
||||
"strategy": "Strateji",
|
||||
"strategyLeastLoad": "En düşük yük",
|
||||
"strategyLeastPing": "En düşük ping",
|
||||
"strategyRandom": "Rastgele",
|
||||
"strategyRoundRobin": "Sıralı",
|
||||
"sortOrder": "Sıra",
|
||||
"sortOrderHelp": "Abonelik listesindeki konumu, inbound sırası ile iç içe yerleşir; eşit numarada dengeleyici inbound'dan sonra gelir.",
|
||||
"inbounds": "Inbound'lar",
|
||||
"inboundsCount": "{count} Inbound'lar",
|
||||
"enabled": "Etkin",
|
||||
"empty": "Henüz dengeleyici yok",
|
||||
"deleteConfirm": "Bu dengeleyici silinsin mi?",
|
||||
"errRemarkRequired": "Açıklama zorunludur",
|
||||
"errInboundsRequired": "En az bir inbound seçin",
|
||||
"errSortOrder": "Sıra 1 veya daha büyük bir tam sayı olmalı",
|
||||
"toasts": {
|
||||
"list": "Abonelik dengeleyicileri listelenemedi",
|
||||
"create": "Abonelik dengeleyicisi oluşturulamadı",
|
||||
"update": "Abonelik dengeleyicisi güncellenemedi",
|
||||
"delete": "Abonelik dengeleyicisi silinemedi",
|
||||
"invalidId": "Geçersiz id"
|
||||
},
|
||||
"tabBalancers": "Dengeleyiciler",
|
||||
"tabObservatory": "Gözlemci",
|
||||
"observatory": {
|
||||
"title": "Dengeleyici gözlemi",
|
||||
"desc": "Her leastPing/leastLoad dengeleyici profiline gömülen burstObservatory probe parametreleri. random/roundRobin için gözlem eklenmez. Paneller arası JSON abonelik ayarı olarak saklanır.",
|
||||
"destination": "Probe URL'si",
|
||||
"destinationDesc": "İstemcinin her üye çıkışı ölçmek için denediği adres.",
|
||||
"connectivity": "Bağlantı URL'si",
|
||||
"connectivityDesc": "Üyenin hedefe ulaşabildiğini tek kez doğrulamak için isteğe bağlı adres. Atlamak için boş bırakın.",
|
||||
"interval": "Probe aralığı",
|
||||
"intervalDesc": "Probe turları arasındaki süre, örn. 1m.",
|
||||
"timeout": "Probe zaman aşımı",
|
||||
"timeoutDesc": "Tek bir probe için zaman aşımı, örn. 5s.",
|
||||
"sampling": "Örnekleme",
|
||||
"samplingDesc": "Kararlılık ortalaması için ardışık probe sayısı.",
|
||||
"httpMethod": "HTTP yöntemi",
|
||||
"httpMethodDesc": "Probe isteklerinde kullanılan HTTP yöntemi.",
|
||||
"note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "Kaydet",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Григоріанський (звичайний)",
|
||||
"calendarJalalian": "Джалалі (شمسی)",
|
||||
"ipLimitAllowlist": "Довірені адреси для ліміту",
|
||||
"ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа."
|
||||
"ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа.",
|
||||
"subBalancers": {
|
||||
"menu": "Балансувальники підписки",
|
||||
"title": "Балансувальник підписки",
|
||||
"add": "Додати балансувальник",
|
||||
"desc": "Кожний увімкнений балансувальник додається до JSON-підписки як окремий профіль, що автоматично обирає найкращу з кінцевих точок вибраних інбаундів.",
|
||||
"remark": "Примітка",
|
||||
"remarkPlaceholder": "Авто · найшвидший",
|
||||
"strategy": "Стратегія",
|
||||
"strategyLeastLoad": "Найменше навантаження",
|
||||
"strategyLeastPing": "Найменший ping",
|
||||
"strategyRandom": "Випадково",
|
||||
"strategyRoundRobin": "По черзі",
|
||||
"sortOrder": "Порядок",
|
||||
"sortOrderHelp": "Позиція у списку підписки, чергується з порядком інбаундів; за однакового номера йде після інбаунда.",
|
||||
"inbounds": "Інбаунди",
|
||||
"inboundsCount": "{count} Інбаунди",
|
||||
"enabled": "Увімкнено",
|
||||
"empty": "Балансувальників ще немає",
|
||||
"deleteConfirm": "Видалити цей балансувальник?",
|
||||
"errRemarkRequired": "Вкажіть примітку",
|
||||
"errInboundsRequired": "Виберіть хоча б один інбаунд",
|
||||
"errSortOrder": "Порядок — ціле число ≥ 1",
|
||||
"toasts": {
|
||||
"list": "Не вдалося отримати список балансувальників підписки",
|
||||
"create": "Не вдалося створити балансувальник підписки",
|
||||
"update": "Не вдалося оновити балансувальник підписки",
|
||||
"delete": "Не вдалося видалити балансувальник підписки",
|
||||
"invalidId": "Некоректний id"
|
||||
},
|
||||
"tabBalancers": "Балансери",
|
||||
"tabObservatory": "Обсерваторія",
|
||||
"observatory": {
|
||||
"title": "Обсерваторія балансувальника",
|
||||
"desc": "Параметри probe-запитів для burstObservatory, що додається у профілі leastPing/leastLoad. random/roundRobin обходяться без обсерваторії. Зберігається як загальна налаштування JSON-підписки.",
|
||||
"destination": "URL перевірки",
|
||||
"destinationDesc": "Адреса, за якою клієнт перевіряє доступність кожного учасника.",
|
||||
"connectivity": "URL зв’язності",
|
||||
"connectivityDesc": "Необов’язкова адреса для одноразової перевірки доступності цілі. Залиште порожнім, щоб пропустити.",
|
||||
"interval": "Інтервал перевірок",
|
||||
"intervalDesc": "Час між раундами перевірок, наприклад 1m.",
|
||||
"timeout": "Тайм-аут перевірки",
|
||||
"timeoutDesc": "Тайм-аут однієї перевірки, наприклад 5s.",
|
||||
"sampling": "Вибірка",
|
||||
"samplingDesc": "Кількість підряд перевірок для усереднення стабільності.",
|
||||
"httpMethod": "HTTP-метод",
|
||||
"httpMethodDesc": "Метод запитів під час перевірок.",
|
||||
"note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "Зберегти",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "Danh sách cho phép của giới hạn IP",
|
||||
"ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy."
|
||||
"ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy.",
|
||||
"subBalancers": {
|
||||
"menu": "Bộ cân bằng đăng ký",
|
||||
"title": "Bộ cân bằng đăng ký",
|
||||
"add": "Thêm bộ cân bằng",
|
||||
"desc": "Mỗi bộ cân bằng đang bật được thêm vào đăng ký JSON như một hồ sơ riêng, tự động chọn điểm cuối tốt nhất trong các inbound đã chọn.",
|
||||
"remark": "Ghi chú",
|
||||
"remarkPlaceholder": "Tự động · nhanh nhất",
|
||||
"strategy": "Chiến lược",
|
||||
"strategyLeastLoad": "Tải thấp nhất",
|
||||
"strategyLeastPing": "Ping thấp nhất",
|
||||
"strategyRandom": "Ngẫu nhiên",
|
||||
"strategyRoundRobin": "Luân phiên",
|
||||
"sortOrder": "Thứ tự",
|
||||
"sortOrderHelp": "Vị trí trong danh sách đăng ký, xen kẽ với thứ tự inbound; khi cùng số, bộ cân bằng đứng sau inbound.",
|
||||
"inbounds": "Inbound",
|
||||
"inboundsCount": "{count} Inbound",
|
||||
"enabled": "Đã bật",
|
||||
"empty": "Chưa có bộ cân bằng nào",
|
||||
"deleteConfirm": "Xóa bộ cân bằng này?",
|
||||
"errRemarkRequired": "Cần nhập ghi chú",
|
||||
"errInboundsRequired": "Chọn ít nhất một inbound",
|
||||
"errSortOrder": "Thứ tự phải là số nguyên ≥ 1",
|
||||
"toasts": {
|
||||
"list": "Không thể liệt kê các bộ cân bằng đăng ký",
|
||||
"create": "Không thể tạo bộ cân bằng đăng ký",
|
||||
"update": "Không thể cập nhật bộ cân bằng đăng ký",
|
||||
"delete": "Không thể xóa bộ cân bằng đăng ký",
|
||||
"invalidId": "Id không hợp lệ"
|
||||
},
|
||||
"tabBalancers": "Cân bằng",
|
||||
"tabObservatory": "Observatory",
|
||||
"observatory": {
|
||||
"title": "Đài quan sát bộ cân bằng",
|
||||
"desc": "Tham số probe cho burstObservatory nhúng vào mỗi hồ sơ leastPing/leastLoad. random/roundRobin không có đài quan sát. Lưu thành cài đặt chung của đăng ký JSON.",
|
||||
"destination": "URL probe",
|
||||
"destinationDesc": "Địa chỉ client thăm dò để đo mỗi outbound thành viên.",
|
||||
"connectivity": "URL kết nối",
|
||||
"connectivityDesc": "Địa chỉ tuỳ chọn để kiểm tra một lần thành viên có tới đích được không. Để trống để bỏ qua.",
|
||||
"interval": "Khoảng probe",
|
||||
"intervalDesc": "Thời gian giữa các vòng probe, vd. 1m.",
|
||||
"timeout": "Hết giờ probe",
|
||||
"timeoutDesc": "Hết giờ cho mỗi probe, vd. 5s.",
|
||||
"sampling": "Lấy mẫu",
|
||||
"samplingDesc": "Số lần probe liên tiếp để trung bình độ ổn định.",
|
||||
"httpMethod": "Phương thức HTTP",
|
||||
"httpMethodDesc": "Phương thức dùng cho yêu cầu probe.",
|
||||
"note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển."
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "Nhập quy tắc",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "IP 限制白名单",
|
||||
"ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。"
|
||||
"ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。",
|
||||
"subBalancers": {
|
||||
"menu": "订阅均衡器",
|
||||
"title": "订阅均衡器",
|
||||
"add": "添加均衡器",
|
||||
"desc": "每个启用的均衡器会作为额外配置加入 JSON 订阅,自动在所选入站的端点中选择最优节点(客户端配置中的 routing.balancers + burstObservatory)。",
|
||||
"remark": "备注",
|
||||
"remarkPlaceholder": "自动 · 最快",
|
||||
"strategy": "策略",
|
||||
"strategyLeastLoad": "最小负载",
|
||||
"strategyLeastPing": "最低延迟",
|
||||
"strategyRandom": "随机",
|
||||
"strategyRoundRobin": "轮询",
|
||||
"sortOrder": "顺序",
|
||||
"sortOrderHelp": "在订阅列表中的位置,与入站顺序交错排列;序号相同时排在入站之后。",
|
||||
"inbounds": "入站",
|
||||
"inboundsCount": "{count} 入站",
|
||||
"enabled": "启用",
|
||||
"empty": "暂无均衡器",
|
||||
"deleteConfirm": "确定删除此均衡器?",
|
||||
"errRemarkRequired": "请填写备注",
|
||||
"errInboundsRequired": "请至少选择一个入站",
|
||||
"errSortOrder": "顺序必须为不小于 1 的整数",
|
||||
"toasts": {
|
||||
"list": "列出订阅均衡器失败",
|
||||
"create": "创建订阅均衡器失败",
|
||||
"update": "更新订阅均衡器失败",
|
||||
"delete": "删除订阅均衡器失败",
|
||||
"invalidId": "无效的 id"
|
||||
},
|
||||
"tabBalancers": "负载均衡",
|
||||
"tabObservatory": "观测器",
|
||||
"observatory": {
|
||||
"title": "均衡器探活",
|
||||
"desc": "写入每个 leastPing/leastLoad 均衡器配置的 burstObservatory 探活参数。random/roundRobin 不生成探活。作为面板级 JSON 订阅设置保存。",
|
||||
"destination": "探活 URL",
|
||||
"destinationDesc": "客户端探测每个成员出站的地址。",
|
||||
"connectivity": "连通性 URL",
|
||||
"connectivityDesc": "可选地址,检查成员能否到达探活目标。留空则跳过。",
|
||||
"interval": "探活间隔",
|
||||
"intervalDesc": "探活轮次之间的时间,例如 1m。",
|
||||
"timeout": "探活超时",
|
||||
"timeoutDesc": "单次探活超时,例如 5s。",
|
||||
"sampling": "采样",
|
||||
"samplingDesc": "用于稳定度平均的连续探活次数。",
|
||||
"httpMethod": "HTTP 方法",
|
||||
"httpMethodDesc": "探活请求使用的 HTTP 方法。",
|
||||
"note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"importRules": "导入规则",
|
||||
|
||||
@@ -1386,7 +1386,56 @@
|
||||
"calendarGregorian": "Gregorian (Standard)",
|
||||
"calendarJalalian": "Jalalian (شمسی)",
|
||||
"ipLimitAllowlist": "IP 限制白名單",
|
||||
"ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。"
|
||||
"ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。",
|
||||
"subBalancers": {
|
||||
"menu": "訂閱平衡器",
|
||||
"title": "訂閱平衡器",
|
||||
"add": "新增平衡器",
|
||||
"desc": "每個啟用的平衡器會作為額外設定加入 JSON 訂閱,自動從所選入站的端點中挑選最佳節點(用戶端設定中的 routing.balancers + burstObservatory)。",
|
||||
"remark": "備註",
|
||||
"remarkPlaceholder": "自動 · 最快",
|
||||
"strategy": "策略",
|
||||
"strategyLeastLoad": "最小負載",
|
||||
"strategyLeastPing": "最低延遲",
|
||||
"strategyRandom": "隨機",
|
||||
"strategyRoundRobin": "輪詢",
|
||||
"sortOrder": "順序",
|
||||
"sortOrderHelp": "在訂閱列表中的位置,與入站順序交錯排列;序號相同時排在入站之後。",
|
||||
"inbounds": "入站",
|
||||
"inboundsCount": "{count} 入站",
|
||||
"enabled": "啟用",
|
||||
"empty": "尚無平衡器",
|
||||
"deleteConfirm": "確定刪除此平衡器?",
|
||||
"errRemarkRequired": "請填寫備註",
|
||||
"errInboundsRequired": "請至少選擇一個入站",
|
||||
"errSortOrder": "順序必須為不小於 1 的整數",
|
||||
"toasts": {
|
||||
"list": "列出訂閱平衡器失敗",
|
||||
"create": "建立訂閱平衡器失敗",
|
||||
"update": "更新訂閱平衡器失敗",
|
||||
"delete": "刪除訂閱平衡器失敗",
|
||||
"invalidId": "無效的 id"
|
||||
},
|
||||
"tabBalancers": "負載均衡",
|
||||
"tabObservatory": "觀測器",
|
||||
"observatory": {
|
||||
"title": "平衡器探活",
|
||||
"desc": "寫入每個 leastPing/leastLoad 平衡器設定檔的 burstObservatory 探活參數。random/roundRobin 不產生探活。以面板級 JSON 訂閱設定儲存。",
|
||||
"destination": "探活 URL",
|
||||
"destinationDesc": "用戶端探測每個成員出站的位址。",
|
||||
"connectivity": "連通性 URL",
|
||||
"connectivityDesc": "選用位址,檢查成員能否到達探活目標。留空則跳過。",
|
||||
"interval": "探活間隔",
|
||||
"intervalDesc": "探活輪次之間的時間,例如 1m。",
|
||||
"timeout": "探活逾時",
|
||||
"timeoutDesc": "單次探活逾時,例如 5s。",
|
||||
"sampling": "取樣",
|
||||
"samplingDesc": "用於穩定度平均的連續探活次數。",
|
||||
"httpMethod": "HTTP 方法",
|
||||
"httpMethodDesc": "探活請求使用的 HTTP 方法。",
|
||||
"note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"xray": {
|
||||
"save": "儲存",
|
||||
|
||||
Reference in New Issue
Block a user