7 Commits 8cec47a8a5 ... 6e80a468e3

Autore SHA1 Messaggio Data
  n0ctal 6e80a468e3 feat(server): keep this machine's own settings when importing a database (#6227) 13 ore fa
  n0ctal e940f30bb8 feat(clients): cap how many times a client may auto-renew (#6238) 13 ore fa
  n0ctal 6a674c7f0c fix(node): keep disabled inbounds the node snapshot cannot report (#6221) 13 ore fa
  n0ctal 81cfd8570e fix(inbounds): close the port check-and-claim race on the serial writer (#6225) 13 ore fa
  n0ctal 5c9268c431 feat(i18n): translate the log levels, access events and calendar labels (#6226) 13 ore fa
  n0ctal 2b1fe1fd02 ci: actually run the PostgreSQL schema and migration tests (#6224) 13 ore fa
  n0ctal dc1979a14c ci(release): stamp released binaries with their source revision (#6223) 13 ore fa
47 ha cambiato i file con 1170 aggiunte e 126 eliminazioni
  1. 23 3
      .github/workflows/ci.yml
  2. 3 2
      .github/workflows/release.yml
  3. 21 9
      frontend/public/mockServiceWorker.js
  4. 25 0
      frontend/public/openapi.json
  5. 6 0
      frontend/src/generated/examples.ts
  6. 21 0
      frontend/src/generated/schemas.ts
  7. 4 0
      frontend/src/generated/types.ts
  8. 4 0
      frontend/src/generated/zod.ts
  9. 1 1
      frontend/src/layouts/AppSidebar.tsx
  10. 11 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  11. 14 0
      frontend/src/pages/clients/ClientFormModal.tsx
  12. 10 0
      frontend/src/pages/clients/ClientInfoModal.tsx
  13. 13 1
      frontend/src/pages/index/BackupModal.tsx
  14. 1 1
      frontend/src/pages/index/IndexPage.tsx
  15. 6 6
      frontend/src/pages/index/LogModal.tsx
  16. 19 5
      frontend/src/pages/index/XrayLogModal.tsx
  17. 4 5
      frontend/src/pages/settings/GeneralTab.tsx
  18. 5 0
      frontend/src/schemas/client.ts
  19. 10 0
      internal/database/model/model.go
  20. 5 1
      internal/web/controller/server.go
  21. 0 0
      internal/web/dist/.gitkeep
  22. 16 0
      internal/web/service/client_crud.go
  23. 1 0
      internal/web/service/client_link.go
  24. 2 0
      internal/web/service/client_paging.go
  25. 99 0
      internal/web/service/import_host_settings_test.go
  26. 17 19
      internal/web/service/inbound.go
  27. 287 0
      internal/web/service/inbound_autorenew_maxcount_test.go
  28. 99 0
      internal/web/service/inbound_create_race_test.go
  29. 6 0
      internal/web/service/inbound_node.go
  30. 23 1
      internal/web/service/inbound_traffic.go
  31. 37 0
      internal/web/service/node_dirty_test.go
  32. 8 2
      internal/web/service/port_conflict.go
  33. 92 5
      internal/web/service/server.go
  34. 20 4
      internal/web/translation/ar-EG.json
  35. 20 4
      internal/web/translation/en-US.json
  36. 20 4
      internal/web/translation/es-ES.json
  37. 20 4
      internal/web/translation/fa-IR.json
  38. 20 4
      internal/web/translation/id-ID.json
  39. 20 4
      internal/web/translation/ja-JP.json
  40. 20 4
      internal/web/translation/pt-BR.json
  41. 20 4
      internal/web/translation/ru-RU.json
  42. 20 4
      internal/web/translation/tr-TR.json
  43. 20 4
      internal/web/translation/uk-UA.json
  44. 20 4
      internal/web/translation/vi-VN.json
  45. 20 4
      internal/web/translation/zh-CN.json
  46. 20 4
      internal/web/translation/zh-TW.json
  47. 17 13
      internal/xray/client_traffic.go

+ 23 - 3
.github/workflows/ci.yml

@@ -8,6 +8,7 @@ on:
       - "go.sum"
       - "frontend/**"
       - ".nvmrc"
+      - ".github/workflows/ci.yml"
   push:
     branches:
       - main
@@ -17,6 +18,7 @@ on:
       - "go.sum"
       - "frontend/**"
       - ".nvmrc"
+      - ".github/workflows/ci.yml"
 
 permissions:
   contents: read
@@ -53,6 +55,9 @@ jobs:
           --health-interval 10s
           --health-timeout 5s
           --health-retries 5
+    env:
+      XUI_DB_TYPE: postgres
+      XUI_DB_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=xui_durable sslmode=disable"
     steps:
       - uses: actions/checkout@v7
       - uses: actions/setup-go@v7
@@ -64,9 +69,24 @@ jobs:
       - name: PostgreSQL durable-first tests
         run: |
           set -o pipefail
-          XUI_DB_TYPE=postgres XUI_DB_DSN="host=127.0.0.1 port=5432 user=postgres password=postgres dbname=xui_durable sslmode=disable" \
-            go test ./internal/web/service -run 'PostgresCommitFailure' -count=1 -v | tee /tmp/postgres-durable-first.log
-          if grep -q -- '--- SKIP' /tmp/postgres-durable-first.log; then
+          go test ./internal/web/service -run 'PostgresCommitFailure' -count=1 -v | tee /tmp/postgres-durable-first.log
+          # Count passes rather than assert no SKIP: a renamed or deleted test
+          # prints "no tests to run" and exits 0, leaving the step green for nothing.
+          passed=$(grep -c -- '--- PASS' /tmp/postgres-durable-first.log || true)
+          if [ "$passed" -lt 1 ]; then
+            echo "expected at least 1 passing durable-first test, got $passed" >&2
+            exit 1
+          fi
+
+      - name: PostgreSQL schema and migration tests
+        run: |
+          set -o pipefail
+          go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
+          # Both must pass. Counting, not SKIP-matching: renaming either test would
+          # otherwise leave this step green while testing nothing.
+          passed=$(grep -c -- '--- PASS' /tmp/postgres-schema.log || true)
+          if [ "$passed" -lt 2 ]; then
+            echo "expected 2 passing PostgreSQL schema tests, got $passed" >&2
             exit 1
           fi
 

+ 3 - 2
.github/workflows/release.yml

@@ -109,7 +109,7 @@ jobs:
           if [[ "$GITHUB_REF" != refs/tags/* ]]; then
             LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA::8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
           fi
-          go build -ldflags "$LDFLAGS" -o xui-release -v main.go
+          go build -buildvcs=true -ldflags "$LDFLAGS" -o xui-release -v .
           file xui-release
           ldd xui-release || echo "Static binary confirmed"
 
@@ -247,6 +247,7 @@ jobs:
           msystem: MINGW64
           update: true
           install: >-
+            git
             mingw-w64-x86_64-gcc
             mingw-w64-x86_64-sqlite3
             mingw-w64-x86_64-pkg-config
@@ -270,7 +271,7 @@ jobs:
           if [[ "$GITHUB_REF" != refs/tags/* ]]; then
             LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA:0:8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
           fi
-          go build -ldflags "$LDFLAGS" -o xui-release.exe -v main.go
+          go build -buildvcs=true -ldflags "$LDFLAGS" -o xui-release.exe -v .
 
       - name: Copy and download resources
         shell: pwsh

+ 21 - 9
frontend/public/mockServiceWorker.js

@@ -7,8 +7,8 @@
  * - Please do NOT modify this file.
  */
 
-const PACKAGE_VERSION = '2.14.7'
-const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
+const PACKAGE_VERSION = '2.15.0'
+const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
 const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
 const activeClientIds = new Set()
 
@@ -137,8 +137,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
   if (client && activeClientIds.has(client.id)) {
     const serializedRequest = await serializeRequest(requestCloneForEvents)
 
+    // Omit the body of server-sent event stream responses.
+    // Cloning such responses would prevent client-side stream cancelations
+    // from reaching the original stream (a teed stream only cancels its
+    // source once both of its branches cancel) and would buffer the
+    // entire stream into the unconsumed clone indefinitely.
+    const isEventStreamResponse = response.headers
+      .get('content-type')
+      ?.toLowerCase()
+      .startsWith('text/event-stream')
+
     // Clone the response so both the client and the library could consume it.
-    const responseClone = response.clone()
+    const responseClone = isEventStreamResponse ? null : response.clone()
 
     sendToClient(
       client,
@@ -151,15 +161,17 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
             ...serializedRequest,
           },
           response: {
-            type: responseClone.type,
-            status: responseClone.status,
-            statusText: responseClone.statusText,
-            headers: Object.fromEntries(responseClone.headers.entries()),
-            body: responseClone.body,
+            type: response.type,
+            status: response.status,
+            statusText: response.statusText,
+            headers: Object.fromEntries(response.headers.entries()),
+            body: responseClone ? responseClone.body : null,
           },
         },
       },
-      responseClone.body ? [serializedRequest.body, responseClone.body] : [],
+      responseClone && responseClone.body
+        ? [serializedRequest.body, responseClone.body]
+        : [],
     )
   }
 

+ 25 - 0
frontend/public/openapi.json

@@ -1110,6 +1110,10 @@
             "description": "Reset period in days",
             "type": "integer"
           },
+          "resetMax": {
+            "description": "Max auto-renew count, 0 = unlimited",
+            "type": "integer"
+          },
           "reverse": {
             "allOf": [
               {
@@ -1154,6 +1158,7 @@
           "expiryTime",
           "limitIp",
           "reset",
+          "resetMax",
           "security",
           "subId",
           "tgId",
@@ -1246,6 +1251,9 @@
           "reset": {
             "type": "integer"
           },
+          "resetMax": {
+            "type": "integer"
+          },
           "reverse": {},
           "secret": {
             "type": "string"
@@ -1292,6 +1300,7 @@
           "privateKey",
           "publicKey",
           "reset",
+          "resetMax",
           "reverse",
           "secret",
           "security",
@@ -1357,6 +1366,16 @@
             "example": 0,
             "type": "integer"
           },
+          "resetCount": {
+            "description": "ResetCount is how many have fired, so a prepaid plan stops on its own.",
+            "example": 0,
+            "type": "integer"
+          },
+          "resetMax": {
+            "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
+            "example": 0,
+            "type": "integer"
+          },
           "subId": {
             "example": "i7tvdpeffi0hvvf1",
             "type": "string"
@@ -1386,6 +1405,8 @@
           "lastOnline",
           "lastSubFetch",
           "reset",
+          "resetCount",
+          "resetMax",
           "subId",
           "total",
           "up",
@@ -3327,6 +3348,8 @@
                           "lastOnline": 1735680000000,
                           "lastSubFetch": 1735680000000,
                           "reset": 0,
+                          "resetCount": 0,
+                          "resetMax": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
                           "up": 1048576,
@@ -8130,6 +8153,8 @@
                     "lastOnline": 1735680000000,
                     "lastSubFetch": 1735680000000,
                     "reset": 0,
+                    "resetCount": 0,
+                    "resetMax": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
                     "up": 1048576,

+ 6 - 0
frontend/src/generated/examples.ts

@@ -256,6 +256,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetMax": 0,
     "reverse": null,
     "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
     "security": "",
@@ -290,6 +291,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "privateKey": "",
     "publicKey": "",
     "reset": 0,
+    "resetMax": 0,
     "reverse": null,
     "secret": "",
     "security": "",
@@ -312,6 +314,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "lastOnline": 1735680000000,
     "lastSubFetch": 1735680000000,
     "reset": 0,
+    "resetCount": 0,
+    "resetMax": 0,
     "subId": "i7tvdpeffi0hvvf1",
     "total": 10737418240,
     "up": 1048576,
@@ -478,6 +482,8 @@ export const EXAMPLES: Record<string, unknown> = {
         "lastOnline": 1735680000000,
         "lastSubFetch": 1735680000000,
         "reset": 0,
+        "resetCount": 0,
+        "resetMax": 0,
         "subId": "i7tvdpeffi0hvvf1",
         "total": 10737418240,
         "up": 1048576,

+ 21 - 0
frontend/src/generated/schemas.ts

@@ -1084,6 +1084,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Reset period in days",
         "type": "integer"
       },
+      "resetMax": {
+        "description": "Max auto-renew count, 0 = unlimited",
+        "type": "integer"
+      },
       "reverse": {
         "allOf": [
           {
@@ -1128,6 +1132,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expiryTime",
       "limitIp",
       "reset",
+      "resetMax",
       "security",
       "subId",
       "tgId",
@@ -1220,6 +1225,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "reset": {
         "type": "integer"
       },
+      "resetMax": {
+        "type": "integer"
+      },
       "reverse": {},
       "secret": {
         "type": "string"
@@ -1266,6 +1274,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "privateKey",
       "publicKey",
       "reset",
+      "resetMax",
       "reverse",
       "secret",
       "security",
@@ -1331,6 +1340,16 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 0,
         "type": "integer"
       },
+      "resetCount": {
+        "description": "ResetCount is how many have fired, so a prepaid plan stops on its own.",
+        "example": 0,
+        "type": "integer"
+      },
+      "resetMax": {
+        "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
+        "example": 0,
+        "type": "integer"
+      },
       "subId": {
         "example": "i7tvdpeffi0hvvf1",
         "type": "string"
@@ -1360,6 +1379,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "lastOnline",
       "lastSubFetch",
       "reset",
+      "resetCount",
+      "resetMax",
       "subId",
       "total",
       "up",

+ 4 - 0
frontend/src/generated/types.ts

@@ -266,6 +266,7 @@ export interface Client {
   privateKey?: string;
   publicKey?: string;
   reset: number;
+  resetMax: number;
   reverse?: ClientReverse | null;
   secret?: string;
   security: string;
@@ -302,6 +303,7 @@ export interface ClientRecord {
   privateKey: string;
   publicKey: string;
   reset: number;
+  resetMax: number;
   reverse: unknown;
   secret: string;
   security: string;
@@ -326,6 +328,8 @@ export interface ClientTraffic {
   lastOnline: number;
   lastSubFetch: number;
   reset: number;
+  resetCount: number;
+  resetMax: number;
   subId: string;
   total: number;
   up: number;

+ 4 - 0
frontend/src/generated/zod.ts

@@ -286,6 +286,7 @@ export const ClientSchema = z.object({
   privateKey: z.string().optional(),
   publicKey: z.string().optional(),
   reset: z.number().int(),
+  resetMax: z.number().int(),
   reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
   secret: z.string().optional(),
   security: z.string(),
@@ -324,6 +325,7 @@ export const ClientRecordSchema = z.object({
   privateKey: z.string(),
   publicKey: z.string(),
   reset: z.number().int(),
+  resetMax: z.number().int(),
   reverse: z.unknown(),
   secret: z.string(),
   security: z.string(),
@@ -350,6 +352,8 @@ export const ClientTrafficSchema = z.object({
   lastOnline: z.number().int(),
   lastSubFetch: z.number().int(),
   reset: z.number().int(),
+  resetCount: z.number().int(),
+  resetMax: z.number().int(),
   subId: z.string(),
   total: z.number().int(),
   up: z.number().int(),

+ 1 - 1
frontend/src/layouts/AppSidebar.tsx

@@ -219,7 +219,7 @@ export default function AppSidebar() {
       { key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
     ];
     if (showSubFormats) {
-      children.push({ key: '/settings#subscription-formats', icon: <CodeOutlined />, label: 'Sub Formats' });
+      children.push({ key: '/settings#subscription-formats', icon: <CodeOutlined />, label: t('menu.subFormats') });
     }
     return children;
   }, [t, showSubFormats]);

+ 11 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -37,6 +37,7 @@ const EMPTY: ClientBulkAddFormValues = {
   totalGB: 0,
   expiryTime: 0,
   reset: 0,
+  resetMax: 0,
   inboundIds: [],
 };
 
@@ -176,6 +177,7 @@ export default function ClientBulkAddModal({
           totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
           expiryTime: current.expiryTime,
           reset: Number(current.reset) || 0,
+          resetMax: Number(current.resetMax) || 0,
           limitIp: Number(current.limitIp) || 0,
           limitHwid: Number(current.limitHwid) || 0,
           group: current.group,
@@ -374,6 +376,15 @@ export default function ClientBulkAddModal({
             >
               <InputNumber min={0} />
             </FormField>
+
+            <FormField
+              name="resetMax"
+              label={t('pages.clients.renewMax')}
+              tooltip={t('pages.clients.renewMaxDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} />
+            </FormField>
           </Form>
         </FormProvider>
       </Modal>

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

@@ -131,6 +131,7 @@ const EMPTY: Values = {
   delayedStart: false,
   delayedDays: 0,
   reset: 0,
+  resetMax: 0,
   limitIp: 0,
   limitHwid: 0,
   tgId: 0,
@@ -250,6 +251,7 @@ export default function ClientFormModal({
         reverseTag: client.reverse?.tag || '',
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
+        resetMax: Number(client.resetMax) || 0,
         limitIp: client.limitIp || 0,
         limitHwid: client.limitHwid || 0,
         tgId: Number(client.tgId) || 0,
@@ -538,6 +540,7 @@ email: values.email,
       delayedStart: values.delayedStart,
       delayedDays: values.delayedDays,
       reset: values.reset,
+      resetMax: values.resetMax,
       limitIp: values.limitIp,
       limitHwid: values.limitHwid,
       tgId: values.tgId,
@@ -566,6 +569,7 @@ email: values.email,
       totalGB: totalBytes,
       expiryTime,
 reset: Number(values.reset) || 0,
+      resetMax: Number(values.resetMax) || 0,
       limitIp: Number(values.limitIp) || 0,
       limitHwid: Number(values.limitHwid) || 0,
       tgId: Number(values.tgId) || 0,
@@ -785,6 +789,16 @@ reset: Number(values.reset) || 0,
                             <InputNumber min={0} style={{ width: '100%' }} />
                           </FormField>
                         </Col>
+                        <Col xs={12} md={6}>
+                          <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>
 
                       <Row gutter={16}>

+ 10 - 0
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -325,6 +325,16 @@ export default function ClientInfoModal({
                     </Button>
                   </td>
                 </tr>
+                {(traffic?.resetMax ?? 0) > 0 && (
+                  <tr>
+                    <td>{t('pages.clients.renewsUsed')}</td>
+                    <td>
+                      <Tag color={(traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'}>
+                        {traffic?.resetCount ?? 0} / {traffic?.resetMax}
+                      </Tag>
+                    </td>
+                  </tr>
+                )}
                 <tr>
                   <td>{t('pages.inbounds.createdAt')}</td>
                   <td><Tag>{dateLabel(client.createdAt)}</Tag></td>

+ 13 - 1
frontend/src/pages/index/BackupModal.tsx

@@ -1,5 +1,6 @@
+import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Modal } from 'antd';
+import { Button, Checkbox, Modal } from 'antd';
 import { DownloadOutlined, UploadOutlined } from '@ant-design/icons';
 
 import { HttpUtil, PromiseUtil } from '@/utils';
@@ -20,6 +21,7 @@ interface BackupModalProps {
 export default function BackupModal({ open, basePath: _basePath, onClose, onBusy }: BackupModalProps) {
   const { t } = useTranslation();
   const isPostgres = window.X_UI_DB_TYPE === 'postgres';
+  const [keepHostSettings, setKeepHostSettings] = useState(true);
 
   function exportDb() {
     window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
@@ -39,6 +41,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
 
       const formData = new FormData();
       formData.append('db', dbFile);
+      formData.append('keepHostSettings', String(keepHostSettings));
 
       onClose();
       onBusy({ busy: true, tip: `${t('pages.index.importDatabase')}…` });
@@ -105,6 +108,15 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
           </div>
           <Button type="primary" aria-label={t('pages.index.importDatabase')} onClick={importDb} icon={<UploadOutlined />} />
         </div>
+
+        <div className="backup-item">
+          <div className="backup-meta">
+            <Checkbox checked={keepHostSettings} onChange={(e) => setKeepHostSettings(e.target.checked)}>
+              {t('pages.index.importKeepHostSettings')}
+            </Checkbox>
+            <div className="backup-description">{t('pages.index.importKeepHostSettingsDesc')}</div>
+          </div>
+        </div>
       </div>
     </Modal>
   );

+ 1 - 1
frontend/src/pages/index/IndexPage.tsx

@@ -127,7 +127,7 @@ export default function IndexPage() {
 
   async function copyConfig() {
     const ok = await ClipboardManager.copyText(configText || '');
-    if (ok) messageApi.success('Copied');
+    if (ok) messageApi.success(t('copied'));
   }
 
   function downloadConfig() {

+ 6 - 6
frontend/src/pages/index/LogModal.tsx

@@ -105,14 +105,14 @@ export default function LogModal({ open, onClose }: LogModalProps) {
             <Select
               value={level}
               size="small"
-              style={{ width: 95 }}
+              style={{ minWidth: 95 }}
               onChange={setLevel}
               options={[
-                { value: 'debug', label: 'Debug' },
-                { value: 'info', label: 'Info' },
-                { value: 'notice', label: 'Notice' },
-                { value: 'warning', label: 'Warning' },
-                { value: 'err', label: 'Error' },
+                { value: 'debug', label: t('pages.index.logLevelDebug') },
+                { value: 'info', label: t('pages.index.logLevelInfo') },
+                { value: 'notice', label: t('pages.index.logLevelNotice') },
+                { value: 'warning', label: t('pages.index.logLevelWarning') },
+                { value: 'err', label: t('pages.index.logLevelError') },
               ]}
             />
           </Space.Compact>

+ 19 - 5
frontend/src/pages/index/XrayLogModal.tsx

@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { TFunction } from 'i18next';
 import { useTranslation } from 'react-i18next';
 import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
 import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
@@ -24,11 +25,24 @@ interface XrayLogEntry {
   Event?: number;
 }
 
-const EVENT_LABELS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
+// The downloaded log is a data format people grep, so it keeps the stable
+// tokens; only what is rendered on screen follows the panel language.
+const EVENT_TOKENS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
+
+const EVENT_KEYS: Record<number, string> = {
+  0: 'pages.index.accessDirect',
+  1: 'pages.index.accessBlocked',
+  2: 'pages.index.accessProxy',
+};
 const EVENT_COLORS: Record<number, string> = { 0: 'green', 1: 'red', 2: 'blue' };
 
-function eventLabel(ev?: number): string {
-  return EVENT_LABELS[ev ?? -1] ?? String(ev ?? '');
+function eventToken(ev?: number): string {
+  return EVENT_TOKENS[ev ?? -1] ?? String(ev ?? '');
+}
+
+function eventLabel(t: TFunction, ev?: number): string {
+  const key = EVENT_KEYS[ev ?? -1];
+  return key ? t(key) : String(ev ?? '');
 }
 
 function eventColor(ev?: number): string {
@@ -112,7 +126,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
       try {
         const dt = l.DateTime ? new Date(l.DateTime) : null;
         const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
-        const eventText = eventLabel(l.Event);
+        const eventText = eventToken(l.Event);
         const emailPart = l.Email ? ` Email=${l.Email}` : '';
         return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
       } catch {
@@ -193,7 +207,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
                   {shortTime(log.DateTime)}
                 </span>
                 <Tag color={eventColor(log.Event)} className="log-event-tag">
-                  {eventLabel(log.Event)}
+                  {eventLabel(t, log.Event)}
                 </Tag>
               </div>
               <div className="log-route">

+ 4 - 5
frontend/src/pages/settings/GeneralTab.tsx

@@ -34,10 +34,6 @@ interface GeneralTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
-const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
-  { name: 'Gregorian (Standard)', value: 'gregorian' },
-  { name: 'Jalalian (شمسی)', value: 'jalalian' },
-];
 
 export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
   const { t } = useTranslation();
@@ -290,7 +286,10 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
                 value={allSetting.datepicker || 'gregorian'}
                 onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
                 style={{ width: '100%' }}
-                options={DATEPICKER_LIST.map((d) => ({ value: d.value, label: d.name }))}
+                options={[
+                  { value: 'gregorian', label: t('pages.settings.calendarGregorian') },
+                  { value: 'jalalian', label: t('pages.settings.calendarJalalian') },
+                ]}
               />
             </SettingListItem>
           </>

+ 5 - 0
frontend/src/schemas/client.ts

@@ -11,6 +11,8 @@ export const ClientTrafficSchema = z.object({
   enable: z.boolean().optional(),
   lastOnline: z.number().optional(),
   lastSubFetch: z.number().optional(),
+  resetMax: z.number().optional(),
+  resetCount: z.number().optional(),
 });
 
 export const ClientRecordSchema = z.object({
@@ -31,6 +33,7 @@ export const ClientRecordSchema = z.object({
   comment: z.string().optional(),
   enable: z.boolean().optional(),
   reset: z.number().optional(),
+  resetMax: z.number().optional(),
   inboundIds: nullableNumberArray.optional(),
   traffic: ClientTrafficSchema.nullable().optional(),
   reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(),
@@ -205,6 +208,7 @@ export const ClientFormSchema = z.object({
   delayedStart: z.boolean(),
   delayedDays: z.number().int().min(0),
   reset: z.number().int().min(0),
+  resetMax: z.number().int().min(0),
   limitIp: z.number().int().min(0),
   limitHwid: z.number().int().min(0),
   tgId: z.number().int().min(0),
@@ -244,6 +248,7 @@ export const ClientBulkAddFormSchema = z.object({
   totalGB: z.number().min(0),
   expiryTime: z.number(),
   reset: z.number().int().min(0),
+  resetMax: z.number().int().min(0),
   inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
 });
 

+ 10 - 0
internal/database/model/model.go

@@ -894,6 +894,7 @@ type Client struct {
 	Group        string         `json:"group,omitempty" form:"group"` // Logical grouping label
 	Comment      string         `json:"comment" form:"comment"`       // Client comment
 	Reset        int            `json:"reset" form:"reset"`           // Reset period in days
+	ResetMax     int            `json:"resetMax" form:"resetMax"`     // Max auto-renew count, 0 = unlimited
 	CreatedAt    int64          `json:"created_at,omitempty"`         // Creation timestamp
 	UpdatedAt    int64          `json:"updated_at,omitempty"`         // Last update timestamp
 }
@@ -924,6 +925,7 @@ type ClientRecord struct {
 	Group        string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
 	Comment      string `json:"comment"`
 	Reset        int    `json:"reset" gorm:"default:0"`
+	ResetMax     int    `json:"resetMax" gorm:"column:reset_max;default:0"`
 	CreatedAt    int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 	UpdatedAt    int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 	// Owned solely by the node-snapshot sweep, which soft-orphans instead of
@@ -1105,6 +1107,7 @@ func (c *Client) ToRecord() *ClientRecord {
 		Group:      c.Group,
 		Comment:    c.Comment,
 		Reset:      c.Reset,
+		ResetMax:   c.ResetMax,
 		CreatedAt:  c.CreatedAt,
 		UpdatedAt:  c.UpdatedAt,
 
@@ -1158,6 +1161,7 @@ func (r *ClientRecord) ToClient() *Client {
 		Group:      r.Group,
 		Comment:    r.Comment,
 		Reset:      r.Reset,
+		ResetMax:   r.ResetMax,
 		CreatedAt:  r.CreatedAt,
 		UpdatedAt:  r.UpdatedAt,
 
@@ -1306,6 +1310,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.Reset = incoming.Reset
 		}
 	}
+	if existing.ResetMax != incoming.ResetMax && incoming.ResetMax != 0 {
+		if incomingNewer || existing.ResetMax == 0 {
+			keep("resetMax", existing.ResetMax, incoming.ResetMax, incoming.ResetMax)
+			existing.ResetMax = incoming.ResetMax
+		}
+	}
 	if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
 		if incomingNewer || existing.Reverse == "" {
 			keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)

+ 5 - 1
internal/web/controller/server.go

@@ -375,7 +375,11 @@ func (a *ServerController) importDB(c *gin.Context) {
 		return
 	}
 	defer file.Close()
-	if err := a.serverService.ImportDB(file); err != nil {
+	// Absent field keeps this machine's own listen addresses, certificates and
+	// node identity: the safe default for the common case of moving a config to
+	// a new host. Send keepHostSettings=false to clone a machine wholesale.
+	keepHostSettings := c.Request.FormValue("keepHostSettings") != "false"
+	if err := a.serverService.ImportDB(file, keepHostSettings); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
 		return
 	}

+ 0 - 0
internal/web/dist/.gitkeep


+ 16 - 0
internal/web/service/client_crud.go

@@ -43,6 +43,15 @@ func validateClientSubID(subID string) error {
 	return nil
 }
 
+// Rejected rather than coerced: a negative cap reads as "unlimited" to a caller
+// but selects nothing, so the client would silently stop renewing.
+func validateClientResetMax(resetMax int) error {
+	if resetMax < 0 {
+		return common.NewError("client resetMax must not be negative, got:", resetMax)
+	}
+	return nil
+}
+
 func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
 	if payload == nil {
 		return false, common.NewError("empty payload")
@@ -57,6 +66,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 	if err := validateClientSubID(client.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetMax(client.ResetMax); err != nil {
+		return false, err
+	}
 	if len(payload.InboundIds) == 0 {
 		return false, common.NewError("at least one inbound is required")
 	}
@@ -344,6 +356,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if err := validateClientSubID(updated.SubID); err != nil {
 		return false, err
 	}
+	if err := validateClientResetMax(updated.ResetMax); err != nil {
+		return false, err
+	}
 	if updated.SubID == "" {
 		updated.SubID = existing.SubID
 	}
@@ -466,6 +481,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 				"tg_id":             merged.TgID,
 				"comment":           merged.Comment,
 				"reset":             merged.Reset,
+				"reset_max":         merged.ResetMax,
 			}).Error; err != nil {
 			return needRestart, err
 		}

+ 1 - 0
internal/web/service/client_link.go

@@ -63,6 +63,7 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
 	}
 	row.Comment = incoming.Comment
 	row.Reset = incoming.Reset
+	row.ResetMax = incoming.ResetMax
 	if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
 		row.CreatedAt = incoming.CreatedAt
 	}

+ 2 - 0
internal/web/service/client_paging.go

@@ -26,6 +26,7 @@ type ClientSlim struct {
 	LimitIP    int                 `json:"limitIp"`
 	LimitHwid  int                 `json:"limitHwid"`
 	Reset      int                 `json:"reset"`
+	ResetMax   int                 `json:"resetMax"`
 	Group      string              `json:"group,omitempty"`
 	Comment    string              `json:"comment,omitempty"`
 	InboundIds []int               `json:"inboundIds"`
@@ -605,6 +606,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim {
 		LimitIP:    c.LimitIP,
 		LimitHwid:  c.LimitHwid,
 		Reset:      c.Reset,
+		ResetMax:   c.ResetMax,
 		Group:      c.Group,
 		Comment:    c.Comment,
 		InboundIds: c.InboundIds,

+ 99 - 0
internal/web/service/import_host_settings_test.go

@@ -0,0 +1,99 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// An imported database carries the source machine's listen addresses,
+// certificates and node identity. Keeping this machine's own values is what
+// stops the panel from becoming unreachable on its own address after a restore.
+func TestImportKeepsHostBoundSettings(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	mine := map[string]string{
+		"webPort":               "8443",
+		"webCertFile":           "/etc/ssl/this-host.pem",
+		"webBasePath":           "/mine/",
+		"subURI":                "https://this-host.example/sub/",
+		"panelGuid":             "this-host-guid",
+		"nodeMtlsClientCertPem": "this-host-leaf",
+	}
+	for key, value := range mine {
+		if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
+			t.Fatalf("seed %s: %v", key, err)
+		}
+	}
+	// A setting that belongs to the configuration, not the machine.
+	if err := db.Create(&model.Setting{Key: "remarkTemplate", Value: "mine"}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	kept := captureHostBoundSettings()
+	if len(kept.values) != len(mine) {
+		t.Fatalf("captured %d host settings, want %d: %v", len(kept.values), len(mine), kept.values)
+	}
+
+	// Stand in for the import: every row now holds the source machine's value.
+	for key := range mine {
+		if err := db.Model(&model.Setting{}).Where("key = ?", key).
+			Update("value", "from-imported-file").Error; err != nil {
+			t.Fatalf("overwrite %s: %v", key, err)
+		}
+	}
+	if err := db.Model(&model.Setting{}).Where("key = ?", "remarkTemplate").
+		Update("value", "from-imported-file").Error; err != nil {
+		t.Fatal(err)
+	}
+
+	restoreHostBoundSettings(kept)
+
+	for key, want := range mine {
+		var got model.Setting
+		if err := db.Where("key = ?", key).First(&got).Error; err != nil {
+			t.Fatalf("read back %s: %v", key, err)
+		}
+		if got.Value != want {
+			t.Fatalf("setting %s = %q after import, want this machine's %q", key, got.Value, want)
+		}
+	}
+
+	var carried model.Setting
+	if err := db.Where("key = ?", "remarkTemplate").First(&carried).Error; err != nil {
+		t.Fatal(err)
+	}
+	if carried.Value != "from-imported-file" {
+		t.Fatalf("remarkTemplate = %q, want the imported value: only host-bound keys may survive", carried.Value)
+	}
+}
+
+// The destination usually has no row at all for the certificate paths and the
+// node identity — the built-in default applies. The imported row must go, or
+// the panel quietly adopts the source machine's certificate path.
+func TestImportDropsHostBoundSettingsThisMachineNeverHad(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	kept := captureHostBoundSettings()
+
+	for _, key := range []string{"webCertFile", "subCertFile", "nodeMtlsClientCertPem"} {
+		if err := db.Create(&model.Setting{Key: key, Value: "from-imported-file"}).Error; err != nil {
+			t.Fatalf("seed imported %s: %v", key, err)
+		}
+	}
+
+	restoreHostBoundSettings(kept)
+
+	for _, key := range []string{"webCertFile", "subCertFile", "nodeMtlsClientCertPem"} {
+		var count int64
+		if err := db.Model(&model.Setting{}).Where("key = ?", key).Count(&count).Error; err != nil {
+			t.Fatal(err)
+		}
+		if count != 0 {
+			t.Fatalf("imported %s survived although this machine had no row for it", key)
+		}
+	}
+}

+ 17 - 19
internal/web/service/inbound.go

@@ -930,18 +930,11 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, err
 	}
 
-	conflict, err := s.checkPortConflict(inbound, 0)
-	if err != nil {
-		return inbound, false, err
-	}
-	if conflict != nil {
-		return inbound, false, common.NewError(conflict.String())
-	}
-
-	inbound.Tag, err = s.resolveInboundTag(inbound, 0)
+	tag, err := s.resolveInboundTag(inbound, 0)
 	if err != nil {
 		return inbound, false, err
 	}
+	inbound.Tag = tag
 
 	clients, err := s.GetClients(inbound)
 	if err != nil {
@@ -1027,10 +1020,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		}
 	}
 
-	db := database.GetDB()
 	needRestart := false
 	var postCommitApply func()
-	err = db.Transaction(func(tx *gorm.DB) error {
+	err = runSerializedTx(func(tx *gorm.DB) error {
+		conflict, cErr := checkPortConflictTx(tx, inbound, 0)
+		if cErr != nil {
+			return cErr
+		}
+		if conflict != nil {
+			return common.NewError(conflict.String())
+		}
 		markDirty := false
 		if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
 			return err
@@ -1416,14 +1415,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	inbound.NodeID = oldInbound.NodeID
 
-	conflict, err := s.checkPortConflict(inbound, inbound.Id)
-	if err != nil {
-		return inbound, false, err
-	}
-	if conflict != nil {
-		return inbound, false, common.NewError(conflict.String())
-	}
-
 	// Capture the pre-edit protocol and routing state before oldInbound is
 	// overwritten with the new values further down, then ensure a routed
 	// inbound keeps a stable egress port (reusing the one already stored).
@@ -1441,6 +1432,13 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	var postCommitApply func()
 
 	txErr := runSerializedTx(func(tx *gorm.DB) error {
+		conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
+		if cErr != nil {
+			return cErr
+		}
+		if conflict != nil {
+			return common.NewError(conflict.String())
+		}
 		if err := s.updateClientTraffics(tx, oldInbound, inbound); err != nil {
 			return err
 		}

+ 287 - 0
internal/web/service/inbound_autorenew_maxcount_test.go

@@ -0,0 +1,287 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// A prepaid plan must stop itself: once as many renewals have fired as the
+// operator allowed, the client expires like any other (#5804).
+func TestAutoRenewClients_StopsAtMaxCount(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "spent@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
+		{Email: "left@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30101, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	rows := []xray.ClientTraffic{
+		{InboundId: ib.Id, Email: "spent@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 2, ExpiryTime: past},
+		{InboundId: ib.Id, Email: "left@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 1, ExpiryTime: past},
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("renewed count = %d, want 1: only the client with an allowance left", count)
+	}
+
+	var spent xray.ClientTraffic
+	if err := db.Where("email = ?", "spent@x").First(&spent).Error; err != nil {
+		t.Fatal(err)
+	}
+	if spent.ExpiryTime != past {
+		t.Fatalf("a client that used its allowance was renewed anyway: expiry %d", spent.ExpiryTime)
+	}
+
+	var left xray.ClientTraffic
+	if err := db.Where("email = ?", "left@x").First(&left).Error; err != nil {
+		t.Fatal(err)
+	}
+	if left.ExpiryTime <= past {
+		t.Fatal("a client with an allowance left was not renewed")
+	}
+	if left.ResetCount != 2 {
+		t.Fatalf("reset count = %d after one renewal, want 2", left.ResetCount)
+	}
+}
+
+// Catching up several missed periods spends one allowance per period: a client
+// that was away for three cycles must not receive three of them for free.
+func TestAutoRenewClients_CatchUpSpendsOneAllowancePerPeriod(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	// Three whole 30-day periods behind.
+	past := time.Now().Add(-95 * 24 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "away@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30102, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "away@x", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "away@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ResetCount != 2 {
+		t.Fatalf("reset count = %d, want the 2 the cap allowed", row.ResetCount)
+	}
+	// Two periods granted, three needed: the client stays expired rather than
+	// silently receiving the third.
+	want := past + 2*30*86400000
+	if row.ExpiryTime != want {
+		t.Fatalf("expiry = %d, want %d: exactly the periods the cap paid for", row.ExpiryTime, want)
+	}
+	if row.ExpiryTime > time.Now().UnixMilli() {
+		t.Fatal("the capped catch-up handed out a future expiry it had not paid for")
+	}
+}
+
+// No cap set is the existing behaviour: renew for as long as the client keeps
+// expiring.
+func TestAutoRenewClients_NoCapRenewsAsBefore(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "forever@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, Reset: 30, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30103, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "forever@x", Enable: false, Reset: 30, ResetCount: 99, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("renewed count = %d, want 1: a client without a cap keeps renewing", count)
+	}
+}
+
+// The cap has to survive the clients table, not just the settings JSON: an
+// ordinary edit rebuilds the client from the record and writes it back (#5804).
+func TestClientEditKeepsTheRenewalCap(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "cap@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Reset: 30, ResetMax: 3, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
+	}
+	ib := mkInbound(t, 30104, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	mkTraffic(t, ib.Id, "cap@x", 10, 20, 0, 0, true)
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "cap@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.ResetMax != 3 {
+		t.Fatalf("clients.reset_max = %d, want the 3 the client was created with", rec.ResetMax)
+	}
+
+	// What the edit dialog does: hydrate the record, change something else, save.
+	edited := rec.ToClient()
+	edited.Comment = "renamed"
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	var stored model.Inbound
+	if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
+		t.Fatal(err)
+	}
+	var settings struct {
+		Clients []model.Client `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
+		t.Fatalf("parse inbound settings: %v", err)
+	}
+	if len(settings.Clients) != 1 {
+		t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
+	}
+	if settings.Clients[0].ResetMax != 3 {
+		t.Fatalf("inbound settings resetMax = %d after an unrelated edit, want 3: the cap was silently lifted", settings.Clients[0].ResetMax)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "cap@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.ResetMax != 3 {
+		t.Fatalf("clients.reset_max = %d after an unrelated edit, want 3", rec.ResetMax)
+	}
+}
+
+// A cap that runs out mid-catch-up leaves the client expired, so the renewal
+// side effects must not fire: disableInvalidClients would undo them at once.
+func TestAutoRenewClients_TruncatedCatchUpLeavesTheClientDisabled(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	// Five periods behind with one allowance left: one 30-day step cannot reach
+	// the present, so the client stays expired.
+	past := time.Now().Add(-150 * 24 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "short@x", ID: "55555555-5555-5555-5555-555555555555", Enable: false, Reset: 30, ResetMax: 3, ExpiryTime: past},
+	}
+	ib := mkInbound(t, 30105, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: "short@x", Enable: false, Reset: 30, ResetMax: 3, ResetCount: 2,
+		Up: 111, Down: 222, ExpiryTime: past,
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	}
+
+	var row xray.ClientTraffic
+	if err := db.Where("email = ?", "short@x").First(&row).Error; err != nil {
+		t.Fatal(err)
+	}
+	if row.ExpiryTime >= time.Now().UnixMilli() {
+		t.Fatalf("expiry %d reached the present: the cap did not truncate the catch-up", row.ExpiryTime)
+	}
+	if row.Enable {
+		t.Fatal("a client still expired after a truncated catch-up was enabled: xray gains a user only to lose it again")
+	}
+	if row.Up != 111 || row.Down != 222 {
+		t.Fatalf("counters zeroed for periods the client can never use: up=%d down=%d", row.Up, row.Down)
+	}
+}
+
+// The cap is useless if it can only be chosen once. The test above passes even
+// without the record write, because nothing overwrites the value it checks.
+func TestClientEditChangesTheRenewalCap(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+
+	clients := []model.Client{
+		{
+			Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, Reset: 30, ResetMax: 3,
+			ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
+		},
+	}
+	ib := mkInbound(t, 30106, model.VLESS, clientsSettings(t, clients))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true)
+
+	rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+
+	// The customer buys another block of periods, which is the whole point of
+	// the field being editable.
+	edited := rec.ToClient()
+	edited.ResetMax = 6
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after edit: %v", err)
+	}
+	if rec.ResetMax != 6 {
+		t.Fatalf("clients.reset_max = %d after the operator raised the cap to 6", rec.ResetMax)
+	}
+
+	// Lifting the cap entirely has to work too.
+	edited = rec.ToClient()
+	edited.ResetMax = 0
+	if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
+		t.Fatalf("Update to uncapped: %v", err)
+	}
+	rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail after lifting the cap: %v", err)
+	}
+	if rec.ResetMax != 0 {
+		t.Fatalf("clients.reset_max = %d after the operator lifted the cap", rec.ResetMax)
+	}
+}

+ 99 - 0
internal/web/service/inbound_create_race_test.go

@@ -0,0 +1,99 @@
+package service
+
+import (
+	"fmt"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// A wildcard listener and a specific one on the same port overlap, but they are
+// two different rows: only the in-transaction check can reject the pair, and it
+// can only do so if the check and the insert cannot interleave.
+func TestAddInboundConcurrentOverlappingListenersSingleWinner(t *testing.T) {
+	setupConflictDB(t)
+
+	const rounds = 25
+	for round := range rounds {
+		port := 24000 + round
+		claims := []*model.Inbound{
+			{
+				Tag: fmt.Sprintf("race-%d-wildcard", round), Listen: "",
+				Port: port, Protocol: model.VLESS,
+				StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+			},
+			{
+				Tag: fmt.Sprintf("race-%d-specific", round), Listen: "127.0.0.1",
+				Port: port, Protocol: model.Trojan,
+				StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+			},
+		}
+
+		start := make(chan struct{})
+		errs := make(chan error, len(claims))
+		var wg sync.WaitGroup
+		for _, claim := range claims {
+			wg.Add(1)
+			go func(inbound *model.Inbound) {
+				defer wg.Done()
+				<-start
+				_, _, err := (&InboundService{}).AddInbound(inbound)
+				errs <- err
+			}(claim)
+		}
+		close(start)
+		wg.Wait()
+		close(errs)
+
+		committed := 0
+		rejections := make([]string, 0, len(claims))
+		for err := range errs {
+			if err == nil {
+				committed++
+				continue
+			}
+			rejections = append(rejections, err.Error())
+		}
+		if committed != 1 {
+			t.Fatalf("round %d port %d: concurrent AddInbound committed=%d, want exactly 1 (rejections: %v)",
+				round, port, committed, rejections)
+		}
+	}
+}
+
+// Editing an inbound onto a port another one already holds must be rejected —
+// the check moved inside the transaction, and nothing else guards this path.
+func TestUpdateInboundRejectsPortTakenByAnother(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	first := &model.Inbound{
+		Tag: "update-holder", Listen: "", Port: 25101, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+	}
+	if _, _, err := svc.AddInbound(first); err != nil {
+		t.Fatalf("seed holder: %v", err)
+	}
+	second := &model.Inbound{
+		Tag: "update-mover", Listen: "", Port: 25102, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
+	}
+	if _, _, err := svc.AddInbound(second); err != nil {
+		t.Fatalf("seed mover: %v", err)
+	}
+
+	second.Port = first.Port
+	if _, _, err := svc.UpdateInbound(second); err == nil {
+		t.Fatal("moving an inbound onto a port already in use was accepted")
+	}
+
+	var stored model.Inbound
+	if err := database.GetDB().First(&stored, second.Id).Error; err != nil {
+		t.Fatal(err)
+	}
+	if stored.Port != 25102 {
+		t.Fatalf("rejected update still changed the stored port to %d", stored.Port)
+	}
+}

+ 6 - 0
internal/web/service/inbound_node.go

@@ -714,6 +714,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if dirty {
 			continue
 		}
+		// Disabled inbounds are intentionally absent from the node's runtime
+		// snapshot. Their absence is not evidence of deletion; retain the row,
+		// client history and port reservation until an explicit delete occurs.
+		if !c.Enable {
+			continue
+		}
 		if len(snapTags) == 0 {
 			// A node mid-restart or with a transient DB error can return an empty
 			// inbound list with success=true. Treat "zero inbounds reported" as

+ 23 - 1
internal/web/service/inbound_traffic.go

@@ -334,6 +334,9 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 	// local inbounds. The email-based join through client_inbounds is authoritative.
 	err = tx.Model(xray.ClientTraffic{}).
 		Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now).
+		// A prepaid plan stops itself: once as many renewals have fired as the
+		// operator allowed, the client is left to expire like any other.
+		Where("reset_max <= 0 or reset_count < reset_max").
 		Where("email IN (?)", tx.Table("client_inbounds ci").
 			Select("c.email").
 			Joins("JOIN clients c ON c.id = ci.client_id").
@@ -411,12 +414,29 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
 			if !ok {
 				continue
 			}
+			// One allowance per period, not per tick: a client away for three
+			// cycles must not catch up three of them against a prepaid cap.
 			newExpiryTime := traffic.ExpiryTime
+			renewals := 0
 			for newExpiryTime < now {
+				if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {
+					break
+				}
 				newExpiryTime += (int64(traffic.Reset) * 86400000)
+				renewals++
+			}
+			if renewals == 0 {
+				continue
 			}
 			c["expiryTime"] = newExpiryTime
 			traffic.ExpiryTime = newExpiryTime
+			traffic.ResetCount += renewals
+			if newExpiryTime <= now {
+				// Cap ran out mid-catch-up and the client is still expired: enabling it
+				// for disableInvalidClients to undo adds and removes an xray user for nothing.
+				clients[client_index] = any(c)
+				continue
+			}
 			traffic.Down = 0
 			traffic.Up = 0
 			if !traffic.Enable {
@@ -508,10 +528,11 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
 		ExpiryTime: client.ExpiryTime,
 		Enable:     client.Enable,
 		Reset:      client.Reset,
+		ResetMax:   client.ResetMax,
 	}
 	return tx.Clauses(clause.OnConflict{
 		Columns:   []clause.Column{{Name: "email"}},
-		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset"}),
+		DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}),
 	}).Create(&clientTraffic).Error
 }
 
@@ -524,6 +545,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
 			"total":       client.TotalGB,
 			"expiry_time": client.ExpiryTime,
 			"reset":       client.Reset,
+			"reset_max":   client.ResetMax,
 		})
 	err := result.Error
 	return err

+ 37 - 0
internal/web/service/node_dirty_test.go

@@ -68,6 +68,43 @@ func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
 	}
 }
 
+func TestSetRemoteTraffic_MissingDisabledInboundIsNotSwept(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	node := &model.Node{Name: "disabled-snapshot", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatal(err)
+	}
+	disabled := &model.Inbound{
+		UserId: 1, NodeID: &node.Id, Tag: "disabled", Enable: false,
+		Port: 24443, Protocol: model.VLESS, Settings: `{"clients":[]}`,
+	}
+	reported := &model.Inbound{
+		UserId: 1, NodeID: &node.Id, Tag: "reported", Enable: true,
+		Port: 24444, Protocol: model.VLESS, Settings: `{"clients":[]}`,
+	}
+	if err := db.Create(disabled).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(reported).Error; err != nil {
+		t.Fatal(err)
+	}
+	snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
+		Tag: reported.Tag, Enable: true,
+		Port: reported.Port, Protocol: reported.Protocol, Settings: reported.Settings,
+	}}}
+	if _, err := (&InboundService{}).setRemoteTrafficLocked(node.Id, snap, false); err != nil {
+		t.Fatal(err)
+	}
+	var count int64
+	if err := db.Model(&model.Inbound{}).Where("id=?", disabled.Id).Count(&count).Error; err != nil {
+		t.Fatal(err)
+	}
+	if count != 1 {
+		t.Fatalf("disabled inbound rows=%d, want 1", count)
+	}
+}
+
 // Deleting a *disabled* client attached to a node inbound must still propagate
 // to the node. The node's own DB carries the (disabled) client, so the central
 // panel has to mark the node dirty (→ reconcile) instead of dropping the delete

+ 8 - 2
internal/web/service/port_conflict.go

@@ -8,6 +8,8 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+
+	"gorm.io/gorm"
 )
 
 type transportBits uint8
@@ -158,7 +160,13 @@ func reservedAPIPort() int {
 	return defaultXrayAPIPort
 }
 
+// checkPortConflict reads outside any transaction; callers that must not race a
+// concurrent create use checkPortConflictTx inside their own transaction.
 func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
+	return checkPortConflictTx(database.GetDB(), inbound, ignoreId)
+}
+
+func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
 	newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
 
 	// The internal Xray API inbound (tag "api", loopback TCP) isn't a DB row,
@@ -175,8 +183,6 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
 		}, nil
 	}
 
-	db := database.GetDB()
-
 	var candidates []*model.Inbound
 	q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
 	if ignoreId > 0 {

+ 92 - 5
internal/web/service/server.go

@@ -30,6 +30,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
@@ -1434,9 +1435,81 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
 	return data, "x-ui.dump", nil
 }
 
-func (s *ServerService) ImportDB(file multipart.File) error {
+// hostBoundSettingKeys are the settings that describe *this* machine rather
+// than the configuration being carried: where the panel and the subscription
+// service listen, the certificates they present, and the identity this panel
+// uses towards its nodes. An import that overwrites them leaves the
+// destination unreachable on its own address, or impersonating the source.
+var hostBoundSettingKeys = []string{
+	"webListen", "webDomain", "webPort", "webCertFile", "webKeyFile", "webBasePath",
+	"subListen", "subDomain", "subPort", "subCertFile", "subKeyFile", "subURI", "subJsonURI",
+	"secret", "panelGuid",
+	"nodeMtlsCaCertPem", "nodeMtlsCaKeyPem", "nodeMtlsClientCertPem",
+	"nodeMtlsClientKeyPem", "nodeMtlsClientCertSha256", "nodeMtlsClientCAPem",
+}
+
+// hostBoundSnapshot records this machine's values, and just as importantly
+// which keys it had no row for: an absent row means the built-in default is in
+// force, and leaving the imported row in place would silently adopt the source
+// machine's certificate path or listen address.
+type hostBoundSnapshot struct {
+	values  map[string]string
+	present map[string]struct{}
+	taken   bool
+}
+
+func captureHostBoundSettings() hostBoundSnapshot {
+	db := database.GetDB()
+	if db == nil {
+		return hostBoundSnapshot{}
+	}
+	var rows []model.Setting
+	if err := db.Model(&model.Setting{}).Where("key IN ?", hostBoundSettingKeys).Find(&rows).Error; err != nil {
+		logger.Warningf("Import: could not read this machine's settings, they will come from the uploaded file: %v", err)
+		return hostBoundSnapshot{}
+	}
+	snap := hostBoundSnapshot{
+		values:  make(map[string]string, len(rows)),
+		present: make(map[string]struct{}, len(rows)),
+		taken:   true,
+	}
+	for _, row := range rows {
+		snap.values[row.Key] = row.Value
+		snap.present[row.Key] = struct{}{}
+	}
+	return snap
+}
+
+func restoreHostBoundSettings(snap hostBoundSnapshot) {
+	if !snap.taken {
+		return
+	}
+	db := database.GetDB()
+	if db == nil {
+		return
+	}
+	for _, key := range hostBoundSettingKeys {
+		if _, had := snap.present[key]; !had {
+			// No row here before the import, so the default applied. Drop the
+			// imported row rather than inherit the source machine's value.
+			if err := db.Where("key = ?", key).Delete(&model.Setting{}).Error; err != nil {
+				logger.Warningf("Import: could not drop imported setting %q: %v", key, err)
+			}
+			continue
+		}
+		// The imported row may or may not exist; settings are key-value, so an
+		// upsert keyed on the name is the only safe write here.
+		if err := db.Where(model.Setting{Key: key}).
+			Assign(model.Setting{Value: snap.values[key]}).
+			FirstOrCreate(&model.Setting{}).Error; err != nil {
+			logger.Warningf("Import: could not restore setting %q for this machine: %v", key, err)
+		}
+	}
+}
+
+func (s *ServerService) ImportDB(file multipart.File, keepHostSettings bool) error {
 	if database.IsPostgres() {
-		return s.importPostgresDB(file)
+		return s.importPostgresDB(file, keepHostSettings)
 	}
 	kind, err := sniffUploadKind(file)
 	if err != nil {
@@ -1488,6 +1561,11 @@ func (s *ServerService) ImportDB(file multipart.File) error {
 		logger.Warningf("Failed to stop Xray before DB import: %v", errStop)
 	}
 
+	var keptSettings hostBoundSnapshot
+	if keepHostSettings {
+		keptSettings = captureHostBoundSettings()
+	}
+
 	if errClose := database.CloseDB(); errClose != nil {
 		logger.Warningf("Failed to close existing DB before replacement: %v", errClose)
 	}
@@ -1543,6 +1621,8 @@ func (s *ServerService) ImportDB(file multipart.File) error {
 	}
 	dbReopened = true
 
+	restoreHostBoundSettings(keptSettings)
+
 	s.inboundService.MigrateDB()
 
 	xrayStopped = false
@@ -1697,14 +1777,14 @@ func sniffUploadKind(file multipart.File) (int, error) {
 	return sniffImportKind(header[:n]), nil
 }
 
-func (s *ServerService) importPostgresDB(file multipart.File) error {
+func (s *ServerService) importPostgresDB(file multipart.File, keepHostSettings bool) error {
 	kind, err := sniffUploadKind(file)
 	if err != nil {
 		return common.NewErrorf("Error reading uploaded file: %v", err)
 	}
 	switch kind {
 	case importKindPgDump:
-		return s.restorePostgresDump(file)
+		return s.restorePostgresDump(file, keepHostSettings)
 	case importKindSQLiteDB:
 		return s.migrateSQLiteIntoPostgres(file, false)
 	case importKindSQLiteDump:
@@ -1714,7 +1794,7 @@ func (s *ServerService) importPostgresDB(file multipart.File) error {
 	}
 }
 
-func (s *ServerService) restorePostgresDump(file multipart.File) error {
+func (s *ServerService) restorePostgresDump(file multipart.File, keepHostSettings bool) error {
 	bin, err := exec.LookPath("pg_restore")
 	if err != nil {
 		return common.NewError("pg_restore not found on the server; install the postgresql-client package to restore a PostgreSQL database")
@@ -1754,6 +1834,11 @@ func (s *ServerService) restorePostgresDump(file multipart.File) error {
 		logger.Warningf("Failed to stop Xray before DB restore: %v", errStop)
 	}
 
+	var keptSettings hostBoundSnapshot
+	if keepHostSettings {
+		keptSettings = captureHostBoundSettings()
+	}
+
 	if errClose := database.CloseDB(); errClose != nil {
 		logger.Warningf("Failed to close existing DB before restore: %v", errClose)
 	}
@@ -1770,6 +1855,8 @@ func (s *ServerService) restorePostgresDump(file multipart.File) error {
 	if errInit := database.InitDB(config.GetDBPath()); errInit != nil {
 		return common.NewErrorf("Restore finished but reopening the database failed: %v", errInit)
 	}
+	restoreHostBoundSettings(keptSettings)
+
 	s.inboundService.MigrateDB()
 
 	if runErr != nil {

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

@@ -112,7 +112,8 @@
     "docs": "التوثيق",
     "openMenu": "فتح القائمة",
     "pinSidebar": "تثبيت الشريط الجانبي",
-    "unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
+    "unpinSidebar": "إلغاء تثبيت الشريط الجانبي",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — حرج",
       "panel": "اللوحة",
       "threads": "الخيوط",
-      "uptime": "مدة التشغيل"
+      "uptime": "مدة التشغيل",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "إجمالي المرسل/المستقبل",
@@ -860,7 +871,10 @@
         "delOrphans": "تم حذف {count} عميل غير مرتبط",
         "imported": "تم استيراد {count} عميل",
         "importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم"
-      }
+      },
+      "renewMax": "الحد الأقصى للتجديدات",
+      "renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.",
+      "renewsUsed": "التجديدات المستخدمة"
     },
     "groups": {
       "name": "الاسم",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
       },
       "secretClear": "مسح",
-      "secretClearUndo": "تراجع عن المسح"
+      "secretClearUndo": "تراجع عن المسح",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "save": "احفظ",

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

@@ -112,7 +112,8 @@
     "docs": "Documentation",
     "openMenu": "Open menu",
     "pinSidebar": "Pin sidebar",
-    "unpinSidebar": "Unpin sidebar"
+    "unpinSidebar": "Unpin sidebar",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — critical",
       "panel": "Panel",
       "threads": "Threads",
-      "uptime": "Uptime"
+      "uptime": "Uptime",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Total Sent/Received",
@@ -860,7 +871,10 @@
         "delOrphans": "{count} unattached clients deleted",
         "imported": "{count} clients imported",
         "importedMixed": "{ok} imported, {failed} skipped"
-      }
+      },
+      "renewMax": "Max renewals",
+      "renewMaxDesc": "How many times auto-renew may fire before the client is left to expire. 0 means no limit. Catching up several missed periods spends one renewal per period.",
+      "renewsUsed": "Renewals used"
     },
     "groups": {
       "name": "Name",
@@ -1469,7 +1483,9 @@
         "pathLeadingSlash": "Path must start with /"
       },
       "secretClear": "Clear",
-      "secretClearUndo": "Undo clear"
+      "secretClearUndo": "Undo clear",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "save": "Save",

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

@@ -112,7 +112,8 @@
     "docs": "Documentación",
     "openMenu": "Abrir menú",
     "pinSidebar": "Fijar barra lateral",
-    "unpinSidebar": "Desfijar barra lateral"
+    "unpinSidebar": "Desfijar barra lateral",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — crítico",
       "panel": "Panel",
       "threads": "Hilos",
-      "uptime": "Tiempo activo"
+      "uptime": "Tiempo activo",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Subidas/Descargas Totales",
@@ -860,7 +871,10 @@
         "delOrphans": "{count} clientes sin entrante eliminados",
         "imported": "{count} clientes importados",
         "importedMixed": "{ok} importados, {failed} omitidos"
-      }
+      },
+      "renewMax": "Renovaciones máximas",
+      "renewMaxDesc": "Cuántas veces puede activarse la renovación automática antes de dejar que el cliente caduque. 0 significa sin límite. Recuperar varios periodos perdidos consume una renovación por periodo.",
+      "renewsUsed": "Renovaciones usadas"
     },
     "groups": {
       "name": "Nombre",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "La ruta debe comenzar con /"
       },
       "secretClear": "Borrar",
-      "secretClearUndo": "Deshacer borrado"
+      "secretClearUndo": "Deshacer borrado",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "save": "Guardar configuración",

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

@@ -112,7 +112,8 @@
     "docs": "مستندات",
     "openMenu": "باز کردن منو",
     "pinSidebar": "ثابت کردن نوار کناری",
-    "unpinSidebar": "برداشتن تثبیت نوار کناری"
+    "unpinSidebar": "برداشتن تثبیت نوار کناری",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — بحرانی",
       "panel": "پنل",
       "threads": "نخ‌ها",
-      "uptime": "مدت کارکرد"
+      "uptime": "مدت کارکرد",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "دریافت/ارسال کل",
@@ -860,7 +871,10 @@
         "delOrphans": "{count} کلاینت بدون اینباند حذف شد",
         "imported": "{count} کلاینت وارد شد",
         "importedMixed": "{ok} وارد شد، {failed} رد شد"
-      }
+      },
+      "renewMax": "حداکثر تعداد تمدید",
+      "renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.",
+      "renewsUsed": "تمدیدهای استفاده‌شده"
     },
     "groups": {
       "name": "نام",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "مسیر باید با / شروع شود"
       },
       "secretClear": "پاک کردن",
-      "secretClearUndo": "لغو پاک کردن"
+      "secretClearUndo": "لغو پاک کردن",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "save": "ذخیره",

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

@@ -112,7 +112,8 @@
     "docs": "Dokumentasi",
     "openMenu": "Buka menu",
     "pinSidebar": "Sematkan bilah sisi",
-    "unpinSidebar": "Lepas sematan bilah sisi"
+    "unpinSidebar": "Lepas sematan bilah sisi",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — kritis",
       "panel": "Panel",
       "threads": "Thread",
-      "uptime": "Waktu aktif"
+      "uptime": "Waktu aktif",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Total Terkirim/Diterima",
@@ -860,7 +871,10 @@
         "delOrphans": "{count} klien tanpa inbound dihapus",
         "imported": "{count} klien diimpor",
         "importedMixed": "{ok} diimpor, {failed} dilewati"
-      }
+      },
+      "renewMax": "Maksimum perpanjangan",
+      "renewMaxDesc": "Berapa kali perpanjangan otomatis boleh berjalan sebelum klien dibiarkan kedaluwarsa. 0 berarti tanpa batas. Mengejar beberapa periode yang terlewat menghabiskan satu perpanjangan per periode.",
+      "renewsUsed": "Perpanjangan terpakai"
     },
     "groups": {
       "name": "Nama",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "Path harus diawali dengan /"
       },
       "secretClear": "Hapus",
-      "secretClearUndo": "Batalkan hapus"
+      "secretClearUndo": "Batalkan hapus",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "save": "Simpan",

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

@@ -112,7 +112,8 @@
     "docs": "ドキュメント",
     "openMenu": "メニューを開く",
     "pinSidebar": "サイドバーを固定",
-    "unpinSidebar": "サイドバーの固定を解除"
+    "unpinSidebar": "サイドバーの固定を解除",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — 危険水準",
       "panel": "パネル",
       "threads": "スレッド",
-      "uptime": "稼働時間"
+      "uptime": "稼働時間",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "総アップロード / ダウンロード",
@@ -860,7 +871,10 @@
         "delOrphans": "未アタッチの {count} 件のクライアントを削除しました",
         "imported": "{count} 件のクライアントをインポートしました",
         "importedMixed": "{ok} 件インポート、{failed} 件スキップ"
-      }
+      },
+      "renewMax": "最大更新回数",
+      "renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。",
+      "renewsUsed": "使用済み更新回数"
     },
     "groups": {
       "name": "名前",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "パスは / で始まる必要があります"
       },
       "secretClear": "クリア",
-      "secretClearUndo": "クリアを取り消す"
+      "secretClearUndo": "クリアを取り消す",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "importRules": "ルールをインポート",

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

@@ -112,7 +112,8 @@
     "docs": "Documentação",
     "openMenu": "Abrir menu",
     "pinSidebar": "Fixar barra lateral",
-    "unpinSidebar": "Desafixar barra lateral"
+    "unpinSidebar": "Desafixar barra lateral",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — crítico",
       "panel": "Painel",
       "threads": "Threads",
-      "uptime": "Tempo ativo"
+      "uptime": "Tempo ativo",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Total Enviado/Recebido",
@@ -860,7 +871,10 @@
         "delOrphans": "{count} clientes sem inbound excluídos",
         "imported": "{count} clientes importados",
         "importedMixed": "{ok} importados, {failed} ignorados"
-      }
+      },
+      "renewMax": "Renovações máximas",
+      "renewMaxDesc": "Quantas vezes a renovação automática pode ocorrer antes de o cliente ser deixado a expirar. 0 significa sem limite. Recuperar vários períodos perdidos consome uma renovação por período.",
+      "renewsUsed": "Renovações usadas"
     },
     "groups": {
       "name": "Nome",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "O caminho deve começar com /"
       },
       "secretClear": "Limpar",
-      "secretClearUndo": "Desfazer limpeza"
+      "secretClearUndo": "Desfazer limpeza",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "importRules": "Importar regras",

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

@@ -112,7 +112,8 @@
     "docs": "Документация",
     "openMenu": "Открыть меню",
     "pinSidebar": "Закрепить боковую панель",
-    "unpinSidebar": "Открепить боковую панель"
+    "unpinSidebar": "Открепить боковую панель",
+    "subFormats": "Форматы подписки"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — критический уровень",
       "panel": "Панель",
       "threads": "Потоки",
-      "uptime": "Время работы"
+      "uptime": "Время работы",
+      "logLevelDebug": "Отладка",
+      "logLevelInfo": "Информация",
+      "logLevelNotice": "Уведомление",
+      "logLevelWarning": "Предупреждение",
+      "logLevelError": "Ошибка",
+      "accessDirect": "НАПРЯМУЮ",
+      "accessBlocked": "ЗАБЛОКИРОВАНО",
+      "accessProxy": "ЧЕРЕЗ ПРОКСИ",
+      "importKeepHostSettings": "Сохранить настройки этой машины",
+      "importKeepHostSettingsDesc": "Оставляет адреса и порты этой панели, базовый путь, сертификаты и удостоверение для узлов вместо тех, что в загруженном файле."
     },
     "inbounds": {
       "totalDownUp": "Отправлено/получено",
@@ -860,7 +871,10 @@
         "delOrphans": "Удалено клиентов без входящего: {count}",
         "imported": "Импортировано клиентов: {count}",
         "importedMixed": "Импортировано: {ok}, пропущено: {failed}"
-      }
+      },
+      "renewMax": "Лимит продлений",
+      "renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.",
+      "renewsUsed": "Продлений израсходовано"
     },
     "groups": {
       "name": "Имя",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "Путь должен начинаться с /"
       },
       "secretClear": "Очистить",
-      "secretClearUndo": "Отменить очистку"
+      "secretClearUndo": "Отменить очистку",
+      "calendarGregorian": "Григорианский (обычный)",
+      "calendarJalalian": "Джалали (شمسی)"
     },
     "xray": {
       "importRules": "Импорт правил",

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

@@ -112,7 +112,8 @@
     "docs": "Belgeler",
     "openMenu": "Menüyü aç",
     "pinSidebar": "Kenar çubuğunu sabitle",
-    "unpinSidebar": "Kenar çubuğu sabitlemesini kaldır"
+    "unpinSidebar": "Kenar çubuğu sabitlemesini kaldır",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — kritik",
       "panel": "Panel",
       "threads": "İş parçacıkları",
-      "uptime": "Çalışma süresi"
+      "uptime": "Çalışma süresi",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Toplam Gönderilen/Alınan",
@@ -860,7 +871,10 @@
         "delOrphans": "{count} bağsız kullanıcı silindi",
         "imported": "{count} kullanıcı içe aktarıldı",
         "importedMixed": "{ok} içe aktarıldı, {failed} atlandı"
-      }
+      },
+      "renewMax": "En fazla yenileme",
+      "renewMaxDesc": "İstemcinin süresi dolmaya bırakılmadan önce otomatik yenilemenin kaç kez çalışabileceği. 0 sınırsız demektir. Kaçırılan birden fazla dönemi telafi etmek, dönem başına bir yenileme harcar.",
+      "renewsUsed": "Kullanılan yenileme"
     },
     "groups": {
       "name": "İsim",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "Yol / ile başlamalıdır"
       },
       "secretClear": "Temizle",
-      "secretClearUndo": "Temizlemeyi geri al"
+      "secretClearUndo": "Temizlemeyi geri al",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "save": "Kaydet",

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

@@ -112,7 +112,8 @@
     "docs": "Документація",
     "openMenu": "Відкрити меню",
     "pinSidebar": "Закріпити бічну панель",
-    "unpinSidebar": "Відкріпити бічну панель"
+    "unpinSidebar": "Відкріпити бічну панель",
+    "subFormats": "Формати підписки"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — критичний рівень",
       "panel": "Панель",
       "threads": "Потоки",
-      "uptime": "Час роботи"
+      "uptime": "Час роботи",
+      "logLevelDebug": "Налагодження",
+      "logLevelInfo": "Інформація",
+      "logLevelNotice": "Сповіщення",
+      "logLevelWarning": "Попередження",
+      "logLevelError": "Помилка",
+      "accessDirect": "НАПРЯМУ",
+      "accessBlocked": "ЗАБЛОКОВАНО",
+      "accessProxy": "ЧЕРЕЗ ПРОКСІ",
+      "importKeepHostSettings": "Зберегти налаштування цієї машини",
+      "importKeepHostSettingsDesc": "Залишає адреси та порти цієї панелі, базовий шлях, сертифікати та посвідчення для вузлів замість тих, що у завантаженому файлі."
     },
     "inbounds": {
       "totalDownUp": "Всього надісланих/отриманих",
@@ -860,7 +871,10 @@
         "delOrphans": "Видалено клієнтів без вхідного: {count}",
         "imported": "Імпортовано клієнтів: {count}",
         "importedMixed": "Імпортовано: {ok}, пропущено: {failed}"
-      }
+      },
+      "renewMax": "Ліміт подовжень",
+      "renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.",
+      "renewsUsed": "Подовжень витрачено"
     },
     "groups": {
       "name": "Назва",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "Шлях має починатися з /"
       },
       "secretClear": "Очистити",
-      "secretClearUndo": "Скасувати очищення"
+      "secretClearUndo": "Скасувати очищення",
+      "calendarGregorian": "Григоріанський (звичайний)",
+      "calendarJalalian": "Джалалі (شمسی)"
     },
     "xray": {
       "save": "Зберегти",

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

@@ -112,7 +112,8 @@
     "docs": "Tài liệu",
     "openMenu": "Mở menu",
     "pinSidebar": "Ghim thanh bên",
-    "unpinSidebar": "Bỏ ghim thanh bên"
+    "unpinSidebar": "Bỏ ghim thanh bên",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — nguy cấp",
       "panel": "Panel",
       "threads": "Luồng",
-      "uptime": "Thời gian chạy"
+      "uptime": "Thời gian chạy",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Tổng tải lên/tải xuống",
@@ -860,7 +871,10 @@
         "delOrphans": "Đã xóa {count} khách hàng không gắn inbound",
         "imported": "Đã nhập {count} khách hàng",
         "importedMixed": "Đã nhập {ok}, bỏ qua {failed}"
-      }
+      },
+      "renewMax": "Số lần gia hạn tối đa",
+      "renewMaxDesc": "Gia hạn tự động được phép chạy bao nhiêu lần trước khi để khách hàng hết hạn. 0 nghĩa là không giới hạn. Bù lại nhiều kỳ đã bỏ lỡ sẽ tiêu tốn một lần gia hạn cho mỗi kỳ.",
+      "renewsUsed": "Số lần gia hạn đã dùng"
     },
     "groups": {
       "name": "Tên",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
       },
       "secretClear": "Xóa",
-      "secretClearUndo": "Hoàn tác xóa"
+      "secretClearUndo": "Hoàn tác xóa",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "importRules": "Nhập quy tắc",

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

@@ -112,7 +112,8 @@
     "docs": "文档",
     "openMenu": "打开菜单",
     "pinSidebar": "固定侧边栏",
-    "unpinSidebar": "取消固定侧边栏"
+    "unpinSidebar": "取消固定侧边栏",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — 危险",
       "panel": "面板",
       "threads": "线程",
-      "uptime": "运行时间"
+      "uptime": "运行时间",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "总上传 / 下载",
@@ -860,7 +871,10 @@
         "delOrphans": "已删除 {count} 个未关联的客户端",
         "imported": "已导入 {count} 个客户端",
         "importedMixed": "已导入 {ok} 个,跳过 {failed} 个"
-      }
+      },
+      "renewMax": "最大续期次数",
+      "renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。",
+      "renewsUsed": "已用续期次数"
     },
     "groups": {
       "name": "名称",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "路径必须以 / 开头"
       },
       "secretClear": "清除",
-      "secretClearUndo": "撤销清除"
+      "secretClearUndo": "撤销清除",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "importRules": "导入规则",

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

@@ -112,7 +112,8 @@
     "docs": "文件",
     "openMenu": "開啟選單",
     "pinSidebar": "固定側邊欄",
-    "unpinSidebar": "取消固定側邊欄"
+    "unpinSidebar": "取消固定側邊欄",
+    "subFormats": "Sub Formats"
   },
   "pages": {
     "login": {
@@ -257,7 +258,17 @@
       "healthCritical": "{list} — 危險",
       "panel": "面板",
       "threads": "執行緒",
-      "uptime": "執行時間"
+      "uptime": "執行時間",
+      "logLevelDebug": "Debug",
+      "logLevelInfo": "Info",
+      "logLevelNotice": "Notice",
+      "logLevelWarning": "Warning",
+      "logLevelError": "Error",
+      "accessDirect": "DIRECT",
+      "accessBlocked": "BLOCKED",
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "總上傳 / 下載",
@@ -860,7 +871,10 @@
         "delOrphans": "已刪除 {count} 個未關聯的客戶端",
         "imported": "已匯入 {count} 個客戶端",
         "importedMixed": "已匯入 {ok} 個,跳過 {failed} 個"
-      }
+      },
+      "renewMax": "最大續期次數",
+      "renewMaxDesc": "自動續期最多可觸發的次數,達到後用戶端將自然到期。填 0 表示不限制。補齊多個錯過的週期時,每個週期消耗一次續期。",
+      "renewsUsed": "已用續期次數"
     },
     "groups": {
       "name": "名稱",
@@ -1352,7 +1366,9 @@
         "pathLeadingSlash": "路徑必須以 / 開頭"
       },
       "secretClear": "清除",
-      "secretClearUndo": "復原清除"
+      "secretClearUndo": "復原清除",
+      "calendarGregorian": "Gregorian (Standard)",
+      "calendarJalalian": "Jalalian (شمسی)"
     },
     "xray": {
       "save": "儲存",

+ 17 - 13
internal/xray/client_traffic.go

@@ -3,17 +3,21 @@ package xray
 // ClientTraffic represents traffic statistics and limits for a specific client.
 // It tracks upload/download usage, expiry times, and online status for inbound clients.
 type ClientTraffic struct {
-	Id           int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
-	InboundId    int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
-	Enable       bool   `json:"enable" form:"enable" example:"true"`
-	Email        string `json:"email" form:"email" gorm:"unique" example:"user1"`
-	UUID         string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
-	SubId        string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
-	Up           int64  `json:"up" form:"up" example:"1048576"`
-	Down         int64  `json:"down" form:"down" example:"2097152"`
-	ExpiryTime   int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
-	Total        int64  `json:"total" form:"total" example:"10737418240"`
-	Reset        int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
-	LastOnline   int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
-	LastSubFetch int64  `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"`
+	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
+	InboundId  int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
+	Enable     bool   `json:"enable" form:"enable" example:"true"`
+	Email      string `json:"email" form:"email" gorm:"unique" example:"user1"`
+	UUID       string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
+	SubId      string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
+	Up         int64  `json:"up" form:"up" example:"1048576"`
+	Down       int64  `json:"down" form:"down" example:"2097152"`
+	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
+	Total      int64  `json:"total" form:"total" example:"10737418240"`
+	Reset      int    `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
+	// ResetMax caps how many times auto-renew may fire; 0 means no cap.
+	ResetMax int `json:"resetMax" form:"resetMax" gorm:"default:0" example:"0"`
+	// ResetCount is how many have fired, so a prepaid plan stops on its own.
+	ResetCount   int   `json:"resetCount" form:"resetCount" gorm:"default:0" example:"0"`
+	LastOnline   int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
+	LastSubFetch int64 `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"`
 }