Selaa lähdekoodia

Bulk extend client expiry / traffic + clients page polish (#4499)

* chore(sub): drop unused getFallbackMaster

projectThroughFallbackMaster fully supersedes it for both
panel-tracked and legacy unix-socket fallbacks.

* feat(clients): bulk extend expiry / traffic for selected clients

Adds POST /panel/api/clients/bulkAdjust which shifts ExpiryTime by
addDays and TotalGB by addBytes for every email in one request. The
endpoint is wired into the clients page through a new ClientBulkAdjustModal
that opens from the existing multi-select toolbar.

Clients with unlimited expiry (expiryTime=0) or unlimited traffic
(totalGB=0) are skipped for the corresponding field so bulk extend
never accidentally converts an unlimited client to a limited one.
Negative values are allowed for refunds / corrections.

Translations added for all 13 locales.

* fix(db): silence GORM record-not-found spam in debug mode

getSetting handles ErrRecordNotFound via database.IsNotFound and falls
back to defaults, but GORM's Default logger still logs each miss as an
error. With periodic jobs reading unset keys (xrayTemplateConfig,
externalTrafficInformEnable) the panel log flooded thousands of times.
Switch to a logger.New with IgnoreRecordNotFoundError=true so legitimate
slow-query and SQL traces still surface in debug mode.

* fix(clients): include inboundsById in columns memo deps

Without it, the table's first paint captured an empty inboundsById and
rendered each attached inbound as #<id>. Once a sort/filter forced the
memo to rebuild it self-corrected, hence the visible flicker on reload.

* fix(clients): handle delayed-start expiry in bulk adjust

Negative ExpiryTime encodes a delay duration (magnitude = ms until
the trial begins on first use). Adding positive addDays was simply
arithmetically added, so e.g. a -7d delay + 30d turned into +23d
since epoch (1970), making the client instantly expired.

Branch on sign now: positive ExpiryTime extends additively, negative
extends by subtracting so the value stays negative (more delay).
Cross-sign reductions are skipped with an explicit reason instead of
silently corrupting the field.

* fix(clients): step traffic input by 1 GB instead of 0.1

The +/- buttons on the Total Sent/Received field nudged in 0.1 GB
increments which is too granular for typical use. Set step=1 so each
press moves a whole GB; users can still type decimal values directly.

* fix(inbounds): step Total Flow input by 1 GB instead of 0.1

Matches the same nudge fix applied to the client form's Total
Sent/Received field.
Sanaei 1 päivä sitten
vanhempi
sitoutus
9c60ed7ea8

+ 9 - 1
database/db.go

@@ -346,7 +346,15 @@ func isTableEmpty(tableName string) (bool, error) {
 func InitDB(dbPath string) error {
 	var gormLogger logger.Interface
 	if config.IsDebug() {
-		gormLogger = logger.Default
+		gormLogger = logger.New(
+			log.New(os.Stdout, "\r\n", log.LstdFlags),
+			logger.Config{
+				SlowThreshold:             time.Second,
+				LogLevel:                  logger.Info,
+				IgnoreRecordNotFoundError: true,
+				Colorful:                  true,
+			},
+		)
 	} else {
 		gormLogger = logger.Discard
 	}

+ 12 - 0
frontend/src/hooks/useClients.ts

@@ -146,6 +146,17 @@ export function useClients() {
     return results;
   }, [refresh]);
 
+  const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => {
+    if (!Array.isArray(emails) || emails.length === 0) return null;
+    const msg = await HttpUtil.post(
+      '/panel/api/clients/bulkAdjust',
+      { emails, addDays, addBytes },
+      JSON_HEADERS,
+    ) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
   const attach = useCallback(async (email: string, inboundIds: number[]) => {
     if (!email) return null;
     const encoded = encodeURIComponent(email);
@@ -269,6 +280,7 @@ export function useClients() {
     update,
     remove,
     removeMany,
+    bulkAdjust,
     attach,
     detach,
     resetTraffic,

+ 7 - 0
frontend/src/pages/api-docs/endpoints.js

@@ -461,6 +461,13 @@ export const sections = [
         summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
         response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/bulkAdjust',
+        summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.',
+        body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/resetTraffic/:email',

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -308,7 +308,7 @@ export default function ClientBulkAddModal({
         )}
 
         <Form.Item label={t('pages.clients.totalGB')}>
-          <InputNumber value={form.totalGB} min={0} step={0.1} onChange={(v) => update('totalGB', Number(v) || 0)} />
+          <InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />
         </Form.Item>
 
         <Form.Item label={t('pages.clients.delayedStart')}>

+ 97 - 0
frontend/src/pages/clients/ClientBulkAdjustModal.tsx

@@ -0,0 +1,97 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Form, InputNumber, Modal, message } from 'antd';
+
+const GB = 1024 * 1024 * 1024;
+
+interface ClientBulkAdjustModalProps {
+  open: boolean;
+  count: number;
+  onOpenChange: (open: boolean) => void;
+  onSubmit: (addDays: number, addBytes: number) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
+}
+
+export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [addDays, setAddDays] = useState<number>(0);
+  const [addGB, setAddGB] = useState<number>(0);
+  const [submitting, setSubmitting] = useState(false);
+
+  useEffect(() => {
+    if (open) {
+      setAddDays(0);
+      setAddGB(0);
+    }
+  }, [open]);
+
+  async function handleOk() {
+    const days = Math.trunc(Number(addDays) || 0);
+    const gb = Number(addGB) || 0;
+    if (days === 0 && gb === 0) {
+      messageApi.warning(t('pages.clients.bulkAdjustNothing'));
+      return;
+    }
+    setSubmitting(true);
+    try {
+      const bytes = Math.trunc(gb * GB);
+      const result = await onSubmit(days, bytes);
+      if (!result) return;
+      const ok = result.adjusted ?? 0;
+      const skipped = result.skipped?.length ?? 0;
+      if (skipped === 0) {
+        messageApi.success(t('pages.clients.toasts.bulkAdjusted', { count: ok }));
+      } else {
+        const firstReason = result.skipped?.[0]?.reason ?? '';
+        messageApi.warning(firstReason
+          ? `${t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped })} — ${firstReason}`
+          : t('pages.clients.toasts.bulkAdjustedMixed', { ok, skipped }));
+      }
+      onOpenChange(false);
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={t('pages.clients.bulkAdjustTitle', { count })}
+        okText={t('apply')}
+        cancelText={t('cancel')}
+        confirmLoading={submitting}
+        onOk={handleOk}
+        onCancel={() => onOpenChange(false)}
+        destroyOnHidden
+      >
+        <Alert
+          type="info"
+          showIcon
+          style={{ marginBottom: 16 }}
+          message={t('pages.clients.bulkAdjustHint')}
+        />
+        <Form layout="vertical">
+          <Form.Item label={t('pages.clients.addDays')}>
+            <InputNumber
+              value={addDays}
+              onChange={(v) => setAddDays(Number(v) || 0)}
+              style={{ width: '100%' }}
+              step={1}
+              precision={0}
+            />
+          </Form.Item>
+          <Form.Item label={t('pages.clients.addTrafficGB')}>
+            <InputNumber
+              value={addGB}
+              onChange={(v) => setAddGB(Number(v) || 0)}
+              style={{ width: '100%' }}
+              step={1}
+            />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </>
+  );
+}

+ 1 - 1
frontend/src/pages/clients/ClientFormModal.tsx

@@ -393,7 +393,7 @@ export default function ClientFormModal({
           </Col>
           <Col xs={24} md={ipLimitEnable ? 8 : 12}>
             <Form.Item label={t('pages.clients.totalGB')}>
-              <InputNumber value={form.totalGB} min={0} step={0.1} style={{ width: '100%' }}
+              <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
                 onChange={(v) => update('totalGB', Number(v) || 0)} />
             </Form.Item>
           </Col>

+ 26 - 5
frontend/src/pages/clients/ClientsPage.tsx

@@ -25,6 +25,7 @@ import {
 } from 'antd';
 import type { ColumnsType, TableProps } from 'antd/es/table';
 import {
+  ClockCircleOutlined,
   DeleteOutlined,
   EditOutlined,
   FilterOutlined,
@@ -54,6 +55,7 @@ import ClientFormModal from './ClientFormModal';
 import ClientInfoModal from './ClientInfoModal';
 import ClientQrModal from './ClientQrModal';
 import ClientBulkAddModal from './ClientBulkAddModal';
+import ClientBulkAdjustModal from './ClientBulkAdjustModal';
 import '@/styles/page-cards.css';
 import './ClientsPage.css';
 
@@ -96,7 +98,7 @@ export default function ClientsPage() {
   const {
     clients, inbounds, onlines, loading, fetched, subSettings,
     ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
-    create, update, remove, removeMany, attach, detach,
+    create, update, remove, removeMany, bulkAdjust, attach, detach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent, applyInvalidate,
   } = useClients();
@@ -117,6 +119,7 @@ export default function ClientsPage() {
   const [qrOpen, setQrOpen] = useState(false);
   const [qrClient, setQrClient] = useState<ClientRecord | null>(null);
   const [bulkAddOpen, setBulkAddOpen] = useState(false);
+  const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
   const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
 
   const initial = readFilterState();
@@ -587,7 +590,7 @@ export default function ClientsPage() {
       }, 'expiryTime'),
     ];
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline]);
+  }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline, inboundsById]);
 
   const tablePagination = {
     current: currentPage,
@@ -700,9 +703,14 @@ export default function ClientsPage() {
                             {!isMobile && t('pages.clients.bulk')}
                           </Button>
                           {selectedRowKeys.length > 0 && (
-                            <Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
-                              {t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
-                            </Button>
+                            <>
+                              <Button size="small" icon={<ClockCircleOutlined />} onClick={() => setBulkAdjustOpen(true)}>
+                                {t('pages.clients.adjustSelected', { count: selectedRowKeys.length })}
+                              </Button>
+                              <Button danger size="small" icon={<DeleteOutlined />} onClick={onBulkDelete}>
+                                {t('pages.clients.deleteSelected', { count: selectedRowKeys.length })}
+                              </Button>
+                            </>
                           )}
                           <Button size="small" icon={<RetweetOutlined />} onClick={onResetAllTraffics}>
                             {!isMobile && t('pages.clients.resetAllTraffics')}
@@ -902,6 +910,19 @@ export default function ClientsPage() {
           onOpenChange={setBulkAddOpen}
           onSaved={() => setBulkAddOpen(false)}
         />
+        <ClientBulkAdjustModal
+          open={bulkAdjustOpen}
+          count={selectedRowKeys.length}
+          onOpenChange={setBulkAdjustOpen}
+          onSubmit={async (addDays, addBytes) => {
+            const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
+            if (msg?.success) {
+              setSelectedRowKeys([]);
+              return msg.obj ?? { adjusted: 0 };
+            }
+            return null;
+          }}
+        />
       </Layout>
     </ConfigProvider>
   );

+ 1 - 1
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -966,7 +966,7 @@ export default function InboundFormModal({
         <InputNumber
           value={totalGB}
           min={0}
-          step={0.1}
+          step={1}
           onChange={(v) => {
             form.total = NumberFormatter.toFixed((Number(v) || 0) * SizeFormatter.ONE_GB, 0);
             refresh();

+ 0 - 14
sub/subService.go

@@ -174,20 +174,6 @@ func (s *SubService) getClientTraffics(traffics []xray.ClientTraffic, email stri
 	return xray.ClientTraffic{}
 }
 
-func (s *SubService) getFallbackMaster(dest string, streamSettings string) (string, int, string, error) {
-	db := database.GetDB()
-	var inbound *model.Inbound
-	err := db.Model(model.Inbound{}).
-		Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
-		Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", dest).
-		Find(&inbound).Error
-	if err != nil {
-		return "", 0, "", err
-	}
-
-	return inbound.Listen, inbound.Port, mergeStreamFromMaster(streamSettings, inbound.StreamSettings), nil
-}
-
 // projectThroughFallbackMaster mutates the inbound in place so its
 // Listen/Port/StreamSettings reflect the externally reachable master
 // when applicable. Covers both fallback mechanisms:

+ 25 - 0
web/controller/client.go

@@ -42,6 +42,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/:email/detach", a.detach)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/delDepleted", a.delDepleted)
+	g.POST("/bulkAdjust", a.bulkAdjust)
 	g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
 	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
 	g.POST("/ips/:email", a.getIps)
@@ -162,6 +163,30 @@ func (a *ClientController) resetAllTraffics(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+type bulkAdjustRequest struct {
+	Emails   []string `json:"emails"`
+	AddDays  int      `json:"addDays"`
+	AddBytes int64    `json:"addBytes"`
+}
+
+func (a *ClientController) bulkAdjust(c *gin.Context) {
+	var req bulkAdjustRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, 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 {

+ 93 - 0
web/service/client.go

@@ -803,6 +803,99 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
 	return needRestart, nil
 }
 
+// BulkAdjustResult is returned by BulkAdjust to report how many clients were
+// successfully updated and which were skipped (typically because the field
+// being adjusted was unlimited for that client) or failed.
+type BulkAdjustResult struct {
+	Adjusted int                `json:"adjusted"`
+	Skipped  []BulkAdjustReport `json:"skipped,omitempty"`
+}
+
+type BulkAdjustReport struct {
+	Email  string `json:"email"`
+	Reason string `json:"reason"`
+}
+
+// BulkAdjust shifts ExpiryTime by addDays (days) and TotalGB by addBytes
+// for every email in the list. Clients whose corresponding field is
+// unlimited (0) are skipped — bulk extend should not accidentally
+// limit an unlimited client. addDays and addBytes may be negative.
+func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64) (BulkAdjustResult, bool, error) {
+	result := BulkAdjustResult{}
+	needRestart := false
+	if len(emails) == 0 {
+		return result, needRestart, nil
+	}
+	if addDays == 0 && addBytes == 0 {
+		return result, needRestart, common.NewError("no adjustment specified")
+	}
+
+	addExpiryMs := int64(addDays) * 24 * 60 * 60 * 1000
+
+	for _, email := range emails {
+		email = strings.TrimSpace(email)
+		if email == "" {
+			continue
+		}
+		rec, err := s.GetRecordByEmail(nil, email)
+		if err != nil {
+			result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: err.Error()})
+			continue
+		}
+		client := rec.ToClient()
+
+		applied := false
+		if addDays != 0 {
+			switch {
+			case rec.ExpiryTime == 0:
+				result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "unlimited expiry"})
+			case rec.ExpiryTime > 0:
+				next := rec.ExpiryTime + addExpiryMs
+				if next <= 0 {
+					result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "reduction exceeds remaining time"})
+				} else {
+					client.ExpiryTime = next
+					applied = true
+				}
+			default:
+				next := rec.ExpiryTime - addExpiryMs
+				if next >= 0 {
+					result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "reduction exceeds delay window"})
+				} else {
+					client.ExpiryTime = next
+					applied = true
+				}
+			}
+		}
+		if addBytes != 0 {
+			if rec.TotalGB == 0 {
+				result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "unlimited traffic"})
+			} else {
+				next := rec.TotalGB + addBytes
+				if next < 0 {
+					next = 0
+				}
+				client.TotalGB = next
+				applied = true
+			}
+		}
+		if !applied {
+			continue
+		}
+
+		nr, err := s.Update(inboundSvc, rec.Id, *client)
+		if err != nil {
+			result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: err.Error()})
+			continue
+		}
+		if nr {
+			needRestart = true
+		}
+		result.Adjusted++
+	}
+	return result, needRestart, nil
+}
+
 func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
 	db := database.GetDB()
 	now := time.Now().UnixMilli()

