3 İşlemeler c9207b6f3e ... f217212247

Yazar SHA1 Mesaj Tarih
  Matt Van Horn f217212247 web: overlay WireGuard peer public key from the inbound (#6751) 2 gün önce
  Jack 54d1712544 fix(clients): clarify renewal preview times and estimates (#6662) 2 gün önce
  libmur-dev b3f1cced70 fix(tuic): preserve IP destinations during sniffing (#6753) 2 gün önce

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

@@ -75,6 +75,16 @@ explicit button can set the first calendar cutoff. Selecting a mode alone never
 rewrites an existing expiry. First-use clients keep their initial duration, and
 their calendar dates are available after activation.
 
+Preview dates use the Gregorian format `YYYY-MM-DD HH:mm:ss UTC±HH:mm`,
+preserving any fractional seconds in the cutoff. Each date includes its own
+UTC offset, so a daylight-saving change can show different offsets before and
+after renewal. The heading identifies the panel time zone; **Server local time**
+means the server's `Local` setting, not the browser's time zone. For example,
+`2026-09-27 05:00:00 UTC+00:00` is 13:00 in UTC+08:00. **Estimated renewals used**
+is the allowance this preview would consume, including any catch-up periods,
+not a count of renewals already performed. The first-cutoff button uses the same
+date format; clicking it still requires saving the form to persist the change.
+
 For legacy last-second calendar cutoffs, the renewal boundary includes the
 existing free alignment to the following midnight. The last-valid-second
 preview still uses the **stored expiry**, not that alignment: an exclusive

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

@@ -67,6 +67,14 @@ icon: Users
 明确点击按钮设置首次日历截止时间;仅选择模式不会修改已有到期时间。
 “首次使用后开始”保留原来的初始天数,激活后才能确定日历日期。
 
+预览日期使用公历格式 `YYYY-MM-DD HH:mm:ss UTC±HH:mm`,截止时间中的小数秒
+会原样保留。每个日期分别标注 UTC 偏移,因此跨夏令时续期前后的偏移可能不同。
+标题注明面板时区;**服务器本地时区**指服务器的 `Local` 设置,不是浏览器时区。
+例如 `2026-09-27 05:00:00 UTC+00:00` 对应 UTC+08:00 的 13:00。
+**预计消耗续期次数**是本次预览预计使用的额度,包含补续周期,不是已经执行的
+续期次数。设置首个周期截止时间的按钮使用同样的日期格式;点击后仍需保存表单
+才会持久化修改。
+
 旧配置以最后一秒为日历截止时间时,续期边界包含原有的不计次数向下个
 零点对齐规则。但最后有效秒仍按**已存储的到期时间**计算,不会假装
 初始时间已被修改:排他截止时间 `23:59:59` 实际有效至 `23:59:58`。

+ 36 - 7
frontend/src/pages/clients/ClientRenewalFields.tsx

@@ -12,6 +12,19 @@ import type { ClientFormValues } from '@/schemas/client';
 type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
 type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
 
+function RenewalDate({ value }: { value: string }) {
+  // Keep the server's wall time and per-date offset, including across DST.
+  // Parsing into a browser-local Date would show a different renewal boundary.
+  const display = value
+    .replace('T', ' ')
+    .replace(/(Z|[+-]\d{2}:\d{2})$/, (offset) => ` UTC${offset === 'Z' ? '+00:00' : offset}`);
+  return (
+    <time dateTime={value} dir="ltr">
+      {display}
+    </time>
+  );
+}
+
 export default function ClientRenewalFields({
   active,
   expiryTime,
@@ -146,9 +159,17 @@ export default function ClientRenewalFields({
             </Typography.Paragraph>
           )}
           {preview && (
-            <Space orientation="vertical" size={4} style={{ marginBottom: 16 }}>
+            <Space orientation="vertical" size={4} style={{ width: '100%', marginBottom: 16 }}>
               <Typography.Text>
-                {t('pages.clients.renewPreview', { zone: preview.timeZone })}
+                {t('pages.clients.renewPreview', {
+                  zone:
+                    preview.timeZone === 'Local'
+                      ? t('pages.clients.renewServerLocal')
+                      : preview.timeZone,
+                })}
+              </Typography.Text>
+              <Typography.Text type="secondary">
+                {t('pages.clients.renewPreviewHint')}
               </Typography.Text>
               {delayedStart || preview.delayedStart ? (
                 <Typography.Text type="secondary">
@@ -160,22 +181,30 @@ export default function ClientRenewalFields({
                     {t('pages.clients.renewNeedsExpiry')}
                   </Typography.Text>
                   {preview.suggestedExpiryTime > 0 && (
-                    <Button onClick={() => setExpiry(preview.suggestedExpiryTime)}>
-                      {t('pages.clients.renewSetExpiry')}: {preview.suggestedExpiry}
+                    <Button
+                      style={{ maxWidth: '100%', height: 'auto', whiteSpace: 'normal' }}
+                      onClick={() => setExpiry(preview.suggestedExpiryTime)}
+                    >
+                      <span>
+                        {t('pages.clients.renewSetExpiry')}:{' '}
+                        <RenewalDate value={preview.suggestedExpiry} />
+                      </span>
                     </Button>
                   )}
                 </>
               ) : (
                 <>
                   <Typography.Text>
-                    {t('pages.clients.renewAt')}: {preview.renewAt}
+                    {t('pages.clients.renewAt')}: <RenewalDate value={preview.renewAt} />
                   </Typography.Text>
                   <Typography.Text>
-                    {t('pages.clients.renewValidThrough')}: {preview.validThrough}
+                    {t('pages.clients.renewValidThrough')}:{' '}
+                    <RenewalDate value={preview.validThrough} />
                   </Typography.Text>
                   {preview.nextExpiry && (
                     <Typography.Text>
-                      {t('pages.clients.renewNextExpiry')}: {preview.nextExpiry}
+                      {t('pages.clients.renewNextExpiry')}:{' '}
+                      <RenewalDate value={preview.nextExpiry} />
                     </Typography.Text>
                   )}
                   <Typography.Text>

+ 5 - 1
frontend/src/test/client-bulk-calendar-renewal.test.tsx

@@ -74,7 +74,11 @@ it('keeps bulk renewal disabled by default and requires explicit cutoff selectio
         }),
       ]),
     );
-    fireEvent.click(screen.getByRole('button', { name: /Set first cycle cutoff/ }));
+    fireEvent.click(
+      screen.getByRole('button', {
+        name: 'Set first cycle cutoff: 2030-01-01 00:00:00 UTC+08:00',
+      }),
+    );
     await submit();
     await waitFor(() =>
       expect(bulkCreate).toHaveBeenLastCalledWith([

+ 3 - 1
frontend/src/test/client-calendar-renewal.test.tsx

@@ -50,7 +50,9 @@ it('preserves monthly clients, previews backend dates, and saves exclusive weekl
     );
     const mode = screen.getByLabelText('Auto renewal');
     expect(mode.closest('.ant-select')?.textContent).toContain('Calendar monthly');
-    await waitFor(() => expect(document.body.textContent).toContain('2029-12-31T23:59:59+08:00'));
+    await waitFor(() =>
+      expect(document.body.textContent).toContain('2029-12-31 23:59:59 UTC+08:00'),
+    );
     expect(post).toHaveBeenCalledWith(
       '/panel/api/clients/renewalPreview',
       expect.objectContaining({ resetMax: 3, resetCount: 2 }),

+ 80 - 0
frontend/src/test/client-renewal-preview-display.test.tsx

@@ -0,0 +1,80 @@
+import { expect, it, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import ClientRenewalFields from '@/pages/clients/ClientRenewalFields';
+import { HttpUtil, Msg } from '@/utils';
+import { renderWithProviders } from './test-utils';
+
+function PreviewForm() {
+  const form = useForm({ defaultValues: { reset: 1, resetDay: 0, resetWeekday: 0, resetMax: 0 } });
+  return (
+    <FormProvider {...form}>
+      <ClientRenewalFields active expiryTime={1790485200000} setExpiry={() => {}} />
+    </FormProvider>
+  );
+}
+
+it.each([
+  {
+    name: 'explains Local UTC without converting into the browser timezone',
+    zone: 'Local',
+    label: 'Server local time',
+    dates: ['2026-09-27T05:00:00Z', '2026-09-27T04:59:59Z', '2026-09-28T05:00:00Z'],
+    displayed: [
+      '2026-09-27 05:00:00 UTC+00:00',
+      '2026-09-27 04:59:59 UTC+00:00',
+      '2026-09-28 05:00:00 UTC+00:00',
+    ],
+  },
+  {
+    name: 'preserves different offsets across a daylight-saving transition',
+    zone: 'America/New_York',
+    label: 'America/New_York',
+    dates: ['2030-03-10T00:00:00-05:00', '2030-03-09T23:59:59-05:00', '2030-03-17T00:00:00-04:00'],
+    displayed: [
+      '2030-03-10 00:00:00 UTC-05:00',
+      '2030-03-09 23:59:59 UTC-05:00',
+      '2030-03-17 00:00:00 UTC-04:00',
+    ],
+  },
+  {
+    name: 'keeps fractional cutoffs and fractional-hour server offsets',
+    zone: 'Local',
+    label: 'Server local time',
+    dates: [
+      '2030-01-01T00:00:00.123+05:45',
+      '2030-01-01T00:00:00+05:45',
+      '2030-01-02T00:00:00.123+05:45',
+    ],
+    displayed: [
+      '2030-01-01 00:00:00.123 UTC+05:45',
+      '2030-01-01 00:00:00 UTC+05:45',
+      '2030-01-02 00:00:00.123 UTC+05:45',
+    ],
+  },
+])('$name', async ({ zone, label, dates, displayed }) => {
+  const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
+    new Msg(true, '', {
+      timeZone: zone,
+      renewAt: dates[0],
+      validThrough: dates[1],
+      nextExpiry: dates[2],
+      suggestedExpiryTime: 0,
+      suggestedExpiry: '',
+      renewals: 1,
+      canRenew: true,
+      delayedStart: false,
+    }),
+  );
+  try {
+    renderWithProviders(<PreviewForm />);
+    await screen.findByText(`Renewal preview (panel time zone: ${label})`);
+    const times = Array.from(document.querySelectorAll('time'));
+    expect(times.map((el) => el.textContent)).toEqual(displayed);
+    expect(times.map((el) => el.dateTime)).toEqual(dates);
+    expect(times.map((el) => el.dir)).toEqual(['ltr', 'ltr', 'ltr']);
+  } finally {
+    post.mockRestore();
+  }
+});

+ 14 - 2
internal/web/service/inbound_tuic_test.go

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/json"
 	"fmt"
+	"slices"
 	"strings"
 	"testing"
 
@@ -50,8 +51,19 @@ func TestInjectTuicSocks(t *testing.T) {
 	if string(sc.Listen) != `"127.0.0.1"` {
 		t.Fatalf("expected listen 127.0.0.1, got %s", sc.Listen)
 	}
-	if string(sc.Sniffing) != tuicEgressSniffingSettings {
-		t.Fatalf("expected sniffing settings %s, got %s", tuicEgressSniffingSettings, sc.Sniffing)
+	var parsedSniffing struct {
+		Enabled      bool     `json:"enabled"`
+		DestOverride []string `json:"destOverride"`
+		RouteOnly    bool     `json:"routeOnly"`
+	}
+	if err := json.Unmarshal(sc.Sniffing, &parsedSniffing); err != nil {
+		t.Fatalf("failed to unmarshal sniffing settings: %v", err)
+	}
+	if !parsedSniffing.Enabled || !parsedSniffing.RouteOnly {
+		t.Fatalf("sniffing must be enabled with routeOnly, got %+v", parsedSniffing)
+	}
+	if want := []string{"http", "tls", "quic", "fakedns"}; !slices.Equal(parsedSniffing.DestOverride, want) {
+		t.Fatalf("destOverride = %v, want %v", parsedSniffing.DestOverride, want)
 	}
 
 	var parsedSettings struct {

+ 12 - 2
internal/web/service/xray.go

@@ -286,6 +286,14 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 				if inboundClient, ok := wireguardClientsByEmail[strings.ToLower(strings.TrimSpace(c.Email))]; ok {
 					c.AllowedIPs = inboundClient.AllowedIPs
 					c.PreSharedKey = inboundClient.PreSharedKey
+					c.PublicKey = inboundClient.PublicKey
+					// #6731: a nil settings keepalive must not keep the other tunnel's value.
+					if inboundClient.KeepAlive == nil {
+						c.KeepAlive = nil
+					} else {
+						keepalive := *inboundClient.KeepAlive
+						c.KeepAlive = &keepalive
+					}
 				}
 				wgPeers = append(wgPeers, model.WireguardPeerFromClient(c))
 				continue
@@ -796,8 +804,10 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
 }
 
 const (
-	tuicEgressSocksSettings    = `{"auth":"noauth","udp":true}`
-	tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}`
+	tuicEgressSocksSettings = `{"auth":"noauth","udp":true}`
+	// TUIC clients can connect to an IP while TLS carries a different or unresolvable SNI.
+	// Keep sniffed domains available for routing without replacing the requested destination.
+	tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"],"routeOnly":true}`
 )
 
 func injectTuicSocks(cfg *xray.Config, inbounds []*model.Inbound) {

+ 38 - 6
internal/web/service/xray_wireguard_config_test.go

@@ -55,7 +55,7 @@ func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
 	}
 }
 
-func seedDualTunnelClient(t *testing.T, enabled bool) string {
+func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
 	t.Helper()
 	setupSettingTestDB(t)
 	db := database.GetDB()
@@ -64,13 +64,16 @@ func seedDualTunnelClient(t *testing.T, enabled bool) string {
 	wgClient := model.Client{
 		Email:        email,
 		Enable:       true,
-		PublicKey:    "pub-dual",
+		PublicKey:    "pub-wg",
 		AllowedIPs:   []string{"10.0.0.5/32"},
 		PreSharedKey: "wg-psk",
+		KeepAlive:    wgKeepAlive,
 	}
 	awgClient := wgClient
+	awgClient.PublicKey = "pub-awg"
 	awgClient.AllowedIPs = []string{"10.8.1.5/32"}
 	awgClient.PreSharedKey = "awg-psk"
+	awgClient.KeepAlive = model.KeepAlivePtr(25)
 
 	wgSettings, err := json.Marshal(map[string]any{
 		"secretKey": wgTestSecretKey(),
@@ -191,14 +194,14 @@ func TestGetXrayConfigWireGuardDisabledClientExcluded(t *testing.T) {
 }
 
 func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
-	email := seedDualTunnelClient(t, true)
+	email := seedDualTunnelClient(t, true, model.KeepAlivePtr(15))
 
 	var shared model.ClientRecord
 	if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
 		t.Fatalf("read shared client: %v", err)
 	}
-	if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" {
-		t.Fatalf("test setup did not persist AmneziaWG last: allowedIPs=%q preSharedKey=%q", shared.AllowedIPs, shared.PreSharedKey)
+	if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
+		t.Fatalf("test setup did not persist AmneziaWG last: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
 	}
 
 	peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
@@ -212,10 +215,39 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
 	if peers[0]["preSharedKey"] != "wg-psk" {
 		t.Fatalf("WireGuard peer preSharedKey = %v, want wg-psk", peers[0]["preSharedKey"])
 	}
+	if peers[0]["publicKey"] != "pub-wg" {
+		t.Fatalf("WireGuard peer publicKey = %v, want pub-wg", peers[0]["publicKey"])
+	}
+	if peers[0]["keepAlive"] != float64(15) {
+		t.Fatalf("WireGuard peer keepAlive = %v, want 15", peers[0]["keepAlive"])
+	}
+}
+
+func TestGetXrayConfigWireGuardOmitsKeepAliveAbsentFromSettings(t *testing.T) {
+	email := seedDualTunnelClient(t, true, nil)
+
+	var shared model.ClientRecord
+	if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
+		t.Fatalf("read shared client: %v", err)
+	}
+	if shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
+		t.Fatalf("test setup did not persist AmneziaWG identity: publicKey=%q keepAlive=%d", shared.PublicKey, shared.KeepAlive)
+	}
+
+	peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
+	if len(peers) != 1 {
+		t.Fatalf("expected 1 peer, got %d: %v", len(peers), peers)
+	}
+	if peers[0]["publicKey"] != "pub-wg" {
+		t.Fatalf("WireGuard peer publicKey = %v, want pub-wg", peers[0]["publicKey"])
+	}
+	if _, ok := peers[0]["keepAlive"]; ok {
+		t.Fatalf("WireGuard peer keepAlive = %v, want absent", peers[0]["keepAlive"])
+	}
 }
 
 func TestGetXrayConfigWireGuardDisabledDualProtocolClientExcluded(t *testing.T) {
-	seedDualTunnelClient(t, false)
+	seedDualTunnelClient(t, false, model.KeepAlivePtr(15))
 
 	peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
 	if len(peers) != 0 {

+ 4 - 2
internal/web/translation/ar-EG.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "معاينة التجديد (المنطقة الزمنية للوحة: {zone})",
+      "renewServerLocal": "التوقيت المحلي للخادم",
+      "renewPreviewHint": "تُعرض التواريخ حسب المنطقة الزمنية للوحة. المعاينة لا تجدد العميل.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "عدد التجديدات المتوقع استهلاكها: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/en-US.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Renewal preview (panel time zone: {zone})",
+      "renewServerLocal": "Server local time",
+      "renewPreviewHint": "Dates use the panel time zone. Previewing does not renew the client.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Estimated renewals used: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/es-ES.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Vista previa de renovación (zona horaria del panel: {zone})",
+      "renewServerLocal": "Hora local del servidor",
+      "renewPreviewHint": "Las fechas usan la zona horaria del panel. La vista previa no renueva el cliente.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Renovaciones que se consumirían: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/fa-IR.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "پیش‌نمایش تمدید (منطقهٔ زمانی پنل: {zone})",
+      "renewServerLocal": "زمان محلی سرور",
+      "renewPreviewHint": "تاریخ‌ها بر اساس منطقهٔ زمانی پنل نمایش داده می‌شوند. پیش‌نمایش باعث تمدید کاربر نمی‌شود.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "تعداد تمدیدهای قابل مصرف در این پیش‌نمایش: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/id-ID.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Pratinjau perpanjangan (zona waktu panel: {zone})",
+      "renewServerLocal": "Waktu lokal server",
+      "renewPreviewHint": "Tanggal menggunakan zona waktu panel. Pratinjau tidak memperpanjang klien.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Perkiraan jumlah perpanjangan yang digunakan: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/ja-JP.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "更新プレビュー(パネルのタイムゾーン:{zone})",
+      "renewServerLocal": "サーバーのローカル時刻",
+      "renewPreviewHint": "日時はパネルのタイムゾーンで表示されます。プレビューではクライアントは更新されません。",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "消費予定の更新回数:{count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/pt-BR.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Prévia da renovação (fuso horário do painel: {zone})",
+      "renewServerLocal": "Hora local do servidor",
+      "renewPreviewHint": "As datas usam o fuso horário do painel. A prévia não renova o cliente.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Renovações que seriam consumidas: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/ru-RU.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Предпросмотр продления (часовой пояс панели: {zone})",
+      "renewServerLocal": "Местное время сервера",
+      "renewPreviewHint": "Даты указаны в часовом поясе панели. Предпросмотр не продлевает клиента.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Будет использовано продлений: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/tr-TR.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Yenileme önizlemesi (panel saat dilimi: {zone})",
+      "renewServerLocal": "Sunucunun yerel saati",
+      "renewPreviewHint": "Tarihler panelin saat dilimine göre gösterilir. Önizleme istemciyi yenilemez.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Kullanılması beklenen yenileme sayısı: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/uk-UA.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Попередній перегляд продовження (часовий пояс панелі: {zone})",
+      "renewServerLocal": "Місцевий час сервера",
+      "renewPreviewHint": "Дати вказано в часовому поясі панелі. Попередній перегляд не продовжує клієнта.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Буде використано продовжень: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/vi-VN.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "Calendar monthly",
       "renewWeekday": "Renew on weekday",
       "renewScheduleDesc": "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. Monthly day 1 means valid through the preceding month; day 31 is not the same boundary.",
-      "renewPreview": "Renewal preview ({zone})",
+      "renewPreview": "Xem trước gia hạn (múi giờ bảng điều khiển: {zone})",
+      "renewServerLocal": "Giờ địa phương của máy chủ",
+      "renewPreviewHint": "Ngày giờ dùng múi giờ của bảng điều khiển. Xem trước không thực hiện gia hạn máy khách.",
       "renewAt": "Renewal boundary",
       "renewValidThrough": "Valid through",
       "renewNextExpiry": "Cutoff after renewal",
-      "renewPeriods": "Periods charged: {count}",
+      "renewPeriods": "Số lượt gia hạn dự kiến sử dụng: {count}",
       "renewUnavailable": "The remaining renewal allowances cannot provide a future cutoff.",
       "renewFirstUse": "Dates are available after first-use activation. The initial duration is unchanged.",
       "renewSetExpiry": "Set first cycle cutoff",

+ 4 - 2
internal/web/translation/zh-CN.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "日历月",
       "renewWeekday": "每周续期日",
       "renewScheduleDesc": "日历续期按面板时区的午夜执行,并重置客户端流量。若不需要额外重置,请关闭独立的流量重置。每月 1 日续期表示有效至上个月末;每月 31 日不是同一边界。",
-      "renewPreview": "续期预览({zone})",
+      "renewPreview": "续期预览(面板时区:{zone})",
+      "renewServerLocal": "服务器本地时区",
+      "renewPreviewHint": "日期按面板时区显示,查看预览不会执行续期。",
       "renewAt": "续期边界",
       "renewValidThrough": "本期有效至",
       "renewNextExpiry": "续期后的截止时间",
-      "renewPeriods": "计入续期次数:{count}",
+      "renewPeriods": "预计消耗续期次数:{count}",
       "renewUnavailable": "剩余续期次数不足以提供未来的截止时间。",
       "renewFirstUse": "首次使用激活后才能确定日期,首次使用后的初始时长保持不变。",
       "renewSetExpiry": "设置首个周期截止时间",

+ 4 - 2
internal/web/translation/zh-TW.json

@@ -953,11 +953,13 @@
       "renewModeMonthly": "日曆月",
       "renewWeekday": "每週續期日",
       "renewScheduleDesc": "日曆續期按面板時區的午夜執行,並重置客戶端流量。若不需要額外重置,請關閉獨立的流量重置。每月 1 日續期表示有效至上個月末;每月 31 日不是同一邊界。",
-      "renewPreview": "續期預覽({zone})",
+      "renewPreview": "續期預覽(面板時區:{zone})",
+      "renewServerLocal": "伺服器本地時區",
+      "renewPreviewHint": "日期依面板時區顯示,查看預覽不會執行續期。",
       "renewAt": "續期邊界",
       "renewValidThrough": "本期有效至",
       "renewNextExpiry": "續期後的截止時間",
-      "renewPeriods": "計入續期次數:{count}",
+      "renewPeriods": "預計消耗續期次數:{count}",
       "renewUnavailable": "剩餘續期次數不足以提供未來的截止時間。",
       "renewFirstUse": "首次使用啟用後才能確定日期,首次使用後的初始時長保持不變。",
       "renewSetExpiry": "設定首個週期截止時間",