Browse Source

inbounds: allow custom monthly traffic reset days (#6071)

Shichao Song 6 giờ trước cách đây
mục cha
commit
17e6b5a460
37 tập tin đã thay đổi với 179 bổ sung16 xóa
  1. 2 2
      docs/architecture.md
  2. 3 0
      docs/content/docs/en/config/inbounds.mdx
  3. 3 0
      docs/content/docs/fa/config/inbounds.mdx
  4. 3 0
      docs/content/docs/ru/config/inbounds.mdx
  5. 3 0
      docs/content/docs/zh/config/inbounds.mdx
  6. 9 0
      frontend/public/openapi.json
  7. 1 0
      frontend/src/generated/examples.ts
  8. 8 0
      frontend/src/generated/schemas.ts
  9. 1 0
      frontend/src/generated/types.ts
  10. 1 0
      frontend/src/generated/zod.ts
  11. 4 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  12. 3 0
      frontend/src/models/dbinbound.ts
  13. 12 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  14. 1 0
      frontend/src/schemas/forms/inbound-form.ts
  15. 7 0
      frontend/src/test/inbound-form-adapter.test.ts
  16. 1 0
      internal/database/model/model.go
  17. 24 3
      internal/web/job/periodic_traffic_reset_job.go
  18. 31 0
      internal/web/job/periodic_traffic_reset_job_test.go
  19. 3 0
      internal/web/runtime/remote.go
  20. 6 3
      internal/web/runtime/remote_test.go
  21. 10 0
      internal/web/service/inbound.go
  22. 5 1
      internal/web/service/inbound_node.go
  23. 18 0
      internal/web/service/inbound_traffic_reset_test.go
  24. 1 0
      internal/web/translation/ar-EG.json
  25. 1 0
      internal/web/translation/en-US.json
  26. 1 0
      internal/web/translation/es-ES.json
  27. 1 0
      internal/web/translation/fa-IR.json
  28. 1 0
      internal/web/translation/id-ID.json
  29. 1 0
      internal/web/translation/ja-JP.json
  30. 1 0
      internal/web/translation/pt-BR.json
  31. 1 0
      internal/web/translation/ru-RU.json
  32. 1 0
      internal/web/translation/tr-TR.json
  33. 1 0
      internal/web/translation/uk-UA.json
  34. 1 0
      internal/web/translation/vi-VN.json
  35. 1 0
      internal/web/translation/zh-CN.json
  36. 1 0
      internal/web/translation/zh-TW.json
  37. 7 7
      internal/web/web.go

+ 2 - 2
docs/architecture.md

@@ -369,8 +369,8 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
 | `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
 | `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
 | `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
-| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | IP-limit and Xray access/error log cleanup; traffic resets |
-| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
+| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
+| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
 | default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
 | default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
 | `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |

+ 3 - 0
docs/content/docs/en/config/inbounds.mdx

@@ -40,6 +40,9 @@ See [Clients](/docs/config/clients).
 Optionally cap total traffic and set an expiry date for the inbound, and choose a
 periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
 `weekly`, or `monthly`.
+
+For `monthly` resets, select a day from 1 to 31. If the selected day does not
+exist in a shorter month, the reset runs on that month's last day.
 </Step>
 
 </Steps>

+ 3 - 0
docs/content/docs/fa/config/inbounds.mdx

@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
 به‌صورت اختیاری می‌توانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
 تعیین کنید، و یک زمان‌بندی **بازنشانی ترافیک** دوره‌ای انتخاب کنید: `never`
 (پیش‌فرض)، `hourly`، `daily`، `weekly` یا `monthly`.
+
+برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاه‌تر
+وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام می‌شود.
 </Step>
 
 </Steps>

+ 3 - 0
docs/content/docs/ru/config/inbounds.mdx

@@ -41,6 +41,9 @@ icon: ArrowDownToLine
 При необходимости ограничьте общий объём трафика и установите дату истечения для
 входящего подключения, а также выберите расписание периодического **сброса трафика**:
 `never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
+
+Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
+коротком месяце, сброс выполняется в последний день этого месяца.
 </Step>
 
 </Steps>

+ 3 - 0
docs/content/docs/zh/config/inbounds.mdx

@@ -37,6 +37,9 @@ icon: ArrowDownToLine
 
 可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
 `never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
+
+选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
+则在该月最后一天重置。
 </Step>
 
 </Steps>

+ 9 - 0
frontend/public/openapi.json

@@ -1868,6 +1868,13 @@
             ],
             "type": "string"
           },
