Parcourir la source

fix(clients): balance renewal form layout and mode guidance (#6672)

* fix(clients): balance renewal form layout and mode guidance

Move renewal out of the traffic-reset half-column so its help and preview
no longer leave a tall empty area beside the reset selector. Keep the
mode, active schedule field and renewal limit in equal desktop columns,
stack them on small screens, and preserve the bulk form's stacked layout.
Keep monthly traffic-reset controls together in their own row and allow
the first-cutoff button to wrap without shrinking its click target.

Show fixed-duration guidance only for interval renewal and the month-end
boundary note only for monthly renewal. Synchronize all thirteen locales
and the client docs, remove obsolete zero-value mode-switching tooltips,
and cover the help transitions through the real client form.

Keep preview markup and existing monthly copy intact so the independent
date-display work in #6662 can merge without overlapping these edits.

This is presentation-only: saved values, expiry selection, renewal and
traffic-reset calculations, APIs and database formats are unchanged.

* refactor(clients): drop the renewal CSS that #6662 made redundant

#6662 landed the full-width preview Space and the wrapping first-cutoff
Button as inline styles, so ClientRenewalFields.css and its wrapper div
styled the same two elements a second time; both are removed.

* test(tuic): wait for downstream association activity refresh

The UDP lifecycle regression can receive its QUIC response before the
server's sending goroutine updates the association timestamp. Reading
the protected field once then mistakes a valid scheduling order for a
missing refresh, intermittently failing the race-enabled CI job.

Wait for lastActive to advance under the registry mutex, bounded by a
one-second deadline, before checking idle reaping. Retain the payload,
association ID, idle-survival and shutdown assertions so a missing
activity update still fails the test.

This only changes test synchronization. Production relay behavior,
renewal logic, network timeouts and CI configuration remain unchanged.

---------

Co-authored-by: JacktheRanger <[email protected]>
Co-authored-by: MHSanaei <[email protected]>
Jack il y a 19 heures
Parent
commit
881f646d2c

+ 6 - 0
docs/content/docs/en/config/clients.mdx

@@ -46,6 +46,12 @@ inbounds** at once, with per-client traffic accounting.
 
 The individual and bulk-create forms offer one renewal mode at a time:
 
+In the individual form, the mode, its day/weekday setting, and the renewal limit
+share a row on desktop and stack on mobile. Help and the read-only preview use
+the full width below them. The bulk-create form keeps its stacked fields. Help
+follows the selected mode; the day-1/month-end note appears only for monthly
+renewal. Independent traffic-reset settings remain separate.
+
 | Mode | API fields | Schedule |
 | --- | --- | --- |
 | Disabled | `reset=0`, `resetDay=0`, `resetWeekday=0` | The expiry is not renewed. |

+ 5 - 0
docs/content/docs/zh/config/clients.mdx

@@ -43,6 +43,11 @@ icon: Users
 
 单个客户端和批量创建表单使用统一的续期模式选择:
 
+单个客户端表单在桌面端将模式、天数或星期参数及次数上限排在同一行,
+手机端则纵向排列;说明与只读预览在下方占满宽度。批量创建表单保留
+纵向字段布局。说明随模式切换,仅每月续期显示 1 日与月末的边界提示。
+独立流量重置仍单独设置。
+
 | 模式 | API 字段 | 续期规则 |
 | --- | --- | --- |
 | 关闭 | `reset=0`、`resetDay=0`、`resetWeekday=0` | 不自动延长到期时间。 |

+ 13 - 14
frontend/src/pages/clients/ClientFormModal.tsx

@@ -989,7 +989,9 @@ export default function ClientFormModal({
                               }))}
                             />
                           </FormField>