+ 8 - 0
web/translation/ar-EG.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "حذف العميل {email}؟",
       "deleteConfirmContent": "سيؤدي هذا إلى إزالة العميل من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.",
       "deleteSelected": "حذف ({count})",
+      "adjustSelected": "تعديل ({count})",
       "bulkDeleteConfirmTitle": "حذف {count} عميل؟",
       "bulkDeleteConfirmContent": "سيتم إزالة كل عميل محدد من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.",
+      "bulkAdjustTitle": "تعديل {count} عميل",
+      "bulkAdjustHint": "القيم الموجبة تزيد، السالبة تنقص. العملاء بصلاحية أو ترافيك غير محدود يُتخطّون لذلك الحقل.",
+      "bulkAdjustNothing": "حدد الأيام أو الترافيك قبل التطبيق.",
+      "addDays": "إضافة أيام",
+      "addTrafficGB": "إضافة ترافيك (GB)",
       "delDepleted": "حذف المنتهية",
       "delDepletedConfirmTitle": "حذف العملاء المنتهية حصصهم؟",
       "delDepletedConfirmContent": "يُحذف كل عميل استُنفِدت حصة حركة مروره أو انتهت صلاحيته. لا يمكن التراجع.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}",
         "bulkCreated": "تم إنشاء {count} عميل",
         "bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