+          "trafficResetDay": {
+            "description": "Day of month for monthly traffic resets",
+            "example": 1,
+            "maximum": 31,
+            "minimum": 1,
+            "type": "integer"
+          },
           "up": {
             "description": "Upload traffic in bytes",
             "format": "int64",
@@ -1894,6 +1901,7 @@
           "tag",
           "total",
           "trafficReset",
+          "trafficResetDay",
           "up"
         ],
         "type": "object"
@@ -3159,6 +3167,7 @@
                       "tag": "in-443-tcp",
                       "total": 0,
                       "trafficReset": "never",
+                      "trafficResetDay": 1,
                       "up": 0
                     }
                   ]

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

@@ -450,6 +450,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "tag": "in-443-tcp",
     "total": 0,
     "trafficReset": "never",
+    "trafficResetDay": 1,
     "up": 0
   },
   "InboundClientIps": {

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

@@ -1842,6 +1842,13 @@ export const SCHEMAS: Record<string, unknown> = {
         ],
         "type": "string"
       },
+      "trafficResetDay": {
+        "description": "Day of month for monthly traffic resets",
+        "example": 1,
+        "maximum": 31,
+        "minimum": 1,
+        "type": "integer"
+      },
       "up": {
         "description": "Upload traffic in bytes",
         "format": "int64",
@@ -1868,6 +1875,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "tag",
       "total",
       "trafficReset",
+      "trafficResetDay",
       "up"
     ],
     "type": "object"

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

@@ -428,6 +428,7 @@ export interface Inbound {
   tag: string;
   total: number;
   trafficReset: string;
+  trafficResetDay: number;
   up: number;
 }
 

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

@@ -453,6 +453,7 @@ export const InboundSchema = z.object({
   tag: z.string(),
   total: z.number().int(),
   trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
+  trafficResetDay: z.number().int().min(1).max(31),
   up: z.number().int(),
 });
 export type Inbound = z.infer<typeof InboundSchema>;

