Feat/multi inbound clients (#4469)

* feat(clients): add shadow tables for first-class client promotion

Introduces three new GORM-backed tables (clients, client_inbounds,
inbound_fallback_children) and a populate-only seeder that backfills
them from each inbound's existing settings.clients JSON. Duplicate
emails across inbounds auto-merge under one client row, with each
field conflict logged. Existing services are unchanged and continue
reading from settings.clients — this commit is groundwork only.

* feat(clients): make clients+client_inbounds the runtime source of truth

Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.

GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.

* feat(clients): add top-level Clients tab and CRUD API

Adds /panel/api/clients endpoints (list, get, add, update, del,
attach, detach) backed by ClientService methods that orchestrate
the per-inbound Add/Update/Del flows so a single client row is
created once and attached to many inbounds in one operation.

The frontend gains a dedicated Clients page (frontend/clients.html
+ src/pages/clients/) with an AntD table, multi-inbound attach
modal, and full CRUD. Axios interceptor learns to honour
Content-Type: application/json so the JSON endpoints work
alongside the legacy form-encoded ones.

The legacy per-inbound client modal stays untouched in this PR —
both flows now write to the same source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): add Port-with-Fallback inbound type

Adds a new "portfallback" protocol that emits as a VLESS-TLS inbound
under the hood but is paired with a sidecar table of child inbounds.
Panel auto-builds settings.fallbacks at Xray-config-gen time from the
sidecar — each child's listen+port becomes the fallback dest, with
SNI/ALPN/path/xver match criteria pulled from the row. No more typing
loopback ports by hand or keeping settings.fallbacks in sync.

Backend: new FallbackService (Get/SetChildren, BuildFallbacksJSON);
two new routes (GET/POST /panel/api/inbounds/:id/fallbackChildren);
xray.GetXrayConfig injects fallbacks for PortFallback inbounds; the
inbound model emits protocol="vless" so Xray accepts the config.

Frontend: PORTFALLBACK joins the protocol dropdown; selecting it
shows the standard VLESS controls plus a Fallback Children table
(inbound picker + per-row SNI/ALPN/path/xver). Children are loaded
on edit and replaced atomically on save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Reset Traffic, QR Code, Info actions + Online/Remaining columns

The Clients page table gains:
- Online column — green/grey tag driven by /panel/api/inbounds/onlines,
  polled every 10s.
- Remaining column — bytes-remaining tag, coloured green/orange/red
  against quota, purple infinity when unlimited.
- Action icons per row: QR, Info, Reset traffic, Edit, Delete.

ClientInfoModal shows the full client detail (uuid/password/auth,
traffic ↑/↓ + remaining + all-time, expiry absolute + relative,
attached inbounds chip list, online + last-online).

ClientQrModal fetches links for the client's subId via
/panel/api/inbounds/getSubLinks/:subId and renders each one through
the existing QrPanel component.

Reset Traffic confirms then calls the existing per-inbound endpoint
on the client's first attached inbound (the traffic row is keyed on
email globally, so any attached inbound resets the shared counter).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): expose Attached inbounds in edit mode

The multi-select was gated on add-only, so editing a client had no way
to change which inbounds it belonged to. The picker now shows in both
modes, and on submit the modal diffs the picked set against the
original attachedIds — additions go through the /attach endpoint,
removals through /detach, both after the field update lands so the
new attachments get the latest values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): unbreak template parsing + stale i18n keys

- InboundFormModal: split the multi-line help string in the
  PortFallback section onto one line — Vue's template parser was
  bailing on Unterminated string constant because a single-quoted
  literal spanned two lines inside a {{ }} interpolation.
- ClientInfoModal: t('disable') was missing at the root level, so
  vue-i18n returned the key path literally. Use t('disabled') which
  exists.
- Linter cleanup elsewhere: pages.client.* references renamed to
  pages.clients.* to match the merged i18n block; whitespace
  normalisation in a few unrelated Vue templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(traffic): drop all-time traffic tracking

Removes the AllTime field from Inbound and ClientTraffic and migrates
existing DBs by dropping the all_time columns on startup. The counter
duplicated up+down without adding signal, and the per-event accumulator
ran on every traffic write.

Frontend: drop the All-time column from the inbound list and the
client-row table, the All-time row from the client info modal, and the
All-Time Total Usage tile from the inbounds summary card. The
allTimeTraffic/allTimeTrafficUsage i18n keys are removed across every
locale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): mobile cards, multi-select, bulk add

Adds the same row-card layout the inbounds page uses on mobile: the
table is suppressed under the mobile breakpoint and each client renders
as a compact card with a status dot, email, Info button, Enable switch,
and overflow menu. All the per-client detail (traffic, remaining,
expiry, attached inbounds, flow, created/updated, URL, subscription)
opens through the existing info modal.

Multi-select with bulk delete wires AntD row-selection on desktop and
a per-card checkbox on mobile; a Delete (N) button appears in the
toolbar when anything is selected.

Bulk add reuses the five email-generation modes from the inbound bulk
modal but takes a multi-inbound picker so one bulk run can attach to
several inbounds at once. Submits client-by-client through the
existing /panel/api/clients/add endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(inbounds): remove legacy per-inbound client UI

Now that clients live as first-class rows attached to one or many
inbounds, the per-inbound client UI on the inbounds page is dead
weight — every client action either has a global equivalent on the
Clients page or makes no sense in a many-to-many world.

Deletes ClientFormModal, ClientBulkModal, CopyClientsModal, and
ClientRowTable from inbounds/. Strips the matching emits, refs,
handlers, and dropdown menu items from InboundList and InboundsPage,
and removes the dead mobile expand-chevron state and the desktop
expanded-row plumbing that drove the inline client table.

The InboundFormModal Clients tab still works in add-mode (one inline
client at inbound creation) — that flow goes through ClientService.
SyncInbound on save and remains useful.

Fixes a stray "</a-dropdown>" left over by an earlier toolbar edit
in ClientsPage that broke the template parser.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Delete depleted action

Mirrors the legacy delDepletedClients action that lived under the
inbounds page, but as a first-class /panel/api/clients/delDepleted
endpoint backed by ClientService. The new path goes through
ClientService.Delete for each depleted email, so the new clients +
client_inbounds + xray_client_traffic tables stay consistent.

Adds a danger-styled toolbar button on the Clients page (next to
Reset all client traffic) with a confirm dialog and a toast
reporting the deleted count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(api): move every client-shaped endpoint off /inbounds onto /clients

After the multi-inbound client migration, client state belongs to the
client API surface, not the inbound one. Twelve routes that were
crammed under /panel/api/inbounds/* now live where they belong, under
/panel/api/clients/*.

Moved (route, handler, doc):
  POST  /clientIps/:email
  POST  /clearClientIps/:email
  POST  /onlines
  POST  /lastOnline
  POST  /updateClientTraffic/:email
  POST  /resetAllClientTraffics/:id
  POST  /delDepletedClients/:id
  POST  /:id/resetClientTraffic/:email
  GET   /getClientTraffics/:email
  GET   /getClientTrafficsById/:id
  GET   /getSubLinks/:subId
  GET   /getClientLinks/:id/:email

Their /clients/* counterparts are:
  POST  /clients/clientIps/:email
  POST  /clients/clearClientIps/:email
  POST  /clients/onlines
  POST  /clients/lastOnline
  POST  /clients/updateTraffic/:email
  POST  /clients/resetTraffic/:email          (email-only, fans out)
  GET   /clients/traffic/:email
  GET   /clients/traffic/byId/:id
  GET   /clients/subLinks/:subId
  GET   /clients/links/:id/:email

per-inbound resetAllClientTraffics and delDepletedClients are dropped
entirely — the Clients page already exposes global Reset All Traffic
and Delete depleted actions, and per-inbound resets are meaningless
once a client can be attached to many inbounds.

ClientService.ResetTrafficByEmail is the new email-only reset path:
it looks up every inbound the client is attached to and pushes the
counter reset + Xray re-add through inboundService.ResetClientTraffic
for each one, so depleted users come back online instantly.

Frontend callers (ClientsPage, useClients, ClientQrModal,
ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all
switched to the new paths. The Inbounds page drops its per-inbound
"Reset client traffic" and "Delete depleted clients" dropdown items —
users do those at the client level now. api-docs is rebuilt to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(service): switch tgbot + ldap callers to ClientService

Adds two thin helpers to ClientService (CreateOne, DetachByEmail) and
rewrites tgbot.SubmitAddClient and ldap_sync_job to call ClientService
directly. Removes the JSON-blob payloads (BuildJSONForProtocol output for
add, clientsToJSON/clientToJSON helpers) that callers previously fed to
InboundService.AddInboundClient/DelInboundClient.

ldap_sync_job.batchSetEnable now loops InboundService.SetClientEnableByEmail
per email instead of trying to coerce AddInboundClient into doing the
update — the old path would have failed duplicate-email validation for
existing clients anyway.

The legacy InboundService.AddInboundClient/UpdateInboundClient/
DelInboundClient methods stay in place; they are now only used internally
by ClientService Create/Update/Delete/Attach. Inlining + deleting them
follows in a separate commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(service): move all client mutation methods to ClientService

Moves the client mutation surface out of InboundService and into
ClientService. These methods all operate on a single client (identity
fields, traffic limits, expiry, ip limit, enable state, telegram tg id)
and didn't belong on the inbound aggregate.

Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID,
checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail,
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail,
ResetClientTrafficLimitByEmail.

Each method now takes an explicit *InboundService for the helpers that
legitimately stay on InboundService (GetInbound, GetClients, runtimeFor,
AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs /
UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs,
GetClientInboundByEmail / GetClientInboundByTrafficID,
GetClientTrafficByEmail).

Stays on InboundService: ResetClientTrafficByEmail and
ResetClientTraffic(id, email) — these mutate xray_client_traffic rows,
not client identity, so they're inbound-side bookkeeping.

Callers updated: tgbot (6 calls), ldap_sync_job (1 call),
InboundService internal (writeBackClientSubID, CopyInboundClients,
AddInbound's email-uniqueness check), ClientService Create/Update/
Delete/Attach/Detach.

Also removes a dead resetAllClientTraffics controller handler whose
route was already gone after the previous /clients API migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(clients): finish migrating to ClientService + tidy IP routes

Two related cleanups in the new /clients surface:

1. Move ResetAllClientTraffics (bulk-reset of xray_client_traffic +
   last_traffic_reset_time, with node-runtime propagation) from
   InboundService to ClientService. PeriodicTrafficResetJob now holds
   a clientService and calls
   j.clientService.ResetAllClientTraffics(&j.inboundService, id).
   The last client-mutation method on InboundService is gone.

2. Shorten redundantly-named routes/handlers under /panel/api/clients:
   - /clientIps/:email      -> /ips/:email      (handler getIps)
   - /clearClientIps/:email -> /clearIps/:email (handler clearIps)
   The "client" prefix was redundant inside the clients namespace.

Frontend (InboundInfoModal) and api-docs updated to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds,clients): clean up inbound modal + enrich client modal

Inbound modal rework (InboundFormModal.vue + inbound.js):
- Drop the embedded Client subform in the Protocol tab. Multi-inbound
  clients are managed exclusively from the Clients page now; a fresh
  inbound is created with zero clients (settings constructors default
  to []) and the user attaches clients afterwards.
- Hide the Protocol tab entirely when it has nothing to render
  (VMESS, Trojan without fallbacks, Hysteria). Auto-switches active
  tab to Basic when the tab disappears while focused.
- Move the Security section (Security selector + TLS block with certs
  and ECH + Reality block) out of the Stream tab into its own
  Security tab, sharing the canEnableStream gate.

Client modal additions (ClientFormModal.vue + ClientBulkAddModal.vue):
- Flow select (xtls-rprx-vision / -udp443) appears only when the
  panel actually has a Vision-capable inbound (VLESS or PortFallback
  on TCP with TLS or Reality). Hidden otherwise, and cleared when
  it disappears.
- IP Limit input is disabled when the panel-level ipLimitEnable
  setting is off, fetched into useClients alongside subSettings and
  threaded through ClientsPage to both modals.
- Edit modal now shows an "IP Log" section listing IPs that have
  connected with the client's credentials, with refresh and clear
  buttons (calls the renamed /panel/api/clients/ips and /clearIps
  endpoints).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(inbounds): drop manual Fallbacks UI from inbound modal

The PortFallback protocol type now covers the common
VLESS-master-plus-children case with auto-wired dests, so the manual
Fallbacks editor (showFallbacks block in the Protocol tab) is mostly
redundant. Removed:

- the v-if="showFallbacks" template block (SNI/ALPN/Path/dest/PROXY rows)
- the showFallbacks computed
- the addFallback / delFallback helpers
- the .fallbacks-header / .fallbacks-title styles
- the showFallbacks gate from hasProtocolTabContent (so Trojan-over-TCP
  no longer shows an empty Protocol tab)

Power users who need a non-inbound fallback dest (nginx, static site)
can still author settings.fallbacks via the Advanced JSON tab.

* feat(clients,inbounds): move search/filter to Clients page + small fixes

Search/filter relocation:
- Remove the search/filter toolbar (search switch + filter radio +
  protocol/node selects + the visibleInbounds projection +
  inboundsFilterState localStorage + filter CSS + the SearchOutlined/
  FilterOutlined/ObjectUtil/Inbound imports it required) from
  InboundList. The filters were all client-oriented buckets bolted
  onto the inbound row.
- Add a search/filter toolbar to ClientsPage with the same shape:
  switch between deep-text search and bucket filter (active /
  deactive / depleted / expiring / online) + protocol filter that
  matches clients attached to at least one inbound with the chosen
  protocol. State persists in clientsFilterState localStorage.
  filteredClients drives both the desktop table and the mobile card
  list, and select-all / allSelected / someSelected only span the
  visible subset.
- useClients now also fetches expireDiff and trafficDiff from
  /panel/setting/defaultSettings (used to detect the expiring
  bucket); ClientsPage threads them into the client-bucket helper.

Loose fixes folded in:
- Add Client: email field is auto-filled with a random handle on
  open, matching uuid/subId/password/auth.
- Inbound clone: parse and reuse the source settings JSON (with
  clients reset to []) instead of building a fresh defaulted
  Settings, so VLESS Encryption/Decryption and other non-client
  fields survive the clone.
- en-US.json: add the ipLog string used by the edit-client modal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Reverse tag field for VLESS-attached clients

Mirrors the Flow field's pattern: a Reverse tag input appears in the
Add/Edit Client modal whenever at least one selected inbound is VLESS
or PortFallback. The value rides over the wire as
client.reverse = { tag: '...' } so it lands directly in model.Client's
*ClientReverse field; an empty value omits the reverse key entirely.

On edit the field is hydrated from props.client.reverse?.tag, and the
showReverseTag watcher clears the field if the user drops the last
VLESS-like inbound from the selection.

* fix(xray): emit only protocol-relevant fields per client entry

The Xray config synthesizer was writing every identifier field (id,
password, flow, auth, security/method, reverse) on every client entry
regardless of the inbound's protocol. Xray ignores unknown fields, so
the config worked, but it diverged from the spec and leaked secrets
across protocols when one client was attached to multiple inbounds —
a VLESS inbound's generated config carried the same client's Trojan
password and Hysteria auth alongside its uuid.

Switch on inbound.Protocol when building each entry:
- VLESS / PortFallback: id, flow, reverse
- VMess: id, security
- Trojan: password, flow
- Shadowsocks: password, method
- Hysteria / Hysteria2: auth
email is emitted for every protocol.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): restore auto-disable kick under new schema

disableInvalidClients still resolved (inbound_tag, email) pairs via
JSON_EACH(inbounds.settings.clients), which is empty after migrating
to the clients + client_inbounds tables. Result: xrayApi.RemoveUser
never ran for depleted clients, clients.enable stayed true so the UI
showed them as active, and only xray_client_traffic.enable got flipped
- making "Restart Xray After Auto Disable" only half-work.

Resolve the targets via a JOIN through the new schema, flip clients.enable
so the Clients page reflects the state, and drop the legacy JSON
write-back plus the subId cascade workaround (email is unique now).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): live WebSocket updates + Ended status surfacing

ClientsPage now subscribes to traffic / client_stats / invalidate
WebSocket events instead of polling /onlines every 10s. Per-row
traffic counters refresh in place, online state stays current, and
list-level mutations elsewhere trigger a refresh.

The client roll-up summary moves from InboundsPage to ClientsPage
where it belongs, restructured into six labeled stat tiles
(Total / Online / Ended / Expiring / Disabled / Active) with email
popovers on the ones with issues.

Auto-disabled clients (traffic exhausted or expiry passed) now
classify as 'depleted' even though clients.enable=false, so they
show up under the Ended filter and render a red Ended tag instead
of looking indistinguishable from an operator-disabled row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(nodes): per-node client roll-up and panel version

Added transient inboundCount / clientCount / onlineCount /
depletedCount fields to model.Node, populated by NodeService.GetAll
via aggregated queries (one join across inbounds + client_inbounds,
one over client_traffics intersected with the in-memory online
emails). The Nodes list renders these as colored chips on a new
"Clients" column so an operator can see at a glance how many users
each node carries and how many are currently online or depleted.

Also exposes the remote panel's version. The central panel adds
panelVersion to its /api/server/status payload (sourced from
config.GetVersion). Probe reads that field and persists it on the
node row, mirroring how xrayVersion already flows. NodesPage gets
a new column next to Xray Version, in both desktop and mobile
views, with English and Persian strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): stop node sync from resurrecting deleted clients

Several related issues around node-managed clients:

- Remote runtime: drop the per-inbound resetAllClientTraffics path
  and point traffic/onlines/lastOnline fetches at the new
  /panel/api/clients/* routes.
- Delete from master: always push the updated inbound to the node
  even when the client was already disabled or depleted, so the
  node actually loses the user instead of silently keeping it.
- setRemoteTraffic: mirror remote clients into the central tables
  only on first discovery of a node inbound. Matched inbounds let
  the master own the join table, so a stale snap can no longer
  re-create a ClientRecord (and join row) for a client that was
  just deleted on the master.
- ClientService.Delete: route through submitTrafficWrite so deletes
  serialize with node traffic merges, and switch the final
  ClientRecord delete to an explicit Where("id = ?") clause.
- setRemoteTraffic UNIQUE-constraint fix: use clause.OnConflict on
  inserts and email-keyed UPDATEs for client_traffics, so mirroring
  a snap doesn't trip the unique email index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(clients): switch client API endpoints from id to email

All client-scoped routes now use the unique email as the path key
(get, update, del, attach, detach, links). Email is the stable,
protocol-independent identifier — UUIDs don't exist for trojan or
shadowsocks, and internal numeric ids leaked panel implementation
detail into the public API.

Removed the redundant /traffic/byId/:id endpoint (covered by
/traffic/:email) and collapsed /links/:id/:email into /links/:email,
which now returns links across every attached inbound for the client.

Frontend selection, bulk delete, and toggle state are now keyed by
email as well, dropping the id→email lookup workaround.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(server): move cached state and helpers into ServerService

ServerController had grown to hold its own status cache, version-list
TTL cache, history-bucket whitelist, and the loop that drove all three
— concerns that belong in the service layer. Pull them out:

- lastStatus + the @2s refresh become ServerService.RefreshStatus and
  ServerService.LastStatus; the controller's cron now just orchestrates
  the cross-service side effects (xrayMetrics sample, websocket broadcast).
- The 15-minute Xray-versions cache (with stale-on-error fallback) moves
  into ServerService.GetXrayVersionsCached, collapsing the controller
  handler to a single call.
- The freedom/blackhole outbound-tag walk used by /xraylogs becomes
  ServerService.GetDefaultLogOutboundTags.
- The allowed-history-bucket whitelist moves to package-level
  service.IsAllowedHistoryBucket, so both NodeController and
  ServerController validate against the same list.

Net result: web/controller/server.go drops from 458 to 365 lines and
contains only HTTP wiring + presentation-y side effects.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(api): emit JSON-text columns as nested objects

Inbound, ClientRecord, and InboundClientIps store settings /
streamSettings / sniffing / reverse / ips as JSON-text in the DB. The
API was passing that text through verbatim, so every consumer had to
JSON.parse a string inside a string. Add MarshalJSON / UnmarshalJSON so
the wire format is a real nested object, while still accepting the
legacy escaped-string shape on write. Frontend dbinbound.js gets a
matching coerceInboundJsonField helper for the same dual-shape read
path, and inbound.js toJson stops emitting empty/placeholder fields
(externalProxy [], sniffing destOverride when disabled, etc.) so the
new normalised JSON stays terse. api-docs and the inbound-clone path
are updated to the new shape. Controller route lists are regrouped so
all GETs sit above POSTs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): include inboundIds and traffic in /clients/list

ClientRecord got its own MarshalJSON in the previous commit, and
ClientWithAttachments embeds it to add inboundIds and traffic. Go
promotes the embedded MarshalJSON to the outer struct, so the encoder
was calling ClientRecord.MarshalJSON for the whole value and silently
dropping the extras. The frontend reads row.inboundIds / row.traffic
from /clients/list, so attached inbounds didn't render and newly added
clients looked like they hadn't saved. Add an explicit MarshalJSON on
ClientWithAttachments that splices the extras in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): gate IP Log on ipLimitEnable + clean access-log dropdown

Legacy panel hid the IP Log section when access logging was off; the
Vue 3 migration left it gated on isEdit only, so the section showed
even when xray's access log was 'none' and nothing was being recorded.
Restore the ipLimitEnable gate on the edit modal's IP Log form-item.

While here, clean up the Xray Settings access-log dropdown: previously
two 'none' entries appeared (an empty value labelled with t('none') and
the literal 'none' from the options array). Drop the empty option for
access log (the literal 'none' covers it) and relabel the empty option
for error log / mask address to t('empty') so they're distinguishable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(nodes): route per-client ops through node clients API + orphan sweep

Adds Runtime methods AddClient, UpdateUser, and DeleteUser so master
mutates clients on a node via /panel/api/clients/{add,update,del} rather
than pushing the whole inbound. The previous rt.UpdateInbound path made
the node DelInbound+AddInbound on every single-client change, briefly
cycling every other user on the same inbound.

DelInbound no longer filters by enable=true, so a disabled node inbound
actually gets removed from the node instead of being resurrected by the
next snap.

setRemoteTrafficLocked now sweeps any ClientRecord with zero
ClientInbound rows after SyncInbound rebuilds the attachments, which is
how a node-side delete propagates back to master instead of leaving a
detached ghost. ClientService.Delete tombstones the email first so a
snap arriving mid-delete can't re-create the record.

WebSocket broadcasts an "invalidate(clients)" message on every client
mutation so the Clients page refreshes without manual reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(balancers): allow fallback on all strategies + feed burstObservatory from random/roundRobin

Drops the random/roundRobin gate on the Fallback field in
BalancerFormModal so every strategy can pick a fallback outbound.

syncObservatories now feeds burstObservatory from leastLoad +
random + roundRobin balancers (was leastLoad only), matching how
leastPing feeds observatory.

Fix the JsonEditor "Unexpected end of JSON input" that appeared
when switching a balancer between leastPing and another strategy:
the obsView watcher was gated on showObsEditor (a boolean OR of
the two flags) and missed the case where one observatory
swapped for the other in the same tick. Watch the individual
flags instead so obsView flips to the surviving editor and the
getter stops pointing at a deleted key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(inbounds): use sortedInbounds for mobile empty-state check

InboundList referenced an undefined visibleInbounds in the mobile
card list's empty-state guard, throwing "Cannot read properties of
undefined (reading 'length')" and breaking the entire mobile render.

* feat(clients): sortable table columns

Adds the same sortState / sortableCol / sortFns pattern InboundList
uses, wrapping filteredClients in sortedClients so sort composes with
the existing search/filter pipeline. Sortable: enable, email,
inboundIds (attachment count), traffic, remaining, expiryTime;
actions and online stay unsorted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(shadowsocks): generate valid ss2022 keys and per-client method for legacy ciphers

The Add Client flow on shadowsocks inbounds was producing xray configs
that failed to start:

- 2022-blake3-* ciphers need a base64-encoded key of an exact byte
  length per cipher. fillProtocolDefaults was assigning a uuid-style
  string, which xray rejects as "bad key". Now the password is
  generated (or replaced if invalid) via random.Base64Bytes(n) sized
  to the chosen cipher.
- Legacy ciphers (aes-256-gcm, chacha20-*, xchacha20-*) require a
  per-client method field in multi-user mode; model.Client has no
  Method, so settings.clients was stored without one and xray failed
  with "unsupported cipher method:". applyShadowsocksClientMethod
  now injects the top-level method into each client on add/update,
  and healShadowsocksClientMethods backfills it at xray-config-build
  time so existing inbounds heal on the next start.
- xray/api.go ssCipherType switch was missing aes-256-gcm, which
  fell through to ss2022 path.
- SSMethods dropdown now offers aes-256-gcm.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): preserve ClientRecord on inbound delete + filter Attached inbounds to multi-client protocols

Replace the global orphan sweep in setRemoteTrafficLocked with a
per-inbound diff cleanup: only delete a ClientRecord whose email
disappeared from a snap-tracked inbound (i.e. a node-side delete).
Inbounds that vanished entirely from the snap (e.g. admin deleted
the inbound on master) aren't iterated, so a client whose last
attachment came from that inbound is now left alone instead of
being deleted alongside the inbound.

ClientFormModal and ClientBulkAddModal now filter the Attached
inbounds dropdown to protocols that actually support multiple
clients: shadowsocks, vless, vmess, trojan, hysteria, hysteria2,
and portfallback (which routes through VLESS settings).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): make empty-state text readable on dark/ultra themes

The "No clients yet" empty state had a hardcoded black color
(rgba(0,0,0,0.45)) that vanished against the dark backgrounds.
Drop the inline color, let it inherit from the AntD theme, and
fade with opacity like the mobile card empty state already does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): client-first tgbot add flow, tgId field, lightweight inbound options

- tgbot: drop legacy per-protocol Add Client UI in favour of a client-first
  multi-inbound flow. New BuildClientDraftMessage / getInboundsAttachPicker
  let an admin pick one or more inbounds and submit a single client; per-
  protocol secrets are now generated server-side via fillProtocolDefaults.
  Drops awaiting_id/awaiting_password_tr/awaiting_password_sh state cases
  and add_client_ch_default_id/pass_tr/pass_sh/flow callbacks. Adds a
  setTGUser button + awaiting_tg_id state so the bot can set Client.TgID
  during Add.
- clients UI: add Telegram user ID input to ClientFormModal (0 = none).
  Hide IP Limit field entirely when ipLimitEnable is off — disabled fields
  still take layout space, this collapses Auth(Hysteria) to full width.
- inbounds API: new GET /panel/api/inbounds/options that returns just
  {id, remark, protocol, port, tlsFlowCapable}. Used by the clients page
  pickers so the dropdown payload stays small on panels with thousands of
  clients (drops settings JSON, clientStats, streamSettings). Server-side
  TlsFlowCapable mirrors Inbound.canEnableTlsFlow so the modal no longer
  needs to parse streamSettings client-side.
- clientInfoMsg now shows attached inbound remarks, and getInboundUsages
  reports the attached client count per inbound.
- api-docs: document the new /options endpoint and add tgId / flow to the
  clients add/update bodies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(inbounds): keep Node column visible for node-attached inbounds

The Node column was bound to hasActiveNode, so disabling every node hid
the column even when inbounds were still attached to those nodes — the
admin lost the visual cue that those inbounds belonged to a node and
would come back when it was re-enabled. Combine hasActiveNode with a
new hasNodeAttachedInbound check (any dbInbound with nodeId != null) so
the column survives node-disable.

* fix(api-docs): accept functional-component icons in EndpointSection

AntD-Vue icons (SafetyCertificateOutlined, etc.) are functional
components, so the icon prop's type: Object validator was rejecting
them with a "Expected Object, got Function" warning at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: cover crypto, random, netsafe, sub helpers, xray equals, websocket hub, node service

Adds ~110 unit tests across previously untested packages. Focus on
pure-logic and concurrency surfaces where regressions would silently
affect users:

- util/crypto, util/random: password hashing round-trip, ss2022 key
  generation, alphabet/length invariants.
- util/netsafe: IsBlockedIP edge cases, NormalizeHost validation,
  SSRF guard with AllowPrivate context bypass.
- util/common, util/json_util: traffic formatter, Combine nil-skip,
  RawMessage empty-as-null and copy-on-unmarshal.
- sub: splitLinkLines, searchKey/searchHost, kcp share fields,
  finalmask normalization, buildVmessLink round-trip.
- xray: Config.Equals and InboundConfig.Equals field-by-field,
  getRequiredUserString/getOptionalUserString type checks.
- web/websocket: hub registration, throttling, slow-client eviction,
  nil-receiver safety, concurrent register/unregister.
- web/service: NodeService.normalize validation, normalizeBasePath,
  HeartbeatPatch.ToUI mapping.
- web/job: atomicBool concurrent set/takeAndReset semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(clients): replace English fallbacks with proper translation keys

Pulls every hard-coded English label/title in the Clients page and its
four modals through the i18n layer so localized panels stop leaking
English. New keys live under pages.clients (auth, hysteriaAuth, uuid,
flow, flowNone, reverseTag, reverseTagPlaceholder, telegramId,
telegramIdPlaceholder, created, updated, ipLimit) plus refresh at the
root and toasts.bulkDeletedMixed / bulkCreatedMixed for partial-failure
toasts. Also switches the add-client modal's primary button from "Add"
to "Create" for consistency with other create flows.

The bulk-add Random/Random+Prefix/... email-method options stay
hard-coded by request - they're identifier-shaped strings.

* i18n: backfill 99 missing keys across all 12 non-English locales

Brings every translation file up to parity with en-US.json so the
Clients page, the fallback-children inbound section, the new refresh
verb, the Nodes panel-version label and a handful of older holes stop
falling through to the English fallback. New strings span:

- pages.clients.* (labels, confirmations, toasts, emailMethods)
- pages.inbounds.portFallback.* (Reality fallback inbound section)
- pages.nodes.panelVersion, menu.clients, refresh

Technical identifiers (Auth, UUID, Flow, Reverse tag) are intentionally
left untranslated since they correspond to xray-core field names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n: drop stale pages.client block duplicated in every non-English locale

Every non-English locale carried a pages.client (singular) section with
30 entries that duplicated pages.clients (plural). The plural namespace
is what the Vue code actually consumes; the singular one was dead
weight from an older rename that never got cleaned up in the
non-English files. Removing it brings every locale to exactly 984
keys, matching en-US.json.

* chore: apply modernize analyzer fixes across codebase

Mechanical replacements suggested by golang.org/x/tools/.../modernize:
strings.Cut/CutPrefix/SplitSeq, slices.Contains, maps.Copy, min(),
range-over-int, new(expr), strings.Builder for hot += loops,
reflect.TypeFor[T](), sync.WaitGroup.Go(), drop legacy //+build lines.

* feat(database): add PostgreSQL as an optional backend alongside SQLite

Lets operators with large client counts or multi-node setups pick PostgreSQL
at install time without breaking the existing SQLite default. Backend is
selected at runtime via XUI_DB_TYPE/XUI_DB_DSN, a small dialect layer keeps
the five JSON_EXTRACT/JSON_EACH queries portable, and a new `x-ui migrate-db`
subcommand copies SQLite data into PostgreSQL in FK-aware order.

* fix(inbounds): gate node selector to multi-node-capable protocols

Hide the Deploy-To selector and clear nodeId when switching to a
protocol that can't run on a remote node. Also:

- subs: return 404 (not 400) when subId matches no inbounds, so VPN
  clients distinguish "deleted/unknown" from a server error
- hysteria link gen: use the inbound's resolved address so node-managed
  inbounds advertise the node host instead of the central panel
- shadowsocks: default network to 'tcp' (udp was causing issues for some
  clients on first-create)
- vite dev proxy: rewrite migrated-route bypass against the live base
  path instead of a hardcoded single-segment regex

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form

Bulk add/delete were serial on the frontend (one toast per call, N round-trips)
and the backend race exposed by parallelizing them lost client attachments and
hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also
had no Start-After-First-Use option, and the table never showed the delayed
duration.

Backend (web/service/client.go):
- Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on
  the same inbound don't lose the read-modify-write of settings JSON.
- SyncInbound skips create+join when the email is tombstoned so a concurrent
  maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn-
  Settings) that did a stale RMW can't resurrect a just-deleted client with a
  fresh id.
- compactOrphans sweeps settings.clients entries whose ClientRecord no longer
  exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each
  user-initiated mutation self-heals the inbound's settings.
- DelInboundClient uses Pluck instead of First for the stats lookup so a
  missing row doesn't abort the delete with a noisy ErrRecordNotFound log.

Frontend:
- HttpUtil.{get,post} accept a silent option that suppresses the auto-toast.
- ClientBulkAddModal fires creates in parallel + silent + one summary toast.
- useClients.removeMany runs deletes in parallel + silent and refreshes once;
  ClientsPage bulk delete uses it and shows one aggregate toast.
- useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket
  invalidate events from the backend collapses into a single refresh.
- ClientsPage pagination is reactive (paginationState ref + tablePagination
  computed); onTableChange persists page-size and page changes.
- ClientFormModal gains a Start-After-First-Use switch + Duration days input
  alongside the existing Expiry Date picker; on edit-mode open a negative
  expiryTime is decoded back to delayed mode + days; on submit the payload
  sends -86400000 * days or the absolute timestamp.
- ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip
  Start After First Use: Nd) instead of infinity.
- Telegram ID field in the form is hidden when /panel/setting/defaultSettings
  reports tgBotEnable=false; Comment then fills the row.
- Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4)
  when ipLimitEnable is on, else UUID + Total GB at 12/12.
- useInbounds.rollupClients counts only clients with a matching clientStats
  row, so orphans in settings.clients no longer inflate the inbound's count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(windows): clean shutdown, working panel restart, harden kernel32 load

Three Windows-specific issues addressed:

1. Orphaned xray-windows-amd64 after VS Code debugger stop. Delve's
   "Stop" sends TerminateProcess to the Go binary, which is uncatchable
   — our signal handlers never run, so xrayService.StopXray() is skipped
   and xray is left dangling. Spawn xray as a child of a Job Object with
   JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so the OS kills xray when our
   handle to the job is closed (which happens even on TerminateProcess).
   Also trap os.Interrupt in main so Ctrl+C in the terminal runs the
   graceful path.

2. /panel/setting/restartPanel logged "failed to send SIGHUP signal: not
   supported by windows" because Windows can't deliver arbitrary signals.
   Add a restart hook in web/global; main registers it to push SIGHUP
   into its own signal channel, and RestartPanel calls the hook before
   falling back to the (Unix-only) signal path. Same restart-loop code
   runs in both cases.

3. util/sys/sys_windows.go now uses windows.NewLazySystemDLL so the
   kernel32.dll resolve is pinned to %SystemRoot%\System32 (prevents
   DLL hijacking by a planted DLL next to the binary). Local filetime
   type replaced with windows.Filetime, and the unreliable
   syscall.GetLastError() fallback replaced with a type assertion on the
   errno captured at call time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sys): correct CPU/connection accounting on linux + darwin

util/sys/sys_linux.go:
- GetTCPCount/GetUDPCount were counting the column header row in
  /proc/net/{tcp,udp}[6] as a connection, inflating the reported total
  by 1 per non-empty file (so the panel status line always showed 2
  more connections than actually existed). Replace getLinesNum +
  safeGetLinesNum with a single bufio.Scanner-based countConnections
  that skips the header.
- CPUPercentRaw now opens HostProc("stat") instead of a hardcoded
  /proc/stat so HOST_PROC overrides apply, matching the connection
  counters in the same file.
- Simplify CPU field unpacking: pad nums to 8 once instead of guarding
  every assignment with a len check.

util/sys/sys_darwin.go:
- Fix swapped idle/intr indices on kern.cp_time. BSD CPUSTATES order
  is user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4) — gopsutil's
  cpu_darwin_nocgo.go reads the same layout. The previous code used
  out[3] as idle and out[4] as intr, so busy = total - dIdle was
  actually subtracting interrupt time, making the panel report CPU
  usage close to 100% on macOS regardless of actual load.
- Collapse the per-field delta math into a single loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(xray): rotate crash reports into log folder, prevent overwrites

writeCrashReport had two flaws: it wrote to the bin folder (alongside the
xray binary) which conflates artifacts, and the second-precision timestamp
meant a tight restart-loop crash burst overwrote prior reports. Write to
the log folder with nanosecond precision and keep the last 10 reports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* revert(inbounds): drop unreleased portfallback protocol

The Port-with-Fallback inbound (commit 62fd9f9d) was confusing as a
standalone protocol — fallbacks belong on a regular VLESS/Trojan TCP-TLS
inbound, the way Xray models them natively. Rip out the entire feature
cleanly (no migration needed since it was never released): protocol
constant, fallback children DB table, FallbackService, 2 API endpoints,
all UI rows, related translations and api-docs. A native fallback flow
attached to VLESS/Trojan TCP-TLS/Reality will land in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): native fallbacks on VLESS/Trojan TCP-TLS, with working child links

A VLESS or Trojan inbound on TCP with TLS or Reality can now act as a
fallback master: pick existing inbounds as children and the panel auto-
fills the SNI / ALPN / path / xver routing fields from each child's
transport, auto-builds settings.fallbacks at config-gen time, and
rewrites the child's client-share link so it advertises the master's
reachable endpoint and TLS state instead of the child's loopback listen.

Layout matches the Xray All-in-One Nginx example: master at :443 with
clients + TLS, each child on 127.0.0.1 with its own transport+clients.
Order matters (Xray walks fallbacks top-to-bottom) — reorder via the
per-row up/down arrows. Path / SNI / ALPN are exposed under a per-row
Edit toggle for the rare cases where the auto-derivation needs
overriding; otherwise just pick a child and you're done.

Backend: new InboundFallback table + FallbackService (GetByMaster /
SetByMaster / GetParentForChild / BuildFallbacksJSON); two routes
(GET / POST /panel/api/inbounds/:id/fallbacks); xray.GetXrayConfig
injects settings.fallbacks for any VLESS/Trojan TCP-TLS/Reality
inbound; GetInbounds annotates each child with FallbackParent so the
frontend can rewrite links without an extra round-trip.

Link projection covers every emission path — clients-page QR/links,
per-inbound Get URL, raw subscription, sub-JSON, sub-Clash, and the
inbounds-page link/info/QR — via a shared projectThroughFallbackMaster
on the backend and a shared projectChildThroughMaster on the frontend
that both handle the panel-tracked relationship and the legacy
unix-socket (@vless-ws) convention.

Strings translated into all 12 non-English locales.

* docs: rewrite CONTRIBUTING with full local-dev setup

The prior three-line CONTRIBUTING left newcomers guessing at every
non-trivial step: which Go / Node versions, where xray comes from, why
the panel goes blank when XUI_DEBUG=true is flipped on, how the Vue
multi-page setup is wired, what to do on Windows when go build trips
on the CGo SQLite driver.

Now covers prerequisites, MinGW-w64 install on Windows (niXman builds
or MSYS2), one-shot first-time setup, two frontend dev workflows with
the XUI_DEBUG asset-cache gotcha called out, the architecture and
conventions of the Vue side, a project-layout map, useful env vars,
and the PR checklist.

---------
This commit is contained in:
Sanaei
2026-05-19 12:16:42 +02:00
committed by MHSanaei
parent f9ae0347c6
commit 85e2ded0e1
125 changed files with 12973 additions and 6784 deletions

View File

@@ -32,8 +32,8 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
tok := after
if a.apiTokenService.Match(tok) {
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
@@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
inbounds := api.Group("/inbounds")
a.inboundController = NewInboundController(inbounds)
clients := api.Group("/clients")
NewClientController(clients)
// Server API
server := api.Group("/server")
a.serverController = NewServerController(server)

View File

@@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
basePath = "/panel/api"
case "inbound.go":
basePath = "/panel/api/inbounds"
case "client.go":
basePath = "/panel/api/clients"
case "server.go":
basePath = "/panel/api/server"
case "node.go":
@@ -127,7 +129,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
// Skip SPA page routes (these are UI pages, not API endpoints)
spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/clients": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/api-docs": true,
}
if spaPages[r.Path] {

311
web/controller/client.go Normal file
View File

@@ -0,0 +1,311 @@
package controller
import (
"encoding/json"
"fmt"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/web/websocket"
"github.com/gin-gonic/gin"
)
func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
}
type ClientController struct {
clientService service.ClientService
inboundService service.InboundService
xrayService service.XrayService
}
func NewClientController(g *gin.RouterGroup) *ClientController {
a := &ClientController{}
a.initRouter(g)
return a
}
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/get/:email", a.get)
g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/subLinks/:subId", a.getSubLinks)
g.GET("/links/:email", a.getClientLinks)
g.POST("/add", a.create)
g.POST("/update/:email", a.update)
g.POST("/del/:email", a.delete)
g.POST("/:email/attach", a.attach)
g.POST("/:email/detach", a.detach)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/delDepleted", a.delDepleted)
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines)
g.POST("/lastOnline", a.lastOnline)
}
func (a *ClientController) list(c *gin.Context) {
rows, err := a.clientService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *ClientController) get(c *gin.Context) {
email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
}
func (a *ClientController) create(c *gin.Context) {
var payload service.ClientCreatePayload
if err := c.ShouldBindJSON(&payload); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) update(c *gin.Context) {
email := c.Param("email")
var updated model.Client
if err := c.ShouldBindJSON(&updated); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) delete(c *gin.Context) {
email := c.Param("email")
keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type attachDetachBody struct {
InboundIds []int `json:"inboundIds"`
}
func (a *ClientController) attach(c *gin.Context) {
email := c.Param("email")
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) resetAllTraffics(c *gin.Context) {
needRestart, err := a.clientService.ResetAllTraffics()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) delDepleted(c *gin.Context) {
deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"deleted": deleted}, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
email := c.Param("email")
needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type trafficUpdateRequest struct {
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
email := c.Param("email")
var req trafficUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
notifyClientsChanged()
}
func (a *ClientController) getIps(c *gin.Context) {
email := c.Param("email")
ips, err := a.inboundService.GetInboundClientIps(email)
if err != nil || ips == "" {
jsonObj(c, "No IP Record", nil)
return
}
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
formatted := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
formatted = append(formatted, item.IP)
}
jsonObj(c, formatted, nil)
return
}
var oldIps []string
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
jsonObj(c, oldIps, nil)
return
}
jsonObj(c, ips, nil)
}
func (a *ClientController) clearIps(c *gin.Context) {
email := c.Param("email")
if err := a.inboundService.ClearClientIps(email); err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
}
func (a *ClientController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
func (a *ClientController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)
}
func (a *ClientController) getTrafficByEmail(c *gin.Context) {
email := c.Param("email")
traffic, err := a.inboundService.GetClientTrafficByEmail(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, traffic, nil)
}
func (a *ClientController) getSubLinks(c *gin.Context) {
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
func (a *ClientController) getClientLinks(c *gin.Context) {
links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
func (a *ClientController) detach(c *gin.Context) {
email := c.Param("email")
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}

View File

@@ -6,7 +6,6 @@ import (
"net"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -18,8 +17,9 @@ import (
// InboundController handles HTTP requests related to Xray inbounds management.
type InboundController struct {
inboundService service.InboundService
xrayService service.XrayService
inboundService service.InboundService
xrayService service.XrayService
fallbackService service.FallbackService
}
// NewInboundController creates a new InboundController and sets up its routes.
@@ -61,38 +61,18 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds)
g.GET("/options", a.getInboundOptions)
g.GET("/get/:id", a.getInbound)
g.GET("/getClientTraffics/:email", a.getClientTraffics)
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
g.GET("/getSubLinks/:subId", a.getSubLinks)
g.GET("/getClientLinks/:id/:email", a.getClientLinks)
g.GET("/:id/fallbacks", a.getFallbacks)
g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound)
g.POST("/update/:id", a.updateInbound)
g.POST("/setEnable/:id", a.setInboundEnable)
g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient)
g.POST("/:id/copyClients", a.copyInboundClients)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
g.POST("/delDepletedClients/:id", a.delDepletedClients)
g.POST("/import", a.importInbound)
g.POST("/onlines", a.onlines)
g.POST("/lastOnline", a.lastOnline)
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
}
type CopyInboundClientsRequest struct {
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
Flow string `form:"flow" json:"flow"`
g.POST("/:id/fallbacks", a.setFallbacks)
}
// getInbounds retrieves the list of inbounds for the logged-in user.
@@ -106,6 +86,19 @@ func (a *InboundController) getInbounds(c *gin.Context) {
jsonObj(c, inbounds, nil)
}
// getInboundOptions returns a lightweight projection of the user's inbounds
// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
// Avoids shipping per-client settings and traffic stats just to fill a dropdown.
func (a *InboundController) getInboundOptions(c *gin.Context) {
user := session.GetLoginUser(c)
options, err := a.inboundService.GetInboundOptions(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, options, nil)
}
// getInbound retrieves a specific inbound by its ID.
func (a *InboundController) getInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
@@ -121,28 +114,6 @@ func (a *InboundController) getInbound(c *gin.Context) {
jsonObj(c, inbound, nil)
}
// getClientTraffics retrieves client traffic information by email.
func (a *InboundController) getClientTraffics(c *gin.Context) {
email := c.Param("email")
clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, clientTraffics, nil)
}
// getClientTrafficsById retrieves client traffic information by inbound ID.
func (a *InboundController) getClientTrafficsById(c *gin.Context) {
id := c.Param("id")
clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, clientTraffics, nil)
}
// addInbound creates a new inbound configuration.
func (a *InboundController) addInbound(c *gin.Context) {
inbound := &model.Inbound{}
@@ -274,174 +245,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) {
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
}
// getClientIps retrieves the IP addresses associated with a client by email.
func (a *InboundController) getClientIps(c *gin.Context) {
email := c.Param("email")
ips, err := a.inboundService.GetInboundClientIps(email)
if err != nil || ips == "" {
jsonObj(c, "No IP Record", nil)
return
}
// Prefer returning a normalized string list for consistent UI rendering
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
formatted := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
formatted = append(formatted, item.IP)
}
jsonObj(c, formatted, nil)
return
}
var oldIps []string
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
jsonObj(c, oldIps, nil)
return
}
// If parsing fails, return as string
jsonObj(c, ips, nil)
}
// clearClientIps clears the IP addresses for a client by email.
func (a *InboundController) clearClientIps(c *gin.Context) {
email := c.Param("email")
err := a.inboundService.ClearClientIps(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
}
// addInboundClient adds a new client to an existing inbound.
func (a *InboundController) addInboundClient(c *gin.Context) {
data := &model.Inbound{}
err := c.ShouldBind(data)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
needRestart, err := a.inboundService.AddInboundClient(data)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// copyInboundClients copies clients from source inbound to target inbound.
func (a *InboundController) copyInboundClients(c *gin.Context) {
targetID, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
req := &CopyInboundClientsRequest{}
err = c.ShouldBind(req)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if req.SourceInboundID <= 0 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
return
}
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
func (a *InboundController) delInboundClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
clientId := c.Param("clientId")
needRestart, err := a.inboundService.DelInboundClient(id, clientId)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// updateInboundClient updates a client's configuration in an inbound.
func (a *InboundController) updateInboundClient(c *gin.Context) {
clientId := c.Param("clientId")
inbound := &model.Inbound{}
err := c.ShouldBind(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// resetClientTraffic resets the traffic counter for a specific client in an inbound.
func (a *InboundController) resetClientTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
email := c.Param("email")
needRestart, err := a.inboundService.ResetClientTraffic(id, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// resetInboundTraffic resets traffic counters for a specific inbound.
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
@@ -472,24 +275,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
}
// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.ResetAllClientTraffics(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
}
// importInbound imports an inbound configuration from provided data.
func (a *InboundController) importInbound(c *gin.Context) {
inbound := &model.Inbound{}
@@ -522,79 +307,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
}
}
// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
func (a *InboundController) delDepletedClients(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.DelDepletedClients(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
}
// onlines retrieves the list of currently online clients.
func (a *InboundController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
// lastOnline retrieves the last online timestamps for clients.
func (a *InboundController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)
}
// updateClientTraffic updates the traffic statistics for a client by email.
func (a *InboundController) updateClientTraffic(c *gin.Context) {
email := c.Param("email")
// Define the request structure for traffic update
type TrafficUpdateRequest struct {
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
var request TrafficUpdateRequest
err := c.ShouldBindJSON(&request)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.UpdateClientTrafficByEmail(email, request.Upload, request.Download)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
}
// delInboundClientByEmail deletes a client from an inbound by email address.
func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
inboundId, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid inbound ID", err)
return
}
email := c.Param("email")
needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email)
if err != nil {
jsonMsg(c, "Failed to delete client by email", err)
return
}
jsonMsg(c, "Client deleted successfully", nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
@@ -621,30 +333,42 @@ func resolveHost(c *gin.Context) string {
return c.Request.Host
}
// getSubLinks returns every protocol URL produced for the given subscription
// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
func (a *InboundController) getSubLinks(c *gin.Context) {
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
// getClientLinks returns the URL(s) for one client on one inbound — the same
// string the Copy URL button copies in the panel UI. Empty array when the
// protocol has no URL form, or when the email isn't found on the inbound.
func (a *InboundController) getClientLinks(c *gin.Context) {
// getFallbacks returns the fallback rules attached to the master inbound.
func (a *InboundController) getFallbacks(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
rows, err := a.fallbackService.GetByMaster(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
jsonObj(c, links, nil)
jsonObj(c, rows, nil)
}
// setFallbacks atomically replaces the master inbound's fallback list
// and triggers an Xray restart so the new settings.fallbacks take effect.
func (a *InboundController) setFallbacks(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
type body struct {
Fallbacks []service.FallbackInput `json:"fallbacks"`
}
var b body
if err := c.ShouldBindJSON(&b); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
}

View File

@@ -178,7 +178,7 @@ func (a *NodeController) history(c *gin.Context) {
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}

View File

@@ -27,11 +27,6 @@ type ServerController struct {
settingService service.SettingService
panelService service.PanelService
xrayMetricsService service.XrayMetricsService
lastStatus *service.Status
lastVersions []string
lastGetVersionsTime int64 // unix seconds
}
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
@@ -74,63 +69,43 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/getNewEchCert", a.getNewEchCert)
}
// refreshStatus updates the cached server status and collects time-series
// metrics. CPU/Mem/Net/Online/Load are all written in one call so the
// SystemHistoryModal's tabs share an identical x-axis.
func (a *ServerController) refreshStatus() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
if a.lastStatus != nil {
now := time.Now()
a.serverService.AppendStatusSample(now, a.lastStatus)
a.xrayMetricsService.Sample(now)
// Broadcast status update via WebSocket
websocket.BroadcastStatus(a.lastStatus)
}
}
// startTask initiates background tasks for continuous status monitoring.
// startTask registers the @2s ticker that refreshes server status, samples
// xray metrics, and pushes the new snapshot to all websocket subscribers.
// State + sampling live in ServerService; the controller only orchestrates
// the cross-service side effects (xrayMetrics sample + websocket broadcast).
func (a *ServerController) startTask() {
webServer := global.GetWebServer()
c := webServer.GetCron()
c := global.GetWebServer().GetCron()
c.AddFunc("@every 2s", func() {
// Always refresh to keep CPU history collected continuously.
// Sampling is lightweight and capped to ~6 hours in memory.
a.refreshStatus()
status := a.serverService.RefreshStatus()
if status == nil {
return
}
a.xrayMetricsService.Sample(time.Now())
websocket.BroadcastStatus(status)
})
}
// status returns the current server status information.
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
// allowedHistoryBuckets is the bucket-second whitelist shared by both
// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it
// prevents callers from triggering arbitrary aggregation work and keeps
// the front-end's bucket selector self-documenting.
var allowedHistoryBuckets = map[int]bool{
2: true, // Real-time view
30: true, // 30s intervals
60: true, // 1m intervals
120: true, // 2m intervals
180: true, // 3m intervals
300: true, // 5m intervals
func parseHistoryBucket(c *gin.Context) (int, bool) {
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return 0, false
}
return bucket, true
}
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
// Kept for back-compat; new callers should use /history/cpu/:bucket which
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
bucketStr := c.Param("bucket")
bucket, err := strconv.Atoi(bucketStr)
if err != nil || bucket <= 0 {
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
if !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
points := a.serverService.AggregateCpuHistory(bucket, 60)
jsonObj(c, points, nil)
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
}
// getMetricHistoryBucket returns up to 60 buckets of history for a single
@@ -142,9 +117,8 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
@@ -160,9 +134,8 @@ func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
@@ -178,37 +151,19 @@ func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
}
func (a *ServerController) getXrayVersion(c *gin.Context) {
const cacheTTLSeconds = 15 * 60
now := time.Now().Unix()
if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds {
jsonObj(c, a.lastVersions, nil)
return
}
versions, err := a.serverService.GetXrayVersions()
versions, err := a.serverService.GetXrayVersionsCached()
if err != nil {
if a.lastVersions != nil {
logger.Warning("getXrayVersion failed; serving cached list:", err)
jsonObj(c, a.lastVersions, nil)
return
}
jsonMsg(c, I18nWeb(c, "getVersion"), err)
return
}
a.lastVersions = versions
a.lastGetVersionsTime = now
jsonObj(c, versions, nil)
}
@@ -240,7 +195,6 @@ func (a *ServerController) updatePanel(c *gin.Context) {
func (a *ServerController) updateGeofile(c *gin.Context) {
fileName := c.Param("fileName")
// Validate the filename for security (prevent path traversal attacks)
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
@@ -287,55 +241,22 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
// getLogs retrieves the application logs based on count, level, and syslog filters.
func (a *ServerController) getLogs(c *gin.Context) {
count := c.Param("count")
level := c.PostForm("level")
syslog := c.PostForm("syslog")
logs := a.serverService.GetLogs(count, level, syslog)
logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
jsonObj(c, logs, nil)
}
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
func (a *ServerController) getXrayLogs(c *gin.Context) {
count := c.Param("count")
filter := c.PostForm("filter")
showDirect := c.PostForm("showDirect")
showBlocked := c.PostForm("showBlocked")
showProxy := c.PostForm("showProxy")
var freedoms []string
var blackholes []string
//getting tags for freedom and blackhole outbounds
config, err := a.settingService.GetDefaultXrayConfig()
if err == nil && config != nil {
if cfgMap, ok := config.(map[string]any); ok {
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
for _, outbound := range outbounds {
if obMap, ok := outbound.(map[string]any); ok {
switch obMap["protocol"] {
case "freedom":
if tag, ok := obMap["tag"].(string); ok {
freedoms = append(freedoms, tag)
}
case "blackhole":
if tag, ok := obMap["tag"].(string); ok {
blackholes = append(blackholes, tag)
}
}
}
}
}
}
}
if len(freedoms) == 0 {
freedoms = []string{"direct"}
}
if len(blackholes) == 0 {
blackholes = []string{"blocked"}
}
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes)
freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
logs := a.serverService.GetXrayLogs(
c.Param("count"),
c.PostForm("filter"),
c.PostForm("showDirect"),
c.PostForm("showBlocked"),
c.PostForm("showProxy"),
freedoms,
blackholes,
)
jsonObj(c, logs, nil)
}
@@ -358,36 +279,25 @@ func (a *ServerController) getDb(c *gin.Context) {
}
filename := "x-ui.db"
if !isValidFilename(filename) {
if !filenameRegex.MatchString(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return
}
// Set the headers for the response
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename="+filename)
// Write the file contents to the response
c.Writer.Write(db)
}
func isValidFilename(filename string) bool {
// Validate that the filename only contains allowed characters
return filenameRegex.MatchString(filename)
}
// importDB imports a database file and restarts the Xray service.
func (a *ServerController) importDB(c *gin.Context) {
// Get the file from the request body
file, _, err := c.Request.FormFile("db")
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
return
}
defer file.Close()
err = a.serverService.ImportDB(file)
if err != nil {
if err := a.serverService.ImportDB(file); err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
return
}
@@ -416,8 +326,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) {
// getNewEchCert generates a new ECH certificate for the given SNI.
func (a *ServerController) getNewEchCert(c *gin.Context) {
sni := c.PostForm("sni")
cert, err := a.serverService.GetNewEchCert(sni)
cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
if err != nil {
jsonMsg(c, "get ech certificate", err)
return
@@ -442,7 +351,6 @@ func (a *ServerController) getNewUUID(c *gin.Context) {
jsonMsg(c, "Failed to generate UUID", err)
return
}
jsonObj(c, uuidResp, nil)
}

View File

@@ -27,7 +27,7 @@ func getRemoteIp(c *gin.Context) string {
}
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
for _, part := range strings.Split(xff, ",") {
for part := range strings.SplitSeq(xff, ",") {
if ip, ok := extractTrustedIP(part); ok {
return ip
}
@@ -50,7 +50,7 @@ func isTrustedProxy(ip string) bool {
}
trusted := trustedProxyCIDRs()
for _, value := range strings.Split(trusted, ",") {
for value := range strings.SplitSeq(trusted, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue

View File

@@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/inbounds", a.inbounds)
g.GET("/clients", a.clients)
g.GET("/nodes", a.nodes)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
@@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
serveDistPage(c, "inbounds.html")
}
func (a *XUIController) clients(c *gin.Context) {
serveDistPage(c, "clients.html")
}
// nodes renders the multi-panel nodes management page.
func (a *XUIController) nodes(c *gin.Context) {
serveDistPage(c, "nodes.html")

View File

@@ -195,7 +195,7 @@ func (s *AllSetting) CheckValid() error {
s.SubClashPath += "/"
}
for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue

View File

@@ -3,6 +3,7 @@ package global
import (
"context"
"sync"
_ "unsafe"
"github.com/robfig/cron/v3"
@@ -11,6 +12,9 @@ import (
var (
webServer WebServer
subServer SubServer
restartHookMu sync.RWMutex
restartHook func()
)
// WebServer interface defines methods for accessing the web server instance.
@@ -44,3 +48,24 @@ func SetSubServer(s SubServer) {
func GetSubServer() SubServer {
return subServer
}
// SetRestartHook registers a callback that triggers an in-process panel
// restart. main.go sets this up to push SIGHUP into its own signal channel
// so the restart path works on Windows (where p.Signal(SIGHUP) is unsupported).
func SetRestartHook(fn func()) {
restartHookMu.Lock()
defer restartHookMu.Unlock()
restartHook = fn
}
// TriggerRestart fires the registered restart hook. Returns false if none is set.
func TriggerRestart() bool {
restartHookMu.RLock()
fn := restartHook
restartHookMu.RUnlock()
if fn == nil {
return false
}
fn()
return true
}

View File

@@ -1,18 +1,15 @@
package job
import (
"strings"
"time"
"strings"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
"github.com/mhsanaei/3x-ui/v3/web/service"
"strconv"
"github.com/google/uuid"
)
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
@@ -20,6 +17,7 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
type LdapSyncJob struct {
settingService service.SettingService
inboundService service.InboundService
clientService service.ClientService
xrayService service.XrayService
}
@@ -135,18 +133,29 @@ func (j *LdapSyncJob) Run() {
}
}
// --- Execute batch create ---
for tag, newClients := range clientsToCreate {
if len(newClients) == 0 {
continue
}
payload := &model.Inbound{Id: inboundMap[tag].Id}
payload.Settings = j.clientsToJSON(newClients)
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
logger.Warningf("Failed to add clients for tag %s: %v", tag, err)
} else {
logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag)
j.xrayService.SetToNeedRestart()
ib := inboundMap[tag]
created := 0
restartNeeded := false
for _, c := range newClients {
nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
if err != nil {
logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err)
continue
}
created++
if nr {
restartNeeded = true
}
}
if created > 0 {
logger.Infof("LDAP auto-create: %d clients for %s", created, tag)
if restartNeeded {
j.xrayService.SetToNeedRestart()
}
}
}
@@ -206,34 +215,31 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp
return c
}
// batchSetEnable enables/disables clients in batch through a single call
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
if len(emails) == 0 {
return
}
// Prepare JSON for mass update
clients := make([]model.Client, 0, len(emails))
restartNeeded := false
changed := 0
for _, email := range emails {
clients = append(clients, model.Client{
Email: email,
Enable: enable,
})
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
if err != nil {
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
continue
}
if ok {
changed++
}
if needRestart {
restartNeeded = true
}
}
payload := &model.Inbound{
Id: ib.Id,
Settings: j.clientsToJSON(clients),
if changed > 0 {
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
}
// Use a single AddInboundClient call to update enable
if _, err := j.inboundService.AddInboundClient(payload); err != nil {
logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err)
return
if restartNeeded {
j.xrayService.SetToNeedRestart()
}
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, len(emails), ib.Tag)
j.xrayService.SetToNeedRestart()
}
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
@@ -269,90 +275,28 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
continue
}
// Delete in batches
for i := 0; i < len(toDelete); i += batchSize {
end := min(i+batchSize, len(toDelete))
batch := toDelete[i:end]
for _, c := range batch {
var clientKey string
switch ib.Protocol {
case model.Trojan:
clientKey = c.Password
case model.Shadowsocks:
clientKey = c.Email
default: // vless/vmess
clientKey = c.ID
}
if _, err := j.inboundService.DelInboundClient(ib.Id, clientKey); err != nil {
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
if err != nil {
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
c.Email, ib.Id, ib.Tag, err)
} else {
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
c.Email, ib.Id, ib.Tag)
// do not restart here
continue
}
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
c.Email, ib.Id, ib.Tag)
if nr {
restartNeeded = true
}
}
}
}
// One time after all batches
if restartNeeded {
j.xrayService.SetToNeedRestart()
logger.Info("Xray restart scheduled after batch deletion")
}
}
// clientsToJSON serializes an array of clients to JSON
func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
b := strings.Builder{}
b.WriteString("{\"clients\":[")
for i, c := range clients {
if i > 0 {
b.WriteString(",")
}
b.WriteString(j.clientToJSON(c))
}
b.WriteString("]}")
return b.String()
}
// clientToJSON serializes minimal client fields to JSON object string without extra deps
func (j *LdapSyncJob) clientToJSON(c model.Client) string {
// construct minimal JSON manually to avoid importing json for simple case
b := strings.Builder{}
b.WriteString("{")
if c.ID != "" {
b.WriteString("\"id\":\"")
b.WriteString(c.ID)
b.WriteString("\",")
}
if c.Password != "" {
b.WriteString("\"password\":\"")
b.WriteString(c.Password)
b.WriteString("\",")
}
b.WriteString("\"email\":\"")
b.WriteString(c.Email)
b.WriteString("\",")
b.WriteString("\"enable\":")
if c.Enable {
b.WriteString("true")
} else {
b.WriteString("false")
}
b.WriteString(",")
b.WriteString("\"limitIp\":")
b.WriteString(strconv.Itoa(c.LimitIP))
b.WriteString(",")
b.WriteString("\"totalGB\":")
b.WriteString(strconv.FormatInt(c.TotalGB, 10))
if c.ExpiryTime > 0 {
b.WriteString(",\"expiryTime\":")
b.WriteString(strconv.FormatInt(c.ExpiryTime, 10))
}
b.WriteString("}")
return b.String()
}

View File

@@ -20,6 +20,8 @@ const (
type NodeTrafficSyncJob struct {
nodeService service.NodeService
inboundService service.InboundService
settingService service.SettingService
xrayService service.XrayService
running sync.Mutex
structural atomicBool
}
@@ -83,6 +85,22 @@ func (j *NodeTrafficSyncJob) Run() {
}
wg.Wait()
_, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
if err != nil {
logger.Warning("node traffic sync: depletion check failed:", err)
}
if clientsDisabled {
if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable {
if err := j.xrayService.RestartXray(true); err != nil {
logger.Warning("node traffic sync: restart xray after disabling clients failed:", err)
j.xrayService.SetToNeedRestart()
}
} else if settingErr != nil {
logger.Warning("node traffic sync: get RestartXrayOnClientDisable failed:", settingErr)
}
j.structural.set()
}
if !websocket.HasClients() {
return
}
@@ -123,6 +141,7 @@ func (j *NodeTrafficSyncJob) Run() {
if j.structural.takeAndReset() {
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
}
}

View File

@@ -0,0 +1,69 @@
package job
import (
"sync"
"testing"
)
func TestAtomicBool_DefaultIsFalse(t *testing.T) {
var a atomicBool
if a.takeAndReset() {
t.Fatal("default atomicBool should report false")
}
}
func TestAtomicBool_SetThenTakeReturnsTrueOnce(t *testing.T) {
var a atomicBool
a.set()
if !a.takeAndReset() {
t.Fatal("takeAndReset after set should return true")
}
if a.takeAndReset() {
t.Fatal("second takeAndReset should return false (state was reset)")
}
}
func TestAtomicBool_SetIsIdempotent(t *testing.T) {
var a atomicBool
a.set()
a.set()
a.set()
if !a.takeAndReset() {
t.Fatal("repeated set should still leave the flag true")
}
if a.takeAndReset() {
t.Fatal("flag should be cleared after the first take")
}
}
func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
var a atomicBool
const setters = 100
const readers = 20
var wg sync.WaitGroup
for range setters {
wg.Go(func() {
a.set()
})
}
wg.Wait()
trueCount := 0
var rwg sync.WaitGroup
var mu sync.Mutex
for range readers {
rwg.Go(func() {
if a.takeAndReset() {
mu.Lock()
trueCount++
mu.Unlock()
}
})
}
rwg.Wait()
if trueCount != 1 {
t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
}
}

View File

@@ -11,6 +11,7 @@ type Period string
// PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
type PeriodicTrafficResetJob struct {
inboundService service.InboundService
clientService service.ClientService
period Period
}
@@ -42,7 +43,7 @@ func (j *PeriodicTrafficResetJob) Run() {
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
}
resetClientErr := j.inboundService.ResetAllClientTraffics(inbound.Id)
resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id)
if resetClientErr != nil {
logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -78,6 +79,54 @@ func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) e
})
}
func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
if !client.Enable {
return nil
}
user := map[string]any{
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"auth": client.Auth,
"password": client.Password,
}
return l.AddUser(ctx, ib, user)
}
func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
if email == "" {
return nil
}
if err := l.RemoveUser(ctx, ib, email); err != nil {
if strings.Contains(err.Error(), "not found") {
return nil
}
return err
}
return nil
}
func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail != "" {
if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {
return err
}
}
if !payload.Enable {
return nil
}
user := map[string]any{
"email": payload.Email,
"id": payload.ID,
"security": payload.Security,
"flow": payload.Flow,
"auth": payload.Auth,
"password": payload.Password,
}
return l.AddUser(ctx, ib, user)
}
func (l *Local) RestartXray(_ context.Context) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
@@ -89,10 +138,6 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
return nil
}
func (l *Local) ResetInboundClientTraffics(_ context.Context, _ *model.Inbound) error {
return nil
}
func (l *Local) ResetAllTraffics(_ context.Context) error {
return nil
}

View File

@@ -257,31 +257,58 @@ func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) er
return r.UpdateInbound(ctx, ib, ib)
}
func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err)
}
payload := map[string]any{
"client": client,
"inboundIds": []int{id},
}
if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil {
return err
}
return nil
}
// DeleteUser is idempotent: master's per-inbound Delete loop may call it
// multiple times for the same node, and "not found" on the follow-ups is
// the expected success path.
func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
if email == "" {
return nil
}
_, err := r.do(ctx, http.MethodPost,
"panel/api/clients/del/"+url.PathEscape(email), nil)
if err == nil {
return nil
}
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil
}
return err
}
func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail == "" {
oldEmail = payload.Email
}
if _, err := r.do(ctx, http.MethodPost,
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
return err
}
return nil
}
func (r *Remote) RestartXray(ctx context.Context) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
return err
}
func (r *Remote) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
logger.Warning("remote ResetClientTraffic: tag", ib.Tag, "not found on", r.node.Name)
return nil
}
_, err = r.do(ctx, http.MethodPost,
fmt.Sprintf("panel/api/inbounds/%d/resetClientTraffic/%s", id, url.PathEscape(email)),
nil)
return err
}
func (r *Remote) ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
logger.Warning("remote ResetInboundClientTraffics: tag", ib.Tag, "not found on", r.node.Name)
return nil
}
_, err = r.do(ctx, http.MethodPost,
fmt.Sprintf("panel/api/inbounds/resetAllClientTraffics/%d", id), nil)
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
_, err := r.do(ctx, http.MethodPost,
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
return err
}
@@ -307,14 +334,14 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
return nil, fmt.Errorf("decode inbound list: %w", err)
}
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/onlines", nil)
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
}
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/lastOnline", nil)
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
} else if len(envLastOnline.Obj) > 0 {

View File

@@ -7,8 +7,8 @@ import (
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
tests := []struct {
name string
input string
name string
input string
// wantCertFile / wantKeyFile: expected presence after sanitize
wantCertFile bool
wantKeyFile bool
@@ -55,7 +55,7 @@ func TestSanitizeStreamSettingsForRemote(t *testing.T) {
wantKeyFile: false,
},
{
name: "empty stream settings",
name: "empty stream settings",
input: "",
// empty input returns empty, nothing to check
},

View File

@@ -16,9 +16,15 @@ type Runtime interface {
AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
// Per-client operations that route through the node's clients API on
// Remote (instead of pushing the whole inbound) so the node applies
// per-user xray API calls without a DelInbound+AddInbound cycle.
UpdateUser(ctx context.Context, ib *model.Inbound, email string, payload model.Client) error
DeleteUser(ctx context.Context, ib *model.Inbound, email string) error
AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error
RestartXray(ctx context.Context) error
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error
ResetAllTraffics(ctx context.Context) error
}

1959
web/service/client.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
)
func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) {
c := ClientWithAttachments{
ClientRecord: model.ClientRecord{Id: 1, Email: "alice@example.com"},
InboundIds: []int{3, 5},
Traffic: &xray.ClientTraffic{Email: "alice@example.com", Up: 1024, Down: 4096, Enable: true},
}
out, err := json.Marshal(c)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if parsed["email"] != "alice@example.com" {
t.Errorf("expected ClientRecord fields to survive, got %v", parsed)
}
ids, ok := parsed["inboundIds"].([]any)
if !ok {
t.Fatalf("expected inboundIds to be present as an array, got %T (%s)", parsed["inboundIds"], out)
}
if len(ids) != 2 {
t.Errorf("expected 2 inbound ids, got %d", len(ids))
}
if _, ok := parsed["traffic"].(map[string]any); !ok {
t.Errorf("expected traffic to be present as an object, got %T", parsed["traffic"])
}
}
func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) {
c := ClientWithAttachments{
ClientRecord: model.ClientRecord{Id: 1, Email: "bob@example.com"},
InboundIds: nil,
}
out, err := json.Marshal(c)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if _, present := parsed["traffic"]; present {
t.Errorf("expected traffic to be omitted when nil, got %v", parsed["traffic"])
}
if _, present := parsed["inboundIds"]; !present {
t.Errorf("expected inboundIds key to always be present, got %s", out)
}
}

147
web/service/fallback.go Normal file
View File

@@ -0,0 +1,147 @@
package service
import (
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"gorm.io/gorm"
)
type FallbackService struct{}
// FallbackInput is the payload shape POSTed by the inbound form.
type FallbackInput struct {
ChildId int `json:"childId"`
Name string `json:"name"`
Alpn string `json:"alpn"`
Path string `json:"path"`
Xver int `json:"xver"`
SortOrder int `json:"sortOrder"`
}
// GetByMaster returns every fallback rule attached to the master inbound.
func (s *FallbackService) GetByMaster(masterId int) ([]model.InboundFallback, error) {
var rows []model.InboundFallback
err := database.GetDB().
Where("master_id = ?", masterId).
Order("sort_order ASC, id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
// GetParentForChild finds the first fallback rule that points at childId.
// Used by client-link generation: when a child inbound is attached as a
// fallback, its client links should advertise the master's address+port
// and TLS instead of the child's loopback listen.
func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback, error) {
var row model.InboundFallback
err := database.GetDB().
Where("child_id = ?", childId).
Order("sort_order ASC, id ASC").
First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// SetByMaster replaces the master's entire fallback list atomically.
func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error {
db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil {
return err
}
for i, c := range items {
if c.ChildId <= 0 || c.ChildId == masterId {
continue
}
row := model.InboundFallback{
MasterId: masterId,
ChildId: c.ChildId,
Name: c.Name,
Alpn: c.Alpn,
Path: c.Path,
Xver: c.Xver,
SortOrder: c.SortOrder,
}
if row.SortOrder == 0 {
row.SortOrder = i
}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
})
}
// BuildFallbacksJSON resolves the master's fallback rows into Xray's
// expected settings.fallbacks shape, looking up each child's listen+port
// to fill the dest field. Returns nil when the master has no rules.
func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
if tx == nil {
tx = database.GetDB()
}
var rows []model.InboundFallback
err := tx.Where("master_id = ?", masterId).
Order("sort_order ASC, id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
childIds := make([]int, 0, len(rows))
for i := range rows {
childIds = append(childIds, rows[i].ChildId)
}
var children []model.Inbound
if err := tx.Where("id IN ?", childIds).Find(&children).Error; err != nil {
return nil, err
}
byId := make(map[int]*model.Inbound, len(children))
for i := range children {
byId[children[i].Id] = &children[i]
}
out := make([]map[string]any, 0, len(rows))
for _, r := range rows {
child, ok := byId[r.ChildId]
if !ok {
continue
}
listen := strings.TrimSpace(child.Listen)
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
listen = "127.0.0.1"
}
entry := map[string]any{
"dest": fmt.Sprintf("%s:%d", listen, child.Port),
}
if r.Name != "" {
entry["name"] = r.Name
}
if r.Alpn != "" {
entry["alpn"] = r.Alpn
}
if r.Path != "" {
entry["path"] = r.Path
}
if r.Xver > 0 {
entry["xver"] = r.Xver
}
out = append(out, entry)
}
return out, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -124,7 +124,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
}
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
// fed by ServerController.refreshStatus every 2s. nodeMetrics holds
// fed by ServerService.RefreshStatus every 2s. nodeMetrics holds
// per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
// process-local — survival across panel restart is not required.
var (

View File

@@ -24,6 +24,7 @@ type HeartbeatPatch struct {
LastHeartbeat int64
LatencyMs int
XrayVersion string
PanelVersion string
CpuPct float64
MemPct float64
UptimeSecs uint64
@@ -45,7 +46,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
db := database.GetDB()
var nodes []*model.Node
err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
return nodes, err
if err != nil || len(nodes) == 0 {
return nodes, err
}
type inboundRow struct {
Id int
NodeID int `gorm:"column:node_id"`
}
var inboundRows []inboundRow
if err := db.Table("inbounds").
Select("id, node_id").
Where("node_id IS NOT NULL").
Scan(&inboundRows).Error; err != nil {
return nodes, nil
}
if len(inboundRows) == 0 {
return nodes, nil
}
inboundsByNode := make(map[int][]int, len(nodes))
nodeByInbound := make(map[int]int, len(inboundRows))
for _, row := range inboundRows {
inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
nodeByInbound[row.Id] = row.NodeID
}
type clientCountRow struct {
NodeID int `gorm:"column:node_id"`
Count int `gorm:"column:count"`
}
var clientCounts []clientCountRow
if err := db.Raw(`
SELECT inbounds.node_id AS node_id, COUNT(DISTINCT client_inbounds.client_id) AS count
FROM inbounds
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
WHERE inbounds.node_id IS NOT NULL
GROUP BY inbounds.node_id
`).Scan(&clientCounts).Error; err == nil {
for _, row := range clientCounts {
for _, n := range nodes {
if n.Id == row.NodeID {
n.ClientCount = row.Count
break
}
}
}
}
now := time.Now().UnixMilli()
type trafficRow struct {
InboundID int `gorm:"column:inbound_id"`
Email string
Enable bool
Total int64
Up int64
Down int64
ExpiryTime int64 `gorm:"column:expiry_time"`
}
var trafficRows []trafficRow
inboundIDs := make([]int, 0, len(nodeByInbound))
for id := range nodeByInbound {
inboundIDs = append(inboundIDs, id)
}
if err := db.Table("client_traffics").
Select("inbound_id, email, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", inboundIDs).
Scan(&trafficRows).Error; err == nil {
online := make(map[string]struct{})
for _, email := range s.onlineEmails() {
online[email] = struct{}{}
}
depletedByNode := make(map[int]int)
onlineByNode := make(map[int]int)
for _, row := range trafficRows {
nodeID, ok := nodeByInbound[row.InboundID]
if !ok {
continue
}
expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
if expired || exhausted || !row.Enable {
depletedByNode[nodeID]++
}
if _, ok := online[row.Email]; ok {
onlineByNode[nodeID]++
}
}
for _, n := range nodes {
n.InboundCount = len(inboundsByNode[n.Id])
n.DepletedCount = depletedByNode[n.Id]
n.OnlineCount = onlineByNode[n.Id]
}
}
return nodes, nil
}
func (s *NodeService) onlineEmails() []string {
svc := InboundService{}
return svc.GetOnlineClients()
}
func (s *NodeService) GetById(id int) (*model.Node, error) {
@@ -154,6 +253,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
"last_heartbeat": p.LastHeartbeat,
"latency_ms": p.LatencyMs,
"xray_version": p.XrayVersion,
"panel_version": p.PanelVersion,
"cpu_pct": p.CpuPct,
"mem_pct": p.MemPct,
"uptime_secs": p.UptimeSecs,
@@ -238,7 +338,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
Xray struct {
Version string `json:"version"`
} `json:"xray"`
Uptime uint64 `json:"uptime"`
PanelVersion string `json:"panelVersion"`
Uptime uint64 `json:"uptime"`
} `json:"obj"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
@@ -255,28 +356,31 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
}
patch.XrayVersion = o.Xray.Version
patch.PanelVersion = o.PanelVersion
patch.UptimeSecs = o.Uptime
return patch, nil
}
type ProbeResultUI struct {
Status string `json:"status"`
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
Error string `json:"error"`
Status string `json:"status"`
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
PanelVersion string `json:"panelVersion"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
Error string `json:"error"`
}
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
r := ProbeResultUI{
LatencyMs: p.LatencyMs,
XrayVersion: p.XrayVersion,
CpuPct: p.CpuPct,
MemPct: p.MemPct,
UptimeSecs: p.UptimeSecs,
Error: p.LastError,
LatencyMs: p.LatencyMs,
XrayVersion: p.XrayVersion,
PanelVersion: p.PanelVersion,
CpuPct: p.CpuPct,
MemPct: p.MemPct,
UptimeSecs: p.UptimeSecs,
Error: p.LastError,
}
if ok {
r.Status = "online"

162
web/service/node_test.go Normal file
View File

@@ -0,0 +1,162 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func TestNormalizeBasePath(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "/"},
{" ", "/"},
{"/", "/"},
{"/panel", "/panel/"},
{"panel", "/panel/"},
{"panel/", "/panel/"},
{"/panel/", "/panel/"},
{" /panel ", "/panel/"},
{"/a/b/c", "/a/b/c/"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
got := normalizeBasePath(c.in)
if got != c.want {
t.Fatalf("normalizeBasePath(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
func TestNodeMetricKey(t *testing.T) {
cases := []struct {
id int
metric string
want string
}{
{1, "cpu", "node:1:cpu"},
{42, "mem", "node:42:mem"},
{0, "anything", "node:0:anything"},
}
for _, c := range cases {
got := nodeMetricKey(c.id, c.metric)
if got != c.want {
t.Fatalf("nodeMetricKey(%d, %q) = %q, want %q", c.id, c.metric, got, c.want)
}
}
}
func TestHeartbeatPatch_ToUI_OnlineCopiesFields(t *testing.T) {
p := HeartbeatPatch{
Status: "ignored-source",
LatencyMs: 42,
XrayVersion: "1.8.4",
PanelVersion: "3.0.0",
CpuPct: 12.5,
MemPct: 33.3,
UptimeSecs: 12345,
LastError: "",
}
ui := p.ToUI(true)
if ui.Status != "online" {
t.Fatalf("Status = %q, want online", ui.Status)
}
if ui.LatencyMs != 42 || ui.XrayVersion != "1.8.4" || ui.PanelVersion != "3.0.0" {
t.Fatalf("scalar copy mismatch: %+v", ui)
}
if ui.CpuPct != 12.5 || ui.MemPct != 33.3 || ui.UptimeSecs != 12345 {
t.Fatalf("metric copy mismatch: %+v", ui)
}
if ui.Error != "" {
t.Fatalf("Error = %q, want empty", ui.Error)
}
}
func TestHeartbeatPatch_ToUI_OfflinePreservesError(t *testing.T) {
p := HeartbeatPatch{LastError: "connection refused"}
ui := p.ToUI(false)
if ui.Status != "offline" {
t.Fatalf("Status = %q, want offline", ui.Status)
}
if ui.Error != "connection refused" {
t.Fatalf("Error = %q, want %q", ui.Error, "connection refused")
}
}
func TestNodeService_Normalize_Valid(t *testing.T) {
s := &NodeService{}
n := &model.Node{
Name: " primary ",
ApiToken: " abc ",
Address: "example.com",
Port: 8443,
Scheme: "",
BasePath: "panel",
}
if err := s.normalize(n); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n.Name != "primary" {
t.Fatalf("Name not trimmed: %q", n.Name)
}
if n.ApiToken != "abc" {
t.Fatalf("ApiToken not trimmed: %q", n.ApiToken)
}
if n.Scheme != "https" {
t.Fatalf("empty Scheme should default to https, got %q", n.Scheme)
}
if n.BasePath != "/panel/" {
t.Fatalf("BasePath = %q, want /panel/", n.BasePath)
}
}
func TestNodeService_Normalize_KeepsValidScheme(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: "n", Address: "example.com", Port: 80, Scheme: "http"}
if err := s.normalize(n); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n.Scheme != "http" {
t.Fatalf("Scheme = %q, want http", n.Scheme)
}
}
func TestNodeService_Normalize_RejectsEmptyName(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: " ", Address: "example.com", Port: 443}
if err := s.normalize(n); err == nil {
t.Fatal("expected error for empty name")
}
}
func TestNodeService_Normalize_RejectsBadHost(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: "n", Address: "bad host name with spaces", Port: 443}
if err := s.normalize(n); err == nil {
t.Fatal("expected error for invalid host")
}
}
func TestNodeService_Normalize_RejectsOutOfRangePort(t *testing.T) {
s := &NodeService{}
for _, port := range []int{0, -1, 65536, 100000} {
n := &model.Node{Name: "n", Address: "example.com", Port: port}
if err := s.normalize(n); err == nil {
t.Fatalf("expected error for port %d", port)
}
}
}
func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
s := &NodeService{}
n := &model.Node{Name: "n", Address: "example.com", Port: 443, Scheme: "ftp"}
if err := s.normalize(n); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n.Scheme != "https" {
t.Fatalf("Scheme = %q, want https", n.Scheme)
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/global"
)
// PanelService provides business logic for panel management operations.
@@ -35,14 +36,21 @@ const (
)
func (s *PanelService) RestartPanel(delay time.Duration) error {
p, err := os.FindProcess(syscall.Getpid())
if err != nil {
return err
}
go func() {
time.Sleep(delay)
err := p.Signal(syscall.SIGHUP)
if global.TriggerRestart() {
return
}
if runtime.GOOS == "windows" {
logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)")
return
}
p, err := os.FindProcess(syscall.Getpid())
if err != nil {
logger.Error("panel restart: FindProcess failed:", err)
return
}
if err := p.Signal(syscall.SIGHUP); err != nil {
logger.Error("failed to send SIGHUP signal:", err)
}
}()
@@ -213,7 +221,7 @@ func compareVersionStrings(a string, b string) (int, bool) {
if !okA || !okB {
return 0, false
}
for i := 0; i < len(aParts); i++ {
for i := range len(aParts) {
if aParts[i] > bParts[i] {
return 1, true
}

View File

@@ -72,7 +72,7 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
// "udp", or "tcp,udp". if it's set, it wins outright.
if n, ok := st["network"].(string); ok && n != "" {
bits = 0
for _, part := range strings.Split(n, ",") {
for part := range strings.SplitSeq(n, ",") {
switch strings.TrimSpace(part) {
case "tcp":
bits |= transportTCP

View File

@@ -56,7 +56,8 @@ func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protoco
}
}
func intPtr(v int) *int { return &v }
//go:fix inline
func intPtr(v int) *int { return new(v) }
func TestInboundTransports(t *testing.T) {
cases := []struct {
@@ -360,7 +361,7 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
func TestCheckPortConflict_NodeScope(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, intPtr(1))
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
svc := &InboundService{}
@@ -370,8 +371,8 @@ func TestCheckPortConflict_NodeScope(t *testing.T) {
want bool
}{
{"new local same port + tcp clashes with local", nil, true},
{"new remote on different node from local is fine", intPtr(2), false},
{"new remote on existing node 1 clashes", intPtr(1), true},
{"new remote on different node from local is fine", new(2), false},
{"new remote on existing node 1 clashes", new(1), true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -71,11 +71,12 @@ type Status struct {
ErrorMsg string `json:"errorMsg"`
Version string `json:"version"`
} `json:"xray"`
Uptime uint64 `json:"uptime"`
Loads []float64 `json:"loads"`
TcpCount int `json:"tcpCount"`
UdpCount int `json:"udpCount"`
NetIO struct {
PanelVersion string `json:"panelVersion"`
Uptime uint64 `json:"uptime"`
Loads []float64 `json:"loads"`
TcpCount int `json:"tcpCount"`
UdpCount int `json:"udpCount"`
NetIO struct {
Up uint64 `json:"up"`
Down uint64 `json:"down"`
} `json:"netIO"`
@@ -104,6 +105,7 @@ type Release struct {
type ServerService struct {
xrayService XrayService
inboundService InboundService
settingService SettingService
cachedIPv4 string
cachedIPv6 string
noIPv6 bool
@@ -114,6 +116,128 @@ type ServerService struct {
emaCPU float64
cachedCpuSpeedMhz float64
lastCpuInfoAttempt time.Time
lastStatusMu sync.RWMutex
lastStatus *Status
versionsCacheMu sync.Mutex
versionsCache *cachedXrayVersions
}
type cachedXrayVersions struct {
versions []string
fetchedAt time.Time
}
// xrayVersionsCacheTTL bounds how often /getXrayVersion hits GitHub. The list
// is purely informational (rendered in the "switch Xray version" picker) so a
// quarter-hour staleness window is fine and saves the API budget.
const xrayVersionsCacheTTL = 15 * time.Minute
// allowedHistoryBuckets is the bucket-second whitelist for time-series
// aggregation endpoints (server + node metrics). Restricting it prevents
// callers from triggering arbitrary aggregation work and keeps the
// frontend's bucket selector self-documenting.
var allowedHistoryBuckets = map[int]bool{
2: true, // Real-time view
30: true, // 30s intervals
60: true, // 1m intervals
120: true, // 2m intervals
180: true, // 3m intervals
300: true, // 5m intervals
}
// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
// whitelist used by /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
// /server/xrayObservatoryHistory, and /nodes/history.
func IsAllowedHistoryBucket(bucketSeconds int) bool {
return allowedHistoryBuckets[bucketSeconds]
}
// LastStatus returns the most recent Status snapshot collected by
// RefreshStatus. Safe for concurrent readers.
func (s *ServerService) LastStatus() *Status {
s.lastStatusMu.RLock()
defer s.lastStatusMu.RUnlock()
return s.lastStatus
}
// RefreshStatus collects a new system snapshot, stores it as LastStatus, and
// appends it to the system-metrics time series. Returns the new snapshot (may
// be nil if collection failed). Called by the background ticker; the caller is
// responsible for any side effects (websocket broadcast, xray metrics sample).
func (s *ServerService) RefreshStatus() *Status {
next := s.GetStatus(s.LastStatus())
if next == nil {
return nil
}
s.lastStatusMu.Lock()
s.lastStatus = next
s.lastStatusMu.Unlock()
s.AppendStatusSample(time.Now(), next)
return next
}
// GetXrayVersionsCached wraps GetXrayVersions with a TTL cache. On fetch
// failure we serve the last successful list (if any) so the UI doesn't go
// blank during a GitHub API hiccup; if there's no cache at all the underlying
// error is surfaced.
func (s *ServerService) GetXrayVersionsCached() ([]string, error) {
s.versionsCacheMu.Lock()
cache := s.versionsCache
s.versionsCacheMu.Unlock()
if cache != nil && time.Since(cache.fetchedAt) <= xrayVersionsCacheTTL {
return cache.versions, nil
}
versions, err := s.GetXrayVersions()
if err != nil {
if cache != nil {
logger.Warning("GetXrayVersionsCached: serving stale list:", err)
return cache.versions, nil
}
return nil, err
}
s.versionsCacheMu.Lock()
s.versionsCache = &cachedXrayVersions{versions: versions, fetchedAt: time.Now()}
s.versionsCacheMu.Unlock()
return versions, nil
}
// GetDefaultLogOutboundTags scans the default Xray config for freedom and
// blackhole outbound tags so /getXrayLogs can colour-code log lines without
// the controller re-doing the JSON walk. Falls back to the historical
// "direct"/"blocked" defaults when the config can't be read.
func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
config, err := s.settingService.GetDefaultXrayConfig()
if err == nil && config != nil {
if cfgMap, ok := config.(map[string]any); ok {
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
for _, outbound := range outbounds {
obMap, ok := outbound.(map[string]any)
if !ok {
continue
}
tag, _ := obMap["tag"].(string)
if tag == "" {
continue
}
switch obMap["protocol"] {
case "freedom":
freedoms = append(freedoms, tag)
case "blackhole":
blackholes = append(blackholes, tag)
}
}
}
}
}
if len(freedoms) == 0 {
freedoms = []string{"direct"}
}
if len(blackholes) == 0 {
blackholes = []string{"blocked"}
}
return freedoms, blackholes
}
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
@@ -360,6 +484,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
}
status.Xray.Version = s.xrayService.GetXrayVersion()
status.PanelVersion = config.GetVersion()
// Application stats
var rtm runtime.MemStats
@@ -383,8 +508,8 @@ func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
// AppendStatusSample writes one tick of every metric we keep — CPU, memory
// percent, network throughput (bytes/s), online client count, and the three
// load averages. Called by ServerController.refreshStatus on the same @2s
// cadence as AppendCpuSample, so all series stay aligned.
// load averages. Called by RefreshStatus on the same @2s cadence as
// AppendCpuSample, so all series stay aligned.
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
if status == nil {
return

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import (
)
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
typ := reflect.TypeOf(LoginAttempt{})
typ := reflect.TypeFor[LoginAttempt]()
if _, ok := typ.FieldByName("Password"); ok {
t.Fatal("LoginAttempt must not carry attempted passwords")
}

View File

@@ -4,8 +4,10 @@ import (
"encoding/json"
"errors"
"runtime"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/xray"
@@ -116,57 +118,101 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if inbound.NodeID != nil {
continue
}
// get settings clients
settings := map[string]any{}
json.Unmarshal([]byte(inbound.Settings), &settings)
clients, ok := settings["clients"].([]any)
if ok {
// Fast O(N) lookup map for client traffic enablement
clientStats := inbound.ClientStats
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
enableMap[clientTraffic.Email] = clientTraffic.Enable
dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
if listErr != nil {
return nil, listErr
}
clientStats := inbound.ClientStats
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
var finalClients []any
for i := range dbClients {
c := dbClients[i]
if enable, exists := enableMap[c.Email]; exists && !enable {
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email)
continue
}
// filter and clean clients
var final_clients []any
for _, client := range clients {
c, ok := client.(map[string]any)
if !ok {
continue
}
email, _ := c["email"].(string)
// check users active or not via stats
if enable, exists := enableMap[email]; exists && !enable {
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", email)
continue
}
// check manual disabled flag
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
// clear client config for additional parameters
for key := range c {
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" && key != "reverse" {
delete(c, key)
}
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
c["flow"] = "xtls-rprx-vision"
}
}
final_clients = append(final_clients, any(c))
if !c.Enable {
continue
}
flow := c.Flow
if flow == "xtls-rprx-vision-udp443" {
flow = "xtls-rprx-vision"
}
entry := map[string]any{"email": c.Email}
switch inbound.Protocol {
case model.VLESS:
if c.ID != "" {
entry["id"] = c.ID
}
if flow != "" {
entry["flow"] = flow
}
if c.Reverse != nil {
entry["reverse"] = c.Reverse
}
case model.VMESS:
if c.ID != "" {
entry["id"] = c.ID
}
if c.Security != "" {
entry["security"] = c.Security
}
case model.Trojan:
if c.Password != "" {
entry["password"] = c.Password
}
if flow != "" {
entry["flow"] = flow
}
case model.Shadowsocks:
if c.Password != "" {
entry["password"] = c.Password
}
if c.Security != "" {
entry["method"] = c.Security
}
case model.Hysteria, model.Hysteria2:
if c.Auth != "" {
entry["auth"] = c.Auth
}
}
finalClients = append(finalClients, entry)
}
settings["clients"] = final_clients
_, hadClients := settings["clients"]
mutated := hadClients || len(finalClients) > 0
if mutated {
settings["clients"] = finalClients
}
if inboundCanHostFallbacks(inbound) {
fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, 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
}
}
if mutated {
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return nil, err
}
inbound.Settings = string(modifiedSettings)
}
@@ -195,12 +241,62 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
inbound.StreamSettings = string(newStream)
}
if inbound.Protocol == model.Shadowsocks {
if healed, ok := healShadowsocksClientMethods(inbound.Settings); ok {
inbound.Settings = healed
}
}
inboundConfig := inbound.GenXrayInboundConfig()
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
}
return xrayConfig, nil
}
// healShadowsocksClientMethods is the same idea as applyShadowsocksClientMethod
// (see client.go) but applied at xray-config-build time, to backfill the
// per-client method field for legacy shadowsocks inbounds whose clients were
// stored before applyShadowsocksClientMethod existed. Returns the rewritten
// settings string and true when anything actually changed.
func healShadowsocksClientMethods(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
method, _ := parsed["method"].(string)
if method == "" || strings.HasPrefix(method, "2022-blake3-") {
return settings, false
}
clients, ok := parsed["clients"].([]any)
if !ok {
return settings, false
}
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if existing, _ := cm["method"].(string); existing != "" {
continue
}
cm["method"] = method
clients[i] = cm
changed = true
}
if !changed {
return settings, false
}
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
if !s.IsXrayRunning() {

View File

@@ -3,6 +3,7 @@ package service
import (
_ "embed"
"encoding/json"
"slices"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/xray"
@@ -55,7 +56,7 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
// If `raw` does not look like a wrapper, it is returned unchanged.
func UnwrapXrayTemplateConfig(raw string) string {
const maxDepth = 8 // defensive cap against pathological multi-nest values
for i := 0; i < maxDepth; i++ {
for range maxDepth {
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &top); err != nil {
return raw
@@ -190,10 +191,8 @@ func findApiRule(rules []map[string]any) int {
}
}
case []string:
for _, s := range tags {
if s == "api" {
return i
}
if slices.Contains(tags, "api") {
return i
}
case string:
if tags == "api" {

View File

@@ -65,7 +65,7 @@ func TestUnwrapXrayTemplateConfig(t *testing.T) {
// non-wrapped, and confirm we end up at some valid JSON (we
// don't loop forever and we don't blow the stack).
s := real
for i := 0; i < 16; i++ {
for range 16 {
s = `{"xraySetting":` + s + `}`
}
got := UnwrapXrayTemplateConfig(s)

View File

@@ -18,6 +18,8 @@
"search": "بحث",
"filter": "فلترة",
"loading": "جاري التحميل...",
"refresh": "تحديث",
"clear": "مسح",
"second": "ثانية",
"minute": "دقيقة",
"hour": "ساعة",
@@ -94,6 +96,7 @@
"ultraDark": "داكن جدًا",
"dashboard": "نظرة عامة",
"inbounds": "الإدخالات",
"clients": "العملاء",
"nodes": "النودز",
"settings": "إعدادات البانل",
"xray": "إعدادات Xray",
@@ -127,9 +130,9 @@
"stopXray": "إيقاف",
"restartXray": "إعادة تشغيل",
"xraySwitch": "النسخة",
"xrayUpdates": "تحديثات Xray",
"xraySwitchClick": "اختار النسخة اللي عايز تتحول لها.",
"xraySwitchClickDesk": "اختار بحذر، النسخ القديمة ممكن ما تتوافقش مع الإعدادات الحالية.",
"xrayUpdates": "تحديثات Xray",
"updatePanel": "تحديث البانل",
"panelUpdateDesc": "ده هيحدث 3X-UI لآخر إصدار وهيعيد تشغيل خدمة البانل.",
"currentPanelVersion": "إصدار البانل الحالي",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "سيؤدي هذا إلى تحديث كافة الملفات.",
"geofilesUpdateAll": "تحديث الكل",
"geofileUpdatePopover": "تم تحديث ملف الجغرافيا بنجاح",
"dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة",
"logs": "السجلات",
"config": "الإعدادات",
"backup": "نسخة احتياطية",
"backupTitle": "نسخ احتياطي واستعادة",
"exportDatabase": "اخزن نسخة",
"exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك.",
"importDatabase": "استرجاع",
"importDatabaseDesc": "اضغط عشان تختار وتحمل ملف .db من جهازك لاسترجاع قاعدة البيانات من نسخة احتياطية.",
"importDatabaseSuccess": "تم استيراد قاعدة البيانات بنجاح",
"importDatabaseError": "حدث خطأ أثناء استيراد قاعدة البيانات",
"readDatabaseError": "حدث خطأ أثناء قراءة قاعدة البيانات",
"getDatabaseError": "حدث خطأ أثناء استرجاع قاعدة البيانات",
"getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات",
"customGeoTitle": "GeoSite / GeoIP مخصص",
"customGeoAdd": "إضافة",
"customGeoType": "النوع",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "مصدر geo المخصص غير موجود",
"customGeoErrDownload": "فشل التنزيل",
"customGeoErrUpdateAllIncomplete": "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة",
"customGeoEmpty": "لا توجد مصادر geo مخصصة بعد — انقر على «إضافة» لإنشاء واحد"
"customGeoEmpty": "لا توجد مصادر geo مخصصة بعد — انقر على «إضافة» لإنشاء واحد",
"dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة",
"logs": "السجلات",
"config": "الإعدادات",
"backup": "نسخة احتياطية",
"backupTitle": "نسخ احتياطي واستعادة",
"exportDatabase": "اخزن نسخة",
"exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك.",
"importDatabase": "استرجاع",
"importDatabaseDesc": "اضغط عشان تختار وتحمل ملف .db من جهازك لاسترجاع قاعدة البيانات من نسخة احتياطية.",
"importDatabaseSuccess": "تم استيراد قاعدة البيانات بنجاح",
"importDatabaseError": "حدث خطأ أثناء استيراد قاعدة البيانات",
"readDatabaseError": "حدث خطأ أثناء قراءة قاعدة البيانات",
"getDatabaseError": "حدث خطأ أثناء استرجاع قاعدة البيانات",
"getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات"
},
"inbounds": {
"allTimeTraffic": "إجمالي حركة المرور",
"allTimeTrafficUsage": "إجمالي الاستخدام طوال الوقت",
"title": "الإدخالات",
"totalDownUp": "إجمالي المرسل/المستقبل",
"totalUsage": "إجمالي الاستخدام",
@@ -249,6 +250,23 @@
"node": "نود",
"deployTo": "نشر على",
"localPanel": "بانل محلي",
"fallbacks": {
"title": "الـ Fallbacks",
"help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى inbound آخر. اختر فرعًا أدناه وسيتم ملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقل الفرع — في الغالب لا تحتاج إلى أي تعديل إضافي. يجب أن يستمع كل فرع على 127.0.0.1 مع security=none.",
"empty": "لا توجد fallbacks بعد",
"add": "إضافة fallback",
"pickInbound": "اختر inbound",
"matchAny": "أي",
"rederive": "إعادة الملء من الفرع",
"rederived": "تم إعادة الملء من الفرع",
"editAdvanced": "تحرير حقول التوجيه",
"hideAdvanced": "إخفاء المتقدم",
"quickAddAll": "إضافة سريعة لكل الـ inbounds المؤهلة",
"quickAdded": "تمت إضافة {n} fallback",
"quickAddedNone": "لا توجد inbounds جديدة مؤهلة للإضافة",
"routesWhen": "يوجَّه عندما",
"defaultCatchAll": "افتراضي — يلتقط أي شيء آخر"
},
"protocol": "بروتوكول",
"port": "بورت",
"portMap": "خريطة البورت",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "سجل تاريخ الـ IPs. (عشان تفعل الإدخال بعد التعطيل، امسح السجل)",
"IPLimitlogclear": "امسح السجل",
"setDefaultCert": "استخدم شهادة البانل",
"streamTab": "الدفق",
"securityTab": "الأمان",
"sniffingTab": "الاستشعار",
"sniffingMetadataOnly": "البيانات الوصفية فقط",
"sniffingRouteOnly": "التوجيه فقط",
"sniffingIpsExcluded": "IP المستثناة",
"sniffingDomainsExcluded": "النطاقات المستثناة",
"decryption": "فك التشفير",
"encryption": "التشفير",
"vlessAuthX25519": "مصادقة X25519",
"vlessAuthMlkem768": "مصادقة ML-KEM-768",
"vlessAuthCustom": "مخصص",
"vlessAuthSelected": "المحدد: {auth}",
"advanced": {
"title": "أقسام JSON للاتصال الوارد",
"subtitle": "JSON الكامل للاتصال الوارد ومحررات مخصصة لـ settings و sniffing و streamSettings.",
"all": "الكل",
"allHelp": "كائن الاتصال الوارد الكامل بكل الحقول في محرر واحد.",
"settings": "الإعدادات",
"settingsHelp": "غلاف كتلة settings في Xray:",
"sniffing": "الاستشعار",
"sniffingHelp": "غلاف كتلة sniffing في Xray:",
"stream": "الدفق",
"streamHelp": "غلاف كتلة stream في Xray:",
"jsonErrorPrefix": "JSON متقدم"
},
"telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
"subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
"info": "معلومات",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "أضف عميل",
"edit": "تعديل عميل",
"submitAdd": "أضف العميل",
"submitEdit": "احفظ التعديلات",
"clients": {
"add": "إضافة عميل",
"edit": "تعديل العميل",
"submitAdd": "إضافة عميل",
"submitEdit": "حفظ التغييرات",
"clientCount": "عدد العملاء",
"bulk": "إضافة بالجملة",
"copyFromInbound": "نسخ العملاء من الـ Inbound",
"bulk": "إضافة مجمعة",
"copyFromInbound": "نسخ العملاء من الاتصال الوارد",
"copyToInbound": "نسخ العملاء إلى",
"copySelected": "نسخ المحدد",
"copySource": "المصدر",
"copyEmailPreview": "معاينة البريد الإلكتروني الناتج",
"copySelectSourceFirst": "الرجاء اختيار الـ Inbound المصدر أولاً.",
"copyEmailPreview": "معاينة البريد الناتج",
"copySelectSourceFirst": "يرجى تحديد اتصال وارد مصدر أولاً.",
"copyResult": "نتيجة النسخ",
"copyResultSuccess": "تم النسخ بنجاح",
"copyResultNone": "لا يوجد شيء للنسخ: لم يتم اختيار أي عميل أو أن المصدر فارغ",
"copyResultNone": "لا شيء للنسخ: لم يتم تحديد عملاء أو أن المصدر فارغ",
"copyResultErrors": "أخطاء النسخ",
"copyFlowLabel": "Flow للعملاء الجدد (VLESS)",
"copyFlowHint": "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغاً لتخطيه.",
"copyFlowHint": "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغًا للتخطي.",
"selectAll": "تحديد الكل",
"clearAll": "مسح الكل",
"method": "طريقة",
"first": "أول واحد",
"last": "آخر واحد",
"method": "الطريقة",
"first": "أول",
"last": "آخر",
"ipLog": "سجل IP",
"prefix": "بادئة",
"postfix": "لاحقة",
"delayedStart": "ابدأ بعد أول استخدام",
"delayedStart": "البدء بعد أول استخدام",
"expireDays": "المدة",
"days": "يوم/أيام",
"days": "يوم",
"renew": "تجديد تلقائي",
"renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل)(الوحدة: يوم)"
"renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل) (الوحدة: يوم)",
"title": "العملاء",
"actions": "الإجراءات",
"totalGB": "مجموع المرسل/المستقبل (جيجابايت)",
"expiryTime": "انتهاء الصلاحية",
"addClients": "إضافة عملاء",
"limitIp": "حد عناوين IP",
"password": "كلمة المرور",
"subId": "معرّف الاشتراك",
"online": "متصل",
"email": "البريد الإلكتروني",
"comment": "ملاحظة",
"traffic": "حركة المرور",
"offline": "غير متصل",
"addTitle": "إضافة عميل",
"qrCode": "رمز QR",
"moreInformation": "مزيد من المعلومات",
"delete": "حذف",
"reset": "إعادة ضبط حركة المرور",
"editTitle": "تعديل العميل",
"client": "العميل",
"enabled": "مفعّل",
"remaining": "المتبقي",
"duration": "المدة",
"attachedInbounds": "الاتصالات الواردة المرتبطة",
"selectInbound": "حدد اتصالاً واردًا واحدًا أو أكثر",
"noSubId": "هذا العميل ليس لديه subId، لا يوجد رابط قابل للمشاركة.",
"noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.",
"link": "رابط",
"resetNotPossible": "قم بإرفاق هذا العميل بأحد الاتصالات الواردة أولاً.",
"general": "عام",
"resetAllTraffics": "إعادة ضبط حركة مرور كل العملاء",
"resetAllTrafficsTitle": "إعادة ضبط حركة مرور كل العملاء؟",
"resetAllTrafficsContent": "يُعاد ضبط عدّاد الإرسال/الاستقبال لكل عميل إلى الصفر. لا تتأثر الحصص ومواعيد الانتهاء. لا يمكن التراجع.",
"empty": "لا يوجد عملاء بعد — أضف واحدًا للبدء.",
"deleteConfirmTitle": "حذف العميل {email}؟",
"deleteConfirmContent": "سيؤدي هذا إلى إزالة العميل من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.",
"deleteSelected": "حذف ({count})",
"bulkDeleteConfirmTitle": "حذف {count} عميل؟",
"bulkDeleteConfirmContent": "سيتم إزالة كل عميل محدد من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.",
"delDepleted": "حذف المنتهية",
"delDepletedConfirmTitle": "حذف العملاء المنتهية حصصهم؟",
"delDepletedConfirmContent": "يُحذف كل عميل استُنفِدت حصة حركة مروره أو انتهت صلاحيته. لا يمكن التراجع.",
"auth": "Auth",
"hysteriaAuth": "Auth (Hysteria)",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag اختياري",
"telegramId": "معرّف مستخدم تلغرام",
"telegramIdPlaceholder": "معرّف مستخدم تلغرام رقمي (0 = لا شيء)",
"created": "تاريخ الإنشاء",
"updated": "تاريخ التحديث",
"ipLimit": "حد IP",
"toasts": {
"deleted": "تم حذف العميل",
"trafficReset": "تمت إعادة ضبط حركة المرور",
"allTrafficsReset": "تمت إعادة ضبط حركة مرور كل العملاء",
"bulkDeleted": "تم حذف {count} عميل",
"bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}",
"bulkCreated": "تم إنشاء {count} عميل",
"bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
"delDepleted": "تم حذف {count} عميل منتهٍ"
}
},
"nodes": {
"title": "النودز",
@@ -428,6 +536,7 @@
"latency": "الكمون",
"lastHeartbeat": "آخر نبضة",
"xrayVersion": "إصدار Xray",
"panelVersion": "إصدار اللوحة",
"actions": "العمليات",
"probe": "فحص فوري",
"testConnection": "اختبار الاتصال",
@@ -777,9 +886,6 @@
"unexpectIPs": "عناوين IP غير متوقعة",
"useSystemHosts": "استخدام ملف Hosts الخاص بالنظام",
"useSystemHostsDesc": "استخدام ملف hosts من نظام مثبت",
"usePreset": "استخدام النموذج",
"dnsPresetTitle": "قوالب DNS",
"dnsPresetFamily": "العائلي",
"serveStale": "تقديم النتائج المنتهية",
"serveStaleDesc": "إرجاع نتائج الكاش المنتهية الصلاحية أثناء التحديث في الخلفية",
"serveExpiredTTL": "مدة صلاحية النتائج المنتهية",
@@ -792,6 +898,9 @@
"hostsEmpty": "لم يتم تعريف أي Host",
"hostsDomain": "النطاق (مثل domain:example.com)",
"hostsValues": "عنوان IP أو نطاق — اكتب واضغط Enter",
"usePreset": "استخدام النموذج",
"dnsPresetTitle": "قوالب DNS",
"dnsPresetFamily": "العائلي",
"clearAll": "حذف الكل",
"clearAllTitle": "حذف جميع خوادم DNS؟",
"clearAllConfirm": "سيؤدي هذا إلى إزالة جميع خوادم DNS من القائمة. لا يمكن التراجع عن هذا الإجراء."
@@ -980,4 +1089,4 @@
"chooseInbound": "اختار الإدخال"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Search",
"filter": "Filter",
"loading": "Loading...",
"refresh": "Refresh",
"clear": "Clear",
"second": "Second",
"minute": "Minute",
"hour": "Hour",
@@ -94,6 +96,7 @@
"ultraDark": "Ultra Dark",
"dashboard": "Overview",
"inbounds": "Inbounds",
"clients": "Clients",
"nodes": "Nodes",
"settings": "Panel Settings",
"xray": "Xray Configs",
@@ -111,7 +114,7 @@
"emptyUsername": "Username is required",
"emptyPassword": "Password is required",
"wrongUsernameOrPassword": "Invalid username or password or two-factor code.",
"successLogin": " You have successfully logged into your account."
"successLogin": "You have successfully logged into your account."
}
},
"index": {
@@ -237,8 +240,6 @@
"getConfigError": "An error occurred while retrieving the config file."
},
"inbounds": {
"allTimeTraffic": "All-time Traffic",
"allTimeTrafficUsage": "All-Time Total Usage",
"title": "Inbounds",
"totalDownUp": "Total Sent/Received",
"totalUsage": "Total Usage",
@@ -249,6 +250,23 @@
"node": "Node",
"deployTo": "Deploy to",
"localPanel": "Local panel",
"fallbacks": {
"title": "Fallbacks",
"help": "When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.",
"empty": "No fallbacks yet",
"add": "Add fallback",
"pickInbound": "Pick an inbound",
"matchAny": "any",
"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"
},
"protocol": "Protocol",
"port": "Port",
"portMap": "Port Mapping",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "The IP history log. (to re-enable the inbound after disabling, clear the log)",
"IPLimitlogclear": "Clear the Log",
"setDefaultCert": "Set Cert from Panel",
"streamTab": "Stream",
"securityTab": "Security",
"sniffingTab": "Sniffing",
"sniffingMetadataOnly": "Metadata only",
"sniffingRouteOnly": "Route only",
"sniffingIpsExcluded": "IPs excluded",
"sniffingDomainsExcluded": "Domains excluded",
"decryption": "Decryption",
"encryption": "Encryption",
"vlessAuthX25519": "X25519 auth",
"vlessAuthMlkem768": "ML-KEM-768 auth",
"vlessAuthCustom": "Custom",
"vlessAuthSelected": "Selected: {auth}",
"advanced": {
"title": "Inbound JSON sections",
"subtitle": "Full inbound JSON and focused editors for settings, sniffing, and streamSettings.",
"all": "All",
"allHelp": "Full inbound object with all fields in one editor.",
"settings": "Settings",
"settingsHelp": "Xray settings block wrapper:",
"sniffing": "Sniffing",
"sniffingHelp": "Xray sniffing block wrapper:",
"stream": "Stream",
"streamHelp": "Xray stream block wrapper:",
"jsonErrorPrefix": "Advanced JSON"
},
"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.",
"info": "Info",
@@ -365,7 +409,7 @@
}
}
},
"client": {
"clients": {
"add": "Add Client",
"edit": "Edit Client",
"submitAdd": "Add Client",
@@ -389,13 +433,77 @@
"method": "Method",
"first": "First",
"last": "Last",
"ipLog": "IP Log",
"prefix": "Prefix",
"postfix": "Postfix",
"delayedStart": "Start After First Use",
"expireDays": "Duration",
"days": "Day(s)",
"renew": "Auto Renew",
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
"title": "Clients",
"actions": "Actions",
"totalGB": "Total Sent/Received (GB)",
"expiryTime": "Expiry",
"addClients": "Add Clients",
"limitIp": "IP Limit",
"password": "Password",
"subId": "Subscription ID",
"online": "Online",
"email": "Email",
"comment": "Comment",
"traffic": "Traffic",
"offline": "Offline",
"addTitle": "Add Client",
"qrCode": "QR Code",
"moreInformation": "More Information",
"delete": "Delete",
"reset": "Reset Traffic",
"editTitle": "Edit Client",
"client": "Client",
"enabled": "Enabled",
"remaining": "Remaining",
"duration": "Duration",
"attachedInbounds": "Attached inbounds",
"selectInbound": "Select one or more inbounds",
"noSubId": "This client has no subId, no shareable link.",
"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.",
"empty": "No clients yet — add one to get started.",
"deleteConfirmTitle": "Delete client {email}?",
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
"deleteSelected": "Delete ({count})",
"bulkDeleteConfirmTitle": "Delete {count} clients?",
"bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
"delDepleted": "Delete depleted",
"delDepletedConfirmTitle": "Delete depleted clients?",
"delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"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",
"trafficReset": "Traffic reset",
"allTrafficsReset": "All client traffic reset",
"bulkDeleted": "{count} clients deleted",
"bulkDeletedMixed": "{ok} deleted, {failed} failed",
"bulkCreated": "{count} clients created",
"bulkCreatedMixed": "{ok} created, {failed} failed",
"delDepleted": "{count} depleted clients deleted"
}
},
"nodes": {
"title": "Nodes",
@@ -428,6 +536,7 @@
"latency": "Latency",
"lastHeartbeat": "Last Heartbeat",
"xrayVersion": "Xray Version",
"panelVersion": "Panel Version",
"actions": "Actions",
"probe": "Probe Now",
"testConnection": "Test Connection",
@@ -627,7 +736,7 @@
"generalConfigs": "General",
"generalConfigsDesc": "These options will determine general adjustments.",
"logConfigs": "Log",
"logConfigsDesc": "Logs may affect your server's efficiency. It is recommended to enable it wisely only in case of your needs",
"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.",
@@ -776,7 +885,7 @@
"expectIPs": "Expect IPs",
"unexpectIPs": "Unexpected IPs",
"useSystemHosts": "Use System Hosts",
"useSystemHostsDesc": "Use the hosts file from an installed system",
"useSystemHostsDesc": "Use the operating system's hosts file",
"serveStale": "Serve Stale",
"serveStaleDesc": "Return expired cached results while refreshing in the background",
"serveExpiredTTL": "Serve Expired TTL",
@@ -980,4 +1089,4 @@
"chooseInbound": "Choose an Inbound"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Buscar",
"filter": "Filtrar",
"loading": "Cargando...",
"refresh": "Actualizar",
"clear": "Borrar",
"second": "Segundo",
"minute": "Minuto",
"hour": "Hora",
@@ -94,6 +96,7 @@
"ultraDark": "Ultra Oscuro",
"dashboard": "Estado del Sistema",
"inbounds": "Entradas",
"clients": "Clientes",
"nodes": "Nodos",
"settings": "Configuraciones",
"xray": "Ajustes Xray",
@@ -127,9 +130,9 @@
"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.",
"xrayUpdates": "Actualizaciones de Xray",
"updatePanel": "Actualizar panel",
"panelUpdateDesc": "Esto actualizará 3X-UI a la última versión y reiniciará el servicio del panel.",
"currentPanelVersion": "Versión actual del panel",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "Esto actualizará todos los archivos.",
"geofilesUpdateAll": "Actualizar todo",
"geofileUpdatePopover": "Geofichero actualizado correctamente",
"dontRefresh": "La instalación está en progreso, por favor no actualices esta página.",
"logs": "Registros",
"config": "Configuración",
"backup": "Сopia 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.",
"importDatabase": "Restaurar",
"importDatabaseDesc": "Haz clic para seleccionar y cargar un archivo .db desde tu dispositivo para restaurar tu base de datos desde una copia de seguridad.",
"importDatabaseSuccess": "La base de datos se ha importado correctamente",
"importDatabaseError": "Ocurrió un error al importar la base de datos",
"readDatabaseError": "Ocurrió un error al leer la base de datos",
"getDatabaseError": "Ocurrió un error al obtener la base de datos",
"getConfigError": "Ocurrió un error al obtener el archivo de configuración",
"customGeoTitle": "GeoSite / GeoIP personalizados",
"customGeoAdd": "Añadir",
"customGeoType": "Tipo",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "Fuente geo personalizada no encontrada",
"customGeoErrDownload": "Error de descarga",
"customGeoErrUpdateAllIncomplete": "No se pudieron actualizar una o más fuentes geo personalizadas",
"customGeoEmpty": "Aún no hay fuentes geo personalizadas — haz clic en Añadir para crear una"
"customGeoEmpty": "Aún no hay fuentes geo personalizadas — haz clic en Añadir para crear una",
"dontRefresh": "La instalación está en progreso, por favor no actualices esta página.",
"logs": "Registros",
"config": "Configuración",
"backup": "Сopia 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.",
"importDatabase": "Restaurar",
"importDatabaseDesc": "Haz clic para seleccionar y cargar un archivo .db desde tu dispositivo para restaurar tu base de datos desde una copia de seguridad.",
"importDatabaseSuccess": "La base de datos se ha importado correctamente",
"importDatabaseError": "Ocurrió un error al importar la base de datos",
"readDatabaseError": "Ocurrió un error al leer la base de datos",
"getDatabaseError": "Ocurrió un error al obtener la base de datos",
"getConfigError": "Ocurrió un error al obtener el archivo de configuración"
},
"inbounds": {
"allTimeTraffic": "Tráfico Total",
"allTimeTrafficUsage": "Uso de datos histórico",
"title": "Entradas",
"totalDownUp": "Subidas/Descargas Totales",
"totalUsage": "Uso Total",
@@ -249,6 +250,23 @@
"node": "Nodo",
"deployTo": "Desplegar en",
"localPanel": "Panel local",
"fallbacks": {
"title": "Fallbacks",
"help": "Cuando una conexión en este inbound no coincide con ningún cliente, redirígela a otro inbound. Elige un hijo abajo y los campos de enrutamiento (SNI / ALPN / Path / xver) se rellenan automáticamente desde su transporte; la mayoría de las configuraciones no necesitan más ajustes. Cada 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",
"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"
},
"protocol": "Protocolo",
"port": "Puerto",
"portMap": "Puertos de Destino",
@@ -308,6 +326,32 @@
"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",
"streamTab": "Stream",
"securityTab": "Seguridad",
"sniffingTab": "Sniffing",
"sniffingMetadataOnly": "Solo metadatos",
"sniffingRouteOnly": "Solo enrutamiento",
"sniffingIpsExcluded": "IPs excluidas",
"sniffingDomainsExcluded": "Dominios excluidos",
"decryption": "Descifrado",
"encryption": "Cifrado",
"vlessAuthX25519": "Autenticación X25519",
"vlessAuthMlkem768": "Autenticación ML-KEM-768",
"vlessAuthCustom": "Personalizado",
"vlessAuthSelected": "Seleccionado: {auth}",
"advanced": {
"title": "Secciones JSON del inbound",
"subtitle": "JSON completo del inbound y editores específicos para settings, sniffing y streamSettings.",
"all": "Todo",
"allHelp": "Objeto inbound completo con todos los campos en un solo editor.",
"settings": "Ajustes",
"settingsHelp": "Envoltorio del bloque settings de Xray:",
"sniffing": "Sniffing",
"sniffingHelp": "Envoltorio del bloque sniffing de Xray:",
"stream": "Stream",
"streamHelp": "Envoltorio del bloque stream de Xray:",
"jsonErrorPrefix": "JSON avanzado"
},
"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.",
"info": "Info",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "Agregar Cliente",
"edit": "Editar Cliente",
"submitAdd": "Agregar Cliente",
"submitEdit": "Guardar Cambios",
"clientCount": "Número de Clientes",
"bulk": "Agregar en Lote",
"copyFromInbound": "Copiar clientes desde entrada",
"clients": {
"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 seleccionados",
"copySelected": "Copiar selección",
"copySource": "Origen",
"copyEmailPreview": "Vista previa del email resultante",
"copySelectSourceFirst": "Seleccione primero una entrada de 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: ningún cliente seleccionado o el origen está vacío",
"copyResultErrors": "Errores al copiar",
"copyFlowLabel": "Flow para nuevos clientes (VLESS)",
"copyFlowHint": "Se aplica a todos los clientes copiados. Déjelo vacío para omitir.",
"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",
"first": "Primero",
"last": "Último",
"ipLog": "Registro de IP",
"prefix": "Prefijo",
"postfix": "Sufijo",
"delayedStart": "Iniciar después del primer uso",
"delayedStart": "Iniciar tras el primer uso",
"expireDays": "Duración",
"days": "Día(s)",
"renew": "Renovación automática",
"renewDesc": "Renovación automática después de la expiración. (0 = desactivar) (unidad: día)"
"renewDesc": "Renovación automática tras la expiración. (0 = desactivado) (unidad: día)",
"title": "Clientes",
"actions": "Acciones",
"totalGB": "Total enviado/recibido (GB)",
"expiryTime": "Expiración",
"addClients": "Añadir clientes",
"limitIp": "Límite de IP",
"password": "Contraseña",
"subId": "ID de suscripción",
"online": "En línea",
"email": "Correo",
"comment": "Comentario",
"traffic": "Tráfico",
"offline": "Desconectado",
"addTitle": "Añadir cliente",
"qrCode": "Código QR",
"moreInformation": "Más información",
"delete": "Eliminar",
"reset": "Restablecer tráfico",
"editTitle": "Editar cliente",
"client": "Cliente",
"enabled": "Habilitado",
"remaining": "Restante",
"duration": "Duración",
"attachedInbounds": "Inbounds asociados",
"selectInbound": "Selecciona uno o más inbounds",
"noSubId": "Este cliente no tiene subId, no hay enlace compartible.",
"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.",
"empty": "Aún no hay clientes — añade uno para empezar.",
"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})",
"bulkDeleteConfirmTitle": "¿Eliminar {count} clientes?",
"bulkDeleteConfirmContent": "Cada cliente seleccionado se elimina de los inbounds asociados y se descarta su registro de tráfico. No se puede deshacer.",
"delDepleted": "Eliminar agotados",
"delDepletedConfirmTitle": "¿Eliminar clientes agotados?",
"delDepletedConfirmContent": "Elimina todos los clientes con cuota agotada o expirados. No se puede deshacer.",
"auth": "Auth",
"hysteriaAuth": "Auth de Hysteria",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"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",
"trafficReset": "Tráfico restablecido",
"allTrafficsReset": "Tráfico de todos los clientes restablecido",
"bulkDeleted": "{count} clientes eliminados",
"bulkDeletedMixed": "{ok} eliminados, {failed} fallidos",
"bulkCreated": "{count} clientes creados",
"bulkCreatedMixed": "{ok} creados, {failed} fallidos",
"delDepleted": "{count} clientes agotados eliminados"
}
},
"nodes": {
"title": "Nodos",
@@ -428,6 +536,7 @@
"latency": "Latencia",
"lastHeartbeat": "Último latido",
"xrayVersion": "Versión de Xray",
"panelVersion": "Versión del panel",
"actions": "Acciones",
"probe": "Sondear ahora",
"testConnection": "Probar conexión",
@@ -552,13 +661,13 @@
"subEmailInRemark": "Incluir Email en el nombre",
"subEmailInRemarkDesc": "Incluir el correo del cliente en el nombre del perfil de suscripción.",
"subURI": "URI de proxy inverso",
"subURIDesc": "Cambiar el URI base de la URL de suscripción para usar detrás de los servidores proxy",
"externalTrafficInformEnable": "Informe de tráfico externo",
"externalTrafficInformEnableDesc": "Informar a la API externa sobre cada actualización de tráfico.",
"externalTrafficInformURI": "URI de información de tráfico externo",
"externalTrafficInformURIDesc": "Las actualizaciones de tráfico se envían a este URI.",
"restartXrayOnClientDisable": "Reiniciar Xray tras desactivación automática",
"restartXrayOnClientDisableDesc": "Cuando un cliente se desactive automáticamente por vencimiento o límite de tráfico, reiniciar Xray.",
"subURIDesc": "Cambiar el URI base de la URL de suscripción para usar detrás de los servidores proxy",
"fragment": "Fragmentación",
"fragmentDesc": "Habilitar la fragmentación para el paquete de saludo de TLS",
"fragmentSett": "Configuración de Fragmentación",
@@ -777,9 +886,6 @@
"unexpectIPs": "IPs inesperadas",
"useSystemHosts": "Usar Hosts del sistema",
"useSystemHostsDesc": "Usar el archivo hosts de un sistema instalado",
"usePreset": "Usar plantilla",
"dnsPresetTitle": "Plantillas DNS",
"dnsPresetFamily": "Familiar",
"serveStale": "Servir caducados",
"serveStaleDesc": "Devolver resultados caducados de la caché mientras se actualiza en segundo plano",
"serveExpiredTTL": "TTL de caducados",
@@ -792,6 +898,9 @@
"hostsEmpty": "No hay Hosts definidos",
"hostsDomain": "Dominio (ej. domain:example.com)",
"hostsValues": "IP o dominio — escribe y presiona Enter",
"usePreset": "Usar plantilla",
"dnsPresetTitle": "Plantillas DNS",
"dnsPresetFamily": "Familiar",
"clearAll": "Eliminar todos",
"clearAllTitle": "¿Eliminar todos los servidores DNS?",
"clearAllConfirm": "Esto eliminará todos los servidores DNS de la lista. No se puede deshacer."
@@ -980,4 +1089,4 @@
"chooseInbound": "Elige un Inbound"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "جستجو",
"filter": "فیلتر",
"loading": "...در حال بارگذاری",
"refresh": "تازه‌سازی",
"clear": "پاک کردن",
"second": "ثانیه",
"minute": "دقیقه",
"hour": "ساعت",
@@ -94,6 +96,7 @@
"ultraDark": "فوق تیره",
"dashboard": "نمای کلی",
"inbounds": "ورودی‌ها",
"clients": "کلاینت‌ها",
"nodes": "نودها",
"settings": "تنظیمات پنل",
"xray": "پیکربندی ایکس‌ری",
@@ -127,9 +130,9 @@
"stopXray": "توقف",
"restartXray": "شروع‌مجدد",
"xraySwitch": "‌نسخه",
"xrayUpdates": "به‌روزرسانی‌های Xray",
"xraySwitchClick": "نسخه مورد نظر را انتخاب کنید",
"xraySwitchClickDesk": "لطفا بادقت انتخاب کنید. درصورت انتخاب نسخه قدیمی‌تر، امکان ناهماهنگی با پیکربندی فعلی وجود دارد",
"xrayUpdates": "به‌روزرسانی‌های Xray",
"updatePanel": "به‌روزرسانی پنل",
"panelUpdateDesc": "این عملیات 3X-UI را به آخرین نسخه به‌روزرسانی می‌کند و سرویس پنل را مجدداً راه‌اندازی می‌کند.",
"currentPanelVersion": "نسخه فعلی پنل",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "با این کار همه فایل‌ها به‌روزرسانی می‌شوند.",
"geofilesUpdateAll": "همه را به‌روزرسانی کنید",
"geofileUpdatePopover": "فایل جغرافیایی با موفقیت به‌روز شد",
"dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید",
"logs": "گزارش‌ها",
"config": "پیکربندی",
"backup": "پشتیبان‌گیری",
"backupTitle": "پشتیبان‌گیری و بازیابی",
"exportDatabase": "پشتیبان‌گیری",
"exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید.",
"importDatabase": "بازیابی",
"importDatabaseDesc": "برای انتخاب و آپلود یک فایل .db از دستگاهتان و بازیابی پایگاه داده از یک پشتیبان کلیک کنید.",
"importDatabaseSuccess": "پایگاه داده با موفقیت وارد شد",
"importDatabaseError": "خطا در وارد کردن پایگاه داده",
"readDatabaseError": "خطا در خواندن پایگاه داده",
"getDatabaseError": "خطا در دریافت پایگاه داده",
"getConfigError": "خطا در دریافت فایل پیکربندی",
"customGeoTitle": "GeoSite / GeoIP سفارشی",
"customGeoAdd": "افزودن",
"customGeoType": "نوع",
@@ -234,14 +223,23 @@
"customGeoErrNotFound": "منبع geo سفارشی یافت نشد",
"customGeoErrDownload": "بارگیری ناموفق بود",
"customGeoErrUpdateAllIncomplete": "به‌روزرسانی یک یا چند منبع geo سفارشی ناموفق بود",
"customGeoEmpty": "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید"
"customGeoEmpty": "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید",
"dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید",
"logs": "گزارش‌ها",
"config": "پیکربندی",
"backup": "پشتیبان‌گیری",
"backupTitle": "پشتیبان‌گیری و بازیابی",
"exportDatabase": "پشتیبان‌گیری",
"exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید.",
"importDatabase": "بازیابی",
"importDatabaseDesc": "برای انتخاب و آپلود یک فایل .db از دستگاهتان و بازیابی پایگاه داده از یک پشتیبان کلیک کنید.",
"importDatabaseSuccess": "پایگاه داده با موفقیت وارد شد",
"importDatabaseError": "خطا در وارد کردن پایگاه داده",
"readDatabaseError": "خطا در خواندن پایگاه داده",
"getDatabaseError": "خطا در دریافت پایگاه داده",
"getConfigError": "خطا در دریافت فایل پیکربندی"
},
"inbounds": {
"node": "نود",
"deployTo": "استقرار روی",
"localPanel": "پنل لوکال",
"allTimeTraffic": "کل ترافیک",
"allTimeTrafficUsage": "کل استفاده در تمام مدت",
"title": "کاربران",
"totalDownUp": "دریافت/ارسال کل",
"totalUsage": "‌‌‌مصرف کل",
@@ -249,6 +247,26 @@
"operate": "عملیات",
"enable": "فعال",
"remark": "نام",
"node": "نود",
"deployTo": "استقرار روی",
"localPanel": "پنل لوکال",
"fallbacks": {
"title": "فال‌بک‌ها",
"help": "وقتی اتصالی روی این اینباند با هیچ کلاینتی تطبیق پیدا نمی‌کند، به یک اینباند دیگر ارجاع داده می‌شود. یک فرزند انتخاب کنید، فیلدهای مسیریابی (SNI / ALPN / Path / xver) خودکار از روی transport آن پر می‌شود — برای بیشتر تنظیمات نیازی به ویرایش نیست. هر فرزند باید روی 127.0.0.1 با security=none گوش بدهد.",
"empty": "هنوز فال‌بکی اضافه نشده",
"add": "افزودن فال‌بک",
"pickInbound": "یک اینباند انتخاب کنید",
"matchAny": "همه",
"rederive": "پر کردن مجدد از فرزند",
"rederived": "از فرزند پر شد",
"editAdvanced": "ویرایش فیلدهای مسیریابی",
"hideAdvanced": "بستن پیشرفته",
"quickAddAll": "افزودن سریع همه‌ی موارد واجد شرایط",
"quickAdded": "{n} فال‌بک افزوده شد",
"quickAddedNone": "اینباند جدیدی برای افزودن وجود ندارد",
"routesWhen": "هدایت می‌شود وقتی",
"defaultCatchAll": "پیش‌فرض — همه‌ی موارد دیگر را می‌گیرد"
},
"protocol": "پروتکل",
"port": "پورت",
"portMap": "پورت‌های نظیر",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "گزارش تاریخچه آی‌پی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید",
"IPLimitlogclear": "پاک کردن گزارش‌ها",
"setDefaultCert": "استفاده از گواهی پنل",
"streamTab": "استریم",
"securityTab": "امنیت",
"sniffingTab": "اسنیفینگ",
"sniffingMetadataOnly": "فقط متادیتا",
"sniffingRouteOnly": "فقط مسیریابی",
"sniffingIpsExcluded": "IPهای مستثنا",
"sniffingDomainsExcluded": "دامنه‌های مستثنا",
"decryption": "رمزگشایی",
"encryption": "رمزنگاری",
"vlessAuthX25519": "احراز X25519",
"vlessAuthMlkem768": "احراز ML-KEM-768",
"vlessAuthCustom": "سفارشی",
"vlessAuthSelected": "انتخاب‌شده: {auth}",
"advanced": {
"title": "بخش‌های JSON اینباند",
"subtitle": "JSON کامل اینباند و ویرایشگرهای جداگانه برای settings، sniffing و streamSettings.",
"all": "همه",
"allHelp": "شیء کامل اینباند با همه فیلدها در یک ویرایشگر.",
"settings": "تنظیمات",
"settingsHelp": "ساختار بلوک settings در Xray:",
"sniffing": "اسنیفینگ",
"sniffingHelp": "ساختار بلوک sniffing در Xray:",
"stream": "استریم",
"streamHelp": "ساختار بلوک stream در Xray:",
"jsonErrorPrefix": "JSON پیشرفته"
},
"telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
"subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید",
"info": "اطلاعات",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "کاربر جدید",
"edit": "ویرایش کاربر",
"submitAdd": "اضافه کردن",
"clients": {
"add": "افزودن کلاینت",
"edit": "ویرایش کلاینت",
"submitAdd": "افزودن کلاینت",
"submitEdit": "ذخیره تغییرات",
"clientCount": "تعداد کاربران",
"bulk": "انبوه‌سازی",
"copyFromInbound": "کپی کاربران از اینباند",
"copyToInbound": "کپی کاربران به",
"clientCount": "تعداد کلاینت‌ها",
"bulk": "افزودن گروهی",
"copyFromInbound": "کپی کلاینت‌ها از اینباند",
"copyToInbound": "کپی کلاینت‌ها به",
"copySelected": "کپی انتخاب‌شده‌ها",
"copySource": "منبع",
"copyEmailPreview": "پیش‌نمایش ایمیل نهایی",
"copySelectSourceFirst": "ابتدا یک اینباند منبع انتخاب کنید.",
"copyEmailPreview": "پیش‌نمایش ایمیل خروجی",
"copySelectSourceFirst": "ابتدا یک اینباند مبدأ انتخاب کنید.",
"copyResult": "نتیجه کپی",
"copyResultSuccess": "با موفقیت کپی شد",
"copyResultNone": "چیزی برای کپی نیست: هیچ کاربری انتخاب نشده یا منبع خالی است",
"copyResultNone": "چیزی برای کپی نیست: کلاینتی انتخاب نشده یا منبع خالی است",
"copyResultErrors": "خطاهای کپی",
"copyFlowLabel": "Flow برای کاربران جدید (VLESS)",
"copyFlowHint": "برای همه کاربران کپی‌شده اعمال می‌شود. برای نادیده گرفتن، خالی بگذارید.",
"copyFlowLabel": "Flow برای کلاینت‌های جدید (VLESS)",
"copyFlowHint": "روی همه کلاینت‌های کپی‌شده اعمال می‌شود. خالی بگذارید تا رد شود.",
"selectAll": "انتخاب همه",
"clearAll": "پاک کردن همه",
"method": "روش",
"first": "از",
"last": "تا",
"first": "اول",
"last": "آخر",
"ipLog": "گزارش IP",
"prefix": "پیشوند",
"postfix": "پسوند",
"delayedStart": "شروعپسازاولیناستفاده",
"expireDays": "مدت زمان",
"days": "(روز)",
"delayedStart": "شروع پس از اولین استفاده",
"expireDays": "مدت",
"days": "روز",
"renew": "تمدید خودکار",
"renewDesc": "تمدید خودکار پساز انقضا. (0 = غیرفعال)(واحد: روز)"
"renewDesc": "تمدید خودکار پس از انقضا. (۰ = غیرفعال) (واحد: روز)",
"title": "کلاینت‌ها",
"actions": "عملیات",
"totalGB": "مجموع ارسال/دریافت (گیگابایت)",
"expiryTime": "انقضا",
"addClients": "افزودن کلاینت‌ها",
"limitIp": "محدودیت IP",
"password": "رمز عبور",
"subId": "شناسه اشتراک",
"online": "آنلاین",
"email": "ایمیل",
"comment": "توضیحات",
"traffic": "ترافیک",
"offline": "آفلاین",
"addTitle": "افزودن کلاینت",
"qrCode": "کد QR",
"moreInformation": "اطلاعات بیشتر",
"delete": "حذف",
"reset": "بازنشانی ترافیک",
"editTitle": "ویرایش کلاینت",
"client": "کلاینت",
"enabled": "فعال",
"remaining": "باقی‌مانده",
"duration": "مدت",
"attachedInbounds": "اینباندهای متصل",
"selectInbound": "یک یا چند اینباند انتخاب کنید",
"noSubId": "این کلاینت subId ندارد، لینک اشتراک‌گذاری وجود ندارد.",
"noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.",
"link": "لینک",
"resetNotPossible": "ابتدا این کلاینت را به یک اینباند متصل کنید.",
"general": "عمومی",
"resetAllTraffics": "بازنشانی ترافیک همه کلاینت‌ها",
"resetAllTrafficsTitle": "بازنشانی ترافیک همه کلاینت‌ها؟",
"resetAllTrafficsContent": "شمارنده ارسال/دریافت همه کلاینت‌ها به صفر می‌رسد. سهمیه و تاریخ انقضا تغییری نمی‌کند. این عمل غیرقابل بازگشت است.",
"empty": "هنوز کلاینتی نیست — برای شروع یکی اضافه کنید.",
"deleteConfirmTitle": "حذف کلاینت {email}؟",
"deleteConfirmContent": "این کلاینت از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.",
"deleteSelected": "حذف ({count})",
"bulkDeleteConfirmTitle": "حذف {count} کلاینت؟",
"bulkDeleteConfirmContent": "هر کلاینت انتخاب‌شده از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.",
"delDepleted": "حذف اتمام‌یافته‌ها",
"delDepletedConfirmTitle": "حذف کلاینت‌های اتمام‌یافته؟",
"delDepletedConfirmContent": "هر کلاینتی که سهمیه ترافیک‌اش تمام شده یا تاریخ انقضایش گذشته است حذف می‌شود. این عمل غیرقابل بازگشت است.",
"auth": "Auth",
"hysteriaAuth": "Auth (هیستریا)",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag اختیاری",
"telegramId": "شناسه کاربر تلگرام",
"telegramIdPlaceholder": "شناسه عددی کاربر تلگرام (۰ = هیچ)",
"created": "ساخته‌شده",
"updated": "به‌روزشده",
"ipLimit": "محدودیت IP",
"toasts": {
"deleted": "کلاینت حذف شد",
"trafficReset": "ترافیک بازنشانی شد",
"allTrafficsReset": "ترافیک همه کلاینت‌ها بازنشانی شد",
"bulkDeleted": "{count} کلاینت حذف شد",
"bulkDeletedMixed": "{ok} حذف، {failed} ناموفق",
"bulkCreated": "{count} کلاینت ساخته شد",
"bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
"delDepleted": "{count} کلاینت اتمام‌یافته حذف شد"
}
},
"nodes": {
"title": "نودها",
@@ -428,6 +536,7 @@
"latency": "تاخیر",
"lastHeartbeat": "آخرین ضربان",
"xrayVersion": "نسخه Xray",
"panelVersion": "نسخه پنل",
"actions": "عملیات",
"probe": "بررسی فوری",
"testConnection": "تست اتصال",
@@ -725,9 +834,9 @@
"accessToken": "توکن دسترسی",
"country": "کشور",
"server": "سرور",
"privateKey": "کلید خصوصی",
"city": "شهر",
"allCities": "همه شهرها",
"privateKey": "کلید خصوصی",
"load": "فشار سرور"
},
"balancer": {
@@ -980,4 +1089,4 @@
"chooseInbound": "یک ورودی انتخاب کنید"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Cari",
"filter": "Filter",
"loading": "Memuat...",
"refresh": "Segarkan",
"clear": "Bersihkan",
"second": "Detik",
"minute": "Menit",
"hour": "Jam",
@@ -94,6 +96,7 @@
"ultraDark": "Sangat Gelap",
"dashboard": "Ikhtisar",
"inbounds": "Masuk",
"clients": "Klien",
"nodes": "Node",
"settings": "Pengaturan Panel",
"xray": "Konfigurasi Xray",
@@ -127,9 +130,9 @@
"stopXray": "Stop",
"restartXray": "Restart",
"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.",
"xrayUpdates": "Pembaruan Xray",
"updatePanel": "Perbarui Panel",
"panelUpdateDesc": "Ini akan memperbarui 3X-UI ke rilis terbaru dan me-restart layanan panel.",
"currentPanelVersion": "Versi panel saat ini",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "Ini akan memperbarui semua berkas.",
"geofilesUpdateAll": "Perbarui semua",
"geofileUpdatePopover": "Geofile berhasil diperbarui",
"dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini",
"logs": "Log",
"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.",
"importDatabase": "Pulihkan",
"importDatabaseDesc": "Klik untuk memilih dan mengunggah file .db dari perangkat Anda untuk memulihkan database dari cadangan.",
"importDatabaseSuccess": "Database berhasil diimpor",
"importDatabaseError": "Terjadi kesalahan saat mengimpor database",
"readDatabaseError": "Terjadi kesalahan saat membaca database",
"getDatabaseError": "Terjadi kesalahan saat mengambil database",
"getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi",
"customGeoTitle": "GeoSite / GeoIP kustom",
"customGeoAdd": "Tambah",
"customGeoType": "Jenis",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "Sumber geo kustom tidak ditemukan",
"customGeoErrDownload": "Unduh gagal",
"customGeoErrUpdateAllIncomplete": "Satu atau lebih sumber geo kustom gagal diperbarui",
"customGeoEmpty": "Belum ada sumber geo kustom — klik Tambah untuk membuatnya"
"customGeoEmpty": "Belum ada sumber geo kustom — klik Tambah untuk membuatnya",
"dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini",
"logs": "Log",
"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.",
"importDatabase": "Pulihkan",
"importDatabaseDesc": "Klik untuk memilih dan mengunggah file .db dari perangkat Anda untuk memulihkan database dari cadangan.",
"importDatabaseSuccess": "Database berhasil diimpor",
"importDatabaseError": "Terjadi kesalahan saat mengimpor database",
"readDatabaseError": "Terjadi kesalahan saat membaca database",
"getDatabaseError": "Terjadi kesalahan saat mengambil database",
"getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi"
},
"inbounds": {
"allTimeTraffic": "Total Lalu Lintas",
"allTimeTrafficUsage": "Total Penggunaan Sepanjang Waktu",
"title": "Masuk",
"totalDownUp": "Total Terkirim/Diterima",
"totalUsage": "Penggunaan Total",
@@ -249,6 +250,23 @@
"node": "Node",
"deployTo": "Terapkan ke",
"localPanel": "Panel lokal",
"fallbacks": {
"title": "Fallback",
"help": "Saat koneksi pada inbound ini tidak cocok dengan client mana pun, arahkan ke inbound lain. Pilih child di bawah dan field routing (SNI / ALPN / Path / xver) terisi otomatis dari transport-nya — sebagian besar konfigurasi tidak perlu disesuaikan lagi. Setiap child harus listen di 127.0.0.1 dengan security=none.",
"empty": "Belum ada fallback",
"add": "Tambah fallback",
"pickInbound": "Pilih inbound",
"matchAny": "apa pun",
"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"
},
"protocol": "Protokol",
"port": "Port",
"portMap": "Port Mapping",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "Log histori IP. (untuk mengaktifkan masuk setelah menonaktifkan, hapus log)",
"IPLimitlogclear": "Hapus Log",
"setDefaultCert": "Atur Sertifikat dari Panel",
"streamTab": "Stream",
"securityTab": "Keamanan",
"sniffingTab": "Sniffing",
"sniffingMetadataOnly": "Hanya metadata",
"sniffingRouteOnly": "Hanya routing",
"sniffingIpsExcluded": "IP yang dikecualikan",
"sniffingDomainsExcluded": "Domain yang dikecualikan",
"decryption": "Dekripsi",
"encryption": "Enkripsi",
"vlessAuthX25519": "Auth X25519",
"vlessAuthMlkem768": "Auth ML-KEM-768",
"vlessAuthCustom": "Khusus",
"vlessAuthSelected": "Dipilih: {auth}",
"advanced": {
"title": "Bagian JSON inbound",
"subtitle": "JSON inbound lengkap dan editor fokus untuk settings, sniffing, dan streamSettings.",
"all": "Semua",
"allHelp": "Objek inbound lengkap dengan semua bidang dalam satu editor.",
"settings": "Pengaturan",
"settingsHelp": "Pembungkus blok settings Xray:",
"sniffing": "Sniffing",
"sniffingHelp": "Pembungkus blok sniffing Xray:",
"stream": "Stream",
"streamHelp": "Pembungkus blok stream Xray:",
"jsonErrorPrefix": "JSON lanjutan"
},
"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.",
"info": "Info",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "Tambah Klien",
"edit": "Edit Klien",
"submitAdd": "Tambah Klien",
"submitEdit": "Simpan Perubahan",
"clientCount": "Jumlah Klien",
"bulk": "Tambahkan Massal",
"clients": {
"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 yang dipilih",
"copySelected": "Salin terpilih",
"copySource": "Sumber",
"copyEmailPreview": "Pratinjau email hasil",
"copySelectSourceFirst": "Silakan pilih inbound sumber terlebih dahulu.",
"copyResult": "Hasil penyalinan",
"copySelectSourceFirst": "Pilih inbound sumber terlebih dahulu.",
"copyResult": "Hasil salinan",
"copyResultSuccess": "Berhasil disalin",
"copyResultNone": "Tidak ada yang disalin: tidak ada klien yang dipilih atau sumber kosong",
"copyResultErrors": "Kesalahan penyalinan",
"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. Biarkan kosong untuk melewati.",
"copyFlowHint": "Diterapkan ke semua klien yang disalin. Kosongkan untuk dilewati.",
"selectAll": "Pilih semua",
"clearAll": "Hapus semua",
"method": "Metode",
"first": "Pertama",
"last": "Terakhir",
"ipLog": "Log IP",
"prefix": "Awalan",
"postfix": "Akhiran",
"delayedStart": "Mulai Awal",
"delayedStart": "Mulai setelah penggunaan pertama",
"expireDays": "Durasi",
"days": "Hari",
"renew": "Perpanjang Otomatis",
"renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif)(unit: hari)"
"renew": "Perpanjangan otomatis",
"renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif) (satuan: hari)",
"title": "Klien",
"actions": "Aksi",
"totalGB": "Total Kirim/Terima (GB)",
"expiryTime": "Kedaluwarsa",
"addClients": "Tambah klien",
"limitIp": "Batas IP",
"password": "Kata sandi",
"subId": "ID Langganan",
"online": "Online",
"email": "Email",
"comment": "Komentar",
"traffic": "Lalu lintas",
"offline": "Offline",
"addTitle": "Tambah klien",
"qrCode": "Kode QR",
"moreInformation": "Informasi lebih lanjut",
"delete": "Hapus",
"reset": "Reset lalu lintas",
"editTitle": "Ubah klien",
"client": "Klien",
"enabled": "Aktif",
"remaining": "Sisa",
"duration": "Durasi",
"attachedInbounds": "Inbound terlampir",
"selectInbound": "Pilih satu atau lebih inbound",
"noSubId": "Klien ini tidak punya subId, tidak ada tautan yang bisa dibagikan.",
"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.",
"empty": "Belum ada klien — tambahkan satu untuk memulai.",
"deleteConfirmTitle": "Hapus klien {email}?",
"deleteConfirmContent": "Tindakan ini menghapus klien dari setiap inbound terlampir dan menghapus catatan lalu lintasnya. Tidak dapat dibatalkan.",
"deleteSelected": "Hapus ({count})",
"bulkDeleteConfirmTitle": "Hapus {count} klien?",
"bulkDeleteConfirmContent": "Setiap klien yang dipilih dihapus dari semua inbound terlampir dan catatan lalu lintasnya dihapus. Tidak dapat dibatalkan.",
"delDepleted": "Hapus yang habis",
"delDepletedConfirmTitle": "Hapus klien yang habis?",
"delDepletedConfirmContent": "Hapus setiap klien yang kuota lalu lintasnya habis atau yang masa berlakunya telah berakhir. Tidak dapat dibatalkan.",
"auth": "Auth",
"hysteriaAuth": "Auth Hysteria",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"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",
"trafficReset": "Lalu lintas direset",
"allTrafficsReset": "Lalu lintas semua klien direset",
"bulkDeleted": "{count} klien dihapus",
"bulkDeletedMixed": "{ok} dihapus, {failed} gagal",
"bulkCreated": "{count} klien dibuat",
"bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
"delDepleted": "{count} klien habis dihapus"
}
},
"nodes": {
"title": "Node",
@@ -428,6 +536,7 @@
"latency": "Latensi",
"lastHeartbeat": "Heartbeat Terakhir",
"xrayVersion": "Versi Xray",
"panelVersion": "Versi panel",
"actions": "Aksi",
"probe": "Probe Sekarang",
"testConnection": "Tes Koneksi",
@@ -777,9 +886,6 @@
"unexpectIPs": "IP tak terduga",
"useSystemHosts": "Gunakan Hosts Sistem",
"useSystemHostsDesc": "Gunakan file hosts dari sistem yang terinstal",
"usePreset": "Gunakan templat",
"dnsPresetTitle": "Templat DNS",
"dnsPresetFamily": "Keluarga",
"serveStale": "Sajikan Kedaluwarsa",
"serveStaleDesc": "Mengembalikan hasil cache yang kedaluwarsa saat memperbarui di latar belakang",
"serveExpiredTTL": "TTL Kedaluwarsa",
@@ -792,6 +898,9 @@
"hostsEmpty": "Tidak ada Host yang ditentukan",
"hostsDomain": "Domain (mis. domain:example.com)",
"hostsValues": "IP atau domain — ketik dan tekan Enter",
"usePreset": "Gunakan templat",
"dnsPresetTitle": "Templat DNS",
"dnsPresetFamily": "Keluarga",
"clearAll": "Hapus Semua",
"clearAllTitle": "Hapus semua server DNS?",
"clearAllConfirm": "Ini akan menghapus semua server DNS dari daftar. Tidak dapat dibatalkan."
@@ -980,4 +1089,4 @@
"chooseInbound": "Pilih Inbound"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "検索",
"filter": "フィルター",
"loading": "読み込み中...",
"refresh": "更新",
"clear": "クリア",
"second": "秒",
"minute": "分",
"hour": "時間",
@@ -94,6 +96,7 @@
"ultraDark": "ウルトラダーク",
"dashboard": "ダッシュボード",
"inbounds": "インバウンド一覧",
"clients": "クライアント",
"nodes": "ノード",
"settings": "パネル設定",
"xray": "Xray設定",
@@ -127,9 +130,9 @@
"stopXray": "停止",
"restartXray": "再起動",
"xraySwitch": "バージョン",
"xrayUpdates": "Xrayの更新",
"xraySwitchClick": "切り替えるバージョンを選択してください",
"xraySwitchClickDesk": "慎重に選択してください。古いバージョンは現在の設定と互換性がない可能性があります。",
"xrayUpdates": "Xrayの更新",
"updatePanel": "パネルを更新",
"panelUpdateDesc": "これにより3X-UIが最新リリースに更新され、パネルサービスが再起動されます。",
"currentPanelVersion": "現在のパネルバージョン",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "これにより、すべてのファイルが更新されます。",
"geofilesUpdateAll": "すべて更新",
"geofileUpdatePopover": "ジオファイルの更新が成功しました",
"dontRefresh": "インストール中、このページをリロードしないでください",
"logs": "ログ",
"config": "設定",
"backup": "バックアップ",
"backupTitle": "バックアップと復元",
"exportDatabase": "バックアップ",
"exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。",
"importDatabase": "復元",
"importDatabaseDesc": "クリックして、デバイスから .db ファイルを選択し、アップロードしてバックアップからデータベースを復元します。",
"importDatabaseSuccess": "データベースのインポートに成功しました",
"importDatabaseError": "データベースのインポート中にエラーが発生しました",
"readDatabaseError": "データベースの読み取り中にエラーが発生しました",
"getDatabaseError": "データベースの取得中にエラーが発生しました",
"getConfigError": "設定ファイルの取得中にエラーが発生しました",
"customGeoTitle": "カスタム GeoSite / GeoIP",
"customGeoAdd": "追加",
"customGeoType": "種類",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "カスタム geo ソースが見つかりません",
"customGeoErrDownload": "ダウンロードに失敗しました",
"customGeoErrUpdateAllIncomplete": "カスタム geo ソースの 1 件以上を更新できませんでした",
"customGeoEmpty": "カスタム geo ソースはまだありません — 「追加」をクリックして作成してください"
"customGeoEmpty": "カスタム geo ソースはまだありません — 「追加」をクリックして作成してください",
"dontRefresh": "インストール中、このページをリロードしないでください",
"logs": "ログ",
"config": "設定",
"backup": "バックアップ",
"backupTitle": "バックアップと復元",
"exportDatabase": "バックアップ",
"exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。",
"importDatabase": "復元",
"importDatabaseDesc": "クリックして、デバイスから .db ファイルを選択し、アップロードしてバックアップからデータベースを復元します。",
"importDatabaseSuccess": "データベースのインポートに成功しました",
"importDatabaseError": "データベースのインポート中にエラーが発生しました",
"readDatabaseError": "データベースの読み取り中にエラーが発生しました",
"getDatabaseError": "データベースの取得中にエラーが発生しました",
"getConfigError": "設定ファイルの取得中にエラーが発生しました"
},
"inbounds": {
"allTimeTraffic": "総トラフィック",
"allTimeTrafficUsage": "これまでの総使用量",
"title": "インバウンド一覧",
"totalDownUp": "総アップロード / ダウンロード",
"totalUsage": "総使用量",
@@ -249,6 +250,23 @@
"node": "ノード",
"deployTo": "デプロイ先",
"localPanel": "ローカルパネル",
"fallbacks": {
"title": "フォールバック",
"help": "このインバウンドへの接続がどのクライアントにも一致しない場合、別のインバウンドへルーティングします。下から子インバウンドを選ぶと、ルーティング項目SNI / ALPN / Path / xverはその子のトランスポートから自動的に埋められます — ほとんどの構成で追加の調整は不要です。各子インバウンドは 127.0.0.1 で security=none をリッスンする必要があります。",
"empty": "フォールバックはまだありません",
"add": "フォールバックを追加",
"pickInbound": "インバウンドを選択",
"matchAny": "任意",
"rederive": "子から再取得",
"rederived": "子から再取得しました",
"editAdvanced": "ルーティング項目を編集",
"hideAdvanced": "詳細を隠す",
"quickAddAll": "対象のインバウンドをすべて一括追加",
"quickAdded": "{n} 件のフォールバックを追加しました",
"quickAddedNone": "追加可能な新規インバウンドはありません",
"routesWhen": "次の条件でルーティング",
"defaultCatchAll": "デフォルト — その他すべてを捕捉"
},
"protocol": "プロトコル",
"port": "ポート",
"portMap": "ポートマッピング",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "IP履歴ログ無効なインバウンドトラフィックを有効にするには、ログをクリアしてください",
"IPLimitlogclear": "ログをクリア",
"setDefaultCert": "パネル設定から証明書を設定",
"streamTab": "ストリーム",
"securityTab": "セキュリティ",
"sniffingTab": "スニッフィング",
"sniffingMetadataOnly": "メタデータのみ",
"sniffingRouteOnly": "ルーティングのみ",
"sniffingIpsExcluded": "除外する IP",
"sniffingDomainsExcluded": "除外するドメイン",
"decryption": "復号",
"encryption": "暗号化",
"vlessAuthX25519": "X25519 認証",
"vlessAuthMlkem768": "ML-KEM-768 認証",
"vlessAuthCustom": "カスタム",
"vlessAuthSelected": "選択中: {auth}",
"advanced": {
"title": "インバウンド JSON セクション",
"subtitle": "インバウンド全体の JSON と、settings、sniffing、streamSettings 用の専用エディター。",
"all": "すべて",
"allHelp": "すべてのフィールドを含むインバウンドオブジェクト全体を 1 つのエディターで編集します。",
"settings": "設定",
"settingsHelp": "Xray settings ブロックのラッパー:",
"sniffing": "スニッフィング",
"sniffingHelp": "Xray sniffing ブロックのラッパー:",
"stream": "ストリーム",
"streamHelp": "Xray stream ブロックのラッパー:",
"jsonErrorPrefix": "高度な JSON"
},
"telegramDesc": "TelegramチャットIDを提供してください。ボットで'/id'コマンドを使用)または({'@'}userinfobot",
"subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
"info": "情報",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "クライアント追加",
"edit": "クライアント編集",
"submitAdd": "クライアント追加",
"clients": {
"add": "クライアント追加",
"edit": "クライアント編集",
"submitAdd": "クライアント追加",
"submitEdit": "変更を保存",
"clientCount": "クライアント数",
"bulk": "一括作成",
"bulk": "一括追加",
"copyFromInbound": "インバウンドからクライアントをコピー",
"copyToInbound": "クライアントのコピー先",
"copySelected": "選択項目をコピー",
"copySource": "ソース",
"copyEmailPreview": "結果メールのプレビュー",
"copySelectSourceFirst": "先にソースインバウンドを選択してください。",
"copyToInbound": "コピー先",
"copySelected": "選択をコピー",
"copySource": "コピー元",
"copyEmailPreview": "生成されるメールのプレビュー",
"copySelectSourceFirst": "まずコピー元のインバウンドを選択してください。",
"copyResult": "コピー結果",
"copyResultSuccess": "正常にコピーされました",
"copyResultNone": "コピーする項目がありません: クライアントが選択されていないかソースが空です",
"copyResultSuccess": "コピーに成功しました",
"copyResultNone": "コピーする対象がありませんクライアントが選択されていないか、コピー元が空です",
"copyResultErrors": "コピーエラー",
"copyFlowLabel": "新規クライアントの Flow (VLESS)",
"copyFlowHint": "すべてのコピー対象クライアントに適用されます。空のままにするとスキップします。",
"copyFlowHint": "コピーされる全クライアントに適用されます。空欄でスキップします。",
"selectAll": "すべて選択",
"clearAll": "すべて解除",
"method": "方法",
"clearAll": "すべてクリア",
"method": "メソッド",
"first": "最初",
"last": "最後",
"ipLog": "IP ログ",
"prefix": "プレフィックス",
"postfix": "サフィックス",
"delayedStart": "初回使用後に開始",
"delayedStart": "初回使用から開始",
"expireDays": "期間",
"days": "日",
"renew": "自動更新",
"renewDesc": "期限切れ後に自動更新。(0 = 無効)(単位:日)"
"renewDesc": "有効期限切れ後に自動更新します。(0 = 無効) (単位: 日)",
"title": "クライアント",
"actions": "操作",
"totalGB": "送受信合計 (GB)",
"expiryTime": "有効期限",
"addClients": "クライアントを追加",
"limitIp": "IP 制限",
"password": "パスワード",
"subId": "サブスクリプション ID",
"online": "オンライン",
"email": "メール",
"comment": "コメント",
"traffic": "トラフィック",
"offline": "オフライン",
"addTitle": "クライアントを追加",
"qrCode": "QR コード",
"moreInformation": "詳細情報",
"delete": "削除",
"reset": "トラフィックをリセット",
"editTitle": "クライアントを編集",
"client": "クライアント",
"enabled": "有効",
"remaining": "残量",
"duration": "期間",
"attachedInbounds": "関連付けされたインバウンド",
"selectInbound": "1 つ以上のインバウンドを選択",
"noSubId": "このクライアントには subId がなく、共有可能なリンクはありません。",
"noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。",
"link": "リンク",
"resetNotPossible": "まずこのクライアントをインバウンドに関連付けてください。",
"general": "一般",
"resetAllTraffics": "すべてのクライアントのトラフィックをリセット",
"resetAllTrafficsTitle": "すべてのクライアントのトラフィックをリセットしますか?",
"resetAllTrafficsContent": "すべてのクライアントの送受信カウンターがゼロにリセットされます。クォータと有効期限には影響しません。元に戻せません。",
"empty": "クライアントはまだいません — 1 つ追加して始めましょう。",
"deleteConfirmTitle": "クライアント {email} を削除しますか?",
"deleteConfirmContent": "クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。",
"deleteSelected": "削除 ({count})",
"bulkDeleteConfirmTitle": "{count} 件のクライアントを削除しますか?",
"bulkDeleteConfirmContent": "選択された各クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。",
"delDepleted": "使い切ったクライアントを削除",
"delDepletedConfirmTitle": "使い切ったクライアントを削除しますか?",
"delDepletedConfirmContent": "トラフィック上限に達したか有効期限が切れたクライアントをすべて削除します。元に戻せません。",
"auth": "Auth",
"hysteriaAuth": "Auth (Hysteria)",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "任意の Reverse tag",
"telegramId": "Telegram ユーザー ID",
"telegramIdPlaceholder": "数値の Telegram ユーザー ID (0 = なし)",
"created": "作成日",
"updated": "更新日",
"ipLimit": "IP 制限",
"toasts": {
"deleted": "クライアントを削除しました",
"trafficReset": "トラフィックをリセットしました",
"allTrafficsReset": "すべてのクライアントのトラフィックをリセットしました",
"bulkDeleted": "{count} 件のクライアントを削除しました",
"bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗",
"bulkCreated": "{count} 件のクライアントを作成しました",
"bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
"delDepleted": "使い切った {count} 件のクライアントを削除しました"
}
},
"nodes": {
"title": "ノード",
@@ -428,6 +536,7 @@
"latency": "レイテンシ",
"lastHeartbeat": "最後のハートビート",
"xrayVersion": "Xrayバージョン",
"panelVersion": "パネルのバージョン",
"actions": "操作",
"probe": "今すぐプローブ",
"testConnection": "接続テスト",
@@ -777,9 +886,6 @@
"unexpectIPs": "予期しないIP",
"useSystemHosts": "システムのHostsを使用",
"useSystemHostsDesc": "インストール済みシステムのhostsファイルを使用する",
"usePreset": "テンプレートを使用",
"dnsPresetTitle": "DNSテンプレート",
"dnsPresetFamily": "ファミリー",
"serveStale": "期限切れキャッシュを使用",
"serveStaleDesc": "バックグラウンドで更新中に期限切れキャッシュ結果を返す",
"serveExpiredTTL": "期限切れTTL",
@@ -792,6 +898,9 @@
"hostsEmpty": "Host が定義されていません",
"hostsDomain": "ドメイン (例: domain:example.com)",
"hostsValues": "IP またはドメイン — 入力して Enter",
"usePreset": "テンプレートを使用",
"dnsPresetTitle": "DNSテンプレート",
"dnsPresetFamily": "ファミリー",
"clearAll": "すべて削除",
"clearAllTitle": "すべての DNS サーバを削除しますか?",
"clearAllConfirm": "リストからすべての DNS サーバが削除されます。この操作は元に戻せません。"
@@ -980,4 +1089,4 @@
"chooseInbound": "インバウンドを選択"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Pesquisar",
"filter": "Filtrar",
"loading": "Carregando...",
"refresh": "Atualizar",
"clear": "Limpar",
"second": "Segundo",
"minute": "Minuto",
"hour": "Hora",
@@ -94,6 +96,7 @@
"ultraDark": "Ultra Escuro",
"dashboard": "Visão Geral",
"inbounds": "Inbounds",
"clients": "Clientes",
"nodes": "Nós",
"settings": "Panel Settings",
"xray": "Xray Configs",
@@ -127,9 +130,9 @@
"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.",
"xrayUpdates": "Atualizações do Xray",
"updatePanel": "Atualizar painel",
"panelUpdateDesc": "Isso atualizará o 3X-UI para a versão mais recente e reiniciará o serviço do painel.",
"currentPanelVersion": "Versão atual do painel",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "Isso atualizará todos os arquivos.",
"geofilesUpdateAll": "Atualizar tudo",
"geofileUpdatePopover": "Geofile atualizado com sucesso",
"dontRefresh": "Instalação em andamento, por favor não atualize a página",
"logs": "Logs",
"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.",
"importDatabase": "Restaurar",
"importDatabaseDesc": "Clique para selecionar e enviar um arquivo .db do seu dispositivo para restaurar seu banco de dados a partir de um backup.",
"importDatabaseSuccess": "O banco de dados foi importado com sucesso",
"importDatabaseError": "Ocorreu um erro ao importar o banco de dados",
"readDatabaseError": "Ocorreu um erro ao ler o banco de dados",
"getDatabaseError": "Ocorreu um erro ao recuperar o banco de dados",
"getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração",
"customGeoTitle": "GeoSite / GeoIP personalizados",
"customGeoAdd": "Adicionar",
"customGeoType": "Tipo",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "Fonte geo personalizada não encontrada",
"customGeoErrDownload": "Falha no download",
"customGeoErrUpdateAllIncomplete": "Falha ao atualizar uma ou mais fontes geo personalizadas",
"customGeoEmpty": "Ainda não há fontes geo personalizadas — clique em Adicionar para criar uma"
"customGeoEmpty": "Ainda não há fontes geo personalizadas — clique em Adicionar para criar uma",
"dontRefresh": "Instalação em andamento, por favor não atualize a página",
"logs": "Logs",
"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.",
"importDatabase": "Restaurar",
"importDatabaseDesc": "Clique para selecionar e enviar um arquivo .db do seu dispositivo para restaurar seu banco de dados a partir de um backup.",
"importDatabaseSuccess": "O banco de dados foi importado com sucesso",
"importDatabaseError": "Ocorreu um erro ao importar o banco de dados",
"readDatabaseError": "Ocorreu um erro ao ler o banco de dados",
"getDatabaseError": "Ocorreu um erro ao recuperar o banco de dados",
"getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração"
},
"inbounds": {
"allTimeTraffic": "Tráfego Total",
"allTimeTrafficUsage": "Uso total de todos os tempos",
"title": "Inbounds",
"totalDownUp": "Total Enviado/Recebido",
"totalUsage": "Uso Total",
@@ -249,6 +250,23 @@
"node": "Nó",
"deployTo": "Implantar em",
"localPanel": "Painel local",
"fallbacks": {
"title": "Fallbacks",
"help": "Quando uma conexão neste inbound não corresponde a nenhum cliente, redirecione-a para outro inbound. Escolha um filho abaixo e os campos de roteamento (SNI / ALPN / Path / xver) são preenchidos automaticamente a partir do transporte dele — a maioria das configurações não precisa de mais ajustes. Cada filho deve escutar em 127.0.0.1 com security=none.",
"empty": "Ainda sem fallbacks",
"add": "Adicionar fallback",
"pickInbound": "Escolha um inbound",
"matchAny": "qualquer",
"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"
},
"protocol": "Protocolo",
"port": "Porta",
"portMap": "Porta Mapeada",
@@ -308,6 +326,32 @@
"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",
"streamTab": "Stream",
"securityTab": "Segurança",
"sniffingTab": "Sniffing",
"sniffingMetadataOnly": "Apenas metadados",
"sniffingRouteOnly": "Apenas roteamento",
"sniffingIpsExcluded": "IPs excluídos",
"sniffingDomainsExcluded": "Domínios excluídos",
"decryption": "Descriptografia",
"encryption": "Criptografia",
"vlessAuthX25519": "Autenticação X25519",
"vlessAuthMlkem768": "Autenticação ML-KEM-768",
"vlessAuthCustom": "Personalizado",
"vlessAuthSelected": "Selecionado: {auth}",
"advanced": {
"title": "Seções JSON do inbound",
"subtitle": "JSON completo do inbound e editores específicos para settings, sniffing e streamSettings.",
"all": "Tudo",
"allHelp": "Objeto inbound completo com todos os campos em um único editor.",
"settings": "Configurações",
"settingsHelp": "Wrapper do bloco settings do Xray:",
"sniffing": "Sniffing",
"sniffingHelp": "Wrapper do bloco sniffing do Xray:",
"stream": "Stream",
"streamHelp": "Wrapper do bloco stream do Xray:",
"jsonErrorPrefix": "JSON avançado"
},
"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.",
"info": "Informações",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "Adicionar Cliente",
"edit": "Editar Cliente",
"submitAdd": "Adicionar Cliente",
"submitEdit": "Salvar Alterações",
"clientCount": "Número de Clientes",
"bulk": "Adicionar Vários",
"copyFromInbound": "Copiar clientes da entrada",
"clients": {
"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 email resultante",
"copySelectSourceFirst": "Selecione primeiro uma entrada de 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 origem vazia",
"copyResultErrors": "Erros ao copiar",
"copyFlowLabel": "Flow para novos clientes (VLESS)",
"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",
"first": "Primeiro",
"last": "Último",
"ipLog": "Registro de IP",
"prefix": "Prefixo",
"postfix": "Sufixo",
"delayedStart": "Iniciar Após Primeiro Uso",
"delayedStart": "Iniciar após o primeiro uso",
"expireDays": "Duração",
"days": "Dia(s)",
"renew": "Renovação Automática",
"renewDesc": "Renovação automática após expiração. (0 = desativado)(unidade: dia)"
"renew": "Renovação automática",
"renewDesc": "Renovação automática após a expiração. (0 = desativar) (unidade: dia)",
"title": "Clientes",
"actions": "Ações",
"totalGB": "Total enviado/recebido (GB)",
"expiryTime": "Expiração",
"addClients": "Adicionar clientes",
"limitIp": "Limite de IP",
"password": "Senha",
"subId": "ID da assinatura",
"online": "Online",
"email": "E-mail",
"comment": "Comentário",
"traffic": "Tráfego",
"offline": "Offline",
"addTitle": "Adicionar cliente",
"qrCode": "Código QR",
"moreInformation": "Mais informações",
"delete": "Excluir",
"reset": "Redefinir tráfego",
"editTitle": "Editar cliente",
"client": "Cliente",
"enabled": "Habilitado",
"remaining": "Restante",
"duration": "Duração",
"attachedInbounds": "Inbounds associados",
"selectInbound": "Selecione um ou mais inbounds",
"noSubId": "Este cliente não tem subId, sem link compartilhável.",
"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.",
"empty": "Ainda não há clientes — adicione um para começar.",
"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})",
"bulkDeleteConfirmTitle": "Excluir {count} clientes?",
"bulkDeleteConfirmContent": "Cada cliente selecionado é removido dos inbounds associados e o registro de tráfego é descartado. Não é possível desfazer.",
"delDepleted": "Excluir esgotados",
"delDepletedConfirmTitle": "Excluir clientes esgotados?",
"delDepletedConfirmContent": "Remove todos os clientes cuja cota de tráfego foi esgotada ou cuja expiração já passou. Não é possível desfazer.",
"auth": "Auth",
"hysteriaAuth": "Auth do Hysteria",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"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",
"trafficReset": "Tráfego redefinido",
"allTrafficsReset": "Tráfego de todos os clientes redefinido",
"bulkDeleted": "{count} clientes excluídos",
"bulkDeletedMixed": "{ok} excluídos, {failed} com falha",
"bulkCreated": "{count} clientes criados",
"bulkCreatedMixed": "{ok} criados, {failed} com falha",
"delDepleted": "{count} clientes esgotados excluídos"
}
},
"nodes": {
"title": "Nós",
@@ -428,6 +536,7 @@
"latency": "Latência",
"lastHeartbeat": "Último heartbeat",
"xrayVersion": "Versão do Xray",
"panelVersion": "Versão do painel",
"actions": "Ações",
"probe": "Sondar agora",
"testConnection": "Testar conexão",
@@ -777,9 +886,6 @@
"unexpectIPs": "IPs inesperados",
"useSystemHosts": "Usar Hosts do sistema",
"useSystemHostsDesc": "Usar o arquivo hosts de um sistema instalado",
"usePreset": "Usar modelo",
"dnsPresetTitle": "Modelos DNS",
"dnsPresetFamily": "Familiar",
"serveStale": "Servir Expirados",
"serveStaleDesc": "Retornar resultados expirados do cache enquanto atualiza em segundo plano",
"serveExpiredTTL": "TTL de Expirados",
@@ -792,6 +898,9 @@
"hostsEmpty": "Nenhum Host definido",
"hostsDomain": "Domínio (ex. domain:example.com)",
"hostsValues": "IP ou domínio — digite e pressione Enter",
"usePreset": "Usar modelo",
"dnsPresetTitle": "Modelos DNS",
"dnsPresetFamily": "Familiar",
"clearAll": "Remover Todos",
"clearAllTitle": "Remover todos os servidores DNS?",
"clearAllConfirm": "Isso remove todos os servidores DNS da lista. Não pode ser desfeito."
@@ -980,4 +1089,4 @@
"chooseInbound": "Escolha um Inbound"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Поиск",
"filter": "Фильтр",
"loading": "Загрузка...",
"refresh": "Обновить",
"clear": "Очистить",
"second": "Секунда",
"minute": "Минута",
"hour": "Час",
@@ -94,6 +96,7 @@
"ultraDark": "Очень темная",
"dashboard": "Дашборд",
"inbounds": "Подключения",
"clients": "Клиенты",
"nodes": "Узлы",
"settings": "Настройки",
"xray": "Настройки Xray",
@@ -127,9 +130,9 @@
"stopXray": "Остановить",
"restartXray": "Перезапустить",
"xraySwitch": "Выбор версии",
"xrayUpdates": "Обновления Xray",
"xraySwitchClick": "Выберите нужную версию",
"xraySwitchClickDesk": "Важно: старые версии могут не поддерживать текущие настройки",
"xrayUpdates": "Обновления Xray",
"updatePanel": "Обновить панель",
"panelUpdateDesc": "Это обновит 3X-UI до последнего релиза и перезапустит сервис панели.",
"currentPanelVersion": "Текущая версия панели",
@@ -237,8 +240,6 @@
"getConfigError": "Произошла ошибка при получении конфигурационного файла"
},
"inbounds": {
"allTimeTraffic": "Общий трафик",
"allTimeTrafficUsage": "Общее использование за все время",
"title": "Подключения",
"totalDownUp": "Отправлено/получено",
"totalUsage": "Всего трафика",
@@ -249,6 +250,23 @@
"node": "Узел",
"deployTo": "Развернуть на",
"localPanel": "Локальная панель",
"fallbacks": {
"title": "Фолбэки",
"help": "Когда соединение на этом инбаунде не совпадает ни с одним клиентом, оно перенаправляется на другой инбаунд. Выберите дочерний инбаунд ниже — поля маршрутизации (SNI / ALPN / Path / xver) заполнятся автоматически из его транспорта, для большинства конфигураций больше ничего менять не нужно. Каждый дочерний должен слушать на 127.0.0.1 с security=none.",
"empty": "Фолбэков пока нет",
"add": "Добавить фолбэк",
"pickInbound": "Выберите инбаунд",
"matchAny": "любой",
"rederive": "Заполнить из дочернего",
"rederived": "Заполнено из дочернего",
"editAdvanced": "Изменить поля маршрутизации",
"hideAdvanced": "Скрыть расширенные",
"quickAddAll": "Быстро добавить все подходящие",
"quickAdded": "Добавлено {n} фолбэк(ов)",
"quickAddedNone": "Нет новых подходящих инбаундов",
"routesWhen": "Маршрутизирует, когда",
"defaultCatchAll": "По умолчанию — ловит всё остальное"
},
"protocol": "Протокол",
"port": "Порт",
"portMap": "Порт-маппинг",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)",
"IPLimitlogclear": "Очистить лог",
"setDefaultCert": "Установить сертификат панели",
"streamTab": "Поток",
"securityTab": "Безопасность",
"sniffingTab": "Сниффинг",
"sniffingMetadataOnly": "Только метаданные",
"sniffingRouteOnly": "Только маршрутизация",
"sniffingIpsExcluded": "Исключённые IP",
"sniffingDomainsExcluded": "Исключённые домены",
"decryption": "Расшифрование",
"encryption": "Шифрование",
"vlessAuthX25519": "Аутентификация X25519",
"vlessAuthMlkem768": "Аутентификация ML-KEM-768",
"vlessAuthCustom": "Свой",
"vlessAuthSelected": "Выбрано: {auth}",
"advanced": {
"title": "Разделы JSON входящего",
"subtitle": "Полный JSON входящего и отдельные редакторы для settings, sniffing и streamSettings.",
"all": "Всё",
"allHelp": "Полный объект входящего со всеми полями в одном редакторе.",
"settings": "Настройки",
"settingsHelp": "Обёртка блока settings Xray:",
"sniffing": "Сниффинг",
"sniffingHelp": "Обёртка блока sniffing Xray:",
"stream": "Поток",
"streamHelp": "Обёртка блока stream Xray:",
"jsonErrorPrefix": "Расширенный JSON"
},
"telegramDesc": "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или ({'@'}userinfobot)",
"subscriptionDesc": "Вы можете найти свою ссылку подписки в разделе 'Подробнее'",
"info": "Информация",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"clients": {
"add": "Добавить клиента",
"edit": "Редактировать клиента",
"submitAdd": "Добавить",
"edit": "Изменить клиента",
"submitAdd": "Добавить клиента",
"submitEdit": "Сохранить изменения",
"clientCount": "Количество клиентов",
"bulk": "Добавить несколько",
"copyFromInbound": "Скопировать клиентов из инбаунда",
"bulk": "Массовое добавление",
"copyFromInbound": "Скопировать клиентов из входящего",
"copyToInbound": "Скопировать клиентов в",
"copySelected": "Скопировать выбранных",
"copySelected": "Скопировать выбранное",
"copySource": "Источник",
"copyEmailPreview": "Предпросмотр итоговых email",
"copySelectSourceFirst": "Сначала выберите источник.",
"copyEmailPreview": "Предпросмотр результирующего email",
"copySelectSourceFirst": "Сначала выберите исходный входящий.",
"copyResult": "Результат копирования",
"copyResultSuccess": "Успешно скопировано",
"copyResultNone": "Нечего копировать: ни одного клиента не выбрано или список источника пуст",
"copyResultErrors": "Ошибки при копировании",
"copyResultSuccess": "Скопировано успешно",
"copyResultNone": "Нечего копировать: клиенты не выбраны или источник пуст",
"copyResultErrors": "Ошибки копирования",
"copyFlowLabel": "Flow для новых клиентов (VLESS)",
"copyFlowHint": "Применится ко всем копируемым клиентам. Оставьте пустым, чтобы не задавать.",
"selectAll": "Выбрать всех",
"clearAll": "Снять всё",
"copyFlowHint": "Применяется ко всем скопированным клиентам. Оставьте пустым, чтобы пропустить.",
"selectAll": "Выбрать всё",
"clearAll": "Очистить всё",
"method": "Метод",
"first": "Первый",
"last": "Последний",
"ipLog": "Журнал IP",
"prefix": "Префикс",
"postfix": "Постфикс",
"delayedStart": "Начало использования",
"delayedStart": "Старт после первого использования",
"expireDays": "Длительность",
"days": "дней",
"days": "Дни",
"renew": "Автопродление",
"renewDesc": "Автопродление после истечения срока действия. (0 = отключить)(единица: день)"
"renewDesc": "Автоматическое продление после окончания. (0 = отключено) (единица: день)",
"title": "Клиенты",
"actions": "Действия",
"totalGB": "Всего отправлено/получено (ГБ)",
"expiryTime": "Срок действия",
"addClients": "Добавить клиентов",
"limitIp": "Лимит IP",
"password": "Пароль",
"subId": "ID подписки",
"online": "В сети",
"email": "Email",
"comment": "Комментарий",
"traffic": "Трафик",
"offline": "Не в сети",
"addTitle": "Добавить клиента",
"qrCode": "QR-код",
"moreInformation": "Подробнее",
"delete": "Удалить",
"reset": "Сбросить трафик",
"editTitle": "Изменить клиента",
"client": "Клиент",
"enabled": "Включён",
"remaining": "Остаток",
"duration": "Длительность",
"attachedInbounds": "Привязанные входящие",
"selectInbound": "Выберите один или несколько входящих",
"noSubId": "У этого клиента нет subId, ссылка для общего доступа недоступна.",
"noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.",
"link": "Ссылка",
"resetNotPossible": "Сначала привяжите этого клиента к входящему.",
"general": "Общее",
"resetAllTraffics": "Сбросить трафик всех клиентов",
"resetAllTrafficsTitle": "Сбросить трафик всех клиентов?",
"resetAllTrafficsContent": "Счётчики отправки/приёма всех клиентов сбрасываются в ноль. Квоты и срок действия не затрагиваются. Это действие нельзя отменить.",
"empty": "Клиентов пока нет — добавьте первого, чтобы начать.",
"deleteConfirmTitle": "Удалить клиента {email}?",
"deleteConfirmContent": "Клиент будет удалён из всех привязанных входящих, а его запись трафика будет уничтожена. Это действие нельзя отменить.",
"deleteSelected": "Удалить ({count})",
"bulkDeleteConfirmTitle": "Удалить {count} клиентов?",
"bulkDeleteConfirmContent": "Каждый выбранный клиент удаляется из всех привязанных входящих, его запись трафика уничтожается. Это действие нельзя отменить.",
"delDepleted": "Удалить исчерпанных",
"delDepletedConfirmTitle": "Удалить исчерпанных клиентов?",
"delDepletedConfirmContent": "Удаляются все клиенты, у которых исчерпана квота трафика или истёк срок. Это действие нельзя отменить.",
"auth": "Auth",
"hysteriaAuth": "Auth для Hysteria",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Необязательный Reverse tag",
"telegramId": "ID пользователя Telegram",
"telegramIdPlaceholder": "Числовой ID пользователя Telegram (0 = нет)",
"created": "Создан",
"updated": "Обновлён",
"ipLimit": "Лимит IP",
"toasts": {
"deleted": "Клиент удалён",
"trafficReset": "Трафик сброшен",
"allTrafficsReset": "Трафик всех клиентов сброшен",
"bulkDeleted": "Удалено клиентов: {count}",
"bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}",
"bulkCreated": "Создано клиентов: {count}",
"bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
"delDepleted": "Удалено исчерпанных клиентов: {count}"
}
},
"nodes": {
"title": "Узлы",
@@ -428,6 +536,7 @@
"latency": "Задержка",
"lastHeartbeat": "Последний пинг",
"xrayVersion": "Версия Xray",
"panelVersion": "Версия панели",
"actions": "Действия",
"probe": "Проверить сейчас",
"testConnection": "Проверить соединение",
@@ -777,9 +886,6 @@
"unexpectIPs": "Неожидаемые IP",
"useSystemHosts": "Использовать системные Hosts",
"useSystemHostsDesc": "Использовать файл hosts из установленной системы",
"usePreset": "Использовать шаблон",
"dnsPresetTitle": "Шаблоны DNS",
"dnsPresetFamily": "Семейный",
"serveStale": "Использовать устаревшие",
"serveStaleDesc": "Возвращать устаревшие результаты из кэша во время обновления в фоне",
"serveExpiredTTL": "TTL устаревших",
@@ -792,6 +898,9 @@
"hostsEmpty": "Host не определены",
"hostsDomain": "Домен (напр. domain:example.com)",
"hostsValues": "IP или домен — введите и нажмите Enter",
"usePreset": "Использовать шаблон",
"dnsPresetTitle": "Шаблоны DNS",
"dnsPresetFamily": "Семейный",
"clearAll": "Удалить все",
"clearAllTitle": "Удалить все DNS-серверы?",
"clearAllConfirm": "Все DNS-серверы будут удалены из списка. Это действие нельзя отменить."
@@ -980,4 +1089,4 @@
"chooseInbound": "Выберите входящее подключение"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Ara",
"filter": "Filtrele",
"loading": "Yükleniyor...",
"refresh": "Yenile",
"clear": "Temizle",
"second": "Saniye",
"minute": "Dakika",
"hour": "Saat",
@@ -94,6 +96,7 @@
"ultraDark": "Ultra Koyu",
"dashboard": "Genel Bakış",
"inbounds": "Gelenler",
"clients": "İstemciler",
"nodes": "Düğümler",
"settings": "Panel Ayarları",
"xray": "Xray Yapılandırmaları",
@@ -127,9 +130,9 @@
"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.",
"xrayUpdates": "Xray Güncellemeleri",
"updatePanel": "Paneli Güncelle",
"panelUpdateDesc": "Bu, 3X-UI'yi en son sürüme güncelleyecek ve panel servisini yeniden başlatacaktır.",
"currentPanelVersion": "Mevcut panel sürümü",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "Bu, tüm dosyaları güncelleyecektir.",
"geofilesUpdateAll": "Tümünü güncelle",
"geofileUpdatePopover": "Geofile başarıyla güncellendi",
"dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin",
"logs": "Günlükler",
"config": "Yapılandırma",
"backup": "Yedek",
"backupTitle": "Yedekleme & 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.",
"importDatabase": "Geri Yükle",
"importDatabaseDesc": "Cihazınızdan bir .db dosyası seçip yükleyerek veritabanınızı yedekten geri yüklemek için tıklayın.",
"importDatabaseSuccess": "Veritabanı başarıyla içe aktarıldı",
"importDatabaseError": "Veritabanı içe aktarılırken bir hata oluştu",
"readDatabaseError": "Veritabanı okunurken bir hata oluştu",
"getDatabaseError": "Veritabanı alınırken bir hata oluştu",
"getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu",
"customGeoTitle": "Özel GeoSite / GeoIP",
"customGeoAdd": "Ekle",
"customGeoType": "Tür",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "Özel geo kaynağı bulunamadı",
"customGeoErrDownload": "İndirme başarısız",
"customGeoErrUpdateAllIncomplete": "Bir veya daha fazla özel geo kaynağı güncellenemedi",
"customGeoEmpty": "Henüz özel geo kaynağı yok — oluşturmak için Ekle'ye tıklayın"
"customGeoEmpty": "Henüz özel geo kaynağı yok — oluşturmak için Ekle'ye tıklayın",
"dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin",
"logs": "Günlükler",
"config": "Yapılandırma",
"backup": "Yedek",
"backupTitle": "Yedekleme & 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.",
"importDatabase": "Geri Yükle",
"importDatabaseDesc": "Cihazınızdan bir .db dosyası seçip yükleyerek veritabanınızı yedekten geri yüklemek için tıklayın.",
"importDatabaseSuccess": "Veritabanı başarıyla içe aktarıldı",
"importDatabaseError": "Veritabanı içe aktarılırken bir hata oluştu",
"readDatabaseError": "Veritabanı okunurken bir hata oluştu",
"getDatabaseError": "Veritabanı alınırken bir hata oluştu",
"getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu"
},
"inbounds": {
"allTimeTraffic": "Toplam Trafik",
"allTimeTrafficUsage": "Tüm Zamanların Toplam Kullanımı",
"title": "Gelenler",
"totalDownUp": "Toplam Gönderilen/Alınan",
"totalUsage": "Toplam Kullanım",
@@ -249,6 +250,23 @@
"node": "Düğüm",
"deployTo": "Şuraya dağıt",
"localPanel": "Yerel panel",
"fallbacks": {
"title": "Fallback'ler",
"help": "Bu inbound üzerindeki bir bağlantı hiçbir client ile eşleşmediğinde, başka bir inbound'a yönlendirilir. Aşağıdan bir child seçin; yönlendirme alanları (SNI / ALPN / Path / xver) onun transport'undan otomatik dolar — çoğu kurulum için ek ayar gerekmez. Her child 127.0.0.1 üzerinde security=none ile dinlemelidir.",
"empty": "Henüz fallback yok",
"add": "Fallback ekle",
"pickInbound": "Bir inbound seç",
"matchAny": "herhangi",
"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 inbound yok",
"routesWhen": "Şu durumda yönlendirir",
"defaultCatchAll": "Varsayılan — başka her şeyi yakalar"
},
"protocol": "Protokol",
"port": "Port",
"portMap": "Port Atama",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "IP geçmiş günlüğü. (devre dışı bırakıldıktan sonra gelini etkinleştirmek için günlüğü temizleyin)",
"IPLimitlogclear": "Günlüğü Temizle",
"setDefaultCert": "Panelden Sertifikayı Ayarla",
"streamTab": "Akış",
"securityTab": "Güvenlik",
"sniffingTab": "Sniffing",
"sniffingMetadataOnly": "Yalnızca üst veri",
"sniffingRouteOnly": "Yalnızca yönlendirme",
"sniffingIpsExcluded": "Hariç tutulan IP'ler",
"sniffingDomainsExcluded": "Hariç tutulan alan adları",
"decryption": "Şifre çözme",
"encryption": "Şifreleme",
"vlessAuthX25519": "X25519 kimlik doğrulama",
"vlessAuthMlkem768": "ML-KEM-768 kimlik doğrulama",
"vlessAuthCustom": "Özel",
"vlessAuthSelected": "Seçili: {auth}",
"advanced": {
"title": "Inbound JSON bölümleri",
"subtitle": "Tam inbound JSON'u ve settings, sniffing, streamSettings için odaklanmış düzenleyiciler.",
"all": "Tümü",
"allHelp": "Tüm alanları tek bir düzenleyicide içeren tam inbound nesnesi.",
"settings": "Ayarlar",
"settingsHelp": "Xray settings bloğunun sarmalayıcısı:",
"sniffing": "Sniffing",
"sniffingHelp": "Xray sniffing bloğunun sarmalayıcısı:",
"stream": "Akış",
"streamHelp": "Xray stream bloğunun sarmalayıcısı:",
"jsonErrorPrefix": "Gelişmiş JSON"
},
"telegramDesc": "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya ({'@'}userinfobot)",
"subscriptionDesc": "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla müşteri için kullanabilirsiniz.",
"info": "Bilgi",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "Müşteri Ekle",
"edit": "Müşteriyi Düzenle",
"submitAdd": "Müşteri Ekle",
"submitEdit": "Değişiklikleri Kaydet",
"clientCount": "Müşteri Sayısı",
"bulk": "Toplu Ekle",
"copyFromInbound": "Gelen bağlantıdan istemcileri kopyala",
"copyToInbound": "İstemcileri şuraya kopyala",
"copySelected": "Seçilenleri kopyala",
"clients": {
"add": "İstemci ekle",
"edit": "İstemciyi düzenle",
"submitAdd": "İstemci ekle",
"submitEdit": "Değişiklikleri kaydet",
"clientCount": "İstemci sayısı",
"bulk": "Toplu ekle",
"copyFromInbound": "Inbound'dan istemcileri kopyala",
"copyToInbound": "İstemcileri kopyalanacak yer",
"copySelected": "Seçileni kopyala",
"copySource": "Kaynak",
"copyEmailPreview": "Sonuç e-posta önizlemesi",
"copySelectSourceFirst": "Önce bir kaynak gelen bağlantı seçin.",
"copyResult": "Kopyalama sonucu",
"copyEmailPreview": "Oluşacak e-posta önizlemesi",
"copySelectSourceFirst": "Önce bir kaynak inbound seçin.",
"copyResult": "Kopya sonucu",
"copyResultSuccess": "Başarıyla kopyalandı",
"copyResultNone": "Kopyalanacak bir şey yok: istemci seçilmedi veya kaynak boş",
"copyResultNone": "Kopyalanacak bir şey yok: istemci seçilmemiş veya kaynak boş",
"copyResultErrors": "Kopyalama hataları",
"copyFlowLabel": "Yeni istemciler için Flow (VLESS)",
"copyFlowHint": "Kopyalanan tüm istemcilere uygulanır. Boş bırakırsanız atlanır.",
"copyFlowHint": "Kopyalanan tüm istemcilere uygulanır. Atlamak için boş bırakın.",
"selectAll": "Tümünü seç",
"clearAll": "Tümünü temizle",
"method": "Yöntem",
"first": "İlk",
"last": "Son",
"ipLog": "IP günlüğü",
"prefix": "Önek",
"postfix": "Sonek",
"delayedStart": "İlk Kullanımdan Sonra Başlat",
"delayedStart": "İlk kullanımdan sonra başla",
"expireDays": "Süre",
"days": "Gün",
"renew": "Otomatik Yenile",
"renewDesc": "Süresi dolduktan sonra otomatik yenileme. (0 = devre dışı)(birim: gün)"
"renew": "Otomatik yenileme",
"renewDesc": "Süre dolduktan sonra otomatik yenileme. (0 = devre dışı) (birim: gün)",
"title": "İstemciler",
"actions": "Eylemler",
"totalGB": "Toplam Gönderilen/Alınan (GB)",
"expiryTime": "Son kullanma",
"addClients": "İstemci ekle",
"limitIp": "IP limiti",
"password": "Şifre",
"subId": "Abonelik ID'si",
"online": "Çevrimiçi",
"email": "E-posta",
"comment": "Yorum",
"traffic": "Trafik",
"offline": "Çevrimdışı",
"addTitle": "İstemci ekle",
"qrCode": "QR kodu",
"moreInformation": "Daha fazla bilgi",
"delete": "Sil",
"reset": "Trafiği sıfırla",
"editTitle": "İstemciyi düzenle",
"client": "İstemci",
"enabled": "Etkin",
"remaining": "Kalan",
"duration": "Süre",
"attachedInbounds": "Bağlı inbound'lar",
"selectInbound": "Bir veya daha fazla inbound seçin",
"noSubId": "Bu istemcinin subId'si yok, paylaşılabilir bağlantı yok.",
"noLinks": "Paylaşılabilir bağlantı yok — önce bu istemciyi protokol destekli bir inbound'a bağlayın.",
"link": "Bağlantı",
"resetNotPossible": "Önce bu istemciyi bir inbound'a bağlayın.",
"general": "Genel",
"resetAllTraffics": "Tüm istemcilerin trafiğini sıfırla",
"resetAllTrafficsTitle": "Tüm istemcilerin trafiği sıfırlansın mı?",
"resetAllTrafficsContent": "Her istemcinin yükleme/indirme sayaçları sıfırlanır. Kotalar ve son kullanma tarihleri etkilenmez. Geri alınamaz.",
"empty": "Henüz istemci yok — başlamak için bir tane ekleyin.",
"deleteConfirmTitle": "{email} istemcisi silinsin mi?",
"deleteConfirmContent": "Bu işlem istemciyi bağlı tüm inbound'lardan kaldırır ve trafik kaydını siler. Geri alınamaz.",
"deleteSelected": "Sil ({count})",
"bulkDeleteConfirmTitle": "{count} istemci silinsin mi?",
"bulkDeleteConfirmContent": "Seçili her istemci bağlı tüm inbound'lardan kaldırılır ve trafik kaydı silinir. Geri alınamaz.",
"delDepleted": "Tükenmişleri sil",
"delDepletedConfirmTitle": "Tükenmiş istemciler silinsin mi?",
"delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm istemciler silinir. Geri alınamaz.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"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": "İstemci silindi",
"trafficReset": "Trafik sıfırlandı",
"allTrafficsReset": "Tüm istemcilerin trafiği sıfırlandı",
"bulkDeleted": "{count} istemci silindi",
"bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
"bulkCreated": "{count} istemci oluşturuldu",
"bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
"delDepleted": "{count} tükenmiş istemci silindi"
}
},
"nodes": {
"title": "Düğümler",
@@ -428,6 +536,7 @@
"latency": "Gecikme",
"lastHeartbeat": "Son Sinyal",
"xrayVersion": "Xray Sürümü",
"panelVersion": "Panel sürümü",
"actions": "İşlemler",
"probe": "Şimdi Test Et",
"testConnection": "Bağlantıyı Test Et",
@@ -777,9 +886,6 @@
"unexpectIPs": "Beklenmeyen IP'ler",
"useSystemHosts": "Sistem Hosts'larını Kullan",
"useSystemHostsDesc": "Yüklü bir sistemden hosts dosyasını kullan",
"usePreset": "Şablon kullan",
"dnsPresetTitle": "DNS Şablonları",
"dnsPresetFamily": "Aile",
"serveStale": "Süresi Dolmuş Sonuçları Sun",
"serveStaleDesc": "Arka planda yenilenirken süresi dolmuş önbellek sonuçlarını döndür",
"serveExpiredTTL": "Süresi Dolmuş TTL",
@@ -792,6 +898,9 @@
"hostsEmpty": "Tanımlı Host yok",
"hostsDomain": "Alan adı (ör. domain:example.com)",
"hostsValues": "IP veya alan adı — yazıp Enter'a basın",
"usePreset": "Şablon kullan",
"dnsPresetTitle": "DNS Şablonları",
"dnsPresetFamily": "Aile",
"clearAll": "Tümünü Sil",
"clearAllTitle": "Tüm DNS sunucularını sil?",
"clearAllConfirm": "Bu, tüm DNS sunucularını listeden kaldırır. Geri alınamaz."
@@ -980,4 +1089,4 @@
"chooseInbound": "Bir Gelen Seçin"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Пошук",
"filter": "Фільтр",
"loading": "Завантаження...",
"refresh": "Оновити",
"clear": "Очистити",
"second": "Секунда",
"minute": "Хвилина",
"hour": "Година",
@@ -94,6 +96,7 @@
"ultraDark": "Ультра темна",
"dashboard": "Огляд",
"inbounds": "Вхідні",
"clients": "Клієнти",
"nodes": "Вузли",
"settings": "Параметри панелі",
"xray": "Конфігурації Xray",
@@ -127,9 +130,9 @@
"stopXray": "Зупинити",
"restartXray": "Перезапустити",
"xraySwitch": "Версія",
"xrayUpdates": "Оновлення Xray",
"xraySwitchClick": "Виберіть версію, на яку ви хочете перейти.",
"xraySwitchClickDesk": "Вибирайте уважно, оскільки старіші версії можуть бути несумісними з поточними конфігураціями.",
"xrayUpdates": "Оновлення Xray",
"updatePanel": "Оновити панель",
"panelUpdateDesc": "Це оновить 3X-UI до останнього релізу та перезапустить сервіс панелі.",
"currentPanelVersion": "Поточна версія панелі",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "Це оновить усі геофайли.",
"geofilesUpdateAll": "Оновити все",
"geofileUpdatePopover": "Геофайл успішно оновлено",
"dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку",
"logs": "Журнали",
"config": "Конфігурація",
"backup": "Резервна копія",
"backupTitle": "Резервне копіювання та відновлення",
"exportDatabase": "Резервна копія",
"exportDatabaseDesc": "Натисніть, щоб завантажити файл .db, що містить резервну копію вашої поточної бази даних на ваш пристрій.",
"importDatabase": "Відновити",
"importDatabaseDesc": "Натисніть, щоб вибрати та завантажити файл .db з вашого пристрою для відновлення бази даних з резервної копії.",
"importDatabaseSuccess": "Базу даних успішно імпортовано",
"importDatabaseError": "Виникла помилка під час імпорту бази даних",
"readDatabaseError": "Виникла помилка під час читання бази даних",
"getDatabaseError": "Виникла помилка під час отримання бази даних",
"getConfigError": "Виникла помилка під час отримання файлу конфігурації",
"customGeoTitle": "Користувацькі GeoSite / GeoIP",
"customGeoAdd": "Додати",
"customGeoType": "Тип",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "Джерело geo не знайдено",
"customGeoErrDownload": "Помилка завантаження",
"customGeoErrUpdateAllIncomplete": "Не вдалося оновити один або кілька користувацьких джерел",
"customGeoEmpty": "Користувацьких джерел geo поки немає — натисніть «Додати», щоб створити"
"customGeoEmpty": "Користувацьких джерел geo поки немає — натисніть «Додати», щоб створити",
"dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку",
"logs": "Журнали",
"config": "Конфігурація",
"backup": "Резервна копія",
"backupTitle": "Резервне копіювання та відновлення",
"exportDatabase": "Резервна копія",
"exportDatabaseDesc": "Натисніть, щоб завантажити файл .db, що містить резервну копію вашої поточної бази даних на ваш пристрій.",
"importDatabase": "Відновити",
"importDatabaseDesc": "Натисніть, щоб вибрати та завантажити файл .db з вашого пристрою для відновлення бази даних з резервної копії.",
"importDatabaseSuccess": "Базу даних успішно імпортовано",
"importDatabaseError": "Виникла помилка під час імпорту бази даних",
"readDatabaseError": "Виникла помилка під час читання бази даних",
"getDatabaseError": "Виникла помилка під час отримання бази даних",
"getConfigError": "Виникла помилка під час отримання файлу конфігурації"
},
"inbounds": {
"allTimeTraffic": "Загальний трафік",
"allTimeTrafficUsage": "Загальне використання за весь час",
"title": "Вхідні",
"totalDownUp": "Всього надісланих/отриманих",
"totalUsage": "Всього використанно",
@@ -249,6 +250,23 @@
"node": "Вузол",
"deployTo": "Розгорнути на",
"localPanel": "Локальна панель",
"fallbacks": {
"title": "Фолбеки",
"help": "Коли з'єднання на цьому інбаунді не збігається з жодним клієнтом, воно перенаправляється на інший інбаунд. Оберіть дочірній інбаунд нижче — поля маршрутизації (SNI / ALPN / Path / xver) заповняться автоматично з його транспорту; для більшості налаштувань більше нічого змінювати не треба. Кожен дочірній має слухати на 127.0.0.1 з security=none.",
"empty": "Фолбеків поки немає",
"add": "Додати фолбек",
"pickInbound": "Оберіть інбаунд",
"matchAny": "будь-який",
"rederive": "Заповнити з дочірнього",
"rederived": "Заповнено з дочірнього",
"editAdvanced": "Редагувати поля маршрутизації",
"hideAdvanced": "Сховати розширені",
"quickAddAll": "Швидко додати всі придатні",
"quickAdded": "Додано {n} фолбек(ів)",
"quickAddedNone": "Немає нових придатних інбаундів",
"routesWhen": "Маршрутизує, коли",
"defaultCatchAll": "За замовчуванням — ловить усе інше"
},
"protocol": "Протокол",
"port": "Порт",
"portMap": "Порт-перехід",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "Журнал історії IP-адрес. (щоб увімкнути вхідну після вимкнення, очистіть журнал)",
"IPLimitlogclear": "Очистити журнал",
"setDefaultCert": "Установити сертифікат з панелі",
"streamTab": "Потік",
"securityTab": "Безпека",
"sniffingTab": "Сніфінг",
"sniffingMetadataOnly": "Лише метадані",
"sniffingRouteOnly": "Лише маршрутизація",
"sniffingIpsExcluded": "Виключені IP",
"sniffingDomainsExcluded": "Виключені домени",
"decryption": "Розшифрування",
"encryption": "Шифрування",
"vlessAuthX25519": "Автентифікація X25519",
"vlessAuthMlkem768": "Автентифікація ML-KEM-768",
"vlessAuthCustom": "Користувацький",
"vlessAuthSelected": "Вибрано: {auth}",
"advanced": {
"title": "Розділи JSON вхідного",
"subtitle": "Повний JSON вхідного та окремі редактори для settings, sniffing і streamSettings.",
"all": "Усе",
"allHelp": "Повний об'єкт вхідного з усіма полями в одному редакторі.",
"settings": "Налаштування",
"settingsHelp": "Обгортка блоку settings Xray:",
"sniffing": "Сніфінг",
"sniffingHelp": "Обгортка блоку sniffing Xray:",
"stream": "Потік",
"streamHelp": "Обгортка блоку stream Xray:",
"jsonErrorPrefix": "Розширений JSON"
},
"telegramDesc": "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або ({'@'}userinfobot)",
"subscriptionDesc": "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів.",
"info": "Інформація",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"clients": {
"add": "Додати клієнта",
"edit": "Редагувати клієнта",
"submitAdd": "Додати клієнта",
"submitEdit": "Зберегти зміни",
"clientCount": "Кількість клієнтів",
"bulk": "Додати групу",
"copyFromInbound": "Скопіювати клієнтів з інбаунда",
"bulk": "Масове додавання",
"copyFromInbound": "Скопіювати клієнтів із вхідного",
"copyToInbound": "Скопіювати клієнтів у",
"copySelected": "Скопіювати вибраних",
"copySelected": "Скопіювати вибране",
"copySource": "Джерело",
"copyEmailPreview": опередній перегляд підсумкових email",
"copySelectSourceFirst": "Спочатку виберіть джерело.",
"copyEmailPreview": ерегляд email, що буде створено",
"copySelectSourceFirst": "Спочатку виберіть вхідний-джерело.",
"copyResult": "Результат копіювання",
"copyResultSuccess": "Успішно скопійовано",
"copyResultNone": "Нічого копіювати: жодного клієнта не вибрано або список джерела порожній",
"copyResultErrors": "Помилки під час копіювання",
"copyResultSuccess": "Скопійовано успішно",
"copyResultNone": "Нічого копіювати: не вибрано клієнтів або джерело порожнє",
"copyResultErrors": "Помилки копіювання",
"copyFlowLabel": "Flow для нових клієнтів (VLESS)",
"copyFlowHint": "Застосується до всіх скопійованих клієнтів. Залиште порожнім, щоб не задавати.",
"selectAll": "Вибрати всіх",
"clearAll": "Зняти все",
"copyFlowHint": "Застосовується до всіх скопійованих клієнтів. Залишіть порожнім, щоб пропустити.",
"selectAll": "Вибрати все",
"clearAll": "Очистити все",
"method": "Метод",
"first": "Перший",
"last": "Останній",
"ipLog": "Журнал IP",
"prefix": "Префікс",
"postfix": "Постфікс",
"delayedStart": "Початок використання",
"delayedStart": "Запуск після першого використання",
"expireDays": "Тривалість",
"days": "Дні(в)",
"renew": "Автоматичне оновлення",
"renewDesc": "Автоматичне поновлення після закінчення терміну дії. (0 = вимкнено)(одиниця: день)"
"days": "Дні",
"renew": "Авто-продовження",
"renewDesc": "Автоматичне продовження після закінчення. (0 = вимкнено) (одиниця: день)",
"title": "Клієнти",
"actions": "Дії",
"totalGB": "Усього надіслано/отримано (ГБ)",
"expiryTime": "Термін дії",
"addClients": "Додати клієнтів",
"limitIp": "Ліміт IP",
"password": "Пароль",
"subId": "ID підписки",
"online": "У мережі",
"email": "Email",
"comment": "Коментар",
"traffic": "Трафік",
"offline": "Не в мережі",
"addTitle": "Додати клієнта",
"qrCode": "QR-код",
"moreInformation": "Докладніше",
"delete": "Видалити",
"reset": "Скинути трафік",
"editTitle": "Редагувати клієнта",
"client": "Клієнт",
"enabled": "Увімкнено",
"remaining": "Залишок",
"duration": "Тривалість",
"attachedInbounds": "Прив'язані вхідні",
"selectInbound": "Виберіть один або кілька вхідних",
"noSubId": "У цього клієнта немає subId, посилання для спільного доступу відсутнє.",
"noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.",
"link": "Посилання",
"resetNotPossible": "Спочатку прив'яжіть цього клієнта до вхідного.",
"general": "Загальне",
"resetAllTraffics": "Скинути трафік усіх клієнтів",
"resetAllTrafficsTitle": "Скинути трафік усіх клієнтів?",
"resetAllTrafficsContent": "Лічильники відправлення/отримання кожного клієнта обнулюються. Квоти й термін дії не змінюються. Цю дію неможливо скасувати.",
"empty": "Клієнтів ще немає — додайте першого, щоб почати.",
"deleteConfirmTitle": "Видалити клієнта {email}?",
"deleteConfirmContent": "Клієнт буде вилучений з усіх прив'язаних вхідних, його запис трафіку буде знищено. Цю дію неможливо скасувати.",
"deleteSelected": "Видалити ({count})",
"bulkDeleteConfirmTitle": "Видалити {count} клієнтів?",
"bulkDeleteConfirmContent": "Кожен вибраний клієнт вилучається з усіх прив'язаних вхідних, його запис трафіку знищується. Цю дію неможливо скасувати.",
"delDepleted": "Видалити вичерпаних",
"delDepletedConfirmTitle": "Видалити вичерпаних клієнтів?",
"delDepletedConfirmContent": "Видаляються всі клієнти, у яких вичерпана квота трафіку або сплив термін. Цю дію неможливо скасувати.",
"auth": "Auth",
"hysteriaAuth": "Auth для Hysteria",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
"telegramId": "ID користувача Telegram",
"telegramIdPlaceholder": "Числовий ID користувача Telegram (0 = немає)",
"created": "Створено",
"updated": "Оновлено",
"ipLimit": "Ліміт IP",
"toasts": {
"deleted": "Клієнта видалено",
"trafficReset": "Трафік скинуто",
"allTrafficsReset": "Трафік усіх клієнтів скинуто",
"bulkDeleted": "Видалено клієнтів: {count}",
"bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}",
"bulkCreated": "Створено клієнтів: {count}",
"bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
"delDepleted": "Видалено вичерпаних клієнтів: {count}"
}
},
"nodes": {
"title": "Вузли",
@@ -428,6 +536,7 @@
"latency": "Затримка",
"lastHeartbeat": "Останній пінг",
"xrayVersion": "Версія Xray",
"panelVersion": "Версія панелі",
"actions": "Дії",
"probe": "Перевірити зараз",
"testConnection": "Перевірити з'єднання",
@@ -777,9 +886,6 @@
"unexpectIPs": "Неочікувані IP",
"useSystemHosts": "Використовувати системні Hosts",
"useSystemHostsDesc": "Використовувати файл hosts з встановленої системи",
"usePreset": "Використати шаблон",
"dnsPresetTitle": "Шаблони DNS",
"dnsPresetFamily": "Сімейний",
"serveStale": "Видавати застарілі",
"serveStaleDesc": "Повертати застарілі результати з кешу під час фонового оновлення",
"serveExpiredTTL": "TTL застарілих",
@@ -792,6 +898,9 @@
"hostsEmpty": "Host не визначено",
"hostsDomain": "Домен (напр. domain:example.com)",
"hostsValues": "IP або домен — введіть і натисніть Enter",
"usePreset": "Використати шаблон",
"dnsPresetTitle": "Шаблони DNS",
"dnsPresetFamily": "Сімейний",
"clearAll": "Видалити всі",
"clearAllTitle": "Видалити всі DNS-сервери?",
"clearAllConfirm": "Усі DNS-сервери буде видалено зі списку. Дію не можна скасувати."
@@ -980,4 +1089,4 @@
"chooseInbound": "Виберіть Вхідний"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "Tìm kiếm",
"filter": "Bộ lọc",
"loading": "Đang tải",
"refresh": "Làm mới",
"clear": "Xóa",
"second": "Giây",
"minute": "Phút",
"hour": "Giờ",
@@ -94,11 +96,12 @@
"ultraDark": "Siêu tối",
"dashboard": "Trạng thái hệ thống",
"inbounds": "Đầu vào khách hàng",
"clients": "Khách hàng",
"nodes": "Nút",
"settings": "Cài đặt bảng điều khiển",
"logout": "Đăng xuất",
"xray": "Cài đặt Xray",
"apiDocs": "Tài liệu API",
"logout": "Đăng xuất",
"link": "Quản lý"
},
"pages": {
@@ -127,9 +130,9 @@
"stopXray": "Dừng lại",
"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.",
"xrayUpdates": "Cập nhật Xray",
"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.",
"currentPanelVersion": "Phiên bản panel hiện tại",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "Thao tác này sẽ cập nhật tất cả các tập tin.",
"geofilesUpdateAll": "Cập nhật tất cả",
"geofileUpdatePopover": "Geofile đã được cập nhật thành công",
"dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.",
"logs": "Nhật ký",
"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ị.",
"importDatabase": "Khôi phục",
"importDatabaseDesc": "Nhấp để chọn và tải lên tệp .db từ thiết bị của bạn để khôi phục cơ sở dữ liệu từ bản sao lưu.",
"importDatabaseSuccess": "Đã nhập cơ sở dữ liệu thành công",
"importDatabaseError": "Lỗi xảy ra khi nhập cơ sở dữ liệu",
"readDatabaseError": "Lỗi xảy ra khi đọc cơ sở dữ liệu",
"getDatabaseError": "Lỗi xảy ra khi truy xuất cơ sở dữ liệu",
"getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình",
"customGeoTitle": "GeoSite / GeoIP tùy chỉnh",
"customGeoAdd": "Thêm",
"customGeoType": "Loại",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "Không tìm thấy nguồn geo tùy chỉnh",
"customGeoErrDownload": "Tải xuống thất bại",
"customGeoErrUpdateAllIncomplete": "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được",
"customGeoEmpty": "Chưa có nguồn geo tùy chỉnh nào — nhấp Thêm để tạo"
"customGeoEmpty": "Chưa có nguồn geo tùy chỉnh nào — nhấp Thêm để tạo",
"dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.",
"logs": "Nhật ký",
"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ị.",
"importDatabase": "Khôi phục",
"importDatabaseDesc": "Nhấp để chọn và tải lên tệp .db từ thiết bị của bạn để khôi phục cơ sở dữ liệu từ bản sao lưu.",
"importDatabaseSuccess": "Đã nhập cơ sở dữ liệu thành công",
"importDatabaseError": "Lỗi xảy ra khi nhập cơ sở dữ liệu",
"readDatabaseError": "Lỗi xảy ra khi đọc cơ sở dữ liệu",
"getDatabaseError": "Lỗi xảy ra khi truy xuất cơ sở dữ liệu",
"getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình"
},
"inbounds": {
"allTimeTraffic": "Tổng Lưu Lượng",
"allTimeTrafficUsage": "Tổng mức sử dụng mọi lúc",
"title": "Điểm vào (Inbounds)",
"totalDownUp": "Tổng tải lên/tải xuống",
"totalUsage": "Tổng sử dụng",
@@ -249,6 +250,23 @@
"node": "Nút",
"deployTo": "Triển khai tới",
"localPanel": "Panel cục bộ",
"fallbacks": {
"title": "Fallback",
"help": "Khi một kết nối trên inbound này không khớp với client nào, nó sẽ được chuyển hướng tới inbound khác. Chọn một child bên dưới và các trường định tuyến (SNI / ALPN / Path / xver) sẽ được tự động điền từ transport của child — hầu hết cấu hình không cần chỉnh thêm. Mỗi child 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ỳ",
"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"
},
"protocol": "Giao thức",
"port": "Cổng",
"portMap": "Cổng tạo",
@@ -308,6 +326,32 @@
"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",
"streamTab": "Stream",
"securityTab": "Bảo mật",
"sniffingTab": "Sniffing",
"sniffingMetadataOnly": "Chỉ siêu dữ liệu",
"sniffingRouteOnly": "Chỉ định tuyến",
"sniffingIpsExcluded": "IP bị loại trừ",
"sniffingDomainsExcluded": "Tên miền bị loại trừ",
"decryption": "Giải mã",
"encryption": "Mã hóa",
"vlessAuthX25519": "Xác thực X25519",
"vlessAuthMlkem768": "Xác thực ML-KEM-768",
"vlessAuthCustom": "Tùy chỉnh",
"vlessAuthSelected": "Đã chọn: {auth}",
"advanced": {
"title": "Các phần JSON của inbound",
"subtitle": "JSON inbound đầy đủ và các trình chỉnh sửa riêng cho settings, sniffing và streamSettings.",
"all": "Tất cả",
"allHelp": "Đối tượng inbound đầy đủ với mọi trường trong một trình chỉnh sửa.",
"settings": "Cài đặt",
"settingsHelp": "Bao đóng khối settings của Xray:",
"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"
},
"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",
"info": "Thông tin",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"add": "Thêm người dùng",
"edit": "Chỉnh sửa người dùng",
"submitAdd": "Thêm",
"clients": {
"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 người dùng",
"clientCount": "Số lượng khách hàng",
"bulk": "Thêm hàng loạt",
"copyFromInbound": "Sao chép người dùng từ Inbound",
"copyToInbound": "Sao chép người dùng đến",
"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": "Vui lòng chọn Inbound nguồn trước.",
"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 người dùng hoặc nguồn trố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 người dùng mới (VLESS)",
"copyFlowHint": "Áp dụng cho tất cả người dùng được sao chép. Để trống để bỏ qua.",
"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": "Bỏ chọn tất cả",
"method": "Phương pháp",
"first": "Đầu tiên",
"last": "Cuối cùng",
"clearAll": "Xóa tất cả",
"method": "Phương thức",
"first": "Đầu",
"last": "Cuối",
"ipLog": "Nhật ký IP",
"prefix": "Tiền tố",
"postfix": "Hậu tố",
"delayedStart": "Bắt đầu ở Lần Đầu",
"expireDays": "Khoảng thời gian",
"days": "ngày",
"delayedStart": "Bắt đầu sau lần dùng đầu",
"expireDays": "Thời hạn",
"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)"
"renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt) (đơn vị: ngày)",
"title": "Khách hàng",
"actions": "Hành động",
"totalGB": "Tổng gửi/nhận (GB)",
"expiryTime": "Hết hạn",
"addClients": "Thêm khách hàng",
"limitIp": "Giới hạn IP",
"password": "Mật khẩu",
"subId": "ID đăng ký",
"online": "Trực tuyến",
"email": "Email",
"comment": "Ghi chú",
"traffic": "Lưu lượng",
"offline": "Ngoại tuyến",
"addTitle": "Thêm khách hàng",
"qrCode": "Mã QR",
"moreInformation": "Thông tin thêm",
"delete": "Xóa",
"reset": "Đặt lại lưu lượng",
"editTitle": "Chỉnh sửa khách hàng",
"client": "Khách hàng",
"enabled": "Đã bật",
"remaining": "Còn lại",
"duration": "Thời hạn",
"attachedInbounds": "Inbound đã gắn",
"selectInbound": "Chọn một hoặc nhiều inbound",
"noSubId": "Khách hàng này không có subId, không có liên kết chia sẻ.",
"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.",
"empty": "Chưa có khách hàng nào — thêm một để bắt đầu.",
"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})",
"bulkDeleteConfirmTitle": "Xóa {count} khách hàng?",
"bulkDeleteConfirmContent": "Mỗi khách hàng được chọn sẽ bị gỡ khỏi tất cả inbound đã gắn và bản ghi lưu lượng cũng bị xóa. Không thể hoàn tác.",
"delDepleted": "Xóa hết hạn mức",
"delDepletedConfirmTitle": "Xóa khách hàng hết hạn mức?",
"delDepletedConfirmContent": "Gỡ tất cả khách hàng đã dùng hết hạn mức lưu lượng hoặc đã quá hạn. Không thể hoàn tác.",
"auth": "Auth",
"hysteriaAuth": "Auth Hysteria",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"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",
"trafficReset": "Đã đặt lại lưu lượng",
"allTrafficsReset": "Đã đặt lại lưu lượng của tất cả khách hàng",
"bulkDeleted": "Đã xóa {count} khách hàng",
"bulkDeletedMixed": "Đã xóa {ok}, thất bại {failed}",
"bulkCreated": "Đã tạo {count} khách hàng",
"bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
"delDepleted": "Đã xóa {count} khách hàng hết hạn mức"
}
},
"nodes": {
"title": "Nút",
@@ -428,6 +536,7 @@
"latency": "Độ trễ",
"lastHeartbeat": "Heartbeat gần nhất",
"xrayVersion": "Phiên bản Xray",
"panelVersion": "Phiên bản panel",
"actions": "Hành động",
"probe": "Kiểm tra ngay",
"testConnection": "Kiểm tra kết nối",
@@ -777,9 +886,6 @@
"unexpectIPs": "IP không mong muốn",
"useSystemHosts": "Sử dụng Hosts hệ thống",
"useSystemHostsDesc": "Sử dụng file hosts từ hệ thống đã cài đặt",
"usePreset": "Dùng mẫu",
"dnsPresetTitle": "Mẫu DNS",
"dnsPresetFamily": "Gia đình",
"serveStale": "Phục vụ kết quả hết hạn",
"serveStaleDesc": "Trả về kết quả cache đã hết hạn trong khi làm mới ở chế độ nền",
"serveExpiredTTL": "TTL hết hạn",
@@ -792,6 +898,9 @@
"hostsEmpty": "Chưa có Host nào",
"hostsDomain": "Tên miền (vd. domain:example.com)",
"hostsValues": "IP hoặc tên miền — nhập và nhấn Enter",
"usePreset": "Dùng mẫu",
"dnsPresetTitle": "Mẫu DNS",
"dnsPresetFamily": "Gia đình",
"clearAll": "Xóa tất cả",
"clearAllTitle": "Xóa tất cả máy chủ DNS?",
"clearAllConfirm": "Thao tác này sẽ xóa toàn bộ máy chủ DNS khỏi danh sách. Không thể hoàn tác."
@@ -980,4 +1089,4 @@
"chooseInbound": "Chọn một Inbound"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "搜索",
"filter": "筛选",
"loading": "加载中...",
"refresh": "刷新",
"clear": "清除",
"second": "秒",
"minute": "分钟",
"hour": "小时",
@@ -94,6 +96,7 @@
"ultraDark": "超暗色",
"dashboard": "系统状态",
"inbounds": "入站列表",
"clients": "客户端",
"nodes": "节点",
"settings": "面板设置",
"xray": "Xray 设置",
@@ -127,9 +130,9 @@
"stopXray": "停止",
"restartXray": "重启",
"xraySwitch": "版本",
"xrayUpdates": "Xray 更新",
"xraySwitchClick": "选择你要切换到的版本",
"xraySwitchClickDesk": "请谨慎选择,因为较旧版本可能与当前配置不兼容",
"xrayUpdates": "Xray 更新",
"updatePanel": "更新面板",
"panelUpdateDesc": "这将把 3X-UI 更新到最新版本并重启面板服务。",
"currentPanelVersion": "当前面板版本",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "这将更新所有文件。",
"geofilesUpdateAll": "全部更新",
"geofileUpdatePopover": "地理文件更新成功",
"dontRefresh": "安装中,请勿刷新此页面",
"logs": "日志",
"config": "配置",
"backup": "备份",
"backupTitle": "备份和恢复",
"exportDatabase": "备份",
"exportDatabaseDesc": "点击下载包含当前数据库备份的 .db 文件到您的设备。",
"importDatabase": "恢复",
"importDatabaseDesc": "点击选择并上传设备中的 .db 文件以从备份恢复数据库。",
"importDatabaseSuccess": "数据库导入成功",
"importDatabaseError": "导入数据库时出错",
"readDatabaseError": "读取数据库时出错",
"getDatabaseError": "检索数据库时出错",
"getConfigError": "检索配置文件时出错",
"customGeoTitle": "自定义 GeoSite / GeoIP",
"customGeoAdd": "添加",
"customGeoType": "类型",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "未找到自定义 geo 源",
"customGeoErrDownload": "下载失败",
"customGeoErrUpdateAllIncomplete": "有一个或多个自定义 geo 源更新失败",
"customGeoEmpty": "暂无自定义 geo 源 — 点击「添加」以创建"
"customGeoEmpty": "暂无自定义 geo 源 — 点击「添加」以创建",
"dontRefresh": "安装中,请勿刷新此页面",
"logs": "日志",
"config": "配置",
"backup": "备份",
"backupTitle": "备份和恢复",
"exportDatabase": "备份",
"exportDatabaseDesc": "点击下载包含当前数据库备份的 .db 文件到您的设备。",
"importDatabase": "恢复",
"importDatabaseDesc": "点击选择并上传设备中的 .db 文件以从备份恢复数据库。",
"importDatabaseSuccess": "数据库导入成功",
"importDatabaseError": "导入数据库时出错",
"readDatabaseError": "读取数据库时出错",
"getDatabaseError": "检索数据库时出错",
"getConfigError": "检索配置文件时出错"
},
"inbounds": {
"allTimeTraffic": "累计总流量",
"allTimeTrafficUsage": "所有时间总使用量",
"title": "入站列表",
"totalDownUp": "总上传 / 下载",
"totalUsage": "总用量",
@@ -249,6 +250,23 @@
"node": "节点",
"deployTo": "部署到",
"localPanel": "本地面板",
"fallbacks": {
"title": "回落",
"help": "当此入站的连接未匹配任何客户端时将其路由到另一个入站。在下方选择一个子入站路由字段SNI / ALPN / Path / xver会从子入站的传输方式中自动填充——大多数场景无需再调整。每个子入站应监听 127.0.0.1security=none。",
"empty": "暂无回落",
"add": "添加回落",
"pickInbound": "选择一个入站",
"matchAny": "任意",
"rederive": "从子入站重新填充",
"rederived": "已从子入站重新填充",
"editAdvanced": "编辑路由字段",
"hideAdvanced": "隐藏高级",
"quickAddAll": "一键添加所有可用入站",
"quickAdded": "已添加 {n} 条回落",
"quickAddedNone": "没有可添加的新入站",
"routesWhen": "当满足条件时路由",
"defaultCatchAll": "默认 — 兜底匹配其他所有"
},
"protocol": "协议",
"port": "端口",
"portMap": "端口映射",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "IP 历史日志(要启用被禁用的入站流量,请清除日志)",
"IPLimitlogclear": "清除日志",
"setDefaultCert": "从面板设置证书",
"streamTab": "流",
"securityTab": "安全",
"sniffingTab": "嗅探",
"sniffingMetadataOnly": "仅元数据",
"sniffingRouteOnly": "仅路由",
"sniffingIpsExcluded": "排除的 IP",
"sniffingDomainsExcluded": "排除的域名",
"decryption": "解密",
"encryption": "加密",
"vlessAuthX25519": "X25519 认证",
"vlessAuthMlkem768": "ML-KEM-768 认证",
"vlessAuthCustom": "自定义",
"vlessAuthSelected": "已选择:{auth}",
"advanced": {
"title": "入站 JSON 部分",
"subtitle": "完整入站 JSON 以及针对 settings、sniffing 和 streamSettings 的专用编辑器。",
"all": "全部",
"allHelp": "在单个编辑器中编辑包含所有字段的完整入站对象。",
"settings": "设置",
"settingsHelp": "Xray settings 块包装:",
"sniffing": "嗅探",
"sniffingHelp": "Xray sniffing 块包装:",
"stream": "流",
"streamHelp": "Xray stream 块包装:",
"jsonErrorPrefix": "高级 JSON"
},
"telegramDesc": "请提供Telegram聊天ID。在机器人中使用'/id'命令)或({'@'}userinfobot",
"subscriptionDesc": "要找到你的订阅 URL请导航到“详细信息”。此外你可以为多个客户端使用相同的名称。",
"info": "信息",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"clients": {
"add": "添加客户端",
"edit": "编辑客户端",
"submitAdd": "添加客户端",
"submitEdit": "保存改",
"submitEdit": "保存改",
"clientCount": "客户端数量",
"bulk": "批量创建",
"bulk": "批量添加",
"copyFromInbound": "从入站复制客户端",
"copyToInbound": "复制客户端到",
"copySelected": "复制所选",
"copySource": "来源",
"copyEmailPreview": "最终邮箱预览",
"copySelectSourceFirst": "请先选择来源入站。",
"copyEmailPreview": "生成的邮箱预览",
"copySelectSourceFirst": "请先选择一个来源入站。",
"copyResult": "复制结果",
"copyResultSuccess": "复制成功",
"copyResultNone": "没有可复制的内容:未选客户端或来源为空",
"copyResultNone": "没有内容可复制:未选客户端或来源为空",
"copyResultErrors": "复制错误",
"copyFlowLabel": "新客户端的 Flow (VLESS)",
"copyFlowHint": "应用于所有复制的客户端。留空则跳过。",
"copyFlowHint": "应用于所有复制的客户端。留空则跳过。",
"selectAll": "全选",
"clearAll": "全不选",
"method": "方",
"first": "置顶",
"last": "置底",
"clearAll": "全部清除",
"method": "方",
"first": "首个",
"last": "末位",
"ipLog": "IP 日志",
"prefix": "前缀",
"postfix": "后缀",
"delayedStart": "首次使用后开始",
"expireDays": "期间",
"expireDays": "时长",
"days": "天",
"renew": "自动续",
"renewDesc": "到期后自动续。(0 = 禁用)(单位: 天)"
"renew": "自动续",
"renewDesc": "到期后自动续。(0 = 禁用) (单位: 天)",
"title": "客户端",
"actions": "操作",
"totalGB": "总上传/下载 (GB)",
"expiryTime": "过期时间",
"addClients": "添加客户端",
"limitIp": "IP 限制",
"password": "密码",
"subId": "订阅 ID",
"online": "在线",
"email": "邮箱",
"comment": "备注",
"traffic": "流量",
"offline": "离线",
"addTitle": "添加客户端",
"qrCode": "二维码",
"moreInformation": "更多信息",
"delete": "删除",
"reset": "重置流量",
"editTitle": "编辑客户端",
"client": "客户端",
"enabled": "已启用",
"remaining": "剩余",
"duration": "时长",
"attachedInbounds": "关联入站",
"selectInbound": "选择一个或多个入站",
"noSubId": "该客户端没有 subId无法生成共享链接。",
"noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。",
"link": "链接",
"resetNotPossible": "请先将此客户端关联到入站。",
"general": "通用",
"resetAllTraffics": "重置所有客户端流量",
"resetAllTrafficsTitle": "重置所有客户端流量?",
"resetAllTrafficsContent": "所有客户端的上下行计数器将归零。配额与过期时间不受影响。该操作不可撤销。",
"empty": "尚无客户端 — 添加一个开始使用。",
"deleteConfirmTitle": "删除客户端 {email}",
"deleteConfirmContent": "将从所有关联入站中移除该客户端并删除其流量记录。该操作不可撤销。",
"deleteSelected": "删除 ({count})",
"bulkDeleteConfirmTitle": "删除 {count} 个客户端?",
"bulkDeleteConfirmContent": "每个所选客户端都会从关联的入站中被移除,其流量记录也会被删除。该操作不可撤销。",
"delDepleted": "删除已耗尽",
"delDepletedConfirmTitle": "删除已耗尽的客户端?",
"delDepletedConfirmContent": "删除所有流量配额已用尽或已过期的客户端。该操作不可撤销。",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "可选 Reverse tag",
"telegramId": "Telegram 用户 ID",
"telegramIdPlaceholder": "数字形式的 Telegram 用户 ID (0 = 无)",
"created": "创建时间",
"updated": "更新时间",
"ipLimit": "IP 限制",
"toasts": {
"deleted": "客户端已删除",
"trafficReset": "流量已重置",
"allTrafficsReset": "所有客户端流量已重置",
"bulkDeleted": "已删除 {count} 个客户端",
"bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个",
"bulkCreated": "已创建 {count} 个客户端",
"bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
"delDepleted": "已删除 {count} 个已耗尽的客户端"
}
},
"nodes": {
"title": "节点",
@@ -428,6 +536,7 @@
"latency": "延迟",
"lastHeartbeat": "上次心跳",
"xrayVersion": "Xray 版本",
"panelVersion": "面板版本",
"actions": "操作",
"probe": "立即探测",
"testConnection": "测试连接",
@@ -777,9 +886,6 @@
"unexpectIPs": "意外IP",
"useSystemHosts": "使用系统Hosts",
"useSystemHostsDesc": "使用已安装系统的hosts文件",
"usePreset": "使用模板",
"dnsPresetTitle": "DNS模板",
"dnsPresetFamily": "家庭",
"serveStale": "提供过期结果",
"serveStaleDesc": "在后台刷新时返回过期的缓存结果",
"serveExpiredTTL": "过期TTL",
@@ -792,6 +898,9 @@
"hostsEmpty": "未定义任何 Host",
"hostsDomain": "域名 (例如 domain:example.com)",
"hostsValues": "IP 或域名 — 输入后按 Enter",
"usePreset": "使用模板",
"dnsPresetTitle": "DNS模板",
"dnsPresetFamily": "家庭",
"clearAll": "删除全部",
"clearAllTitle": "删除所有 DNS 服务器?",
"clearAllConfirm": "此操作将从列表中删除所有 DNS 服务器,且无法撤销。"
@@ -980,4 +1089,4 @@
"chooseInbound": "选择一个入站"
}
}
}
}

View File

@@ -18,6 +18,8 @@
"search": "搜尋",
"filter": "篩選",
"loading": "載入中...",
"refresh": "重新整理",
"clear": "清除",
"second": "秒",
"minute": "分鐘",
"hour": "小時",
@@ -94,6 +96,7 @@
"ultraDark": "超深色",
"dashboard": "系統狀態",
"inbounds": "入站列表",
"clients": "客戶端",
"nodes": "節點",
"settings": "面板設定",
"xray": "Xray 設定",
@@ -127,9 +130,9 @@
"stopXray": "停止",
"restartXray": "重啟",
"xraySwitch": "版本",
"xrayUpdates": "Xray 更新",
"xraySwitchClick": "選擇你要切換到的版本",
"xraySwitchClickDesk": "請謹慎選擇,因為較舊版本可能與當前配置不相容",
"xrayUpdates": "Xray 更新",
"updatePanel": "更新面板",
"panelUpdateDesc": "這將把 3X-UI 更新到最新版本並重新啟動面板服務。",
"currentPanelVersion": "目前面板版本",
@@ -179,20 +182,6 @@
"geofilesUpdateDialogDesc": "這將更新所有文件。",
"geofilesUpdateAll": "全部更新",
"geofileUpdatePopover": "地理檔案更新成功",
"dontRefresh": "安裝中,請勿重新整理此頁面",
"logs": "日誌",
"config": "配置",
"backup": "備份和恢復",
"backupTitle": "備份和恢復",
"exportDatabase": "備份",
"exportDatabaseDesc": "點擊下載包含當前資料庫備份的 .db 文件到您的設備。",
"importDatabase": "恢復",
"importDatabaseDesc": "點擊選擇並上傳設備中的 .db 文件以從備份恢復資料庫。",
"importDatabaseSuccess": "資料庫匯入成功",
"importDatabaseError": "匯入資料庫時發生錯誤",
"readDatabaseError": "讀取資料庫時發生錯誤",
"getDatabaseError": "檢索資料庫時發生錯誤",
"getConfigError": "檢索設定檔時發生錯誤",
"customGeoTitle": "自訂 GeoSite / GeoIP",
"customGeoAdd": "新增",
"customGeoType": "類型",
@@ -234,11 +223,23 @@
"customGeoErrNotFound": "找不到自訂 geo 來源",
"customGeoErrDownload": "下載失敗",
"customGeoErrUpdateAllIncomplete": "有一個或多個自訂 geo 來源更新失敗",
"customGeoEmpty": "尚無自訂 geo 來源 — 點擊「新增」以建立"
"customGeoEmpty": "尚無自訂 geo 來源 — 點擊「新增」以建立",
"dontRefresh": "安裝中,請勿重新整理此頁面",
"logs": "日誌",
"config": "配置",
"backup": "備份和恢復",
"backupTitle": "備份和恢復",
"exportDatabase": "備份",
"exportDatabaseDesc": "點擊下載包含當前資料庫備份的 .db 文件到您的設備。",
"importDatabase": "恢復",
"importDatabaseDesc": "點擊選擇並上傳設備中的 .db 文件以從備份恢復資料庫。",
"importDatabaseSuccess": "資料庫匯入成功",
"importDatabaseError": "匯入資料庫時發生錯誤",
"readDatabaseError": "讀取資料庫時發生錯誤",
"getDatabaseError": "檢索資料庫時發生錯誤",
"getConfigError": "檢索設定檔時發生錯誤"
},
"inbounds": {
"allTimeTraffic": "累計總流量",
"allTimeTrafficUsage": "所有时间总使用量",
"title": "入站列表",
"totalDownUp": "總上傳 / 下載",
"totalUsage": "總用量",
@@ -249,6 +250,23 @@
"node": "節點",
"deployTo": "部署到",
"localPanel": "本機面板",
"fallbacks": {
"title": "回落",
"help": "當此入站的連線未匹配任何用戶時將其路由到另一個入站。在下方選擇一個子入站路由欄位SNI / ALPN / Path / xver會自動從子入站的傳輸方式填入——大多數情境不需要再調整。每個子入站應監聽 127.0.0.1security=none。",
"empty": "尚未新增回落",
"add": "新增回落",
"pickInbound": "選擇一個入站",
"matchAny": "任何",
"rederive": "從子入站重新填入",
"rederived": "已從子入站重新填入",
"editAdvanced": "編輯路由欄位",
"hideAdvanced": "隱藏進階",
"quickAddAll": "一鍵新增所有符合的入站",
"quickAdded": "已新增 {n} 個回落",
"quickAddedNone": "沒有可新增的新入站",
"routesWhen": "當條件成立時路由",
"defaultCatchAll": "預設 — 兜底匹配其餘"
},
"protocol": "協議",
"port": "埠",
"portMap": "埠映射",
@@ -308,6 +326,32 @@
"IPLimitlogDesc": "IP 歷史日誌(要啟用被禁用的入站流量,請清除日誌)",
"IPLimitlogclear": "清除日誌",
"setDefaultCert": "從面板設定證書",
"streamTab": "串流",
"securityTab": "安全",
"sniffingTab": "嗅探",
"sniffingMetadataOnly": "僅中繼資料",
"sniffingRouteOnly": "僅路由",
"sniffingIpsExcluded": "排除的 IP",
"sniffingDomainsExcluded": "排除的網域",
"decryption": "解密",
"encryption": "加密",
"vlessAuthX25519": "X25519 認證",
"vlessAuthMlkem768": "ML-KEM-768 認證",
"vlessAuthCustom": "自訂",
"vlessAuthSelected": "已選擇:{auth}",
"advanced": {
"title": "入站 JSON 部分",
"subtitle": "完整入站 JSON 以及針對 settings、sniffing 和 streamSettings 的專用編輯器。",
"all": "全部",
"allHelp": "在單一編輯器中編輯包含所有欄位的完整入站物件。",
"settings": "設定",
"settingsHelp": "Xray settings 區塊包裝:",
"sniffing": "嗅探",
"sniffingHelp": "Xray sniffing 區塊包裝:",
"stream": "串流",
"streamHelp": "Xray stream 區塊包裝:",
"jsonErrorPrefix": "進階 JSON"
},
"telegramDesc": "請提供Telegram聊天ID。在機器人中使用'/id'命令)或({'@'}userinfobot",
"subscriptionDesc": "要找到你的訂閱 URL請導航到“詳細資訊”。此外你可以為多個客戶端使用相同的名稱。",
"info": "資訊",
@@ -365,37 +409,101 @@
}
}
},
"client": {
"clients": {
"add": "新增客戶端",
"edit": "編輯客戶端",
"submitAdd": "新增客戶端",
"submitEdit": "儲存修改",
"submitEdit": "儲存變更",
"clientCount": "客戶端數量",
"bulk": "批量建立",
"copyFromInbound": "從入站複製戶端",
"copyToInbound": "複製戶端",
"bulk": "批次新增",
"copyFromInbound": "從入站複製戶端",
"copyToInbound": "複製戶端",
"copySelected": "複製所選",
"copySource": "來源",
"copyEmailPreview": "最終郵箱預覽",
"copySelectSourceFirst": "請先選擇來源入站。",
"copyEmailPreview": "產生的信箱預覽",
"copySelectSourceFirst": "請先選擇一個來源入站。",
"copyResult": "複製結果",
"copyResultSuccess": "複製成功",
"copyResultNone": "沒有可複製的內容:未選擇用戶端或來源為空",
"copyResultNone": "沒有內容可複製:未選取客戶端或來源為空",
"copyResultErrors": "複製錯誤",
"copyFlowLabel": "新戶端的 Flow (VLESS)",
"copyFlowHint": "套用所有複製的戶端。留空則略過。",
"copyFlowLabel": "新戶端的 Flow (VLESS)",
"copyFlowHint": "套用所有複製的戶端。留空則略過。",
"selectAll": "全選",
"clearAll": "全不選",
"clearAll": "全部清除",
"method": "方法",
"first": "置頂",
"last": "置底",
"prefix": "字首",
"postfix": "字尾",
"first": "首個",
"last": "末位",
"ipLog": "IP 日誌",
"prefix": "前綴",
"postfix": "後綴",
"delayedStart": "首次使用後開始",
"expireDays": "期間",
"expireDays": "時長",
"days": "天",
"renew": "自動續",
"renewDesc": "到期後自動續。(0 = 用)(單位: 天)"
"renew": "自動續",
"renewDesc": "到期後自動續。(0 = 用) (單位: 天)",
"title": "客戶端",
"actions": "操作",
"totalGB": "總上傳/下載 (GB)",
"expiryTime": "到期時間",
"addClients": "新增客戶端",
"limitIp": "IP 限制",
"password": "密碼",
"subId": "訂閱 ID",
"online": "上線",
"email": "信箱",
"comment": "備註",
"traffic": "流量",
"offline": "離線",
"addTitle": "新增客戶端",
"qrCode": "QR 碼",
"moreInformation": "更多資訊",
"delete": "刪除",
"reset": "重設流量",
"editTitle": "編輯客戶端",
"client": "客戶端",
"enabled": "已啟用",
"remaining": "剩餘",
"duration": "時長",
"attachedInbounds": "關聯入站",
"selectInbound": "選擇一個或多個入站",
"noSubId": "此客戶端沒有 subId無法產生共享連結。",
"noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。",
"link": "連結",
"resetNotPossible": "請先將此客戶端關聯至入站。",
"general": "通用",
"resetAllTraffics": "重設所有客戶端流量",
"resetAllTrafficsTitle": "重設所有客戶端流量?",
"resetAllTrafficsContent": "所有客戶端的上下行計數器將歸零。配額與到期時間不受影響。此操作無法復原。",
"empty": "尚無客戶端 — 新增一個開始使用。",
"deleteConfirmTitle": "刪除客戶端 {email}",
"deleteConfirmContent": "將從所有關聯入站中移除該客戶端並刪除其流量紀錄。此操作無法復原。",
"deleteSelected": "刪除 ({count})",
"bulkDeleteConfirmTitle": "刪除 {count} 個客戶端?",
"bulkDeleteConfirmContent": "每個所選客戶端都會從關聯的入站中被移除,其流量紀錄也會被刪除。此操作無法復原。",
"delDepleted": "刪除已耗盡",
"delDepletedConfirmTitle": "刪除已耗盡的客戶端?",
"delDepletedConfirmContent": "刪除所有流量配額已用盡或已過期的客戶端。此操作無法復原。",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "選用 Reverse tag",
"telegramId": "Telegram 使用者 ID",
"telegramIdPlaceholder": "數字形式的 Telegram 使用者 ID (0 = 無)",
"created": "建立時間",
"updated": "更新時間",
"ipLimit": "IP 限制",
"toasts": {
"deleted": "客戶端已刪除",
"trafficReset": "流量已重設",
"allTrafficsReset": "所有客戶端流量已重設",
"bulkDeleted": "已刪除 {count} 個客戶端",
"bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個",
"bulkCreated": "已建立 {count} 個客戶端",
"bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
"delDepleted": "已刪除 {count} 個已耗盡的客戶端"
}
},
"nodes": {
"title": "節點",
@@ -428,6 +536,7 @@
"latency": "延遲",
"lastHeartbeat": "上次心跳",
"xrayVersion": "Xray 版本",
"panelVersion": "面板版本",
"actions": "操作",
"probe": "立即探測",
"testConnection": "測試連線",
@@ -777,9 +886,6 @@
"unexpectIPs": "意外IP",
"useSystemHosts": "使用系統Hosts",
"useSystemHostsDesc": "使用已安裝系統的hosts檔案",
"usePreset": "使用範本",
"dnsPresetTitle": "DNS範本",
"dnsPresetFamily": "家庭",
"serveStale": "提供過期結果",
"serveStaleDesc": "在背景重新整理時傳回過期的快取結果",
"serveExpiredTTL": "過期TTL",
@@ -792,6 +898,9 @@
"hostsEmpty": "未定義任何 Host",
"hostsDomain": "網域 (例如 domain:example.com)",
"hostsValues": "IP 或網域 — 輸入後按 Enter",
"usePreset": "使用範本",
"dnsPresetTitle": "DNS範本",
"dnsPresetFamily": "家庭",
"clearAll": "全部刪除",
"clearAllTitle": "刪除所有 DNS 伺服器?",
"clearAllConfirm": "此操作將從清單中刪除所有 DNS 伺服器,無法復原。"
@@ -980,4 +1089,4 @@
"chooseInbound": "選擇一個入站"
}
}
}
}

View File

@@ -21,36 +21,17 @@ const (
MessageTypeNodes MessageType = "nodes"
MessageTypeNotification MessageType = "notification"
MessageTypeXrayState MessageType = "xray_state"
// MessageTypeClientStats carries absolute traffic counters for the clients
// that had activity in the latest collection window. Frontend applies these
// in-place — far smaller than re-broadcasting the full inbound list and
// scales to 10k+ clients without falling back to REST.
MessageTypeClientStats MessageType = "client_stats"
MessageTypeInvalidate MessageType = "invalidate" // Tells frontend to re-fetch via REST (last-resort).
MessageTypeClientStats MessageType = "client_stats"
MessageTypeClients MessageType = "clients"
MessageTypeInvalidate MessageType = "invalidate"
maxMessageSize = 10 * 1024 * 1024 // 10MB
// maxMessageSize caps the WebSocket payload. Beyond this the hub sends a
// lightweight invalidate signal and the frontend re-fetches via REST.
// 10MB lets typical 2k8k-client deployments push directly via WS (low
// latency); larger installs fall back to invalidate.
maxMessageSize = 10 * 1024 * 1024 // 10MB
enqueueTimeout = 100 * time.Millisecond
clientSendQueue = 512 // ~50s of buffering for a momentarily slow browser.
hubBroadcastQueue = 2048 // Headroom for cron-storm + admin-mutation bursts.
hubControlQueue = 64 // Backlog for register/unregister bursts (page reloads, disconnect storms).
// minBroadcastInterval throttles per-type broadcasts so cron storms or
// rapid mutations cannot drown the hub. Bursts within the interval are
// dropped (not coalesced); the next broadcast outside the window delivers
// the latest state. Only message types in throttledMessageTypes are gated —
// heartbeat and one-shot signals (status, notification, xray_state,
// invalidate) bypass this so they are never delayed.
enqueueTimeout = 100 * time.Millisecond
clientSendQueue = 512 // ~50s of buffering for a momentarily slow browser.
hubBroadcastQueue = 2048 // Headroom for cron-storm + admin-mutation bursts.
hubControlQueue = 64 // Backlog for register/unregister bursts (page reloads, disconnect storms).
minBroadcastInterval = 250 * time.Millisecond
// hubRestartAttempts caps panic-recovery restarts. After this many
// consecutive failures we stop trying and log; the panel keeps running
// (frontend falls back to REST polling) and the operator can investigate.
hubRestartAttempts = 3
hubRestartAttempts = 3
)
// NewClient builds a Client ready for hub registration.
@@ -129,7 +110,7 @@ func (h *Hub) shouldThrottle(msgType MessageType) bool {
// panic doesn't permanently kill real-time updates for commercial deployments.
// After the cap, the hub stays down and the frontend falls back to REST polling.
func (h *Hub) Run() {
for attempt := 0; attempt < hubRestartAttempts; attempt++ {
for attempt := range hubRestartAttempts {
stopped := h.runOnce()
if stopped {
return

257
web/websocket/hub_test.go Normal file
View File

@@ -0,0 +1,257 @@
package websocket
import (
"encoding/json"
"os"
"sync"
"testing"
"time"
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/op/go-logging"
)
func TestMain(m *testing.M) {
_ = os.Setenv("XUI_LOG_FOLDER", os.TempDir())
xuilogger.InitLogger(logging.ERROR)
os.Exit(m.Run())
}
func TestNewClient_HasBufferedSendChannel(t *testing.T) {
c := NewClient("client-1")
if c.ID != "client-1" {
t.Fatalf("ID = %q, want client-1", c.ID)
}
if cap(c.Send) != clientSendQueue {
t.Fatalf("Send cap = %d, want %d", cap(c.Send), clientSendQueue)
}
}
func TestHub_NilReceiver_DoesNotPanic(t *testing.T) {
var h *Hub
if h.GetClientCount() != 0 {
t.Fatal("nil hub GetClientCount should return 0")
}
h.Broadcast(MessageTypeStatus, "anything")
h.Register(NewClient("x"))
h.Unregister(NewClient("x"))
h.Stop()
}
func TestHub_BroadcastDropsWhenNoClients(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
h.Broadcast(MessageTypeStatus, "payload")
select {
case <-h.broadcast:
t.Fatal("Broadcast should drop when client count is zero")
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_BroadcastDropsNilPayload(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
c := NewClient("c1")
h.Register(c)
waitClientCount(t, h, 1)
h.Broadcast(MessageTypeStatus, nil)
select {
case <-c.Send:
t.Fatal("nil payload should be dropped, not delivered")
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_BroadcastDeliversToClient(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
c := NewClient("c1")
h.Register(c)
waitClientCount(t, h, 1)
h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"})
select {
case raw := <-c.Send:
var m Message
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("payload is not valid JSON: %v\n%s", err, raw)
}
if m.Type != MessageTypeStatus {
t.Fatalf("Type = %q, want %q", m.Type, MessageTypeStatus)
}
if m.Time == 0 {
t.Fatal("Time should be set to a non-zero unix-millis value")
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for broadcast to reach client")
}
}
func TestHub_UnregisterClosesSendAndDecrementsCount(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
c := NewClient("c1")
h.Register(c)
waitClientCount(t, h, 1)
h.Unregister(c)
waitClientCount(t, h, 0)
select {
case _, ok := <-c.Send:
if ok {
t.Fatal("expected Send channel to be closed after Unregister")
}
case <-time.After(500 * time.Millisecond):
t.Fatal("Send channel was not closed after Unregister")
}
}
func TestHub_StopClosesAllClients(t *testing.T) {
h := NewHub()
go h.Run()
c1 := NewClient("c1")
c2 := NewClient("c2")
h.Register(c1)
h.Register(c2)
waitClientCount(t, h, 2)
h.Stop()
for _, c := range []*Client{c1, c2} {
select {
case _, ok := <-c.Send:
if ok {
t.Fatalf("client %s Send should be closed after Stop", c.ID)
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("client %s Send not closed after Stop", c.ID)
}
}
}
func TestHub_ShouldThrottle(t *testing.T) {
h := NewHub()
defer h.Stop()
if h.shouldThrottle(MessageTypeStatus) {
t.Fatal("non-gated message type should never throttle")
}
if h.shouldThrottle(MessageTypeStatus) {
t.Fatal("non-gated message type should never throttle on second call")
}
if h.shouldThrottle(MessageTypeTraffic) {
t.Fatal("first call for gated type should not throttle")
}
if !h.shouldThrottle(MessageTypeTraffic) {
t.Fatal("immediate second call for gated type should throttle")
}
}
func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
h := NewHub()
defer h.Stop()
if h.shouldThrottle(MessageTypeTraffic) {
t.Fatal("first Traffic call should not throttle")
}
if h.shouldThrottle(MessageTypeInbounds) {
t.Fatal("first Inbounds call should not throttle even after Traffic")
}
}
func TestTrySend_SucceedsWithRoom(t *testing.T) {
c := &Client{ID: "c", Send: make(chan []byte, 1)}
if !trySend(c, []byte("hi")) {
t.Fatal("trySend should succeed when buffer has room")
}
}
func TestTrySend_FailsWhenFull(t *testing.T) {
c := &Client{ID: "c", Send: make(chan []byte, 1)}
c.Send <- []byte("first")
if trySend(c, []byte("second")) {
t.Fatal("trySend should fail when buffer is full")
}
}
func TestTrySend_FailsOnClosedChannel(t *testing.T) {
c := &Client{ID: "c", Send: make(chan []byte, 1)}
close(c.Send)
if trySend(c, []byte("after-close")) {
t.Fatal("trySend should fail (not panic) when channel is closed")
}
}
func TestHub_FanoutEvictsSlowClient(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
slow := &Client{ID: "slow", Send: make(chan []byte, 1)}
slow.Send <- []byte("buffer-already-full")
h.Register(slow)
waitClientCount(t, h, 1)
h.Broadcast(MessageTypeStatus, "payload")
waitClientCount(t, h, 0)
select {
case _, ok := <-slow.Send:
if ok {
_, ok = <-slow.Send
if ok {
t.Fatal("slow client Send should eventually be closed by fanout eviction")
}
}
case <-time.After(500 * time.Millisecond):
t.Fatal("slow client Send channel was not closed")
}
}
func TestHub_ConcurrentRegisterUnregister(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
const n = 50
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
c := NewClient("c")
h.Register(c)
h.Unregister(c)
}(i)
}
wg.Wait()
waitClientCount(t, h, 0)
}
func waitClientCount(t *testing.T, h *Hub, want int) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if h.GetClientCount() == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("client count never reached %d (last seen %d)", want, h.GetClientCount())
}