+        "bulkAdjusted": "تم تعديل {count} عميل",
+        "bulkAdjustedMixed": "{ok} تم تعديلهم، {skipped} تم تخطيهم",
         "delDepleted": "تم حذف {count} عميل منتهٍ"
       }
     },

+ 8 - 0
web/translation/en-US.json

@@ -479,8 +479,14 @@
       "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})",
+      "adjustSelected": "Adjust ({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.",
+      "bulkAdjustTitle": "Adjust {count} clients",
+      "bulkAdjustHint": "Positive values extend, negative values reduce. Clients with unlimited expiry or traffic are skipped for that field.",
+      "bulkAdjustNothing": "Set days or traffic before applying.",
+      "addDays": "Add days",
+      "addTrafficGB": "Add traffic (GB)",
       "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.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "{ok} deleted, {failed} failed",
         "bulkCreated": "{count} clients created",
         "bulkCreatedMixed": "{ok} created, {failed} failed",
+        "bulkAdjusted": "{count} clients adjusted",
+        "bulkAdjustedMixed": "{ok} adjusted, {skipped} skipped",
         "delDepleted": "{count} depleted clients deleted"
       }
     },

+ 8 - 0
web/translation/es-ES.json

@@ -479,8 +479,14 @@
       "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})",
+      "adjustSelected": "Ajustar ({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.",
+      "bulkAdjustTitle": "Ajustar {count} clientes",
+      "bulkAdjustHint": "Los valores positivos extienden, los negativos reducen. Los clientes con expiración o tráfico ilimitado se omiten para ese campo.",
+      "bulkAdjustNothing": "Establece días o tráfico antes de aplicar.",
+      "addDays": "Añadir días",
+      "addTrafficGB": "Añadir tráfico (GB)",
       "delDepleted": "Eliminar agotados",
       "delDepletedConfirmTitle": "¿Eliminar clientes agotados?",
       "delDepletedConfirmContent": "Elimina todos los clientes con cuota agotada o expirados. No se puede deshacer.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "{ok} eliminados, {failed} fallidos",
         "bulkCreated": "{count} clientes creados",
         "bulkCreatedMixed": "{ok} creados, {failed} fallidos",