+ 4 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -41,6 +41,7 @@ export interface RawInboundRow {
   enable?: boolean;
   expiryTime?: number;
   trafficReset?: string;
+  trafficResetDay?: number;
   lastTrafficResetTime?: number;
   nodeId?: number | null;
   shareAddrStrategy?: string;
@@ -60,6 +61,7 @@ export interface WireInboundPayload {
   enable: boolean;
   expiryTime: number;
   trafficReset: TrafficReset;
+  trafficResetDay: number;
   lastTrafficResetTime: number;
   listen: string;
   port: number;
@@ -202,6 +204,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     down: row.down ?? 0,
     total: row.total ?? 0,
     trafficReset: coerceTrafficReset(row.trafficReset),
+    trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
     lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
     nodeId: row.nodeId ?? null,
     shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
@@ -344,6 +347,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     enable: values.enable,
     expiryTime: values.expiryTime,
     trafficReset: values.trafficReset,
+    trafficResetDay: values.trafficResetDay,
     lastTrafficResetTime: values.lastTrafficResetTime,
     listen: values.listen,
     port: values.port,

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -30,6 +30,7 @@ export type DBInboundInit = Partial<{
     enable: boolean;
     expiryTime: number;
     trafficReset: string;
+    trafficResetDay: number;
     lastTrafficResetTime: number;
     listen: string;
     port: number;
@@ -76,6 +77,7 @@ export class DBInbound {
     enable: boolean;
     expiryTime: number;
     trafficReset: string;
+    trafficResetDay: number;
     lastTrafficResetTime: number;
 
     listen: string;
@@ -105,6 +107,7 @@ export class DBInbound {
         this.enable = true;
         this.expiryTime = 0;
         this.trafficReset = "never";
+        this.trafficResetDay = 1;
         this.lastTrafficResetTime = 0;
 
         this.listen = "";

+ 12 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -33,6 +33,7 @@ import {
   isSS2022,
 } from '@/lib/xray/protocol-capabilities';
 import {
+  InboundDbFieldsSchema,
   InboundFormBaseSchema,
   InboundFormSchema,
   type InboundFormValues,
@@ -255,6 +256,7 @@ export default function InboundFormModal({
   const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
   const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
   const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
+  const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never';
   const autoTagRef = useRef(true);
   const lastWrittenTagRef = useRef('');
   const currentTagInput = (): InboundTagInput => ({
@@ -619,6 +621,16 @@ export default function InboundFormModal({
         />
       </FormField>
 
+      {trafficReset === 'monthly' && (
+        <FormField
+          name="trafficResetDay"
+          label={t('pages.inbounds.periodicTrafficResetDay')}
+          rules={{ validate: rhfZodValidate(InboundDbFieldsSchema.shape.trafficResetDay) }}
+        >
+          <InputNumber min={1} max={31} />
+        </FormField>
+      )}
+
       <Form.Item
         label={
           <Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -22,6 +22,7 @@ export const InboundDbFieldsSchema = z.object({
   down: z.number().int().min(0).default(0),
   total: z.number().int().min(0).default(0),
   trafficReset: TrafficResetSchema.default('never'),
+  trafficResetDay: z.number().int().min(1).max(31).default(1),
   lastTrafficResetTime: z.number().int().default(0),
   nodeId: z.number().int().nullable().optional(),
   shareAddrStrategy: ShareAddrStrategySchema.default('node'),

+ 7 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -32,6 +32,7 @@ const vlessRow: RawInboundRow = {
   total: 1_000_000_000,
   expiryTime: 0,
   trafficReset: 'monthly',
+  trafficResetDay: 15,
   lastTrafficResetTime: 0,
   tag: 'inbound-1',
   nodeId: null,
@@ -267,6 +268,7 @@ describe('formValuesToWirePayload', () => {
       enable: payload.enable,
       expiryTime: payload.expiryTime,
       trafficReset: payload.trafficReset,
+      trafficResetDay: payload.trafficResetDay,
       lastTrafficResetTime: payload.lastTrafficResetTime,
       nodeId: payload.nodeId ?? null,
     });
@@ -276,8 +278,13 @@ describe('formValuesToWirePayload', () => {
     expect(replay.listen).toBe(original.listen);
     expect(replay.up).toBe(original.up);
     expect(replay.down).toBe(original.down);
+    expect(replay.trafficResetDay).toBe(original.trafficResetDay);
     expect(replay.streamSettings).toEqual(original.streamSettings);
   });
+
+  it('defaults a missing monthly reset day to the first', () => {
+    expect(rawInboundToFormValues({ ...vlessRow, trafficResetDay: undefined }).trafficResetDay).toBe(1);
+  });
 });
 
 describe('subSortIndex', () => {

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

@@ -54,6 +54,7 @@ type Inbound struct {
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"`                                                                         // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                                                                                 // Expiration timestamp
 	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
+	TrafficResetDay      int                  `json:"trafficResetDay" form:"trafficResetDay" gorm:"default:1" validate:"omitempty,gte=1,lte=31" example:"1"`                                                        // Day of month for monthly traffic resets
 	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                                                                                            // Last traffic reset timestamp
 	ClientStats          []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`                                                                                     // Client traffic statistics
 

+ 24 - 3
internal/web/job/periodic_traffic_reset_job.go

@@ -1,6 +1,8 @@
 package job
 
 import (
+	"time"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
@@ -13,13 +15,23 @@ type PeriodicTrafficResetJob struct {
 	inboundService service.InboundService
 	clientService  service.ClientService
 	period         Period
+	location       *time.Location
 }
 
 // NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
-func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
+func NewPeriodicTrafficResetJob(period Period, location *time.Location) *PeriodicTrafficResetJob {
 	return &PeriodicTrafficResetJob{
-		period: period,
+		period:   period,
+		location: location,
+	}
+}
+
+func monthlyResetDue(resetDay int, now time.Time) bool {
+	if resetDay < 1 {
+		resetDay = 1
 	}
+	lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
+	return now.Day() == min(resetDay, lastDay)
 }
 
 // Run resets traffic statistics for all inbounds that match the configured reset period.
@@ -30,13 +42,22 @@ func (j *PeriodicTrafficResetJob) Run() {
 		return
 	}
 
+	if j.period == "monthly" {
+		now := time.Now().In(j.location)
+		due := inbounds[:0]
+		for _, inbound := range inbounds {
+			if monthlyResetDue(inbound.TrafficResetDay, now) {
+				due = append(due, inbound)
+			}
+		}
+		inbounds = due
+	}
 	if len(inbounds) == 0 {
 		return
 	}
 	logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
 
 	resetCount := 0
-
 	for _, inbound := range inbounds {
 		resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
 		if resetInboundErr != nil {

+ 31 - 0
internal/web/job/periodic_traffic_reset_job_test.go

@@ -0,0 +1,31 @@
+package job
+
+import (
+	"testing"
+	"time"
+)
+
+func TestMonthlyResetDue(t *testing.T) {
+	cases := []struct {
+		name     string
+		resetDay int
+		now      time.Time
+		want     bool
+	}{
+		{"legacy default on first", 0, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), true},
+		{"configured day", 15, time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), true},
+		{"before configured day", 15, time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC), false},
+		{"month end", 31, time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), true},
+		{"short month fallback", 31, time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), true},
+		{"leap year fallback", 31, time.Date(2028, time.February, 29, 0, 0, 0, 0, time.UTC), true},
+		{"not before short month end", 31, time.Date(2028, time.February, 28, 0, 0, 0, 0, time.UTC), false},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := monthlyResetDue(tc.resetDay, tc.now); got != tc.want {
+				t.Fatalf("monthlyResetDue(%d, %s) = %v, want %v", tc.resetDay, tc.now.Format(time.DateOnly), got, tc.want)
+			}
+		})
+	}
+}

+ 3 - 0
internal/web/runtime/remote.go

@@ -770,6 +770,9 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
 	if ib.TrafficReset != "" {
 		v.Set("trafficReset", ib.TrafficReset)
 	}
+	if ib.TrafficResetDay > 0 {
+		v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
+	}
 	return v
 }
 

+ 6 - 3
internal/web/runtime/remote_test.go

@@ -252,9 +252,12 @@ func TestIsNonEmptySlice(t *testing.T) {
 }
 
 func TestWireInboundTrafficReset(t *testing.T) {
-	with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
-	if got := with.Get("trafficReset"); got != "daily" {
-		t.Fatalf("trafficReset = %q, want daily", got)
+	with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
+	if got := with.Get("trafficReset"); got != "monthly" {
+		t.Fatalf("trafficReset = %q, want monthly", got)
+	}
+	if got := with.Get("trafficResetDay"); got != "15" {
+		t.Fatalf("trafficResetDay = %q, want 15", got)
 	}
 	// Empty TrafficReset must be omitted entirely, not sent as an empty field.
 	without := wireInbound(&model.Inbound{}, 0)

+ 10 - 0
internal/web/service/inbound.go

@@ -34,6 +34,13 @@ type InboundService struct {
 	fallbackService FallbackService
 }
 
+func normalizeTrafficResetDay(day int) int {
+	if day < 1 {
+		return 1
+	}
+	return min(day, 31)
+}
+
 func normalizeInboundShareAddrStrategy(strategy string) string {
 	strategy = strings.TrimSpace(strategy)
 	switch strategy {
@@ -905,6 +912,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
 // Returns the created inbound, whether Xray needs restart, and any error.
 func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
 	inbound.Id = 0
+	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
@@ -1331,6 +1339,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 }
 
 func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
+	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
@@ -1460,6 +1469,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		oldInbound.Enable = inbound.Enable
 		oldInbound.ExpiryTime = inbound.ExpiryTime
 		oldInbound.TrafficReset = inbound.TrafficReset
+		oldInbound.TrafficResetDay = inbound.TrafficResetDay
 		oldInbound.Listen = inbound.Listen
 		oldInbound.Port = inbound.Port
 		oldInbound.Protocol = inbound.Protocol

+ 5 - 1
internal/web/service/inbound_node.go

@@ -311,7 +311,8 @@ func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool {
 		c.ExpiryTime != snapIb.ExpiryTime ||
 		c.StreamSettings != snapIb.StreamSettings ||
 		c.Sniffing != snapIb.Sniffing ||
-		c.TrafficReset != snapIb.TrafficReset
+		c.TrafficReset != snapIb.TrafficReset ||
+		c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay)
 }
 
 // adoptedWireInbound is the central inbound as it reads after adopting the
@@ -330,6 +331,7 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
 	a.StreamSettings = snapIb.StreamSettings
 	a.Sniffing = snapIb.Sniffing
 	a.TrafficReset = snapIb.TrafficReset
+	a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay)
 	return &a
 }
 
@@ -561,6 +563,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				StreamSettings:       snapIb.StreamSettings,
 				Sniffing:             snapIb.Sniffing,
 				TrafficReset:         snapIb.TrafficReset,
+				TrafficResetDay:      normalizeTrafficResetDay(snapIb.TrafficResetDay),
 				LastTrafficResetTime: snapIb.LastTrafficResetTime,
 				Enable:               snapIb.Enable,
 				Remark:               snapIb.Remark,
@@ -616,6 +619,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			updates["stream_settings"] = snapIb.StreamSettings
 			updates["sniffing"] = snapIb.Sniffing
 			updates["traffic_reset"] = snapIb.TrafficReset
+			updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay)
 			updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
 			if adoptedWireChanged(c, snapIb, adoptedSettings) {
 				adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(c, snapIb, adoptedSettings))

+ 18 - 0
internal/web/service/inbound_traffic_reset_test.go

@@ -0,0 +1,18 @@
+package service
+
+import "testing"
+
+func TestNormalizeTrafficResetDay(t *testing.T) {
+	tests := map[int]int{
+		0:  1,
+		1:  1,
+		15: 15,
+		31: 31,
+		32: 31,
+	}
+	for input, want := range tests {
+		if got := normalizeTrafficResetDay(input); got != want {
+			t.Errorf("normalizeTrafficResetDay(%d) = %d, want %d", input, got, want)
+		}
+	}
+}

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -451,6 +451,7 @@
       "importInbound": "استيراد إدخال",
       "periodicTrafficResetTitle": "إعادة تعيين حركة المرور",
       "periodicTrafficResetDesc": "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة",
+      "periodicTrafficResetDay": "يوم إعادة التعيين الشهري",
       "lastReset": "آخر إعادة تعيين",
       "periodicTrafficReset": {
         "never": "أبداً",

+ 1 - 0
internal/web/translation/en-US.json

@@ -451,6 +451,7 @@
       "importInbound": "Import an Inbound",
       "periodicTrafficResetTitle": "Traffic Reset",
       "periodicTrafficResetDesc": "Automatically reset traffic counter at specified intervals",
+      "periodicTrafficResetDay": "Monthly reset day",
       "lastReset": "Last Reset",
       "periodicTrafficReset": {
         "never": "Never",

+ 1 - 0
internal/web/translation/es-ES.json

@@ -451,6 +451,7 @@
       "importInbound": "Importar un entrante",
       "periodicTrafficResetTitle": "Reset de Tráfico",
       "periodicTrafficResetDesc": "Reiniciar automáticamente el contador de tráfico en intervalos especificados",
+      "periodicTrafficResetDay": "Día de reinicio mensual",
       "lastReset": "Último reinicio",
       "periodicTrafficReset": {
         "never": "Nunca",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -451,6 +451,7 @@
       "importInbound": "افزودن یک ورودی",
       "periodicTrafficResetTitle": "بازنشانی ترافیک",
       "periodicTrafficResetDesc": "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص",
+      "periodicTrafficResetDay": "روز بازنشانی ماهانه",
       "lastReset": "آخرین بازنشانی",
       "periodicTrafficReset": {
         "never": "هرگز",

+ 1 - 0
internal/web/translation/id-ID.json

@@ -451,6 +451,7 @@
       "importInbound": "Impor Masuk",
       "periodicTrafficResetTitle": "Reset Trafik Berkala",
       "periodicTrafficResetDesc": "Reset otomatis penghitung trafik pada interval tertentu",
+      "periodicTrafficResetDay": "Hari reset bulanan",
       "lastReset": "Reset Terakhir",
       "periodicTrafficReset": {
         "never": "Tidak Pernah",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -451,6 +451,7 @@
       "importInbound": "インバウンドルールをインポート",
       "periodicTrafficResetTitle": "トラフィックリセット",
       "periodicTrafficResetDesc": "指定された間隔でトラフィックカウンタを自動的にリセット",
+      "periodicTrafficResetDay": "毎月のリセット日",
       "lastReset": "最後のリセット",
       "periodicTrafficReset": {
         "never": "なし",

+ 1 - 0
internal/web/translation/pt-BR.json

@@ -451,6 +451,7 @@
       "importInbound": "Importar um Inbound",
       "periodicTrafficResetTitle": "Reset de Tráfego",
       "periodicTrafficResetDesc": "Reinicia automaticamente o contador de tráfego em intervalos especificados",
+      "periodicTrafficResetDay": "Dia da redefinição mensal",
       "lastReset": "Último Reset",
       "periodicTrafficReset": {
         "never": "Nunca",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -451,6 +451,7 @@
       "importInbound": "Импорт подключений",
       "periodicTrafficResetTitle": "Сброс трафика",
       "periodicTrafficResetDesc": "Автоматический сброс счетчика трафика через указанные интервалы",
+      "periodicTrafficResetDay": "День ежемесячного сброса",
       "lastReset": "Последний сброс",
       "periodicTrafficReset": {
         "never": "Никогда",

+ 1 - 0
internal/web/translation/tr-TR.json

@@ -451,6 +451,7 @@
       "importInbound": "Gelen Bağlantı İçe Aktar",
       "periodicTrafficResetTitle": "Trafik Sıfırlama",
       "periodicTrafficResetDesc": "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla",
+      "periodicTrafficResetDay": "Aylık sıfırlama günü",
       "lastReset": "Son Sıfırlama",
       "periodicTrafficReset": {
         "never": "Asla",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -451,6 +451,7 @@
       "importInbound": "Імпортувати вхідний",
       "periodicTrafficResetTitle": "Скидання трафіку",
       "periodicTrafficResetDesc": "Автоматично скидати лічильник трафіку через певні проміжки часу",
+      "periodicTrafficResetDay": "День щомісячного скидання",
       "lastReset": "Останнє скидання",
       "periodicTrafficReset": {
         "never": "Ніколи",

+ 1 - 0
internal/web/translation/vi-VN.json

@@ -451,6 +451,7 @@
       "importInbound": "Nhập inbound",
       "periodicTrafficResetTitle": "Đặt lại lưu lượng",
       "periodicTrafficResetDesc": "Tự động đặt lại bộ đếm lưu lượng theo khoảng thời gian xác định",
+      "periodicTrafficResetDay": "Ngày đặt lại hàng tháng",
       "lastReset": "Đặt lại lần cuối",
       "periodicTrafficReset": {
         "never": "Không bao giờ",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -451,6 +451,7 @@
       "importInbound": "导入入站规则",
       "periodicTrafficResetTitle": "流量重置",
       "periodicTrafficResetDesc": "按指定间隔自动重置流量计数器",
+      "periodicTrafficResetDay": "每月重置日",
       "lastReset": "上次重置",
       "periodicTrafficReset": {
         "never": "从不",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -451,6 +451,7 @@
       "importInbound": "匯入入站規則",
       "periodicTrafficResetTitle": "流量重置",
       "periodicTrafficResetDesc": "按指定間隔自動重置流量計數器",
+      "periodicTrafficResetDay": "每月重置日",
       "lastReset": "上次重置",
       "periodicTrafficReset": {
         "never": "從不",

+ 7 - 7
internal/web/web.go

@@ -302,7 +302,7 @@ const (
 
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // jobs) which the panel relies on for periodic maintenance and monitoring.
-func (s *Server) startTask(restartXray bool) {
+func (s *Server) startTask(restartXray bool, loc *time.Location) {
 	if restartXray {
 		err := s.xrayService.RestartXray(true)
 		if err != nil {
@@ -344,13 +344,13 @@ func (s *Server) startTask(restartXray bool) {
 
 	// Inbound traffic reset jobs
 	// Run every hour
-	_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
+	_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly", loc))
 	// Run once a day, midnight
-	_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
+	_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily", loc))
 	// Run once a week, midnight between Sat/Sun
-	_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
-	// Run once a month, midnight, first of month
-	_, _ = s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
+	_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly", loc))
+	// Check monthly reset days at midnight
+	_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
 
 	// LDAP sync scheduling
 	if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
@@ -651,7 +651,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		}
 	})
 
-	s.startTask(restartXray)
+	s.startTask(restartXray, loc)
 
 	if startTgBot {
 		isTgbotenabled, err := s.settingService.GetTgbotEnabled()