-                          {trafficReset === 'monthly' && (
+                        </Col>
+                        {trafficReset === 'monthly' && (
+                          <Col xs={24} md={12}>
                             <FormField
                               name="trafficResetDay"
                               label={t('pages.inbounds.periodicTrafficResetDay')}
@@ -997,21 +999,18 @@ export default function ClientFormModal({
                             >
                               <InputNumber min={1} max={31} style={{ width: '100%' }} />
                             </FormField>
-                          )}
-                        </Col>
-                        <Col xs={24} md={12}>
-                          <ClientRenewalFields
-                            active={open}
-                            delayedStart={delayedStart}
-                            expiryTime={
-                              delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
-                            }
-                            resetCount={client?.traffic?.resetCount || 0}
-                            setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
-                          />
-                        </Col>
+                          </Col>
+                        )}
                       </Row>
 
+                      <ClientRenewalFields
+                        active={open}
+                        delayedStart={delayedStart}
+                        expiryTime={delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0}
+                        resetCount={client?.traffic?.resetCount || 0}
+                        setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
+                      />
+
                       <Row gutter={16}>
                         <Col xs={24} md={12}>
                           <FormField name="comment" label={t('pages.clients.comment')}>

+ 81 - 59
frontend/src/pages/clients/ClientRenewalFields.tsx

@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useFormContext, useWatch } from 'react-hook-form';
 import { useQuery } from '@tanstack/react-query';
-import { Button, Form, InputNumber, Select, Space, Typography } from 'antd';
+import { Button, Col, Form, InputNumber, Row, Select, Space, Typography } from 'antd';
 
 import { FormField } from '@/components/form/rhf';
 import { ClientRenewalPreviewSchema } from '@/generated/zod';
@@ -91,67 +91,89 @@ export default function ClientRenewalFields({
   }
   return (
     <>
-      <Form.Item label={t('pages.clients.renewMode')} htmlFor={modeId}>
-        <Select
-          id={modeId}
-          value={mode}
-          onChange={changeMode}
-          options={[
-            { value: 'none', label: t('pages.clients.renewModeNone') },
-            { value: 'interval', label: t('pages.clients.renewModeInterval') },
-            { value: 'weekly', label: t('pages.clients.renewModeWeekly') },
-            { value: 'monthly', label: t('pages.clients.renewModeMonthly') },
-          ]}
-        />
-      </Form.Item>
-      {mode === 'interval' && (
-        <FormField
-          name="reset"
-          label={bulk ? t('pages.clients.renew') : t('pages.clients.renewDays')}
-          tooltip={t('pages.clients.renewDesc')}
-          transform={{ output: (v) => Number(v) || 1 }}
-        >
-          <InputNumber id={'client-renewal-interval-' + formId} min={1} style={{ width: '100%' }} />
-        </FormField>
-      )}
-      {mode === 'monthly' && (
-        <FormField
-          name="resetDay"
-          label={t('pages.clients.renewOnDay')}
-          tooltip={t('pages.clients.renewOnDayDesc')}
-          transform={{ output: (v) => Number(v) || 1 }}
-        >
-          <InputNumber
-            id={'client-renewal-day-' + formId}
-            min={1}
-            max={31}
-            style={{ width: '100%' }}
-          />
-        </FormField>
-      )}
-      {mode === 'weekly' && (
-        <FormField name="resetWeekday" label={t('pages.clients.renewWeekday')}>
-          <Select
-            id={'client-renewal-weekday-' + formId}
-            options={Array.from({ length: 7 }, (_, i) => ({
-              value: i + 1,
-              label: weekdayFormatter.format(new Date(Date.UTC(2026, 0, i + 5))),
-            }))}
-          />
-        </FormField>
-      )}
+      <Row gutter={16}>
+        <Col xs={24} md={bulk ? 24 : 8}>
+          <Form.Item label={t('pages.clients.renewMode')} htmlFor={modeId}>
+            <Select
+              id={modeId}
+              value={mode}
+              onChange={changeMode}
+              options={[
+                { value: 'none', label: t('pages.clients.renewModeNone') },
+                { value: 'interval', label: t('pages.clients.renewModeInterval') },
+                { value: 'weekly', label: t('pages.clients.renewModeWeekly') },
+                { value: 'monthly', label: t('pages.clients.renewModeMonthly') },
+              ]}
+            />
+          </Form.Item>
+        </Col>
+        {mode === 'interval' && (
+          <Col xs={24} md={bulk ? 24 : 8}>
+            <FormField
+              name="reset"
+              label={bulk ? t('pages.clients.renew') : t('pages.clients.renewDays')}
+              tooltip={t('pages.clients.renewDesc')}
+              transform={{ output: (v) => Number(v) || 1 }}
+            >
+              <InputNumber
+                id={'client-renewal-interval-' + formId}
+                min={1}
+                style={{ width: '100%' }}
+              />
+            </FormField>
+          </Col>
+        )}
+        {mode === 'monthly' && (
+          <Col xs={24} md={bulk ? 24 : 8}>
+            <FormField
+              name="resetDay"
+              label={t('pages.clients.renewOnDay')}
+              tooltip={t('pages.clients.renewOnDayDesc')}
+              transform={{ output: (v) => Number(v) || 1 }}
+            >
+              <InputNumber
+                id={'client-renewal-day-' + formId}
+                min={1}
+                max={31}
+                style={{ width: '100%' }}
+              />
+            </FormField>
+          </Col>
+        )}
+        {mode === 'weekly' && (
+          <Col xs={24} md={bulk ? 24 : 8}>
+            <FormField name="resetWeekday" label={t('pages.clients.renewWeekday')}>
+              <Select
+                id={'client-renewal-weekday-' + formId}
+                options={Array.from({ length: 7 }, (_, i) => ({
+                  value: i + 1,
+                  label: weekdayFormatter.format(new Date(Date.UTC(2026, 0, i + 5))),
+                }))}
+              />
+            </FormField>
+          </Col>
+        )}
+        {mode !== 'none' && (
+          <Col xs={24} md={bulk ? 24 : 8}>
+            <FormField
+              name="resetMax"
+              label={t('pages.clients.renewMax')}
+              tooltip={t('pages.clients.renewMaxDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} style={{ width: '100%' }} />
+            </FormField>
+          </Col>
+        )}
+      </Row>
       {mode !== 'none' && (
         <>
-          <FormField
-            name="resetMax"
-            label={t('pages.clients.renewMax')}
-            tooltip={t('pages.clients.renewMaxDesc')}
-            transform={{ output: (v) => Number(v) || 0 }}
-          >
-            <InputNumber min={0} style={{ width: '100%' }} />
-          </FormField>
           <Typography.Paragraph type="secondary">
-            {t('pages.clients.renewScheduleDesc')}
+            {mode === 'interval'
+              ? t('pages.clients.renewIntervalDesc')
+              : mode === 'weekly'
+                ? t('pages.clients.renewWeeklyDesc')
+                : t('pages.clients.renewScheduleDesc')}
           </Typography.Paragraph>
           {query.isError && request === debounced && (
             <Typography.Paragraph type="warning">

+ 42 - 0
frontend/src/test/client-renewal-help.test.tsx

@@ -0,0 +1,42 @@
+import { expect, it } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+
+import ClientFormModal from '@/pages/clients/ClientFormModal';
+import { chooseSelectOption, renderWithProviders } from './test-utils';
+
+it('shows only the active renewal mode guidance when switching between interval, weekly, monthly, and disabled', async () => {
+  renderWithProviders(
+    <ClientFormModal
+      open
+      mode="edit"
+      client={{ email: '[email protected]', reset: 31, expiryTime: 1893427200000 }}
+      attachedIds={[1]}
+      inbounds={[{ id: 1, protocol: 'vless', tag: 'calendar' }]}
+      save={async () => null}
+      onOpenChange={() => {}}
+    />,
+  );
+  const mode = screen.getByLabelText('Auto renewal');
+  const interval = /Adds the configured number of 24-hour days/;
+  const calendar = /Calendar renewal uses midnight/;
+  const monthly = /Monthly day 1 means valid through/;
+
+  await waitFor(() => expect(screen.queryByText(calendar)).toBeNull());
+  expect(screen.queryByText(interval)).not.toBeNull();
+  expect(screen.queryByText(monthly)).toBeNull();
+
+  chooseSelectOption(mode.id, 'Calendar weekly');
+  expect(screen.queryByText(calendar)).not.toBeNull();
+  expect(screen.queryByText(interval)).toBeNull();
+  expect(screen.queryByText(monthly)).toBeNull();
+
+  chooseSelectOption(mode.id, 'Calendar monthly');
+  expect(screen.queryByText(calendar)).not.toBeNull();
+  expect(screen.queryByText(monthly)).not.toBeNull();
+  expect(screen.queryByText(interval)).toBeNull();
+
+  chooseSelectOption(mode.id, 'Disabled');
+  expect(screen.queryByText(calendar)).toBeNull();
+  expect(screen.queryByText(interval)).toBeNull();
+  expect(screen.queryByText(monthly)).toBeNull();
+});

+ 14 - 5
internal/tuic/stream_udp_lifecycle_test.go

@@ -152,11 +152,20 @@ func TestDownstreamUDPResponseRefreshesAssociationIdleTime(t *testing.T) {
 		t.Fatalf("response association id = %d, want %d", header.AssocID, associationID)
 	}
 
-	registry.mu.Lock()
-	refreshedAt := association.lastActive
-	registry.mu.Unlock()
-	if !refreshedAt.After(oldActive) {
-		t.Fatal("successful downstream response did not refresh association activity")
+	// Receiving the datagram can precede the sender's post-send activity update.
+	// Wait for that protected state before exercising idle reaping.
+	deadline := time.Now().Add(time.Second)
+	for {
+		registry.mu.Lock()
+		refreshedAt := association.lastActive
+		registry.mu.Unlock()
+		if refreshedAt.After(oldActive) {
+			break
+		}
+		if time.Now().After(deadline) {
+			t.Fatal("successful downstream response did not refresh association activity")
+		}
+		time.Sleep(time.Millisecond)
 	}
 	registry.reapIdle(oldActive.Add(udpAssociationIdleTimeout + time.Second))
 	registry.mu.Lock()

+ 5 - 3
internal/web/translation/ar-EG.json

@@ -745,7 +745,7 @@
       "delayedStart": "البدء بعد أول استخدام",
       "expireDays": "المدة (أيام)",
       "renew": "تجديد تلقائي",
-      "renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل) (الوحدة: يوم)",
+      "renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (الوحدة: يوم)",
       "renewDays": "تجديد تلقائي (أيام)",
       "searchPlaceholder": "ابحث بالبريد، التعليق، sub ID، UUID، كلمة المرور، auth، Telegram ID…",
       "filterTitle": "تصفية العملاء",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "يوم التجديد",
-      "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
+      "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه.",
       "renewsUsed": "التجديدات المستخدمة",
-      "bulkAdTagHint": "أدخل علامة من 32 حرفًا ست عشريًا للتعيين، أو 'none' للمسح في منافذ MTProto."
+      "bulkAdTagHint": "أدخل علامة من 32 حرفًا ست عشريًا للتعيين، أو 'none' للمسح في منافذ MTProto.",
+      "renewIntervalDesc": "بعد انتهاء الصلاحية، يُضاف عدد الأيام المحدد (24 ساعة لكل يوم) إلى الموعد النهائي السابق ويُعاد تعيين استهلاك العميل. أبقِ إعادة تعيين الاستهلاك المستقلة معطلة ما لم تكن تريد إعادة تعيين إضافية.",
+      "renewWeeklyDesc": "يتم التجديد التقويمي عند منتصف الليل بتوقيت اللوحة ويُعاد أيضًا تعيين استهلاك العميل. أبقِ إعادة تعيين الاستهلاك المستقلة معطلة ما لم تكن تريد إعادة تعيين إضافية."
     },
     "groups": {
       "name": "الاسم",

+ 5 - 3
internal/web/translation/en-US.json

@@ -745,7 +745,7 @@
       "delayedStart": "Start After First Use",
       "expireDays": "Duration (days)",
       "renew": "Auto Renew",
-      "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
+      "renewDesc": "Auto-renewal after expiration. (unit: day)",
       "renewDays": "Auto Renew (days)",
       "searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth, Telegram ID…",
       "filterTitle": "Filter clients",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Renew on day",
-      "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
+      "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day.",
       "renewsUsed": "Renewals used",
-      "bulkAdTagHint": "Enter a 32-character hex tag to set, or 'none' to clear sponsor channel on MTProto inbounds."
+      "bulkAdTagHint": "Enter a 32-character hex tag to set, or 'none' to clear sponsor channel on MTProto inbounds.",
+      "renewIntervalDesc": "Adds the configured number of 24-hour days to the previous cutoff after expiry and resets the client's traffic. Keep independent traffic resets disabled unless an additional reset is intended.",
+      "renewWeeklyDesc": "Calendar renewal uses midnight in the panel timezone and also resets the client's traffic. Keep independent traffic resets disabled unless an additional reset is intended."
     },
     "groups": {
       "name": "Name",

+ 5 - 3
internal/web/translation/es-ES.json

@@ -745,7 +745,7 @@
       "delayedStart": "Iniciar tras el primer uso",
       "expireDays": "Duración (días)",
       "renew": "Renovación automática",
-      "renewDesc": "Renovación automática tras la expiración. (0 = desactivado) (unidad: día)",
+      "renewDesc": "Renovación automática tras la expiración. (unidad: día)",
       "renewDays": "Renovación automática (días)",
       "searchPlaceholder": "Buscar email, comentario, sub ID, UUID, contraseña, auth, Telegram ID…",
       "filterTitle": "Filtrar clientes",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Renovar el día",
-      "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
+      "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día.",
       "renewsUsed": "Renovaciones usadas",
-      "bulkAdTagHint": "Introduce una etiqueta hexadecimal de 32 caracteres para asignar, o 'none' para borrarla en MTProto."
+      "bulkAdTagHint": "Introduce una etiqueta hexadecimal de 32 caracteres para asignar, o 'none' para borrarla en MTProto.",
+      "renewIntervalDesc": "Tras expirar, añade el número configurado de días de 24 horas al vencimiento anterior y reinicia el tráfico del cliente. Mantén desactivados los reinicios de tráfico independientes salvo que quieras un reinicio adicional.",
+      "renewWeeklyDesc": "La renovación por calendario se realiza a medianoche en la zona horaria del panel y también reinicia el tráfico del cliente. Mantén desactivados los reinicios de tráfico independientes salvo que quieras un reinicio adicional."
     },
     "groups": {
       "name": "Nombre",

+ 5 - 3
internal/web/translation/fa-IR.json

@@ -745,7 +745,7 @@
       "delayedStart": "شروع پس از اولین استفاده",
       "expireDays": "مدت اعتبار (روز)",
       "renew": "تمدید خودکار",
-      "renewDesc": "تمدید خودکار پس از انقضا. (۰ = غیرفعال) (واحد: روز)",
+      "renewDesc": "تمدید خودکار پس از انقضا. (واحد: روز)",
       "renewDays": "تمدید خودکار (روز)",
       "searchPlaceholder": "جستجوی ایمیل، توضیح، Sub ID، UUID، رمز، احراز، Telegram ID...",
       "filterTitle": "فیلتر کاربران",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "روز تمدید",
-      "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
+      "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود.",
       "renewsUsed": "تمدیدهای استفاده‌شده",
-      "bulkAdTagHint": "یک برچسب هگز ۳۲ کاراکتری برای تنظیم وارد کنید، یا 'none' برای پاک کردن در MTProto."
+      "bulkAdTagHint": "یک برچسب هگز ۳۲ کاراکتری برای تنظیم وارد کنید، یا 'none' برای پاک کردن در MTProto.",
+      "renewIntervalDesc": "پس از انقضا، تعداد روزهای تعیین‌شده (هر روز ۲۴ ساعت) به مهلت قبلی اضافه و ترافیک کاربر بازنشانی می‌شود. بازنشانی مستقل ترافیک را غیرفعال نگه دارید، مگر اینکه بازنشانی دیگری می‌خواهید.",
+      "renewWeeklyDesc": "تمدید تقویمی در نیمه‌شب به وقت پنل انجام می‌شود و ترافیک کاربر را نیز بازنشانی می‌کند. بازنشانی مستقل ترافیک را غیرفعال نگه دارید، مگر اینکه بازنشانی دیگری می‌خواهید."
     },
     "groups": {
       "name": "نام",

+ 5 - 3
internal/web/translation/id-ID.json

@@ -745,7 +745,7 @@
       "delayedStart": "Mulai setelah penggunaan pertama",
       "expireDays": "Durasi (hari)",
       "renew": "Perpanjangan otomatis",
-      "renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif) (satuan: hari)",
+      "renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (satuan: hari)",
       "renewDays": "Perpanjangan otomatis (hari)",
       "searchPlaceholder": "Cari email, komentar, sub ID, UUID, kata sandi, auth, Telegram ID…",
       "filterTitle": "Filter klien",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Perpanjang pada tanggal",
-      "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
+      "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya.",
       "renewsUsed": "Perpanjangan terpakai",
-      "bulkAdTagHint": "Masukkan tag heksadesimal 32 karakter untuk mengatur, atau 'none' untuk menghapus pada MTProto."
+      "bulkAdTagHint": "Masukkan tag heksadesimal 32 karakter untuk mengatur, atau 'none' untuk menghapus pada MTProto.",
+      "renewIntervalDesc": "Setelah kedaluwarsa, menambahkan jumlah hari yang ditentukan (24 jam per hari) ke batas waktu sebelumnya dan mereset trafik klien. Nonaktifkan reset trafik terpisah kecuali Anda menginginkan reset tambahan.",
+      "renewWeeklyDesc": "Perpanjangan kalender dilakukan pada tengah malam menurut zona waktu panel dan juga mereset trafik klien. Nonaktifkan reset trafik terpisah kecuali Anda menginginkan reset tambahan."
     },
     "groups": {
       "name": "Nama",

+ 5 - 3
internal/web/translation/ja-JP.json

@@ -745,7 +745,7 @@
       "delayedStart": "初回使用から開始",
       "expireDays": "期間 (日)",
       "renew": "自動更新",
-      "renewDesc": "有効期限切れ後に自動更新します。(0 = 無効) (単位: 日)",
+      "renewDesc": "有効期限切れ後に自動更新します。(単位: 日)",
       "renewDays": "自動更新 (日)",
       "searchPlaceholder": "メール、コメント、sub ID、UUID、パスワード、auth、Telegram ID を検索…",
       "filterTitle": "クライアントをフィルタ",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "更新する日",
-      "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
+      "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。",
       "renewsUsed": "使用済み更新回数",
-      "bulkAdTagHint": "設定するには 32 文字の 16 進数タグを入力し、クリアするには 'none' を入力します (MTProto 用)。"
+      "bulkAdTagHint": "設定するには 32 文字の 16 進数タグを入力し、クリアするには 'none' を入力します (MTProto 用)。",
+      "renewIntervalDesc": "期限切れ後、前回の期限に指定日数(1 日 24 時間)を加算し、クライアントの通信量をリセットします。追加のリセットが不要なら、独立した通信量リセットは無効にしてください。",
+      "renewWeeklyDesc": "カレンダー更新はパネルのタイムゾーンの午前 0 時に行われ、クライアントの通信量もリセットします。追加のリセットが不要なら、独立した通信量リセットは無効にしてください。"
     },
     "groups": {
       "name": "名前",

+ 5 - 3
internal/web/translation/pt-BR.json

@@ -745,7 +745,7 @@
       "delayedStart": "Iniciar após o primeiro uso",
       "expireDays": "Duração (dias)",
       "renew": "Renovação automática",
-      "renewDesc": "Renovação automática após a expiração. (0 = desativar) (unidade: dia)",
+      "renewDesc": "Renovação automática após a expiração. (unidade: dia)",
       "renewDays": "Renovação automática (dias)",
       "searchPlaceholder": "Buscar email, comentário, sub ID, UUID, senha, auth, Telegram ID…",
       "filterTitle": "Filtrar clientes",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Renovar no dia",
-      "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
+      "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele.",
       "renewsUsed": "Renovações usadas",
-      "bulkAdTagHint": "Insira uma tag hexadecimal de 32 caracteres para definir ou 'none' para limpar no MTProto."
+      "bulkAdTagHint": "Insira uma tag hexadecimal de 32 caracteres para definir ou 'none' para limpar no MTProto.",
+      "renewIntervalDesc": "Após a expiração, adiciona o número configurado de dias de 24 horas ao prazo anterior e redefine o tráfego do cliente. Mantenha as redefinições independentes de tráfego desativadas, a menos que queira uma redefinição adicional.",
+      "renewWeeklyDesc": "A renovação por calendário ocorre à meia-noite no fuso horário do painel e também redefine o tráfego do cliente. Mantenha as redefinições independentes de tráfego desativadas, a menos que queira uma redefinição adicional."
     },
     "groups": {
       "name": "Nome",

+ 5 - 3
internal/web/translation/ru-RU.json

@@ -745,7 +745,7 @@
       "delayedStart": "Старт после первого использования",
       "expireDays": "Длительность (дней)",
       "renew": "Автопродление",
-      "renewDesc": "Автоматическое продление после окончания. (0 = отключено) (единица: день)",
+      "renewDesc": "Автоматическое продление после окончания. (единица: день)",
       "renewDays": "Автопродление (дней)",
       "searchPlaceholder": "Поиск email, комментария, sub ID, UUID, пароля, auth, Telegram ID…",
       "filterTitle": "Фильтр клиентов",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Продлевать числа",
-      "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
+      "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день.",
       "renewsUsed": "Продлений израсходовано",
-      "bulkAdTagHint": "Введите 32-значный hex-тег для установки или 'none' для очистки спонсорского канала на MTProto."
+      "bulkAdTagHint": "Введите 32-значный hex-тег для установки или 'none' для очистки спонсорского канала на MTProto.",
+      "renewIntervalDesc": "После истечения срока добавляет указанное число 24-часовых дней к предыдущему сроку и сбрасывает трафик клиента. Оставьте независимый сброс трафика выключенным, если дополнительный сброс не нужен.",
+      "renewWeeklyDesc": "Календарное продление выполняется в полночь по часовому поясу панели и также сбрасывает трафик клиента. Оставьте независимый сброс трафика выключенным, если дополнительный сброс не нужен."
     },
     "groups": {
       "name": "Имя",

+ 5 - 3
internal/web/translation/tr-TR.json

@@ -745,7 +745,7 @@
       "delayedStart": "İlk Kullanımdan Sonra Başla",
       "expireDays": "Süre (gün)",
       "renew": "Otomatik Yenileme",
-      "renewDesc": "Süre dolduktan sonra otomatik yeniler. (0 = devre dışı) (birim: gün)",
+      "renewDesc": "Süre dolduktan sonra otomatik yeniler. (birim: gün)",
       "renewDays": "Otomatik Yenileme (gün)",
       "searchPlaceholder": "E-posta, yorum, sub ID, UUID, parola, auth, Telegram ID ara…",
       "filterTitle": "Kullanıcıları Filtrele",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Yenileme günü",
-      "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
+      "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler.",
       "renewsUsed": "Kullanılan yenileme",
-      "bulkAdTagHint": "Aramak için 32 karakterlik onaltılık etiket girin veya MTProto üzerinde temizlemek için 'none' yazın."
+      "bulkAdTagHint": "Aramak için 32 karakterlik onaltılık etiket girin veya MTProto üzerinde temizlemek için 'none' yazın.",
+      "renewIntervalDesc": "Süre dolduktan sonra önceki bitiş zamanına belirlenen sayıda 24 saatlik gün ekler ve istemci trafiğini sıfırlar. Ek bir sıfırlama istemiyorsanız bağımsız trafik sıfırlamalarını devre dışı bırakın.",
+      "renewWeeklyDesc": "Takvim yenilemesi panelin saat diliminde gece yarısı gerçekleşir ve istemci trafiğini de sıfırlar. Ek bir sıfırlama istemiyorsanız bağımsız trafik sıfırlamalarını devre dışı bırakın."
     },
     "groups": {
       "name": "İsim",

+ 5 - 3
internal/web/translation/uk-UA.json

@@ -745,7 +745,7 @@
       "delayedStart": "Запуск після першого використання",
       "expireDays": "Тривалість (днів)",
       "renew": "Авто-продовження",
-      "renewDesc": "Автоматичне продовження після закінчення. (0 = вимкнено) (одиниця: день)",
+      "renewDesc": "Автоматичне продовження після закінчення. (одиниця: день)",
       "renewDays": "Авто-продовження (днів)",
       "searchPlaceholder": "Пошук email, коментаря, sub ID, UUID, паролю, auth, Telegram ID…",
       "filterTitle": "Фільтр клієнтів",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Подовжувати числа",
-      "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
+      "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день.",
       "renewsUsed": "Подовжень витрачено",
-      "bulkAdTagHint": "Введіть 32-значний hex-тег для встановлення або 'none' для очищення спонсорського каналу на MTProto."
+      "bulkAdTagHint": "Введіть 32-значний hex-тег для встановлення або 'none' для очищення спонсорського каналу на MTProto.",
+      "renewIntervalDesc": "Після завершення строку додає вказану кількість 24-годинних днів до попереднього терміну та скидає трафік клієнта. Залиште незалежне скидання трафіку вимкненим, якщо додаткове скидання не потрібне.",
+      "renewWeeklyDesc": "Календарне подовження відбувається опівночі за часовим поясом панелі й також скидає трафік клієнта. Залиште незалежне скидання трафіку вимкненим, якщо додаткове скидання не потрібне."
     },
     "groups": {
       "name": "Назва",

+ 5 - 3
internal/web/translation/vi-VN.json

@@ -745,7 +745,7 @@
       "delayedStart": "Bắt đầu sau lần dùng đầu",
       "expireDays": "Thời hạn (ngày)",
       "renew": "Tự động gia hạn",
-      "renewDesc": "Tự động gia hạn sau khi hết hạn. (0 = tắt) (đơn vị: ngày)",
+      "renewDesc": "Tự động gia hạn sau khi hết hạn. (đơn vị: ngày)",
       "renewDays": "Tự động gia hạn (ngày)",
       "searchPlaceholder": "Tìm email, ghi chú, sub ID, UUID, mật khẩu, auth, Telegram ID…",
       "filterTitle": "Lọc client",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "An expiry must be set before auto-renewal can run.",
       "renewPreviewError": "Could not preview renewal dates.",
       "renewOnDay": "Gia hạn vào ngày",
-      "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
+      "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng.",
       "renewsUsed": "Số lần gia hạn đã dùng",
-      "bulkAdTagHint": "Nhập thẻ hex 32 ký tự để đặt hoặc 'none' để xóa kênh tài trợ trên MTProto."
+      "bulkAdTagHint": "Nhập thẻ hex 32 ký tự để đặt hoặc 'none' để xóa kênh tài trợ trên MTProto.",
+      "renewIntervalDesc": "Sau khi hết hạn, cộng số ngày đã đặt (mỗi ngày 24 giờ) vào thời điểm hết hạn trước đó và đặt lại lưu lượng của khách hàng. Tắt việc đặt lại lưu lượng độc lập trừ khi bạn muốn đặt lại thêm.",
+      "renewWeeklyDesc": "Gia hạn theo lịch diễn ra lúc nửa đêm theo múi giờ của bảng điều khiển và cũng đặt lại lưu lượng của khách hàng. Tắt việc đặt lại lưu lượng độc lập trừ khi bạn muốn đặt lại thêm."
     },
     "groups": {
       "name": "Tên",

+ 5 - 3
internal/web/translation/zh-CN.json

@@ -745,7 +745,7 @@
       "delayedStart": "首次使用后开始",
       "expireDays": "时长 (天)",
       "renew": "自动续期",
-      "renewDesc": "到期后自动续期。(0 = 禁用) (单位: 天)",
+      "renewDesc": "到期后自动续期。(单位: 天)",
       "renewDays": "自动续期 (天)",
       "searchPlaceholder": "搜索邮箱、备注、sub ID、UUID、密码、auth、Telegram ID…",
       "filterTitle": "筛选客户端",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "需要先设置到期时间,自动续期才会执行。",
       "renewPreviewError": "无法预览续期日期。",
       "renewOnDay": "按日期续期",
-      "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
+      "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。",
       "renewsUsed": "已用续期次数",
-      "bulkAdTagHint": "输入 32 位十六进制标签以设置,或输入 'none' 清除 MTProto 赞助频道。"
+      "bulkAdTagHint": "输入 32 位十六进制标签以设置,或输入 'none' 清除 MTProto 赞助频道。",
+      "renewIntervalDesc": "到期后从上次截止时间增加指定天数(每天 24 小时),并重置客户端流量。若不需要额外重置,请关闭独立的流量重置。",
+      "renewWeeklyDesc": "日历续期按面板时区的午夜执行,并重置客户端流量。若不需要额外重置,请关闭独立的流量重置。"
     },
     "groups": {
       "name": "名称",

+ 5 - 3
internal/web/translation/zh-TW.json

@@ -745,7 +745,7 @@
       "delayedStart": "首次使用後開始",
       "expireDays": "時長 (天)",
       "renew": "自動續期",
-      "renewDesc": "到期後自動續期。(0 = 停用) (單位: 天)",
+      "renewDesc": "到期後自動續期。(單位: 天)",
       "renewDays": "自動續期 (天)",
       "searchPlaceholder": "搜尋電子郵件、備註、sub ID、UUID、密碼、auth、Telegram ID…",
       "filterTitle": "篩選客戶端",
@@ -966,9 +966,11 @@
       "renewNeedsExpiry": "需要先設定到期時間,自動續期才會執行。",
       "renewPreviewError": "無法預覽續期日期。",
       "renewOnDay": "按日期續期",
-      "renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。",
+      "renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。",
       "renewsUsed": "已用續期次數",
-      "bulkAdTagHint": "輸入 32 位十六進位標籤以設定,或輸入 'none' 清除 MTProto 贊助頻道。"
+      "bulkAdTagHint": "輸入 32 位十六進位標籤以設定,或輸入 'none' 清除 MTProto 贊助頻道。",
+      "renewIntervalDesc": "到期後從上次截止時間增加指定天數(每天 24 小時),並重置客戶端流量。若不需要額外重置,請關閉獨立的流量重置。",
+      "renewWeeklyDesc": "日曆續期按面板時區的午夜執行,並重置客戶端流量。若不需要額外重置,請關閉獨立的流量重置。"
     },
     "groups": {
       "name": "名稱",