+        "bulkAdjusted": "{count} clientes ajustados",
+        "bulkAdjustedMixed": "{ok} ajustados, {skipped} omitidos",
         "delDepleted": "{count} clientes agotados eliminados"
       }
     },

+ 8 - 0
web/translation/fa-IR.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "حذف کلاینت {email}؟",
       "deleteConfirmContent": "این کلاینت از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.",
       "deleteSelected": "حذف ({count})",
+      "adjustSelected": "تنظیم ({count})",
       "bulkDeleteConfirmTitle": "حذف {count} کلاینت؟",
       "bulkDeleteConfirmContent": "هر کلاینت انتخاب‌شده از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.",
+      "bulkAdjustTitle": "تنظیم {count} کلاینت",
+      "bulkAdjustHint": "مقادیر مثبت اضافه و منفی کم می‌کنند. کلاینت‌هایی که زمان یا ترافیک نامحدود دارند برای همان فیلد رد می‌شوند.",
+      "bulkAdjustNothing": "قبل از اعمال، روز یا ترافیک را تنظیم کنید.",
+      "addDays": "افزودن روز",
+      "addTrafficGB": "افزودن ترافیک (گیگابایت)",
       "delDepleted": "حذف اتمام‌یافته‌ها",
       "delDepletedConfirmTitle": "حذف کلاینت‌های اتمام‌یافته؟",
       "delDepletedConfirmContent": "هر کلاینتی که سهمیه ترافیک‌اش تمام شده یا تاریخ انقضایش گذشته است حذف می‌شود. این عمل غیرقابل بازگشت است.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "{ok} حذف، {failed} ناموفق",
         "bulkCreated": "{count} کلاینت ساخته شد",
         "bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
