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)} + /> + + {mode === 'interval' && ( + Number(v) || 1 }} + > + + + )} + {mode === 'monthly' && ( + Number(v) || 1 }} + > + + + )} + {mode === 'weekly' && ( + +