diff --git a/database/model/model.go b/database/model/model.go index d71e0589..43c3f76c 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -182,6 +182,7 @@ type Client struct { Email string `json:"email"` // Client email identifier LimitIP int `json:"limitIp"` // IP limit for this client TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB + SubTotalGB int64 `json:"subTotalGB" form:"subTotalGB"` // Shared subscription traffic limit in bytes (0 = disabled) ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp Enable bool `json:"enable" form:"enable"` // Whether the client is enabled TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index 830dc9a9..c20b6aa1 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -2483,6 +2483,7 @@ Inbound.ClientBase = class extends XrayCommonClass { email = RandomUtil.randomLowerAndNum(8), limitIp = 0, totalGB = 0, + subTotalGB = 0, expiryTime = 0, enable = true, tgId = '', @@ -2496,6 +2497,7 @@ Inbound.ClientBase = class extends XrayCommonClass { this.email = email; this.limitIp = limitIp; this.totalGB = totalGB; + this.subTotalGB = subTotalGB; this.expiryTime = expiryTime; this.enable = enable; this.tgId = tgId; @@ -2511,6 +2513,7 @@ Inbound.ClientBase = class extends XrayCommonClass { json.email, json.limitIp, json.totalGB, + json.subTotalGB, json.expiryTime, json.enable, json.tgId, @@ -2527,6 +2530,7 @@ Inbound.ClientBase = class extends XrayCommonClass { email: this.email, limitIp: this.limitIp, totalGB: this.totalGB, + subTotalGB: this.subTotalGB, expiryTime: this.expiryTime, enable: this.enable, tgId: this.tgId, @@ -2563,6 +2567,14 @@ Inbound.ClientBase = class extends XrayCommonClass { set _totalGB(gb) { this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0); } + + get _subTotalGB() { + return NumberFormatter.toFixed(this.subTotalGB / SizeFormatter.ONE_GB, 2); + } + + set _subTotalGB(gb) { + this.subTotalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0); + } }; Inbound.VmessSettings = class extends Inbound.Settings { @@ -2608,9 +2620,9 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase { constructor( id = RandomUtil.randomUUID(), security = USERS_SECURITY.AUTO, - email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, + email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, ) { - super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); + super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); this.id = id; this.security = security; } @@ -2725,9 +2737,9 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase { flow = '', reverseTag = '', reverseSniffing = new Sniffing(), - email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, + email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, ) { - super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); + super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); this.id = id; this.flow = flow; this.reverseTag = reverseTag; @@ -2818,9 +2830,9 @@ Inbound.TrojanSettings = class extends Inbound.Settings { Inbound.TrojanSettings.Trojan = class extends Inbound.ClientBase { constructor( password = RandomUtil.randomSeq(10), - email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, + email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, ) { - super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); + super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); this.password = password; } @@ -2902,9 +2914,9 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase { constructor( method = '', password = RandomUtil.randomShadowsocksPassword(), - email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, + email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, ) { - super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); + super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); this.method = method; this.password = password; } @@ -2952,9 +2964,9 @@ Inbound.HysteriaSettings = class extends Inbound.Settings { Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase { constructor( auth = RandomUtil.randomSeq(10), - email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, + email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, ) { - super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); + super(email, limitIp, totalGB, subTotalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at); this.auth = auth; } diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 70415ce5..de7f6b57 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -106,6 +106,14 @@ export const sections = [ ], response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}', }, + { + method: 'GET', + path: '/panel/api/inbounds/getSubTraffic/:subId', + summary: 'Aggregated traffic counters for all clients sharing a subscription ID. Returns combined upload, download, the shared quota (subTotalGB), and the number of linked clients.', + params: [ + { name: 'subId', in: 'path', type: 'string', desc: 'Subscription ID.' }, + ], + }, { method: 'POST', path: '/panel/api/inbounds/add', diff --git a/frontend/src/pages/inbounds/ClientFormModal.vue b/frontend/src/pages/inbounds/ClientFormModal.vue index df167ac0..049135a2 100644 --- a/frontend/src/pages/inbounds/ClientFormModal.vue +++ b/frontend/src/pages/inbounds/ClientFormModal.vue @@ -86,6 +86,17 @@ const totalGB = computed({ }, }); +const subTotalGB = computed({ + get: () => { + if (!client.value || !client.value.subTotalGB) return 0; + return Math.round((client.value.subTotalGB / SizeFormatter.ONE_GB) * 100) / 100; + }, + set: (gb) => { + if (!client.value) return; + client.value.subTotalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB); + }, +}); + const isExpired = computed(() => { if (props.mode !== 'edit' || !client.value) return false; return client.value.expiryTime > 0 && client.value.expiryTime < Date.now(); @@ -340,6 +351,13 @@ const title = computed(() => + + + + + {{ SizeFormatter.sizeFormat(clientStats.up) }} / diff --git a/frontend/src/pages/inbounds/ClientRowTable.vue b/frontend/src/pages/inbounds/ClientRowTable.vue index 6ed33119..c84728a0 100644 --- a/frontend/src/pages/inbounds/ClientRowTable.vue +++ b/frontend/src/pages/inbounds/ClientRowTable.vue @@ -361,6 +361,10 @@ function confirmBulkDelete() { {{ t('remained') }} {{ SizeFormatter.sizeFormat(getRem(client.email)) }} + + {{ t('subscription.totalQuota') }} + {{ SizeFormatter.sizeFormat(client.subTotalGB) }} + diff --git a/frontend/src/pages/inbounds/InboundInfoModal.vue b/frontend/src/pages/inbounds/InboundInfoModal.vue index 61ce2fcf..1cf754de 100644 --- a/frontend/src/pages/inbounds/InboundInfoModal.vue +++ b/frontend/src/pages/inbounds/InboundInfoModal.vue @@ -80,6 +80,38 @@ const refreshing = ref(false); const clientIpsArray = ref([]); const clientIpsText = ref(''); +// Shared subscription quota state. +const subTrafficInfo = ref(null); + +async function loadSubTraffic(subId) { + if (!subId) { subTrafficInfo.value = null; return; } + try { + const msg = await HttpUtil.post(`/panel/api/inbounds/getSubTraffic/${subId}`); + if (msg?.success && msg.obj) { + subTrafficInfo.value = msg.obj; + } else { + subTrafficInfo.value = null; + } + } catch (_e) { + subTrafficInfo.value = null; + } +} + +function getSubRemaining() { + if (!subTrafficInfo.value || !subTrafficInfo.value.total) return '-'; + const remained = subTrafficInfo.value.total - subTrafficInfo.value.up - subTrafficInfo.value.down; + return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-'; +} + +function subStatsColor() { + if (!subTrafficInfo.value || !subTrafficInfo.value.total) return 'default'; + return ColorUtils.usageColor( + subTrafficInfo.value.up + subTrafficInfo.value.down, + props.trafficDiff, + subTrafficInfo.value.total, + ); +} + // === Status flags shown as tags ==================================== const isEnable = computed(() => { if (clientSettings.value) return !!clientSettings.value.enable; @@ -246,6 +278,12 @@ watch(() => props.open, (next) => { ) { loadClientIps(); } + + // Load shared subscription traffic if client has subTotalGB. + subTrafficInfo.value = null; + if (clientSettings.value?.subTotalGB > 0 && clientSettings.value?.subId) { + loadSubTraffic(clientSettings.value.subId); + } }); function close() { @@ -380,6 +418,7 @@ const showSubscriptionTab = computed( {{ t('remained') }} {{ t('pages.inbounds.totalUsage') }} {{ t('pages.inbounds.expireDate') }} + {{ t('subscription.subRemained') }} @@ -409,6 +448,10 @@ const showSubscriptionTab = computed( + + {{ getSubRemaining() }} + {{ SizeFormatter.sizeFormat(subTrafficInfo.total) }} + diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 541ae449..ac5b4c83 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -64,6 +64,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.GET("/get/:id", a.getInbound) g.GET("/getClientTraffics/:email", a.getClientTraffics) g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById) + g.GET("/getSubTraffic/:subId", a.getSubTraffic) g.GET("/getSubLinks/:subId", a.getSubLinks) g.GET("/getClientLinks/:id/:email", a.getClientLinks) @@ -143,6 +144,17 @@ func (a *InboundController) getClientTrafficsById(c *gin.Context) { jsonObj(c, clientTraffics, nil) } +// getSubTraffic retrieves aggregated traffic info for all clients sharing a SubID. +func (a *InboundController) getSubTraffic(c *gin.Context) { + subId := c.Param("subId") + info, err := a.inboundService.GetSubTrafficInfo(subId) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err) + return + } + jsonObj(c, info, nil) +} + // addInbound creates a new inbound configuration. func (a *InboundController) addInbound(c *gin.Context) { inbound := &model.Inbound{} diff --git a/web/service/inbound.go b/web/service/inbound.go index 16bb2528..61c950fa 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1859,13 +1859,21 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien disabledClientsCount = count } + needRestart3, count, err := s.disableSubQuotaClients(tx) + if err != nil { + logger.Warning("Error in disabling sub-quota clients:", err) + } else if count > 0 { + logger.Debugf("%v clients disabled by sub quota", count) + disabledClientsCount += count + } + needRestart2, count, err := s.disableInvalidInbounds(tx) if err != nil { logger.Warning("Error in disabling invalid inbounds:", err) } else if count > 0 { logger.Debugf("%v inbounds disabled", count) } - return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil + return needRestart0 || needRestart1 || needRestart2 || needRestart3, disabledClientsCount > 0, nil } func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error { @@ -2338,6 +2346,290 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) return needRestart, count, nil } +// SubTrafficInfo holds aggregated traffic data for all clients sharing a SubID. +type SubTrafficInfo struct { + SubId string `json:"subId"` + Up int64 `json:"up"` + Down int64 `json:"down"` + Total int64 `json:"total"` // subTotalGB limit (bytes) + Clients int `json:"clients"` // number of client emails sharing this subId +} + +// GetSubTrafficInfo returns aggregated traffic data for all clients sharing +// the given subId. The Total field is the first non-zero subTotalGB found +// across any sibling client. +func (s *InboundService) GetSubTrafficInfo(subId string) (*SubTrafficInfo, error) { + if subId == "" { + return nil, common.NewError("empty subId") + } + db := database.GetDB() + + // 1. Find all emails + subTotalGB for this subId. + var rows []struct { + Email string + SubTotalGB int64 `gorm:"column:sub_total_gb"` + } + err := db.Raw(` + SELECT JSON_EXTRACT(client.value, '$.email') AS email, + COALESCE(JSON_EXTRACT(client.value, '$.subTotalGB'), 0) AS sub_total_gb + FROM inbounds, + JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client + WHERE REPLACE(JSON_EXTRACT(client.value, '$.subId'), '"', '') = ? + `, subId).Scan(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return &SubTrafficInfo{SubId: subId}, nil + } + + emails := make([]string, 0, len(rows)) + seen := make(map[string]struct{}, len(rows)) + var subTotal int64 + for _, r := range rows { + email := strings.Trim(r.Email, "\"") + if email == "" { + continue + } + key := strings.ToLower(email) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + emails = append(emails, email) + if subTotal == 0 && r.SubTotalGB > 0 { + subTotal = r.SubTotalGB + } + } + + // 2. Sum traffic from client_traffics for those emails. + var up, down int64 + for _, batch := range chunkStrings(emails, sqliteMaxVars) { + var result struct { + Up int64 + Down int64 + } + if err := db.Model(xray.ClientTraffic{}). + Select("COALESCE(SUM(up),0) AS up, COALESCE(SUM(down),0) AS down"). + Where("email IN ?", batch).Scan(&result).Error; err != nil { + return nil, err + } + up += result.Up + down += result.Down + } + + return &SubTrafficInfo{ + SubId: subId, + Up: up, + Down: down, + Total: subTotal, + Clients: len(emails), + }, nil +} + +// disableSubQuotaClients checks all SubID groups for shared quota violations. +// If the aggregate traffic (up+down) across all clients sharing a SubID +// exceeds the subTotalGB configured on any sibling, ALL clients in the group +// are disabled. Only considers enabled clients on local-node inbounds. +func (s *InboundService) disableSubQuotaClients(tx *gorm.DB) (bool, int64, error) { + // 1. Collect every (email, subId, subTotalGB) tuple from inbound settings. + var allRows []struct { + InboundId int + Tag string + Email string + SubID string `gorm:"column:sub_id"` + SubTotalGB int64 `gorm:"column:sub_total_gb"` + } + err := tx.Raw(` + SELECT inbounds.id AS inbound_id, + inbounds.tag AS tag, + JSON_EXTRACT(client.value, '$.email') AS email, + REPLACE(JSON_EXTRACT(client.value, '$.subId'), '"', '') AS sub_id, + COALESCE(JSON_EXTRACT(client.value, '$.subTotalGB'), 0) AS sub_total_gb + FROM inbounds, + JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client + WHERE inbounds.node_id IS NULL + `).Scan(&allRows).Error + if err != nil { + return false, 0, err + } + + // 2. Group by subId: collect emails and find the first non-zero subTotalGB. + type subGroup struct { + emails map[string]struct{} + subTotalGB int64 + members []struct{ InboundId int; Tag, Email string } + } + groups := make(map[string]*subGroup) + for _, r := range allRows { + subId := strings.TrimSpace(r.SubID) + if subId == "" { + continue + } + email := strings.Trim(r.Email, "\"") + if email == "" { + continue + } + g, ok := groups[subId] + if !ok { + g = &subGroup{emails: make(map[string]struct{})} + groups[subId] = g + } + g.emails[strings.ToLower(email)] = struct{}{} + if g.subTotalGB == 0 && r.SubTotalGB > 0 { + g.subTotalGB = r.SubTotalGB + } + g.members = append(g.members, struct{ InboundId int; Tag, Email string }{ + InboundId: r.InboundId, Tag: r.Tag, Email: email, + }) + } + + // 3. For each group with a quota, check if aggregate traffic exceeds it. + type disableTarget struct { + InboundId int + Tag string + Email string + } + var toDisable []disableTarget + + for _, g := range groups { + if g.subTotalGB <= 0 { + continue + } + + // Sum traffic for all emails in this subId group. + emails := make([]string, 0, len(g.emails)) + for e := range g.emails { + emails = append(emails, e) + } + + var totalUsed int64 + for _, batch := range chunkStrings(emails, sqliteMaxVars) { + var sum struct{ Total int64 } + if err := tx.Model(xray.ClientTraffic{}). + Select("COALESCE(SUM(up + down), 0) AS total"). + Where("email IN ? AND enable = ?", batch, true). + Scan(&sum).Error; err != nil { + continue + } + totalUsed += sum.Total + } + + if totalUsed < g.subTotalGB { + continue + } + + // Quota exceeded — mark all members for disabling. + for _, m := range g.members { + toDisable = append(toDisable, disableTarget{ + InboundId: m.InboundId, Tag: m.Tag, Email: m.Email, + }) + } + } + + if len(toDisable) == 0 { + return false, 0, nil + } + + // 4. Remove users from Xray runtime. + needRestart := false + if p != nil { + s.xrayApi.Init(p.GetAPIPort()) + for _, t := range toDisable { + err1 := s.xrayApi.RemoveUser(t.Tag, t.Email) + if err1 == nil { + logger.Debug("Sub-quota client disabled by api:", t.Email) + } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", t.Email)) { + logger.Debug("Sub-quota user already disabled:", t.Email) + } else { + logger.Debug("Error disabling sub-quota client by api:", err1) + needRestart = true + } + } + s.xrayApi.Close() + } + + // 5. Disable client_traffics rows. + disableEmails := make([]string, 0, len(toDisable)) + for _, t := range toDisable { + disableEmails = append(disableEmails, t.Email) + } + uniqDisable := uniqueNonEmptyStrings(disableEmails) + var totalDisabled int64 + for _, batch := range chunkStrings(uniqDisable, sqliteMaxVars) { + result := tx.Model(xray.ClientTraffic{}). + Where("email IN ? AND enable = ?", batch, true). + Update("enable", false) + if result.Error != nil { + logger.Warning("disableSubQuotaClients update client_traffics:", result.Error) + } + totalDisabled += result.RowsAffected + } + + // 6. Update inbound settings JSON to set enable=false on affected clients. + inboundEmailMap := make(map[int]map[string]struct{}) + for _, t := range toDisable { + if inboundEmailMap[t.InboundId] == nil { + inboundEmailMap[t.InboundId] = make(map[string]struct{}) + } + inboundEmailMap[t.InboundId][t.Email] = struct{}{} + } + inboundIds := make([]int, 0, len(inboundEmailMap)) + for id := range inboundEmailMap { + inboundIds = append(inboundIds, id) + } + var inbounds []*model.Inbound + if err = tx.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error; err != nil { + logger.Warning("disableSubQuotaClients fetch inbounds:", err) + return needRestart, totalDisabled, nil + } + now := time.Now().Unix() * 1000 + dirty := make([]*model.Inbound, 0, len(inbounds)) + for _, inbound := range inbounds { + settings := map[string]any{} + if jsonErr := json.Unmarshal([]byte(inbound.Settings), &settings); jsonErr != nil { + continue + } + clientsRaw, ok := settings["clients"].([]any) + if !ok { + continue + } + emailSet := inboundEmailMap[inbound.Id] + changed := false + for i := range clientsRaw { + c, ok := clientsRaw[i].(map[string]any) + if !ok { + continue + } + email, _ := c["email"].(string) + if _, shouldDisable := emailSet[email]; !shouldDisable { + continue + } + c["enable"] = false + c["updated_at"] = now + clientsRaw[i] = c + changed = true + } + if !changed { + continue + } + settings["clients"] = clientsRaw + modifiedSettings, jsonErr := json.MarshalIndent(settings, "", " ") + if jsonErr != nil { + continue + } + inbound.Settings = string(modifiedSettings) + dirty = append(dirty, inbound) + } + if len(dirty) > 0 { + if err = tx.Save(dirty).Error; err != nil { + logger.Warning("disableSubQuotaClients update inbound settings:", err) + } + } + + return needRestart, totalDisabled, nil +} + func (s *InboundService) GetInboundTags() (string, error) { db := database.GetDB() var inboundTags []string diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index ecf1c19d..6b5b37b0 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -86,7 +86,11 @@ "active": "نشط", "inactive": "غير نشط", "unlimited": "غير محدود", - "noExpiry": "بدون انتهاء" + "noExpiry": "بدون انتهاء", + "subRemained": "المتبقي (الاشتراك)", + "subTotalFlow": "إجمالي حركة الاشتراك", + "subUsage": "استخدام الاشتراك", + "subDepleted": "الاشتراك منتهي" }, "menu": { "theme": "الثيم", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index eedd3454..bda62644 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -86,7 +86,11 @@ "active": "Active", "inactive": "Inactive", "unlimited": "Unlimited", - "noExpiry": "No expiry" + "noExpiry": "No expiry", + "subRemained": "Remained (Sub.)", + "subTotalFlow": "Sub. Total Flow", + "subUsage": "Sub. Usage", + "subDepleted": "Sub. Depleted" }, "menu": { "theme": "Theme", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 34af89ca..6e54eba1 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -86,7 +86,11 @@ "active": "Activo", "inactive": "Inactivo", "unlimited": "Ilimitado", - "noExpiry": "Sin caducidad" + "noExpiry": "Sin caducidad", + "subRemained": "Restante (Sub.)", + "subTotalFlow": "Flujo Total Sub.", + "subUsage": "Uso Sub.", + "subDepleted": "Sub. Agotada" }, "menu": { "theme": "Tema", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index fd948788..2dfe63bd 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -86,7 +86,11 @@ "active": "فعال", "inactive": "غیرفعال", "unlimited": "نامحدود", - "noExpiry": "بدون انقضا" + "noExpiry": "بدون انقضا", + "subRemained": "باقیمانده (اشتراک)", + "subTotalFlow": "حجم کلی اشتراک", + "subUsage": "مصرف اشتراک", + "subDepleted": "اشتراک تمام شده" }, "menu": { "theme": "تم", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index b8f44ac6..504eeba0 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -86,7 +86,11 @@ "active": "Aktif", "inactive": "Nonaktif", "unlimited": "Tanpa batas", - "noExpiry": "Tanpa kedaluwarsa" + "noExpiry": "Tanpa kedaluwarsa", + "subRemained": "Sisa (Langganan)", + "subTotalFlow": "Total Aliran Langg.", + "subUsage": "Penggunaan Langg.", + "subDepleted": "Langg. Habis" }, "menu": { "theme": "Tema", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 630f5623..601a1d7d 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -86,7 +86,11 @@ "active": "有効", "inactive": "無効", "unlimited": "無制限", - "noExpiry": "期限なし" + "noExpiry": "期限なし", + "subRemained": "残り (サブ.)", + "subTotalFlow": "サブ. 合計フロー", + "subUsage": "サブ. 使用量", + "subDepleted": "サブ. 消耗済み" }, "menu": { "theme": "テーマ", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 9f1b67d7..81393b1d 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -86,7 +86,11 @@ "active": "Ativo", "inactive": "Inativo", "unlimited": "Ilimitado", - "noExpiry": "Sem validade" + "noExpiry": "Sem validade", + "subRemained": "Restante (Assin.)", + "subTotalFlow": "Fluxo Total Assin.", + "subUsage": "Uso Assin.", + "subDepleted": "Assin. Esgotada" }, "menu": { "theme": "Tema", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index f7ddafa6..baba8ab3 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -86,7 +86,11 @@ "active": "Активна", "inactive": "Неактивна", "unlimited": "Неограниченно", - "noExpiry": "Бессрочно" + "noExpiry": "Бессрочно", + "subRemained": "Осталось (Подп.)", + "subTotalFlow": "Общий лимит подписки", + "subUsage": "Использование подписки", + "subDepleted": "Подписка исчерпана" }, "menu": { "theme": "Тема", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index a8dc2c3c..db0a51fc 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -86,7 +86,11 @@ "active": "Aktif", "inactive": "Pasif", "unlimited": "Sınırsız", - "noExpiry": "Süresiz" + "noExpiry": "Süresiz", + "subRemained": "Kalan (Abo.)", + "subTotalFlow": "Abo. Toplam Akış", + "subUsage": "Abo. Kullanım", + "subDepleted": "Abo. Tükendi" }, "menu": { "theme": "Tema", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index 417866d1..e50f018c 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -86,7 +86,11 @@ "active": "Активна", "inactive": "Неактивна", "unlimited": "Безліміт", - "noExpiry": "Без строку" + "noExpiry": "Без строку", + "subRemained": "Залишок (Підп.)", + "subTotalFlow": "Загальний ліміт підписки", + "subUsage": "Використання підписки", + "subDepleted": "Підписку вичерпано" }, "menu": { "theme": "Тема", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index b292c058..69877883 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -86,7 +86,11 @@ "active": "Hoạt động", "inactive": "Không hoạt động", "unlimited": "Không giới hạn", - "noExpiry": "Không hết hạn" + "noExpiry": "Không hết hạn", + "subRemained": "Còn lại (Đăng ký)", + "subTotalFlow": "Tổng lưu lượng đăng ký", + "subUsage": "Sử dụng đăng ký", + "subDepleted": "Đăng ký đã hết" }, "menu": { "theme": "Chủ đề", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 3f648cb2..4913c91b 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -86,7 +86,11 @@ "active": "启用", "inactive": "停用", "unlimited": "无限制", - "noExpiry": "无到期" + "noExpiry": "无到期", + "subRemained": "剩余 (订阅)", + "subTotalFlow": "订阅总流量", + "subUsage": "订阅用量", + "subDepleted": "订阅已耗尽" }, "menu": { "theme": "主题", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index a28c0e0f..0a9487af 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -86,7 +86,11 @@ "active": "啟用", "inactive": "停用", "unlimited": "無限制", - "noExpiry": "無到期" + "noExpiry": "無到期", + "subRemained": "剩餘 (訂閱)", + "subTotalFlow": "訂閱總流量", + "subUsage": "訂閱用量", + "subDepleted": "訂閱已耗盡" }, "menu": { "theme": "主題",