+        "bulkAdjusted": "{count} کلاینت تنظیم شد",
+        "bulkAdjustedMixed": "{ok} تنظیم، {skipped} رد شد",
         "delDepleted": "{count} کلاینت اتمام‌یافته حذف شد"
       }
     },

+ 8 - 0
web/translation/id-ID.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "Hapus klien {email}?",
       "deleteConfirmContent": "Tindakan ini menghapus klien dari setiap inbound terlampir dan menghapus catatan lalu lintasnya. Tidak dapat dibatalkan.",
       "deleteSelected": "Hapus ({count})",
+      "adjustSelected": "Sesuaikan ({count})",
       "bulkDeleteConfirmTitle": "Hapus {count} klien?",
       "bulkDeleteConfirmContent": "Setiap klien yang dipilih dihapus dari semua inbound terlampir dan catatan lalu lintasnya dihapus. Tidak dapat dibatalkan.",
+      "bulkAdjustTitle": "Sesuaikan {count} klien",
+      "bulkAdjustHint": "Nilai positif menambah, negatif mengurangi. Klien dengan masa berlaku atau trafik tak terbatas dilewati untuk bidang tersebut.",
+      "bulkAdjustNothing": "Setel hari atau trafik sebelum menerapkan.",
+      "addDays": "Tambah hari",
+      "addTrafficGB": "Tambah trafik (GB)",
       "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.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "{ok} dihapus, {failed} gagal",
         "bulkCreated": "{count} klien dibuat",
         "bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
