mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
feat: add inbound traffic multiplier
This commit is contained in:
parent
20edaee8ed
commit
8c74a4eff5
22 changed files with 359 additions and 46 deletions
|
|
@ -55,6 +55,7 @@ type Inbound struct {
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
|
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
|
||||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||||
|
TrafficMultiplier float64 `json:"trafficMultiplier" form:"trafficMultiplier" gorm:"default:1"` // Multiplier for inbound/client traffic accounting
|
||||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||||
|
|
||||||
// Xray configuration fields
|
// Xray configuration fields
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
|
"trafficMultiplier": 1,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
|
|
@ -500,6 +501,7 @@
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"total": 0,
|
"total": 0,
|
||||||
|
"trafficMultiplier": 1,
|
||||||
"settings": {
|
"settings": {
|
||||||
"clients": [
|
"clients": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export type DBInboundInit = Partial<{
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
trafficReset: string;
|
trafficReset: string;
|
||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
|
trafficMultiplier: number;
|
||||||
listen: string;
|
listen: string;
|
||||||
port: number;
|
port: number;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
|
|
@ -73,6 +74,7 @@ export class DBInbound {
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
trafficReset: string;
|
trafficReset: string;
|
||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
|
trafficMultiplier: number;
|
||||||
|
|
||||||
listen: string;
|
listen: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
|
@ -99,6 +101,7 @@ export class DBInbound {
|
||||||
this.expiryTime = 0;
|
this.expiryTime = 0;
|
||||||
this.trafficReset = "never";
|
this.trafficReset = "never";
|
||||||
this.lastTrafficResetTime = 0;
|
this.lastTrafficResetTime = 0;
|
||||||
|
this.trafficMultiplier = 1;
|
||||||
|
|
||||||
this.listen = "";
|
this.listen = "";
|
||||||
this.port = 0;
|
this.port = 0;
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const sections: readonly Section[] = [
|
||||||
path: '/panel/api/inbounds/list',
|
path: '/panel/api/inbounds/list',
|
||||||
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
|
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}',
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "trafficMultiplier": 1,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -137,7 +137,7 @@ export const sections: readonly Section[] = [
|
||||||
path: '/panel/api/inbounds/add',
|
path: '/panel/api/inbounds/add',
|
||||||
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
|
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
|
||||||
body:
|
body:
|
||||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}',
|
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "trafficMultiplier": 1,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}',
|
||||||
errorResponse:
|
errorResponse:
|
||||||
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -940,6 +940,7 @@ export default function InboundFormModal({
|
||||||
expiryTime: form.expiryTime,
|
expiryTime: form.expiryTime,
|
||||||
trafficReset: form.trafficReset,
|
trafficReset: form.trafficReset,
|
||||||
lastTrafficResetTime: form.lastTrafficResetTime || 0,
|
lastTrafficResetTime: form.lastTrafficResetTime || 0,
|
||||||
|
trafficMultiplier: form.trafficMultiplier || 1,
|
||||||
listen: ib.listen,
|
listen: ib.listen,
|
||||||
port: ib.port,
|
port: ib.port,
|
||||||
protocol: ib.protocol,
|
protocol: ib.protocol,
|
||||||
|
|
@ -1052,6 +1053,18 @@ export default function InboundFormModal({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.inbounds.trafficMultiplier')}>
|
||||||
|
<InputNumber
|
||||||
|
value={form.trafficMultiplier || 1}
|
||||||
|
min={0.01}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = Number(v);
|
||||||
|
form.trafficMultiplier = next > 0 ? next : 1;
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
<Form.Item label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||||
<Select value={form.trafficReset} onChange={(v) => { form.trafficReset = v; refresh(); }}>
|
<Select value={form.trafficReset} onChange={(v) => { form.trafficReset = v; refresh(); }}>
|
||||||
{TRAFFIC_RESETS.map((r) => (
|
{TRAFFIC_RESETS.map((r) => (
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ export default function InboundsPage() {
|
||||||
remark: `${dbInbound.remark} (clone)`,
|
remark: `${dbInbound.remark} (clone)`,
|
||||||
enable: false,
|
enable: false,
|
||||||
expiryTime: 0,
|
expiryTime: 0,
|
||||||
|
trafficMultiplier: dbInbound.trafficMultiplier || 1,
|
||||||
listen: '',
|
listen: '',
|
||||||
port: RandomUtil.randomInteger(10000, 60000),
|
port: RandomUtil.randomInteger(10000, 60000),
|
||||||
protocol: baseInbound.protocol,
|
protocol: baseInbound.protocol,
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,7 @@ func wireInbound(ib *model.Inbound) url.Values {
|
||||||
v.Set("remark", ib.Remark)
|
v.Set("remark", ib.Remark)
|
||||||
v.Set("enable", strconv.FormatBool(ib.Enable))
|
v.Set("enable", strconv.FormatBool(ib.Enable))
|
||||||
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
|
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
|
||||||
|
v.Set("trafficMultiplier", strconv.FormatFloat(ib.TrafficMultiplier, 'f', -1, 64))
|
||||||
v.Set("listen", ib.Listen)
|
v.Set("listen", ib.Listen)
|
||||||
v.Set("port", strconv.Itoa(ib.Port))
|
v.Set("port", strconv.Itoa(ib.Port))
|
||||||
v.Set("protocol", string(ib.Protocol))
|
v.Set("protocol", string(ib.Protocol))
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -29,6 +30,69 @@ type InboundService struct {
|
||||||
fallbackService FallbackService
|
fallbackService FallbackService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeTrafficMultiplier(multiplier float64) float64 {
|
||||||
|
if multiplier <= 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaleTraffic(value int64, multiplier float64) int64 {
|
||||||
|
if value == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
scaled := float64(value) * normalizeTrafficMultiplier(multiplier)
|
||||||
|
if scaled >= float64(math.MaxInt64) {
|
||||||
|
return math.MaxInt64
|
||||||
|
}
|
||||||
|
if scaled <= float64(math.MinInt64) {
|
||||||
|
return math.MinInt64
|
||||||
|
}
|
||||||
|
return int64(math.Round(scaled))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addScaledTraffic(current int64, value int64, multiplier float64) int64 {
|
||||||
|
delta := scaleTraffic(value, multiplier)
|
||||||
|
if delta > 0 && current > math.MaxInt64-delta {
|
||||||
|
return math.MaxInt64
|
||||||
|
}
|
||||||
|
if delta == math.MinInt64 && current < 0 {
|
||||||
|
return math.MinInt64
|
||||||
|
}
|
||||||
|
if delta < 0 && current < math.MinInt64-delta {
|
||||||
|
return math.MinInt64
|
||||||
|
}
|
||||||
|
return current + delta
|
||||||
|
}
|
||||||
|
|
||||||
|
func saturatedTrafficAddExpr(column string, delta int64) clause.Expr {
|
||||||
|
if delta > 0 {
|
||||||
|
return gorm.Expr(
|
||||||
|
"CASE WHEN "+column+" > ? THEN ? ELSE "+column+" + ? END",
|
||||||
|
math.MaxInt64-delta,
|
||||||
|
int64(math.MaxInt64),
|
||||||
|
delta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if delta < 0 {
|
||||||
|
if delta == math.MinInt64 {
|
||||||
|
return gorm.Expr(
|
||||||
|
"CASE WHEN "+column+" < ? THEN ? ELSE "+column+" + ? END",
|
||||||
|
int64(0),
|
||||||
|
int64(math.MinInt64),
|
||||||
|
delta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return gorm.Expr(
|
||||||
|
"CASE WHEN "+column+" < ? THEN ? ELSE "+column+" + ? END",
|
||||||
|
math.MinInt64-delta,
|
||||||
|
int64(math.MinInt64),
|
||||||
|
delta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return gorm.Expr(column+" + ?", delta)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
|
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
|
||||||
mgr := runtime.GetManager()
|
mgr := runtime.GetManager()
|
||||||
if mgr == nil {
|
if mgr == nil {
|
||||||
|
|
@ -467,6 +531,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
||||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
// Normalize streamSettings based on protocol
|
// Normalize streamSettings based on protocol
|
||||||
s.normalizeStreamSettings(inbound)
|
s.normalizeStreamSettings(inbound)
|
||||||
|
inbound.TrafficMultiplier = normalizeTrafficMultiplier(inbound.TrafficMultiplier)
|
||||||
|
|
||||||
exist, err := s.checkPortConflict(inbound, 0)
|
exist, err := s.checkPortConflict(inbound, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -735,6 +800,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
// Normalize streamSettings based on protocol
|
// Normalize streamSettings based on protocol
|
||||||
s.normalizeStreamSettings(inbound)
|
s.normalizeStreamSettings(inbound)
|
||||||
|
inbound.TrafficMultiplier = normalizeTrafficMultiplier(inbound.TrafficMultiplier)
|
||||||
|
|
||||||
exist, err := s.checkPortConflict(inbound, inbound.Id)
|
exist, err := s.checkPortConflict(inbound, inbound.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -831,6 +897,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||||
oldInbound.Enable = inbound.Enable
|
oldInbound.Enable = inbound.Enable
|
||||||
oldInbound.ExpiryTime = inbound.ExpiryTime
|
oldInbound.ExpiryTime = inbound.ExpiryTime
|
||||||
oldInbound.TrafficReset = inbound.TrafficReset
|
oldInbound.TrafficReset = inbound.TrafficReset
|
||||||
|
oldInbound.TrafficMultiplier = inbound.TrafficMultiplier
|
||||||
oldInbound.Listen = inbound.Listen
|
oldInbound.Listen = inbound.Listen
|
||||||
oldInbound.Port = inbound.Port
|
oldInbound.Port = inbound.Port
|
||||||
oldInbound.Protocol = inbound.Protocol
|
oldInbound.Protocol = inbound.Protocol
|
||||||
|
|
@ -1315,22 +1382,23 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
c, ok := tagToCentral[snapIb.Tag]
|
c, ok := tagToCentral[snapIb.Tag]
|
||||||
if !ok {
|
if !ok {
|
||||||
newIb := model.Inbound{
|
newIb := model.Inbound{
|
||||||
UserId: defaultUserId,
|
UserId: defaultUserId,
|
||||||
NodeID: &nodeID,
|
NodeID: &nodeID,
|
||||||
Tag: snapIb.Tag,
|
Tag: snapIb.Tag,
|
||||||
Listen: snapIb.Listen,
|
Listen: snapIb.Listen,
|
||||||
Port: snapIb.Port,
|
Port: snapIb.Port,
|
||||||
Protocol: snapIb.Protocol,
|
Protocol: snapIb.Protocol,
|
||||||
Settings: snapIb.Settings,
|
Settings: snapIb.Settings,
|
||||||
StreamSettings: snapIb.StreamSettings,
|
StreamSettings: snapIb.StreamSettings,
|
||||||
Sniffing: snapIb.Sniffing,
|
Sniffing: snapIb.Sniffing,
|
||||||
TrafficReset: snapIb.TrafficReset,
|
TrafficReset: snapIb.TrafficReset,
|
||||||
Enable: snapIb.Enable,
|
Enable: snapIb.Enable,
|
||||||
Remark: snapIb.Remark,
|
Remark: snapIb.Remark,
|
||||||
Total: snapIb.Total,
|
Total: snapIb.Total,
|
||||||
ExpiryTime: snapIb.ExpiryTime,
|
ExpiryTime: snapIb.ExpiryTime,
|
||||||
Up: snapIb.Up,
|
TrafficMultiplier: normalizeTrafficMultiplier(snapIb.TrafficMultiplier),
|
||||||
Down: snapIb.Down,
|
Up: snapIb.Up,
|
||||||
|
Down: snapIb.Down,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&newIb).Error; err != nil {
|
if err := tx.Create(&newIb).Error; err != nil {
|
||||||
logger.Warning("setRemoteTraffic: create central inbound for tag", snapIb.Tag, "failed:", err)
|
logger.Warning("setRemoteTraffic: create central inbound for tag", snapIb.Tag, "failed:", err)
|
||||||
|
|
@ -1342,19 +1410,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
}
|
}
|
||||||
|
|
||||||
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
||||||
|
snapMultiplier := normalizeTrafficMultiplier(snapIb.TrafficMultiplier)
|
||||||
|
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"enable": snapIb.Enable,
|
"enable": snapIb.Enable,
|
||||||
"remark": snapIb.Remark,
|
"remark": snapIb.Remark,
|
||||||
"listen": snapIb.Listen,
|
"listen": snapIb.Listen,
|
||||||
"port": snapIb.Port,
|
"port": snapIb.Port,
|
||||||
"protocol": snapIb.Protocol,
|
"protocol": snapIb.Protocol,
|
||||||
"total": snapIb.Total,
|
"total": snapIb.Total,
|
||||||
"expiry_time": snapIb.ExpiryTime,
|
"expiry_time": snapIb.ExpiryTime,
|
||||||
"settings": snapIb.Settings,
|
"traffic_multiplier": snapMultiplier,
|
||||||
"stream_settings": snapIb.StreamSettings,
|
"settings": snapIb.Settings,
|
||||||
"sniffing": snapIb.Sniffing,
|
"stream_settings": snapIb.StreamSettings,
|
||||||
"traffic_reset": snapIb.TrafficReset,
|
"sniffing": snapIb.Sniffing,
|
||||||
|
"traffic_reset": snapIb.TrafficReset,
|
||||||
}
|
}
|
||||||
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
||||||
updates["up"] = snapIb.Up
|
updates["up"] = snapIb.Up
|
||||||
|
|
@ -1367,6 +1437,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
c.Port != snapIb.Port ||
|
c.Port != snapIb.Port ||
|
||||||
c.Total != snapIb.Total ||
|
c.Total != snapIb.Total ||
|
||||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||||
|
c.TrafficMultiplier != snapMultiplier ||
|
||||||
c.Enable != snapIb.Enable {
|
c.Enable != snapIb.Enable {
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
}
|
}
|
||||||
|
|
@ -1640,13 +1711,49 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
tags := make([]string, 0, len(traffics))
|
||||||
|
seenTags := make(map[string]struct{}, len(traffics))
|
||||||
|
for _, traffic := range traffics {
|
||||||
|
if traffic == nil || !traffic.IsInbound || traffic.Tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenTags[traffic.Tag]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTags[traffic.Tag] = struct{}{}
|
||||||
|
tags = append(tags, traffic.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
multiplierByTag := make(map[string]float64, len(tags))
|
||||||
|
if len(tags) > 0 {
|
||||||
|
var rows []struct {
|
||||||
|
Tag string
|
||||||
|
TrafficMultiplier float64
|
||||||
|
}
|
||||||
|
err = tx.Model(&model.Inbound{}).
|
||||||
|
Select("tag, traffic_multiplier").
|
||||||
|
Where("tag IN ? AND node_id IS NULL", tags).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
multiplierByTag[row.Tag] = normalizeTrafficMultiplier(row.TrafficMultiplier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, traffic := range traffics {
|
for _, traffic := range traffics {
|
||||||
|
if traffic == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if traffic.IsInbound {
|
if traffic.IsInbound {
|
||||||
|
multiplier := multiplierByTag[traffic.Tag]
|
||||||
|
up := scaleTraffic(traffic.Up, multiplier)
|
||||||
|
down := scaleTraffic(traffic.Down, multiplier)
|
||||||
err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag).
|
err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"up": gorm.Expr("up + ?", traffic.Up),
|
"up": saturatedTrafficAddExpr("up", up),
|
||||||
"down": gorm.Expr("down + ?", traffic.Down),
|
"down": saturatedTrafficAddExpr("down", down),
|
||||||
}).Error
|
}).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1684,6 +1791,36 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inboundIDs := make([]int, 0, len(dbClientTraffics))
|
||||||
|
seenInboundIDs := make(map[int]struct{}, len(dbClientTraffics))
|
||||||
|
for _, dbClientTraffic := range dbClientTraffics {
|
||||||
|
if dbClientTraffic == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenInboundIDs[dbClientTraffic.InboundId]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenInboundIDs[dbClientTraffic.InboundId] = struct{}{}
|
||||||
|
inboundIDs = append(inboundIDs, dbClientTraffic.InboundId)
|
||||||
|
}
|
||||||
|
multiplierByInboundID := make(map[int]float64, len(inboundIDs))
|
||||||
|
if len(inboundIDs) > 0 {
|
||||||
|
var rows []struct {
|
||||||
|
Id int
|
||||||
|
TrafficMultiplier float64
|
||||||
|
}
|
||||||
|
err = tx.Model(&model.Inbound{}).
|
||||||
|
Select("id, traffic_multiplier").
|
||||||
|
Where("id IN ?", inboundIDs).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
multiplierByInboundID[row.Id] = normalizeTrafficMultiplier(row.TrafficMultiplier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Index by email for O(N) merge — the previous nested loop was O(N²)
|
// Index by email for O(N) merge — the previous nested loop was O(N²)
|
||||||
// and dominated each cron tick on inbounds with thousands of active
|
// and dominated each cron tick on inbounds with thousands of active
|
||||||
// clients (7500 × 7500 = 56M string comparisons every 10 seconds).
|
// clients (7500 × 7500 = 56M string comparisons every 10 seconds).
|
||||||
|
|
@ -1699,8 +1836,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dbClientTraffics[dbTraffic_index].Up += t.Up
|
multiplier := multiplierByInboundID[dbClientTraffics[dbTraffic_index].InboundId]
|
||||||
dbClientTraffics[dbTraffic_index].Down += t.Down
|
dbClientTraffics[dbTraffic_index].Up = addScaledTraffic(dbClientTraffics[dbTraffic_index].Up, t.Up, multiplier)
|
||||||
|
dbClientTraffics[dbTraffic_index].Down = addScaledTraffic(dbClientTraffics[dbTraffic_index].Down, t.Down, multiplier)
|
||||||
if t.Up+t.Down > 0 {
|
if t.Up+t.Down > 0 {
|
||||||
dbClientTraffics[dbTraffic_index].LastOnline = now
|
dbClientTraffics[dbTraffic_index].LastOnline = now
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
web/service/inbound_traffic_multiplier_test.go
Normal file
141
web/service/inbound_traffic_multiplier_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddTrafficAppliesInboundTrafficMultiplier(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
inbound := model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Tag: "inbound-test",
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: `{"clients":[{"id":"11111111-1111-1111-1111-111111111111","email":"alice@example.com","enable":true}],"decryption":"none"}`,
|
||||||
|
TrafficMultiplier: 2,
|
||||||
|
Enable: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&inbound).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{
|
||||||
|
InboundId: inbound.Id,
|
||||||
|
Email: "alice@example.com",
|
||||||
|
Enable: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create client traffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := InboundService{}
|
||||||
|
_, _, err := s.AddTraffic(
|
||||||
|
[]*xray.Traffic{{
|
||||||
|
IsInbound: true,
|
||||||
|
Tag: inbound.Tag,
|
||||||
|
Up: 100,
|
||||||
|
Down: 200,
|
||||||
|
}},
|
||||||
|
[]*xray.ClientTraffic{{
|
||||||
|
Email: "alice@example.com",
|
||||||
|
Up: 10,
|
||||||
|
Down: 20,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddTraffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedInbound model.Inbound
|
||||||
|
if err := db.First(&storedInbound, inbound.Id).Error; err != nil {
|
||||||
|
t.Fatalf("load inbound: %v", err)
|
||||||
|
}
|
||||||
|
if storedInbound.Up != 200 || storedInbound.Down != 400 {
|
||||||
|
t.Fatalf("inbound traffic = up %d/down %d, want up 200/down 400", storedInbound.Up, storedInbound.Down)
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedClient xray.ClientTraffic
|
||||||
|
if err := db.Where("email = ?", "alice@example.com").First(&storedClient).Error; err != nil {
|
||||||
|
t.Fatalf("load client traffic: %v", err)
|
||||||
|
}
|
||||||
|
if storedClient.Up != 20 || storedClient.Down != 40 {
|
||||||
|
t.Fatalf("client traffic = up %d/down %d, want up 20/down 40", storedClient.Up, storedClient.Down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTrafficSaturatesMultipliedInboundCounters(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
inbound := model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Tag: "inbound-overflow",
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Settings: `{"clients":[{"id":"22222222-2222-2222-2222-222222222222","email":"bob@example.com","enable":true}],"decryption":"none"}`,
|
||||||
|
TrafficMultiplier: 2,
|
||||||
|
Up: math.MaxInt64 - 5,
|
||||||
|
Down: math.MaxInt64 - 5,
|
||||||
|
Enable: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&inbound).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&xray.ClientTraffic{
|
||||||
|
InboundId: inbound.Id,
|
||||||
|
Email: "bob@example.com",
|
||||||
|
Enable: true,
|
||||||
|
Up: math.MaxInt64 - 5,
|
||||||
|
Down: math.MaxInt64 - 5,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create client traffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := InboundService{}
|
||||||
|
_, _, err := s.AddTraffic(
|
||||||
|
[]*xray.Traffic{{
|
||||||
|
IsInbound: true,
|
||||||
|
Tag: inbound.Tag,
|
||||||
|
Up: 10,
|
||||||
|
Down: 10,
|
||||||
|
}},
|
||||||
|
[]*xray.ClientTraffic{{
|
||||||
|
Email: "bob@example.com",
|
||||||
|
Up: 10,
|
||||||
|
Down: 10,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddTraffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedInbound model.Inbound
|
||||||
|
if err := db.First(&storedInbound, inbound.Id).Error; err != nil {
|
||||||
|
t.Fatalf("load inbound: %v", err)
|
||||||
|
}
|
||||||
|
if storedInbound.Up != math.MaxInt64 || storedInbound.Down != math.MaxInt64 {
|
||||||
|
t.Fatalf("inbound traffic = up %d/down %d, want saturated MaxInt64", storedInbound.Up, storedInbound.Down)
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedClient xray.ClientTraffic
|
||||||
|
if err := db.Where("email = ?", "bob@example.com").First(&storedClient).Error; err != nil {
|
||||||
|
t.Fatalf("load client traffic: %v", err)
|
||||||
|
}
|
||||||
|
if storedClient.Up != math.MaxInt64 || storedClient.Down != math.MaxInt64 {
|
||||||
|
t.Fatalf("client traffic = up %d/down %d, want saturated MaxInt64", storedClient.Up, storedClient.Down)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "سيبها فاضية لو عايز تستمع على كل الـ IPs",
|
"monitorDesc": "سيبها فاضية لو عايز تستمع على كل الـ IPs",
|
||||||
"meansNoLimit": "= غير محدود. (الوحدة: جيجابايت)",
|
"meansNoLimit": "= غير محدود. (الوحدة: جيجابايت)",
|
||||||
"totalFlow": "إجمالي التدفق",
|
"totalFlow": "إجمالي التدفق",
|
||||||
|
"trafficMultiplier": "مضاعف حركة المرور",
|
||||||
"leaveBlankToNeverExpire": "سيبها فاضية عشان ماتنتهيش",
|
"leaveBlankToNeverExpire": "سيبها فاضية عشان ماتنتهيش",
|
||||||
"noRecommendKeepDefault": "ننصح باستخدام الافتراضي",
|
"noRecommendKeepDefault": "ننصح باستخدام الافتراضي",
|
||||||
"certificatePath": "مسار الملف",
|
"certificatePath": "مسار الملف",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "اختار الإدخال"
|
"chooseInbound": "اختار الإدخال"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Leave blank to listen on all IPs",
|
"monitorDesc": "Leave blank to listen on all IPs",
|
||||||
"meansNoLimit": "= Unlimited. (unit: GB)",
|
"meansNoLimit": "= Unlimited. (unit: GB)",
|
||||||
"totalFlow": "Total Flow",
|
"totalFlow": "Total Flow",
|
||||||
|
"trafficMultiplier": "Traffic Multiplier",
|
||||||
"leaveBlankToNeverExpire": "Leave blank to never expire",
|
"leaveBlankToNeverExpire": "Leave blank to never expire",
|
||||||
"noRecommendKeepDefault": "It is recommended to keep the default",
|
"noRecommendKeepDefault": "It is recommended to keep the default",
|
||||||
"certificatePath": "File Path",
|
"certificatePath": "File Path",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Choose an Inbound"
|
"chooseInbound": "Choose an Inbound"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Dejar en blanco por defecto",
|
"monitorDesc": "Dejar en blanco por defecto",
|
||||||
"meansNoLimit": " = illimitata. (unidad: GB)",
|
"meansNoLimit": " = illimitata. (unidad: GB)",
|
||||||
"totalFlow": "Flujo Total",
|
"totalFlow": "Flujo Total",
|
||||||
|
"trafficMultiplier": "Multiplicador de Tráfico",
|
||||||
"leaveBlankToNeverExpire": "Dejar en Blanco para Nunca Expirar",
|
"leaveBlankToNeverExpire": "Dejar en Blanco para Nunca Expirar",
|
||||||
"noRecommendKeepDefault": "No hay requisitos especiales para mantener la configuración predeterminada",
|
"noRecommendKeepDefault": "No hay requisitos especiales para mantener la configuración predeterminada",
|
||||||
"certificatePath": "Ruta Cert",
|
"certificatePath": "Ruta Cert",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Elige un Inbound"
|
"chooseInbound": "Elige un Inbound"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "بهطور پیشفرض خالیبگذارید",
|
"monitorDesc": "بهطور پیشفرض خالیبگذارید",
|
||||||
"meansNoLimit": "0 = واحد: گیگابایت) نامحدود)",
|
"meansNoLimit": "0 = واحد: گیگابایت) نامحدود)",
|
||||||
"totalFlow": "ترافیک کل",
|
"totalFlow": "ترافیک کل",
|
||||||
|
"trafficMultiplier": "ضریب ترافیک",
|
||||||
"leaveBlankToNeverExpire": "برای منقضینشدن خالیبگذارید",
|
"leaveBlankToNeverExpire": "برای منقضینشدن خالیبگذارید",
|
||||||
"noRecommendKeepDefault": "توصیهمیشود بهطور پیشفرض حفظشود",
|
"noRecommendKeepDefault": "توصیهمیشود بهطور پیشفرض حفظشود",
|
||||||
"certificatePath": "مسیر فایل",
|
"certificatePath": "مسیر فایل",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "یک ورودی انتخاب کنید"
|
"chooseInbound": "یک ورودی انتخاب کنید"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Biarkan kosong untuk mendengarkan semua IP",
|
"monitorDesc": "Biarkan kosong untuk mendengarkan semua IP",
|
||||||
"meansNoLimit": "= Unlimited. (unit: GB)",
|
"meansNoLimit": "= Unlimited. (unit: GB)",
|
||||||
"totalFlow": "Total Aliran",
|
"totalFlow": "Total Aliran",
|
||||||
|
"trafficMultiplier": "Pengali Lalu Lintas",
|
||||||
"leaveBlankToNeverExpire": "Biarkan kosong untuk tidak pernah kedaluwarsa",
|
"leaveBlankToNeverExpire": "Biarkan kosong untuk tidak pernah kedaluwarsa",
|
||||||
"noRecommendKeepDefault": "Disarankan untuk tetap menggunakan pengaturan default",
|
"noRecommendKeepDefault": "Disarankan untuk tetap menggunakan pengaturan default",
|
||||||
"certificatePath": "Path Berkas",
|
"certificatePath": "Path Berkas",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Pilih Inbound"
|
"chooseInbound": "Pilih Inbound"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "空白にするとすべてのIPを監視",
|
"monitorDesc": "空白にするとすべてのIPを監視",
|
||||||
"meansNoLimit": "= 無制限(単位:GB)",
|
"meansNoLimit": "= 無制限(単位:GB)",
|
||||||
"totalFlow": "総トラフィック",
|
"totalFlow": "総トラフィック",
|
||||||
|
"trafficMultiplier": "トラフィック倍率",
|
||||||
"leaveBlankToNeverExpire": "空白にすると期限なし",
|
"leaveBlankToNeverExpire": "空白にすると期限なし",
|
||||||
"noRecommendKeepDefault": "デフォルト値を保持することをお勧めします",
|
"noRecommendKeepDefault": "デフォルト値を保持することをお勧めします",
|
||||||
"certificatePath": "ファイルパス",
|
"certificatePath": "ファイルパス",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "インバウンドを選択"
|
"chooseInbound": "インバウンドを選択"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Deixe em branco para ouvir todos os IPs",
|
"monitorDesc": "Deixe em branco para ouvir todos os IPs",
|
||||||
"meansNoLimit": "= Ilimitado. (unidade: GB)",
|
"meansNoLimit": "= Ilimitado. (unidade: GB)",
|
||||||
"totalFlow": "Fluxo Total",
|
"totalFlow": "Fluxo Total",
|
||||||
|
"trafficMultiplier": "Multiplicador de Tráfego",
|
||||||
"leaveBlankToNeverExpire": "Deixe em branco para nunca expirar",
|
"leaveBlankToNeverExpire": "Deixe em branco para nunca expirar",
|
||||||
"noRecommendKeepDefault": "Recomenda-se manter o padrão",
|
"noRecommendKeepDefault": "Recomenda-se manter o padrão",
|
||||||
"certificatePath": "Caminho",
|
"certificatePath": "Caminho",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Escolha um Inbound"
|
"chooseInbound": "Escolha um Inbound"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Оставьте пустым для прослушивания всех IP-адресов",
|
"monitorDesc": "Оставьте пустым для прослушивания всех IP-адресов",
|
||||||
"meansNoLimit": "= Без ограничений (значение: ГБ)",
|
"meansNoLimit": "= Без ограничений (значение: ГБ)",
|
||||||
"totalFlow": "Общий расход",
|
"totalFlow": "Общий расход",
|
||||||
|
"trafficMultiplier": "Множитель трафика",
|
||||||
"leaveBlankToNeverExpire": "Оставьте пустым, чтобы было бесконечным",
|
"leaveBlankToNeverExpire": "Оставьте пустым, чтобы было бесконечным",
|
||||||
"noRecommendKeepDefault": "Рекомендуется оставить настройки по умолчанию",
|
"noRecommendKeepDefault": "Рекомендуется оставить настройки по умолчанию",
|
||||||
"certificatePath": "Путь к сертификату",
|
"certificatePath": "Путь к сертификату",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Выберите входящее подключение"
|
"chooseInbound": "Выберите входящее подключение"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Tüm IP'leri dinlemek için boş bırakın",
|
"monitorDesc": "Tüm IP'leri dinlemek için boş bırakın",
|
||||||
"meansNoLimit": "= Sınırsız. (birim: GB)",
|
"meansNoLimit": "= Sınırsız. (birim: GB)",
|
||||||
"totalFlow": "Toplam Akış",
|
"totalFlow": "Toplam Akış",
|
||||||
|
"trafficMultiplier": "Trafik Çarpanı",
|
||||||
"leaveBlankToNeverExpire": "Hiçbir zaman sona ermemesi için boş bırakın",
|
"leaveBlankToNeverExpire": "Hiçbir zaman sona ermemesi için boş bırakın",
|
||||||
"noRecommendKeepDefault": "Varsayılanı korumanız önerilir",
|
"noRecommendKeepDefault": "Varsayılanı korumanız önerilir",
|
||||||
"certificatePath": "Dosya Yolu",
|
"certificatePath": "Dosya Yolu",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Bir Gelen Seçin"
|
"chooseInbound": "Bir Gelen Seçin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Залиште порожнім, щоб слухати всі IP-адреси",
|
"monitorDesc": "Залиште порожнім, щоб слухати всі IP-адреси",
|
||||||
"meansNoLimit": "= Необмежено. (одиниця: ГБ)",
|
"meansNoLimit": "= Необмежено. (одиниця: ГБ)",
|
||||||
"totalFlow": "Загальна витрата",
|
"totalFlow": "Загальна витрата",
|
||||||
|
"trafficMultiplier": "Множник трафіку",
|
||||||
"leaveBlankToNeverExpire": "Залиште порожнім, щоб ніколи не закінчувався",
|
"leaveBlankToNeverExpire": "Залиште порожнім, щоб ніколи не закінчувався",
|
||||||
"noRecommendKeepDefault": "Рекомендується зберегти значення за замовчуванням",
|
"noRecommendKeepDefault": "Рекомендується зберегти значення за замовчуванням",
|
||||||
"certificatePath": "Шлях до файлу",
|
"certificatePath": "Шлях до файлу",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Виберіть Вхідний"
|
"chooseInbound": "Виберіть Вхідний"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "Mặc định để trống",
|
"monitorDesc": "Mặc định để trống",
|
||||||
"meansNoLimit": "= Không giới hạn (đơn vị: GB)",
|
"meansNoLimit": "= Không giới hạn (đơn vị: GB)",
|
||||||
"totalFlow": "Tổng lưu lượng",
|
"totalFlow": "Tổng lưu lượng",
|
||||||
|
"trafficMultiplier": "Hệ số lưu lượng",
|
||||||
"leaveBlankToNeverExpire": "Để trống để không bao giờ hết hạn",
|
"leaveBlankToNeverExpire": "Để trống để không bao giờ hết hạn",
|
||||||
"noRecommendKeepDefault": "Không yêu cầu đặc biệt để giữ nguyên cài đặt mặc định",
|
"noRecommendKeepDefault": "Không yêu cầu đặc biệt để giữ nguyên cài đặt mặc định",
|
||||||
"certificatePath": "Đường dẫn tập",
|
"certificatePath": "Đường dẫn tập",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "Chọn một Inbound"
|
"chooseInbound": "Chọn một Inbound"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "留空表示监听所有 IP",
|
"monitorDesc": "留空表示监听所有 IP",
|
||||||
"meansNoLimit": "= 无限制(单位:GB)",
|
"meansNoLimit": "= 无限制(单位:GB)",
|
||||||
"totalFlow": "总流量",
|
"totalFlow": "总流量",
|
||||||
|
"trafficMultiplier": "流量倍率",
|
||||||
"leaveBlankToNeverExpire": "留空表示永不过期",
|
"leaveBlankToNeverExpire": "留空表示永不过期",
|
||||||
"noRecommendKeepDefault": "建议保留默认值",
|
"noRecommendKeepDefault": "建议保留默认值",
|
||||||
"certificatePath": "文件路径",
|
"certificatePath": "文件路径",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "选择一个入站"
|
"chooseInbound": "选择一个入站"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@
|
||||||
"monitorDesc": "留空表示監聽所有 IP",
|
"monitorDesc": "留空表示監聽所有 IP",
|
||||||
"meansNoLimit": "= 無限制(單位:GB)",
|
"meansNoLimit": "= 無限制(單位:GB)",
|
||||||
"totalFlow": "總流量",
|
"totalFlow": "總流量",
|
||||||
|
"trafficMultiplier": "流量倍率",
|
||||||
"leaveBlankToNeverExpire": "留空表示永不過期",
|
"leaveBlankToNeverExpire": "留空表示永不過期",
|
||||||
"noRecommendKeepDefault": "建議保留預設值",
|
"noRecommendKeepDefault": "建議保留預設值",
|
||||||
"certificatePath": "檔案路徑",
|
"certificatePath": "檔案路徑",
|
||||||
|
|
@ -1116,4 +1117,4 @@
|
||||||
"chooseInbound": "選擇一個入站"
|
"chooseInbound": "選擇一個入站"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue