diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 64815e770..500d7e5fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -83,12 +83,12 @@ jobs:
- 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
+ go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
+ # All must pass. Counting, not SKIP-matching: renaming a 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
+ passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
+ if [ "$passed" -lt 3 ]; then
+ echo "expected at least 3 passing PostgreSQL schema tests, got $passed" >&2
exit 1
fi
diff --git a/docs/content/docs/en/config/clients.mdx b/docs/content/docs/en/config/clients.mdx
index 9908322e4..8f3f0feeb 100644
--- a/docs/content/docs/en/config/clients.mdx
+++ b/docs/content/docs/en/config/clients.mdx
@@ -20,7 +20,7 @@ inbounds** at once, with per-client traffic accounting.
| **Limit IP** | all (except TUIC) | Max simultaneous source IPs (enforced via Fail2ban). |
| **Total (GB)** | all (except TUIC) | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
| **Expiry** | all | Date after which the client stops working. |
-| **Reset** | all | Auto-renew period in **days** (rolls the quota over). |
+| **Auto renewal** | all | Disabled, fixed interval in days, calendar weekly, or calendar monthly. |
| **Telegram ID**| all | Links the client to a Telegram user for self-service/notifications.|
| **Sub ID** | all | Subscription identifier grouping this client's links. |
| **Group** | all | Optional client group for organization and bulk filtering. |
@@ -42,6 +42,68 @@ inbounds** at once, with per-client traffic accounting.
- **Online status** and **last-online** times are tracked per client (and per
node in multi-node setups).
+## Automatic renewal
+
+The individual and bulk-create forms offer one renewal mode at a time:
+
+| Mode | API fields | Schedule |
+| --- | --- | --- |
+| Disabled | `reset=0`, `resetDay=0`, `resetWeekday=0` | The expiry is not renewed. |
+| Fixed interval | `reset=N`, other two fields `0` | Add exactly N × 24 hours to the previous cutoff. |
+| Calendar weekly | `resetWeekday=1..7`, other two fields `0` | Renew at panel-local midnight on Monday (1) through Sunday (7). |
+| Calendar monthly | `resetDay=1..31`, `resetWeekday=0` | Renew at panel-local midnight on that day; missing dates clamp to the month's last day without losing the configured day. |
+
+Calendar weeks stay on the selected weekday across daylight-saving changes;
+they are not equivalent to a fixed seven-day interval. A skipped midnight uses
+the first valid instant of that date; a repeated midnight uses the first one.
+If a timezone skips the entire selected date, the next matching week is used.
+Existing monthly clients
+that also have `reset` set retain monthly precedence. The API rejects weekly
+renewal combined with a positive `reset` or `resetDay`.
+
+For a full calendar month, select **monthly, day 1** and set the initial cutoff
+to the next month's first midnight. For example, `2030-09-01 00:00:00` is valid
+through `2030-08-31 23:59:59`. Day 31 renews at the **start** of the 31st and is
+not the same schedule. The existing optional month-end subscription-header
+display remains a separate setting and is not enabled by this form.
+
+The preview uses the panel's timezone and the same calendar/catch-up calculation
+as automatic renewal. It shows the cutoff, last valid second, next expiry, and
+allowances needed. It is informational: it does not save, activate, reserve, or
+guarantee a future renewal. When no expiry is set, auto-renewal cannot run; an
+explicit button can set the first calendar cutoff. Selecting a mode alone never
+rewrites an existing expiry. First-use clients keep their initial duration, and
+their calendar dates are available after activation.
+
+For legacy last-second calendar cutoffs, the renewal boundary includes the
+existing free alignment to the following midnight. The last-valid-second
+preview still uses the **stored expiry**, not that alignment: an exclusive
+`23:59:59` cutoff is valid through `23:59:58`. Use a next-midnight cutoff for
+full-day validity; the preview itself does not repair the initial expiry.
+
+`resetMax=0` means unlimited renewals. A positive limit counts **each elapsed
+period**, including offline catch-up, not each scheduler tick or attached inbound.
+If the remaining allowances cannot reach a future cutoff, the client stays
+expired and its traffic is not reset. Operator-disabled clients stay disabled.
+
+Renewal already resets client traffic. The separate **periodic traffic reset**
+does not move the expiry and is unchanged; keep it disabled unless you intend an
+additional reset. Quarterly, yearly, and every-N-week/month schedules are not
+part of these modes.
+
+
+ Upgrade the main panel and every participating node before enabling weekly
+ renewal. Older versions ignore `resetWeekday`; a weekly-only client would not
+ auto-renew and, after its expiry or quota is exhausted, can be deleted by
+ **delete depleted clients** because older versions lack the weekly protection.
+ Back up the database and convert weekly schedules to a renewal mode supported
+ by every participating version before downgrading. Merely disabling weekly
+ renewal does not protect a depleted client from deletion. Avoid depleted-client
+ cleanup while a mixed-version fleet or unconverted weekly clients remain.
+ Database upgrades default this new field to `0` and preserve existing limits
+ and dates.
+
+
## Share links and external links
Every client has share links and a QR code for its inbounds, plus a combined
diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx
index 0344a8c08..503de5734 100644
--- a/docs/content/docs/en/reference/api/clients.mdx
+++ b/docs/content/docs/en/reference/api/clients.mdx
@@ -37,6 +37,9 @@ _openapi:
call. Body is JSON. Per-protocol secrets are generated server-side when
omitted, so callers can send only the universal fields.
url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields'
+ - depth: 2
+ title: Preview client auto-renewal dates without saving or resetting anything.
+ url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
- depth: 2
title: Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload — supply the full set of fields
@@ -317,6 +320,8 @@ _openapi:
call. Body is JSON. Per-protocol secrets are generated server-side
when omitted, so callers can send only the universal fields.
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+ - content: Preview client auto-renewal dates without saving or resetting anything.
+ id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload — supply the full set of
fields you want to keep (the server replaces the row, it does not
@@ -592,6 +597,16 @@ _openapi:
one per line. `limitHwid` is applied only when every inbound
succeeded, so re-run the call after fixing the failure.
heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
+ - content: Uses the same calendar and catch-up calculation as auto-renew in the
+ panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables
+ weekly mode; it cannot be combined with positive reset or resetDay.
+ Existing resetDay takes precedence over reset. With expiryTime=0,
+ calendar modes suggest a first cutoff but do not activate renewal.
+ Negative expiryTime waits for first-use activation. resetMax and
+ resetCount simulate the existing per-period allowance limit; the
+ preview is informational and does not reserve an allowance or
+ guarantee node availability.
+ heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: `), and several failures
@@ -638,7 +653,7 @@ export default function Layout(props) {
return (
<>
{props.children}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/zh/config/clients.mdx b/docs/content/docs/zh/config/clients.mdx
index 78d0fe97a..afc8d0812 100644
--- a/docs/content/docs/zh/config/clients.mdx
+++ b/docs/content/docs/zh/config/clients.mdx
@@ -19,7 +19,7 @@ icon: Users
| **Limit IP** | 全部(TUIC 除外) | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。 |
| **Total (GB)** | 全部(TUIC 除外) | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
| **Expiry** | 全部 | 该日期之后客户端停止工作。 |
-| **Reset** | 全部 | 以**天**为单位的自动续期周期(滚动重置配额)。 |
+| **自动续期** | 全部 | 关闭、固定天数、日历每周或日历每月。 |
| **Telegram ID**| 全部 | 将客户端关联到 Telegram 用户,用于自助服务/通知。 |
| **Sub ID** | 全部 | 用于对该客户端链接分组的订阅标识符。 |
| **Group** | 全部 | 可选的客户端分组,便于组织管理和批量筛选。 |
@@ -39,6 +39,58 @@ icon: Users
并从该客户端的操作中清除它们。
- 系统会按客户端(在多节点部署中还会按节点)跟踪**在线状态**和**最后在线**时间。
+## 自动续期
+
+单个客户端和批量创建表单使用统一的续期模式选择:
+
+| 模式 | API 字段 | 续期规则 |
+| --- | --- | --- |
+| 关闭 | `reset=0`、`resetDay=0`、`resetWeekday=0` | 不自动延长到期时间。 |
+| 固定天数 | `reset=N`,另两个字段为 `0` | 从上次截止时间增加 N × 24 小时。 |
+| 日历每周 | `resetWeekday=1..7`,另两个字段为 `0` | 在面板时区每周一(1)至周日(7)的零点续期。 |
+| 日历每月 | `resetDay=1..31`、`resetWeekday=0` | 在面板时区指定日的零点续期;短月取月末,之后仍按原配置日续期。 |
+
+日历每周跨夏令时仍保持指定星期,不等于固定 7 天。若零点不存在,使用
+该日期第一个有效时刻;零点重复时取第一次。若时区跳过整天,则使用
+下一周的同一星期。旧配置同时填写
+`reset` 和 `resetDay` 时继续以每月续期为准。API 不允许每周续期与正数
+`reset` 或 `resetDay` 同时启用。
+
+整自然月应选**每月、1 日**,首次截止时间设置为下月 1 日零点。例如
+`2030-09-01 00:00:00` 表示有效至 `2030-08-31 23:59:59`。
+31 日表示在 31 日**开始时**续期,并不是同一边界。订阅头原有的可选
+月末显示设置仍独立存在,本表单不会自动开启它。
+
+日期预览使用面板时区和后端实际续期的同一套计算,显示截止时间、最后
+有效秒、下次到期时间及需要消耗的续期次数。预览不会保存、激活或预留
+续期,也不保证未来一定续期。未设到期时间时自动续期无法运行,可以
+明确点击按钮设置首次日历截止时间;仅选择模式不会修改已有到期时间。
+“首次使用后开始”保留原来的初始天数,激活后才能确定日历日期。
+
+旧配置以最后一秒为日历截止时间时,续期边界包含原有的不计次数向下个
+零点对齐规则。但最后有效秒仍按**已存储的到期时间**计算,不会假装
+初始时间已被修改:排他截止时间 `23:59:59` 实际有效至 `23:59:58`。
+整天有效应使用下一个零点,预览本身不会修复首次截止时间。
+
+最大续期次数 `resetMax=0` 表示不限次数。正数上限按**每个经过的周期**
+计数,包括离线补续,不按定时任务执行次数或关联入站数量计数。剩余
+次数不足以续到未来时,客户端继续过期且不会重置流量;手动禁用的
+客户端保持禁用。
+
+自动续期本身会重置客户端流量。独立的**定期流量重置**不延长到期时间,
+这次未改变其规则;除非需要额外重置,否则保持关闭。本次不包含季度、
+年度和每 N 周/月的续期。
+
+
+ 启用每周续期前,需要升级主面板及所有参与节点。旧版本会忽略
+ `resetWeekday`,仅配置每周的客户端将无法自动续期,且在到期或流量
+ 耗尽后,可能被**删除已耗尽客户端**操作删除,因为旧版没有每周续期
+ 的清理保护。降级前应备份数据库,并将每周配置转换为所有参与版本
+ 都支持的续期模式;仅关闭每周续期并不能防止耗尽后的删除。存在
+ 混合版本或尚未转换的每周客户端时,应避免执行耗尽客户端清理。
+ 数据库升级默认将新字段设为 `0`,保留已有日期和限制。
+
+
## 分享链接与外部链接
每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index ae423a7fa..2df4d2892 100644
--- a/docs/public/openapi.json
+++ b/docs/public/openapi.json
@@ -1531,13 +1531,17 @@
"type": "integer"
},
"resetDay": {
- "description": "Calendar renewal day 1-31, 0 = interval mode",
+ "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
"type": "integer"
},
"resetMax": {
"description": "Max auto-renew count, 0 = unlimited",
"type": "integer"
},
+ "resetWeekday": {
+ "description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
+ "type": "integer"
+ },
"reverse": {
"allOf": [
{
@@ -1600,6 +1604,7 @@
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"security",
"subId",
"tgId",
@@ -1751,6 +1756,9 @@
"resetMax": {
"type": "integer"
},
+ "resetWeekday": {
+ "type": "integer"
+ },
"reverse": {},
"secret": {
"type": "string"
@@ -1806,6 +1814,7 @@
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"reverse",
"secret",
"security",
@@ -1819,6 +1828,97 @@
],
"type": "object"
},
+ "ClientRenewalPreview": {
+ "properties": {
+ "canRenew": {
+ "example": true,
+ "type": "boolean"
+ },
+ "delayedStart": {
+ "example": false,
+ "type": "boolean"
+ },
+ "nextExpiry": {
+ "example": "2030-02-01T00:00:00Z",
+ "type": "string"
+ },
+ "renewAt": {
+ "example": "2030-01-01T00:00:00Z",
+ "type": "string"
+ },
+ "renewals": {
+ "example": 1,
+ "type": "integer"
+ },
+ "suggestedExpiry": {
+ "example": "2030-01-01T00:00:00Z",
+ "type": "string"
+ },
+ "suggestedExpiryTime": {
+ "example": 1893456000000,
+ "format": "int64",
+ "type": "integer"
+ },
+ "timeZone": {
+ "example": "UTC",
+ "type": "string"
+ },
+ "validThrough": {
+ "example": "2029-12-31T23:59:59Z",
+ "type": "string"
+ }
+ },
+ "required": [
+ "canRenew",
+ "delayedStart",
+ "nextExpiry",
+ "renewAt",
+ "renewals",
+ "suggestedExpiry",
+ "suggestedExpiryTime",
+ "timeZone",
+ "validThrough"
+ ],
+ "type": "object"
+ },
+ "ClientRenewalPreviewRequest": {
+ "properties": {
+ "expiryTime": {
+ "example": 1893456000000,
+ "format": "int64",
+ "type": "integer"
+ },
+ "reset": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetCount": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetDay": {
+ "example": 1,
+ "type": "integer"
+ },
+ "resetMax": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetWeekday": {
+ "example": 0,
+ "type": "integer"
+ }
+ },
+ "required": [
+ "expiryTime",
+ "reset",
+ "resetCount",
+ "resetDay",
+ "resetMax",
+ "resetWeekday"
+ ],
+ "type": "object"
+ },
"ClientReverse": {
"properties": {
"tag": {
@@ -1889,6 +1989,10 @@
"example": 0,
"type": "integer"
},
+ "resetWeekday": {
+ "example": 0,
+ "type": "integer"
+ },
"subId": {
"example": "abcd1234",
"type": "string"
@@ -1923,6 +2027,7 @@
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"subId",
"totalGB",
"updatedAt"
@@ -1978,7 +2083,7 @@
"type": "integer"
},
"resetDay": {
- "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+ "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
"example": 0,
"type": "integer"
},
@@ -1987,6 +2092,11 @@
"example": 0,
"type": "integer"
},
+ "resetWeekday": {
+ "description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
+ "example": 0,
+ "type": "integer"
+ },
"subId": {
"example": "i7tvdpeffi0hvvf1",
"type": "string"
@@ -2019,6 +2129,7 @@
"resetCount",
"resetDay",
"resetMax",
+ "resetWeekday",
"subId",
"total",
"up",
@@ -4807,6 +4918,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -8041,6 +8153,7 @@
"reset": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "abcd1234",
"totalGB": 53687091200,
"traffic": null,
@@ -8233,6 +8346,97 @@
}
}
},
+ "/panel/api/clients/renewalPreview": {
+ "post": {
+ "tags": [
+ "Clients"
+ ],
+ "summary": "Preview client auto-renewal dates without saving or resetting anything.",
+ "operationId": "post_panel_api_clients_renewalPreview",
+ "description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "expiryTime": {
+ "type": "integer",
+ "description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
+ },
+ "reset": {
+ "type": "integer",
+ "description": "Fixed interval in days; 0 disabled."
+ },
+ "resetDay": {
+ "type": "integer",
+ "description": "Monthly calendar day 1-31; 0 disabled."
+ },
+ "resetWeekday": {
+ "type": "integer",
+ "description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
+ },
+ "resetMax": {
+ "type": "integer",
+ "description": "Maximum renewals; 0 unlimited."
+ },
+ "resetCount": {
+ "type": "integer",
+ "description": "Renewals already consumed; defaults to 0."
+ }
+ },
+ "required": [
+ "expiryTime",
+ "reset",
+ "resetDay",
+ "resetWeekday",
+ "resetMax",
+ "resetCount"
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "msg": {
+ "type": "string"
+ },
+ "obj": {
+ "$ref": "#/components/schemas/ClientRenewalPreview"
+ }
+ }
+ },
+ "example": {
+ "success": true,
+ "obj": {
+ "canRenew": true,
+ "delayedStart": false,
+ "nextExpiry": "2030-02-01T00:00:00Z",
+ "renewAt": "2030-01-01T00:00:00Z",
+ "renewals": 1,
+ "suggestedExpiry": "2030-01-01T00:00:00Z",
+ "suggestedExpiryTime": 1893456000000,
+ "timeZone": "UTC",
+ "validThrough": "2029-12-31T23:59:59Z"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/panel/api/clients/update/{email}": {
"post": {
"tags": [
@@ -10292,6 +10496,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -15665,6 +15870,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -15747,6 +15953,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -15793,6 +16000,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json
index ae423a7fa..2df4d2892 100644
--- a/frontend/public/openapi.json
+++ b/frontend/public/openapi.json
@@ -1531,13 +1531,17 @@
"type": "integer"
},
"resetDay": {
- "description": "Calendar renewal day 1-31, 0 = interval mode",
+ "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
"type": "integer"
},
"resetMax": {
"description": "Max auto-renew count, 0 = unlimited",
"type": "integer"
},
+ "resetWeekday": {
+ "description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
+ "type": "integer"
+ },
"reverse": {
"allOf": [
{
@@ -1600,6 +1604,7 @@
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"security",
"subId",
"tgId",
@@ -1751,6 +1756,9 @@
"resetMax": {
"type": "integer"
},
+ "resetWeekday": {
+ "type": "integer"
+ },
"reverse": {},
"secret": {
"type": "string"
@@ -1806,6 +1814,7 @@
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"reverse",
"secret",
"security",
@@ -1819,6 +1828,97 @@
],
"type": "object"
},
+ "ClientRenewalPreview": {
+ "properties": {
+ "canRenew": {
+ "example": true,
+ "type": "boolean"
+ },
+ "delayedStart": {
+ "example": false,
+ "type": "boolean"
+ },
+ "nextExpiry": {
+ "example": "2030-02-01T00:00:00Z",
+ "type": "string"
+ },
+ "renewAt": {
+ "example": "2030-01-01T00:00:00Z",
+ "type": "string"
+ },
+ "renewals": {
+ "example": 1,
+ "type": "integer"
+ },
+ "suggestedExpiry": {
+ "example": "2030-01-01T00:00:00Z",
+ "type": "string"
+ },
+ "suggestedExpiryTime": {
+ "example": 1893456000000,
+ "format": "int64",
+ "type": "integer"
+ },
+ "timeZone": {
+ "example": "UTC",
+ "type": "string"
+ },
+ "validThrough": {
+ "example": "2029-12-31T23:59:59Z",
+ "type": "string"
+ }
+ },
+ "required": [
+ "canRenew",
+ "delayedStart",
+ "nextExpiry",
+ "renewAt",
+ "renewals",
+ "suggestedExpiry",
+ "suggestedExpiryTime",
+ "timeZone",
+ "validThrough"
+ ],
+ "type": "object"
+ },
+ "ClientRenewalPreviewRequest": {
+ "properties": {
+ "expiryTime": {
+ "example": 1893456000000,
+ "format": "int64",
+ "type": "integer"
+ },
+ "reset": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetCount": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetDay": {
+ "example": 1,
+ "type": "integer"
+ },
+ "resetMax": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetWeekday": {
+ "example": 0,
+ "type": "integer"
+ }
+ },
+ "required": [
+ "expiryTime",
+ "reset",
+ "resetCount",
+ "resetDay",
+ "resetMax",
+ "resetWeekday"
+ ],
+ "type": "object"
+ },
"ClientReverse": {
"properties": {
"tag": {
@@ -1889,6 +1989,10 @@
"example": 0,
"type": "integer"
},
+ "resetWeekday": {
+ "example": 0,
+ "type": "integer"
+ },
"subId": {
"example": "abcd1234",
"type": "string"
@@ -1923,6 +2027,7 @@
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"subId",
"totalGB",
"updatedAt"
@@ -1978,7 +2083,7 @@
"type": "integer"
},
"resetDay": {
- "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+ "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
"example": 0,
"type": "integer"
},
@@ -1987,6 +2092,11 @@
"example": 0,
"type": "integer"
},
+ "resetWeekday": {
+ "description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
+ "example": 0,
+ "type": "integer"
+ },
"subId": {
"example": "i7tvdpeffi0hvvf1",
"type": "string"
@@ -2019,6 +2129,7 @@
"resetCount",
"resetDay",
"resetMax",
+ "resetWeekday",
"subId",
"total",
"up",
@@ -4807,6 +4918,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -8041,6 +8153,7 @@
"reset": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "abcd1234",
"totalGB": 53687091200,
"traffic": null,
@@ -8233,6 +8346,97 @@
}
}
},
+ "/panel/api/clients/renewalPreview": {
+ "post": {
+ "tags": [
+ "Clients"
+ ],
+ "summary": "Preview client auto-renewal dates without saving or resetting anything.",
+ "operationId": "post_panel_api_clients_renewalPreview",
+ "description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "expiryTime": {
+ "type": "integer",
+ "description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
+ },
+ "reset": {
+ "type": "integer",
+ "description": "Fixed interval in days; 0 disabled."
+ },
+ "resetDay": {
+ "type": "integer",
+ "description": "Monthly calendar day 1-31; 0 disabled."
+ },
+ "resetWeekday": {
+ "type": "integer",
+ "description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
+ },
+ "resetMax": {
+ "type": "integer",
+ "description": "Maximum renewals; 0 unlimited."
+ },
+ "resetCount": {
+ "type": "integer",
+ "description": "Renewals already consumed; defaults to 0."
+ }
+ },
+ "required": [
+ "expiryTime",
+ "reset",
+ "resetDay",
+ "resetWeekday",
+ "resetMax",
+ "resetCount"
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "msg": {
+ "type": "string"
+ },
+ "obj": {
+ "$ref": "#/components/schemas/ClientRenewalPreview"
+ }
+ }
+ },
+ "example": {
+ "success": true,
+ "obj": {
+ "canRenew": true,
+ "delayedStart": false,
+ "nextExpiry": "2030-02-01T00:00:00Z",
+ "renewAt": "2030-01-01T00:00:00Z",
+ "renewals": 1,
+ "suggestedExpiry": "2030-01-01T00:00:00Z",
+ "suggestedExpiryTime": 1893456000000,
+ "timeZone": "UTC",
+ "validThrough": "2029-12-31T23:59:59Z"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/panel/api/clients/update/{email}": {
"post": {
"tags": [
@@ -10292,6 +10496,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -15665,6 +15870,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -15747,6 +15953,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -15793,6 +16000,7 @@
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts
index 27cb25065..4ee0bb42b 100644
--- a/frontend/src/generated/examples.ts
+++ b/frontend/src/generated/examples.ts
@@ -371,6 +371,7 @@ export const EXAMPLES: Record = {
"reset": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"reverse": null,
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
"security": "",
@@ -410,6 +411,7 @@ export const EXAMPLES: Record = {
"reset": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "abcd1234",
"totalGB": 53687091200,
"traffic": null,
@@ -459,6 +461,7 @@ export const EXAMPLES: Record = {
"reset": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"reverse": null,
"secret": "",
"security": "",
@@ -470,6 +473,25 @@ export const EXAMPLES: Record = {
"updatedAt": 0,
"uuid": ""
},
+ "ClientRenewalPreview": {
+ "canRenew": true,
+ "delayedStart": false,
+ "nextExpiry": "2030-02-01T00:00:00Z",
+ "renewAt": "2030-01-01T00:00:00Z",
+ "renewals": 1,
+ "suggestedExpiry": "2030-01-01T00:00:00Z",
+ "suggestedExpiryTime": 1893456000000,
+ "timeZone": "UTC",
+ "validThrough": "2029-12-31T23:59:59Z"
+ },
+ "ClientRenewalPreviewRequest": {
+ "expiryTime": 1893456000000,
+ "reset": 0,
+ "resetCount": 0,
+ "resetDay": 1,
+ "resetMax": 0,
+ "resetWeekday": 0
+ },
"ClientReverse": {
"tag": ""
},
@@ -489,6 +511,7 @@ export const EXAMPLES: Record = {
"reset": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "abcd1234",
"totalGB": 53687091200,
"traffic": null,
@@ -507,6 +530,7 @@ export const EXAMPLES: Record = {
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
@@ -704,6 +728,7 @@ export const EXAMPLES: Record = {
"resetCount": 0,
"resetDay": 0,
"resetMax": 0,
+ "resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts
index db8e7761e..4daed56e2 100644
--- a/frontend/src/generated/schemas.ts
+++ b/frontend/src/generated/schemas.ts
@@ -1505,13 +1505,17 @@ export const SCHEMAS: Record = {
"type": "integer"
},
"resetDay": {
- "description": "Calendar renewal day 1-31, 0 = interval mode",
+ "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
"type": "integer"
},
"resetMax": {
"description": "Max auto-renew count, 0 = unlimited",
"type": "integer"
},
+ "resetWeekday": {
+ "description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
+ "type": "integer"
+ },
"reverse": {
"allOf": [
{
@@ -1574,6 +1578,7 @@ export const SCHEMAS: Record = {
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"security",
"subId",
"tgId",
@@ -1725,6 +1730,9 @@ export const SCHEMAS: Record = {
"resetMax": {
"type": "integer"
},
+ "resetWeekday": {
+ "type": "integer"
+ },
"reverse": {},
"secret": {
"type": "string"
@@ -1780,6 +1788,7 @@ export const SCHEMAS: Record = {
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"reverse",
"secret",
"security",
@@ -1793,6 +1802,97 @@ export const SCHEMAS: Record = {
],
"type": "object"
},
+ "ClientRenewalPreview": {
+ "properties": {
+ "canRenew": {
+ "example": true,
+ "type": "boolean"
+ },
+ "delayedStart": {
+ "example": false,
+ "type": "boolean"
+ },
+ "nextExpiry": {
+ "example": "2030-02-01T00:00:00Z",
+ "type": "string"
+ },
+ "renewAt": {
+ "example": "2030-01-01T00:00:00Z",
+ "type": "string"
+ },
+ "renewals": {
+ "example": 1,
+ "type": "integer"
+ },
+ "suggestedExpiry": {
+ "example": "2030-01-01T00:00:00Z",
+ "type": "string"
+ },
+ "suggestedExpiryTime": {
+ "example": 1893456000000,
+ "format": "int64",
+ "type": "integer"
+ },
+ "timeZone": {
+ "example": "UTC",
+ "type": "string"
+ },
+ "validThrough": {
+ "example": "2029-12-31T23:59:59Z",
+ "type": "string"
+ }
+ },
+ "required": [
+ "canRenew",
+ "delayedStart",
+ "nextExpiry",
+ "renewAt",
+ "renewals",
+ "suggestedExpiry",
+ "suggestedExpiryTime",
+ "timeZone",
+ "validThrough"
+ ],
+ "type": "object"
+ },
+ "ClientRenewalPreviewRequest": {
+ "properties": {
+ "expiryTime": {
+ "example": 1893456000000,
+ "format": "int64",
+ "type": "integer"
+ },
+ "reset": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetCount": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetDay": {
+ "example": 1,
+ "type": "integer"
+ },
+ "resetMax": {
+ "example": 0,
+ "type": "integer"
+ },
+ "resetWeekday": {
+ "example": 0,
+ "type": "integer"
+ }
+ },
+ "required": [
+ "expiryTime",
+ "reset",
+ "resetCount",
+ "resetDay",
+ "resetMax",
+ "resetWeekday"
+ ],
+ "type": "object"
+ },
"ClientReverse": {
"properties": {
"tag": {
@@ -1863,6 +1963,10 @@ export const SCHEMAS: Record = {
"example": 0,
"type": "integer"
},
+ "resetWeekday": {
+ "example": 0,
+ "type": "integer"
+ },
"subId": {
"example": "abcd1234",
"type": "string"
@@ -1897,6 +2001,7 @@ export const SCHEMAS: Record = {
"reset",
"resetDay",
"resetMax",
+ "resetWeekday",
"subId",
"totalGB",
"updatedAt"
@@ -1952,7 +2057,7 @@ export const SCHEMAS: Record = {
"type": "integer"
},
"resetDay": {
- "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
+ "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
"example": 0,
"type": "integer"
},
@@ -1961,6 +2066,11 @@ export const SCHEMAS: Record = {
"example": 0,
"type": "integer"
},
+ "resetWeekday": {
+ "description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
+ "example": 0,
+ "type": "integer"
+ },
"subId": {
"example": "i7tvdpeffi0hvvf1",
"type": "string"
@@ -1993,6 +2103,7 @@ export const SCHEMAS: Record = {
"resetCount",
"resetDay",
"resetMax",
+ "resetWeekday",
"subId",
"total",
"up",
diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts
index 673885115..3ebb9b6c0 100644
--- a/frontend/src/generated/types.ts
+++ b/frontend/src/generated/types.ts
@@ -367,6 +367,7 @@ export interface Client {
reset: number;
resetDay: number;
resetMax: number;
+ resetWeekday: number;
reverse?: ClientReverse | null;
secret?: string;
security: string;
@@ -418,6 +419,7 @@ export interface ClientRecord {
reset: number;
resetDay: number;
resetMax: number;
+ resetWeekday: number;
reverse: unknown;
secret: string;
security: string;
@@ -430,6 +432,27 @@ export interface ClientRecord {
uuid: string;
}
+export interface ClientRenewalPreview {
+ canRenew: boolean;
+ delayedStart: boolean;
+ nextExpiry: string;
+ renewAt: string;
+ renewals: number;
+ suggestedExpiry: string;
+ suggestedExpiryTime: number;
+ timeZone: string;
+ validThrough: string;
+}
+
+export interface ClientRenewalPreviewRequest {
+ expiryTime: number;
+ reset: number;
+ resetCount: number;
+ resetDay: number;
+ resetMax: number;
+ resetWeekday: number;
+}
+
export interface ClientReverse {
tag: string;
}
@@ -447,6 +470,7 @@ export interface ClientSlim {
reset: number;
resetDay: number;
resetMax: number;
+ resetWeekday: number;
subId: string;
totalGB: number;
traffic?: ClientTraffic | null;
@@ -466,6 +490,7 @@ export interface ClientTraffic {
resetCount: number;
resetDay: number;
resetMax: number;
+ resetWeekday: number;
subId: string;
total: number;
up: number;
diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts
index 23c089f74..5aa85f891 100644
--- a/frontend/src/generated/zod.ts
+++ b/frontend/src/generated/zod.ts
@@ -388,6 +388,7 @@ export const ClientSchema = z.object({
reset: z.number().int(),
resetDay: z.number().int(),
resetMax: z.number().int(),
+ resetWeekday: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
secret: z.string().optional(),
security: z.string(),
@@ -442,6 +443,7 @@ export const ClientRecordSchema = z.object({
reset: z.number().int(),
resetDay: z.number().int(),
resetMax: z.number().int(),
+ resetWeekday: z.number().int(),
reverse: z.unknown(),
secret: z.string(),
security: z.string(),
@@ -455,6 +457,29 @@ export const ClientRecordSchema = z.object({
});
export type ClientRecord = z.infer;
+export const ClientRenewalPreviewSchema = z.object({
+ canRenew: z.boolean(),
+ delayedStart: z.boolean(),
+ nextExpiry: z.string(),
+ renewAt: z.string(),
+ renewals: z.number().int(),
+ suggestedExpiry: z.string(),
+ suggestedExpiryTime: z.number().int(),
+ timeZone: z.string(),
+ validThrough: z.string(),
+});
+export type ClientRenewalPreview = z.infer;
+
+export const ClientRenewalPreviewRequestSchema = z.object({
+ expiryTime: z.number().int(),
+ reset: z.number().int(),
+ resetCount: z.number().int(),
+ resetDay: z.number().int(),
+ resetMax: z.number().int(),
+ resetWeekday: z.number().int(),
+});
+export type ClientRenewalPreviewRequest = z.infer;
+
export const ClientReverseSchema = z.object({
tag: z.string(),
});
@@ -473,6 +498,7 @@ export const ClientSlimSchema = z.object({
reset: z.number().int(),
resetDay: z.number().int(),
resetMax: z.number().int(),
+ resetWeekday: z.number().int(),
subId: z.string(),
totalGB: z.number().int(),
traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
@@ -493,6 +519,7 @@ export const ClientTrafficSchema = z.object({
resetCount: z.number().int(),
resetDay: z.number().int(),
resetMax: z.number().int(),
+ resetWeekday: z.number().int(),
subId: z.string(),
total: z.number().int(),
up: z.number().int(),
diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts
index 5740eb743..61d62508a 100644
--- a/frontend/src/hooks/useClients.ts
+++ b/frontend/src/hooks/useClients.ts
@@ -696,6 +696,7 @@ export function useClients(options: UseClientsOptions = {}) {
tgId: Number(base.tgId) || 0,
reset: Number(base.reset) || 0,
resetDay: Number(base.resetDay) || 0,
+ resetWeekday: Number(base.resetWeekday) || 0,
resetMax: Number(base.resetMax) || 0,
trafficReset: base.trafficReset || 'never',
trafficResetDay: Number(base.trafficResetDay) || 1,
diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts
index a6150e0c1..c1026795e 100644
--- a/frontend/src/pages/api-docs/endpoints.ts
+++ b/frontend/src/pages/api-docs/endpoints.ts
@@ -1169,6 +1169,52 @@ export const sections: readonly Section[] = [
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
response: '{\n "success": true,\n "msg": "Client added"\n}',
},
+ {
+ method: 'POST',
+ path: '/panel/api/clients/renewalPreview',
+ summary: 'Preview client auto-renewal dates without saving or resetting anything.',
+ description:
+ 'Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.',
+ params: [
+ {
+ name: 'expiryTime',
+ in: 'body (json)',
+ type: 'integer',
+ desc: 'Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration.',
+ },
+ {
+ name: 'reset',
+ in: 'body (json)',
+ type: 'integer',
+ desc: 'Fixed interval in days; 0 disabled.',
+ },
+ {
+ name: 'resetDay',
+ in: 'body (json)',
+ type: 'integer',
+ desc: 'Monthly calendar day 1-31; 0 disabled.',
+ },
+ {
+ name: 'resetWeekday',
+ in: 'body (json)',
+ type: 'integer',
+ desc: 'Weekly calendar day 1-7 (Monday-Sunday); 0 disabled.',
+ },
+ {
+ name: 'resetMax',
+ in: 'body (json)',
+ type: 'integer',
+ desc: 'Maximum renewals; 0 unlimited.',
+ },
+ {
+ name: 'resetCount',
+ in: 'body (json)',
+ type: 'integer',
+ desc: 'Renewals already consumed; defaults to 0.',
+ },
+ ],
+ responseSchema: 'ClientRenewalPreview',
+ },
{
method: 'POST',
path: '/panel/api/clients/update/:email',
diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx
index f1c50a81a..2c1547b3c 100644
--- a/frontend/src/pages/clients/ClientBulkAddModal.tsx
+++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx
@@ -25,6 +25,7 @@ import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { useClients, type InboundOption } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
+import ClientRenewalFields from './ClientRenewalFields';
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -57,6 +58,7 @@ const EMPTY: ClientBulkAddFormValues = {
expiryTime: 0,
reset: 0,
resetDay: 0,
+ resetWeekday: 0,
resetMax: 0,
trafficReset: 'never' as const,
trafficResetDay: 1,
@@ -215,6 +217,7 @@ export default function ClientBulkAddModal({
expiryTime: current.expiryTime,
reset: Number(current.reset) || 0,
resetDay: Number(current.resetDay) || 0,
+ resetWeekday: Number(current.resetWeekday) || 0,
resetMax: Number(current.resetMax) || 0,
trafficReset: current.trafficReset || 'never',
trafficResetDay: Number(current.trafficResetDay) || 1,
@@ -437,32 +440,13 @@ export default function ClientBulkAddModal({
)}
- Number(v) || 0 }}
- >
-
-
-
- Number(v) || 0 }}
- >
-
-
-
- Number(v) || 0 }}
- >
-
-
+ methods.setValue('expiryTime', expiry)}
+ />