+        "bulkAdjusted": "{count} klien disesuaikan",
+        "bulkAdjustedMixed": "{ok} disesuaikan, {skipped} dilewati",
         "delDepleted": "{count} klien habis dihapus"
       }
     },

+ 8 - 0
web/translation/ja-JP.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "クライアント {email} を削除しますか?",
       "deleteConfirmContent": "クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。",
       "deleteSelected": "削除 ({count})",
+      "adjustSelected": "調整 ({count})",
       "bulkDeleteConfirmTitle": "{count} 件のクライアントを削除しますか?",
       "bulkDeleteConfirmContent": "選択された各クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。",
+      "bulkAdjustTitle": "{count} 件のクライアントを調整",
+      "bulkAdjustHint": "正の値は延長、負の値は短縮します。無期限の有効期限または無制限のトラフィックを持つクライアントは、その項目についてスキップされます。",
+      "bulkAdjustNothing": "適用する前に日数またはトラフィックを設定してください。",
+      "addDays": "日数を追加",
+      "addTrafficGB": "トラフィックを追加 (GB)",
       "delDepleted": "使い切ったクライアントを削除",
       "delDepletedConfirmTitle": "使い切ったクライアントを削除しますか?",
       "delDepletedConfirmContent": "トラフィック上限に達したか有効期限が切れたクライアントをすべて削除します。元に戻せません。",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗",
         "bulkCreated": "{count} 件のクライアントを作成しました",
         "bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
+        "bulkAdjusted": "{count} 件のクライアントを調整しました",
+        "bulkAdjustedMixed": "{ok} 件調整、{skipped} 件スキップ",
         "delDepleted": "使い切った {count} 件のクライアントを削除しました"
       }
     },

+ 8 - 0
web/translation/pt-BR.json

@@ -479,8 +479,14 @@
       "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})",
+      "adjustSelected": "Ajustar ({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.",
+      "bulkAdjustTitle": "Ajustar {count} clientes",
+      "bulkAdjustHint": "Valores positivos estendem, negativos reduzem. Clientes com expiração ou tráfego ilimitado são ignorados para esse campo.",
+      "bulkAdjustNothing": "Defina dias ou tráfego antes de aplicar.",
+      "addDays": "Adicionar dias",
+      "addTrafficGB": "Adicionar tráfego (GB)",
       "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.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "{ok} excluídos, {failed} com falha",
         "bulkCreated": "{count} clientes criados",
         "bulkCreatedMixed": "{ok} criados, {failed} com falha",
+        "bulkAdjusted": "{count} clientes ajustados",
+        "bulkAdjustedMixed": "{ok} ajustados, {skipped} ignorados",
         "delDepleted": "{count} clientes esgotados excluídos"
       }
     },

