feat: add aggregated subscription traffic tracking and display support

This commit is contained in:
SadeghKalami 2026-05-16 04:49:54 +03:30
parent 2928b52b04
commit 2002b84b19
21 changed files with 466 additions and 24 deletions

View file

@ -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

View file

@ -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;
}

View file

@ -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',

View file

@ -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(() =>
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item v-if="subEnable && client.subId">
<template #label>
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('subscription.totalQuota') }}</a-tooltip>
</template>
<a-input-number v-model:value="subTotalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
{{ SizeFormatter.sizeFormat(clientStats.up) }} /

View file

@ -361,6 +361,10 @@ function confirmBulkDelete() {
<td>{{ t('remained') }}</td>
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
</tr>
<tr v-if="client.subTotalGB > 0">
<td>{{ t('subscription.totalQuota') }}</td>
<td>{{ SizeFormatter.sizeFormat(client.subTotalGB) }}</td>
</tr>
</tbody>
</table>
</template>

View file

@ -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(
<th>{{ t('remained') }}</th>
<th>{{ t('pages.inbounds.totalUsage') }}</th>
<th>{{ t('pages.inbounds.expireDate') }}</th>
<th v-if="subTrafficInfo && subTrafficInfo.total > 0">{{ t('subscription.subRemained') }}</th>
</tr>
</thead>
<tbody>
@ -409,6 +448,10 @@ const showSubscriptionTab = computed(
<InfinityIcon />
</a-tag>
</td>
<td v-if="subTrafficInfo && subTrafficInfo.total > 0">
<a-tag :color="subStatsColor()">{{ getSubRemaining() }}</a-tag>
<a-tag>{{ SizeFormatter.sizeFormat(subTrafficInfo.total) }}</a-tag>
</td>
</tr>
</tbody>
</table>

View file

@ -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{}

View file

@ -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

View file

@ -86,7 +86,11 @@
"active": "نشط",
"inactive": "غير نشط",
"unlimited": "غير محدود",
"noExpiry": "بدون انتهاء"
"noExpiry": "بدون انتهاء",
"subRemained": "المتبقي (الاشتراك)",
"subTotalFlow": "إجمالي حركة الاشتراك",
"subUsage": "استخدام الاشتراك",
"subDepleted": "الاشتراك منتهي"
},
"menu": {
"theme": "الثيم",

View file

@ -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",

View file

@ -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",

View file

@ -86,7 +86,11 @@
"active": "فعال",
"inactive": "غیرفعال",
"unlimited": "نامحدود",
"noExpiry": "بدون انقضا"
"noExpiry": "بدون انقضا",
"subRemained": "باقیمانده (اشتراک)",
"subTotalFlow": "حجم کلی اشتراک",
"subUsage": "مصرف اشتراک",
"subDepleted": "اشتراک تمام شده"
},
"menu": {
"theme": "تم",

View file

@ -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",

View file

@ -86,7 +86,11 @@
"active": "有効",
"inactive": "無効",
"unlimited": "無制限",
"noExpiry": "期限なし"
"noExpiry": "期限なし",
"subRemained": "残り (サブ.)",
"subTotalFlow": "サブ. 合計フロー",
"subUsage": "サブ. 使用量",
"subDepleted": "サブ. 消耗済み"
},
"menu": {
"theme": "テーマ",

View file

@ -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",

View file

@ -86,7 +86,11 @@
"active": "Активна",
"inactive": "Неактивна",
"unlimited": "Неограниченно",
"noExpiry": "Бессрочно"
"noExpiry": "Бессрочно",
"subRemained": "Осталось (Подп.)",
"subTotalFlow": "Общий лимит подписки",
"subUsage": "Использование подписки",
"subDepleted": "Подписка исчерпана"
},
"menu": {
"theme": "Тема",

View file

@ -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",

View file

@ -86,7 +86,11 @@
"active": "Активна",
"inactive": "Неактивна",
"unlimited": "Безліміт",
"noExpiry": "Без строку"
"noExpiry": "Без строку",
"subRemained": "Залишок (Підп.)",
"subTotalFlow": "Загальний ліміт підписки",
"subUsage": "Використання підписки",
"subDepleted": "Підписку вичерпано"
},
"menu": {
"theme": "Тема",

View file

@ -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ủ đề",

View file

@ -86,7 +86,11 @@
"active": "启用",
"inactive": "停用",
"unlimited": "无限制",
"noExpiry": "无到期"
"noExpiry": "无到期",
"subRemained": "剩余 (订阅)",
"subTotalFlow": "订阅总流量",
"subUsage": "订阅用量",
"subDepleted": "订阅已耗尽"
},
"menu": {
"theme": "主题",

View file

@ -86,7 +86,11 @@
"active": "啟用",
"inactive": "停用",
"unlimited": "無限制",
"noExpiry": "無到期"
"noExpiry": "無到期",
"subRemained": "剩餘 (訂閱)",
"subTotalFlow": "訂閱總流量",
"subUsage": "訂閱用量",
"subDepleted": "訂閱已耗盡"
},
"menu": {
"theme": "主題",