+ 8 - 0
web/translation/ru-RU.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "Удалить клиента {email}?",
       "deleteConfirmContent": "Клиент будет удалён из всех привязанных входящих, а его запись трафика будет уничтожена. Это действие нельзя отменить.",
       "deleteSelected": "Удалить ({count})",
+      "adjustSelected": "Изменить ({count})",
       "bulkDeleteConfirmTitle": "Удалить {count} клиентов?",
       "bulkDeleteConfirmContent": "Каждый выбранный клиент удаляется из всех привязанных входящих, его запись трафика уничтожается. Это действие нельзя отменить.",
+      "bulkAdjustTitle": "Изменить {count} клиентов",
+      "bulkAdjustHint": "Положительные значения добавляют, отрицательные — уменьшают. Клиенты с неограниченным сроком или трафиком пропускаются для соответствующего поля.",
+      "bulkAdjustNothing": "Укажите дни или трафик перед применением.",
+      "addDays": "Добавить дни",
+      "addTrafficGB": "Добавить трафик (ГБ)",
       "delDepleted": "Удалить исчерпанных",
       "delDepletedConfirmTitle": "Удалить исчерпанных клиентов?",
       "delDepletedConfirmContent": "Удаляются все клиенты, у которых исчерпана квота трафика или истёк срок. Это действие нельзя отменить.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}",
         "bulkCreated": "Создано клиентов: {count}",
         "bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
+        "bulkAdjusted": "Изменено клиентов: {count}",
+        "bulkAdjustedMixed": "Изменено: {ok}, пропущено: {skipped}",
         "delDepleted": "Удалено исчерпанных клиентов: {count}"
       }
     },

+ 8 - 0
web/translation/tr-TR.json

@@ -479,8 +479,14 @@
       "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})",
+      "adjustSelected": "Ayarla ({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.",
+      "bulkAdjustTitle": "{count} istemciyi ayarla",
+      "bulkAdjustHint": "Pozitif değerler ekler, negatif değerler azaltır. Sınırsız süreli veya trafikli istemciler ilgili alan için atlanır.",
+      "bulkAdjustNothing": "Uygulamadan önce gün veya trafik belirleyin.",
+      "addDays": "Gün ekle",
+      "addTrafficGB": "Trafik ekle (GB)",
       "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.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
         "bulkCreated": "{count} istemci oluşturuldu",
         "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
+        "bulkAdjusted": "{count} istemci ayarlandı",
+        "bulkAdjustedMixed": "{ok} ayarlandı, {skipped} atlandı",
         "delDepleted": "{count} tükenmiş istemci silindi"
       }
     },

+ 8 - 0
web/translation/uk-UA.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "Видалити клієнта {email}?",
       "deleteConfirmContent": "Клієнт буде вилучений з усіх прив'язаних вхідних, його запис трафіку буде знищено. Цю дію неможливо скасувати.",
       "deleteSelected": "Видалити ({count})",
+      "adjustSelected": "Змінити ({count})",
       "bulkDeleteConfirmTitle": "Видалити {count} клієнтів?",
       "bulkDeleteConfirmContent": "Кожен вибраний клієнт вилучається з усіх прив'язаних вхідних, його запис трафіку знищується. Цю дію неможливо скасувати.",
+      "bulkAdjustTitle": "Змінити {count} клієнтів",
+      "bulkAdjustHint": "Додатні значення подовжують, від'ємні зменшують. Клієнти з необмеженим терміном або трафіком пропускаються для відповідного поля.",
+      "bulkAdjustNothing": "Вкажіть дні або трафік перед застосуванням.",
+      "addDays": "Додати дні",
+      "addTrafficGB": "Додати трафік (ГБ)",
       "delDepleted": "Видалити вичерпаних",
       "delDepletedConfirmTitle": "Видалити вичерпаних клієнтів?",
       "delDepletedConfirmContent": "Видаляються всі клієнти, у яких вичерпана квота трафіку або сплив термін. Цю дію неможливо скасувати.",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}",
         "bulkCreated": "Створено клієнтів: {count}",
         "bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
+        "bulkAdjusted": "Змінено клієнтів: {count}",
+        "bulkAdjustedMixed": "Змінено: {ok}, пропущено: {skipped}",
         "delDepleted": "Видалено вичерпаних клієнтів: {count}"
       }
     },

+ 8 - 0
web/translation/vi-VN.json

@@ -479,8 +479,14 @@
       "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})",
+      "adjustSelected": "Điều chỉnh ({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.",
+      "bulkAdjustTitle": "Điều chỉnh {count} khách hàng",
+      "bulkAdjustHint": "Giá trị dương kéo dài, giá trị âm rút ngắn. Khách hàng có hạn hoặc lưu lượng không giới hạn sẽ bị bỏ qua cho trường đó.",
+      "bulkAdjustNothing": "Đặt số ngày hoặc lưu lượng trước khi áp dụng.",
+      "addDays": "Thêm ngày",
+      "addTrafficGB": "Thêm lưu lượng (GB)",
       "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.",
@@ -503,6 +509,8 @@
         "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}",
+        "bulkAdjusted": "Đã điều chỉnh {count} khách hàng",
+        "bulkAdjustedMixed": "Đã điều chỉnh {ok}, bỏ qua {skipped}",
         "delDepleted": "Đã xóa {count} khách hàng hết hạn mức"
       }
     },

+ 8 - 0
web/translation/zh-CN.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "删除客户端 {email}?",
       "deleteConfirmContent": "将从所有关联入站中移除该客户端并删除其流量记录。该操作不可撤销。",
       "deleteSelected": "删除 ({count})",
+      "adjustSelected": "调整 ({count})",
       "bulkDeleteConfirmTitle": "删除 {count} 个客户端?",
       "bulkDeleteConfirmContent": "每个所选客户端都会从关联的入站中被移除,其流量记录也会被删除。该操作不可撤销。",
+      "bulkAdjustTitle": "调整 {count} 个客户端",
+      "bulkAdjustHint": "正值延长,负值减少。具有无限期限或流量的客户端将跳过该字段。",
+      "bulkAdjustNothing": "应用前请设置天数或流量。",
+      "addDays": "添加天数",
+      "addTrafficGB": "添加流量 (GB)",
       "delDepleted": "删除已耗尽",
       "delDepletedConfirmTitle": "删除已耗尽的客户端?",
       "delDepletedConfirmContent": "删除所有流量配额已用尽或已过期的客户端。该操作不可撤销。",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个",
         "bulkCreated": "已创建 {count} 个客户端",
         "bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
+        "bulkAdjusted": "已调整 {count} 个客户端",
+        "bulkAdjustedMixed": "已调整 {ok} 个,跳过 {skipped} 个",
         "delDepleted": "已删除 {count} 个已耗尽的客户端"
       }
     },

+ 8 - 0
web/translation/zh-TW.json

@@ -479,8 +479,14 @@
       "deleteConfirmTitle": "刪除客戶端 {email}?",
       "deleteConfirmContent": "將從所有關聯入站中移除該客戶端並刪除其流量紀錄。此操作無法復原。",
       "deleteSelected": "刪除 ({count})",
+      "adjustSelected": "調整 ({count})",
       "bulkDeleteConfirmTitle": "刪除 {count} 個客戶端?",
       "bulkDeleteConfirmContent": "每個所選客戶端都會從關聯的入站中被移除,其流量紀錄也會被刪除。此操作無法復原。",
+      "bulkAdjustTitle": "調整 {count} 個客戶端",
+      "bulkAdjustHint": "正值延長,負值減少。具有無限期限或流量的客戶端將跳過該欄位。",
+      "bulkAdjustNothing": "套用前請設定天數或流量。",
+      "addDays": "新增天數",
+      "addTrafficGB": "新增流量 (GB)",
       "delDepleted": "刪除已耗盡",
       "delDepletedConfirmTitle": "刪除已耗盡的客戶端?",
       "delDepletedConfirmContent": "刪除所有流量配額已用盡或已過期的客戶端。此操作無法復原。",
@@ -503,6 +509,8 @@
         "bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個",
         "bulkCreated": "已建立 {count} 個客戶端",
         "bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
+        "bulkAdjusted": "已調整 {count} 個客戶端",
+        "bulkAdjustedMixed": "已調整 {ok} 個,跳過 {skipped} 個",
         "delDepleted": "已刪除 {count} 個已耗盡的客戶端"
       }
     },