Parcourir la source

fix: resolve a batch of open bug-tagged issues (traffic accounting, share strategy, sub address, CPU) (#5477)

* fix(node): never re-add a node's full counter on reset/restart (#5456, #5476, #5390)

When a node's per-client counter dips below the master's stored baseline
(node reboot, xray restart, or a reset propagated to the node), the delta
accounting clamped delta to the node's whole current counter and re-added it
to the master total — double-counting a client's lifetime usage in a single
sync and often pushing them over quota. Treat a backward-moving counter as a
reset: add 0 and rebaseline to the reported value, so only genuine post-reset
usage accrues.

Resets also now clear the per-node NodeClientTraffic baseline (ResetClient
TrafficByEmail, resetClientTrafficLocked, BulkResetTraffic, resetAllClient
TrafficsLocked), mirroring the delete paths. Without this the node's pre-reset
cumulative — including traffic it had counted but not yet synced — leaks back
onto the master after a reset, which is the 'reset reverts after a while'
report. The next sync then takes the clean delta=0 + rebaseline path regardless
of node state.

Updates TestNodeCounterReset (was _Clamped, now _NoReAdd) to assert rebaseline
instead of re-add, and adds TestCentralResetClearsNodeBaseline_NoLeak.

* fix(inbound): keep persisted node share strategy on edit (#5375)

Opening the edit modal silently reverted shareAddrStrategy from 'node' to
'listen'. The downgrade effect fires before the form settles: availableNodes
is an empty placeholder until /nodes/list resolves, and Form.useWatch('protocol')
is briefly empty on the first edit render — both transiently make the node
option look unavailable, so the effect clobbered the saved value.

Gate the downgrade on availableNodesFetched (threaded from useNodesQuery through
InboundsPage) and on the protocol watch being settled, so a persisted strategy
is only downgraded when the node option is genuinely unavailable. Adds a
rerender-based regression test covering the nodes-loading race.

* <3

* perf(traffic): skip cross-panel quota subquery when no globals exist (#5392, #5389)

disableInvalidClients ran a correlated EXISTS against client_global_traffics
on the full client_traffics table every 5s. On a panel no master pushes to,
that table is empty so the subquery can never match — yet it forced a full
scan that pegged Postgres at 100% CPU on large client counts. Probe the table
first and drop the EXISTS branch when it's empty (the common case), and add an
idx_client_global_email index so the subquery is an index lookup when globals
are present. Cross-panel enforcement is unchanged (TestGlobalUsage_DisablesClient).

This also relieves #5389 ('traffic writer queue full' / panel freeze): the
heavy query runs inside the serialized traffic write, so a slow DB backs the
shared writer queue up until request handlers block.

* fix(sub): don't advertise a leaked client IP for local wildcard inbounds (#5425)

For a local inbound with no node, no custom share address, and a wildcard/blank
listen, resolveInboundAddress fell straight through to the subscriber's request
host. Behind NAT/proxy/CDN that Host can be the requesting client's own IP, so
the subscription wrote the client's address into the inbound instead of the
server's — while the panel's own share link (which doesn't use the request host)
stayed correct.

Prefer the admin's configured public host (Sub/Web domain) over the raw request
host for this last-resort fallback. With no configured host the request host
still stands, so existing single-domain setups are unaffected.
Sanaei il y a 11 heures
Parent
commit
679d2e1cca

+ 2 - 1
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -100,7 +100,7 @@ export default function InboundsPage() {
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
-  const { nodes: nodesList } = useNodesQuery();
+  const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
   const nodesById = useMemo(() => {
     const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     for (const n of nodesList || []) map.set(n.id, n);
@@ -647,6 +647,7 @@ export default function InboundsPage() {
             dbInbound={formDbInbound}
             dbInbounds={dbInbounds}
             availableNodes={nodesList}
+            availableNodesFetched={nodesFetched}
           />
         </LazyMount>
         <LazyMount when={infoOpen}>

+ 11 - 1
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -138,6 +138,7 @@ interface InboundFormModalProps {
   dbInbound: DBInbound | null;
   dbInbounds: DBInbound[];
   availableNodes?: NodeRecord[];
+  availableNodesFetched?: boolean;
 }
 
 function buildAddModeValues(): InboundFormValues {
@@ -167,6 +168,7 @@ export default function InboundFormModal({
   dbInbound,
   dbInbounds,
   availableNodes,
+  availableNodesFetched = true,
 }: InboundFormModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
@@ -373,14 +375,22 @@ export default function InboundFormModal({
   // offered (no node, or a protocol that can't deploy to one) fall back to
   // `listen`, which yields the same link for a local inbound. Mirrors how the
   // protocol reset drops a nodeId that no longer applies.
+  // Only downgrade once the inputs this decision depends on are settled, so a
+  // persisted `node` strategy is never clobbered by transient mount state (#5375):
+  //  - `availableNodesFetched`: an empty `availableNodes` during the async
+  //    /nodes/list fetch is a placeholder, not "no nodes".
+  //  - `protocol`: `Form.useWatch('protocol')` is briefly empty on the first
+  //    edit render before initialValues apply, which would momentarily make the
+  //    node option look unavailable.
   useEffect(() => {
     if (!open) return;
+    if (!availableNodesFetched || !protocol) return;
     const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
       form.setFieldValue('shareAddrStrategy', 'listen');
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open, nodeShareOptionAvailable, shareAddrStrategy]);
+  }, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]);
 
   // Why: protocol picker reset cascades through the form — clearing the
   // settings DU branch and dropping a nodeId that no longer applies. The

+ 52 - 1
frontend/src/test/inbound-form-modal.test.tsx

@@ -1,8 +1,9 @@
 import { describe, it, expect } from 'vitest';
-import { screen, act } from '@testing-library/react';
+import { screen, act, render, cleanup } from '@testing-library/react';
 
 import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
 import { DBInbound } from '@/models/dbinbound';
+import { ThemeProvider } from '@/hooks/useTheme';
 import {
   renderWithProviders,
   fieldLabels,
@@ -90,4 +91,54 @@ describe('InboundFormModal', () => {
     const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
     expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
   });
+
+  it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
+    const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
+    const buildInbound = () => new DBInbound({
+      id: 1,
+      port: 23456,
+      listen: '',
+      protocol: 'vless',
+      remark: 'noded',
+      enable: true,
+      settings: { clients: [] },
+      streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
+      sniffing: { enabled: false },
+      nodeId: 1,
+      shareAddrStrategy: 'node',
+    });
+    const flush = async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); };
+    const strategyItem = (title: string) =>
+      document.querySelector(`.ant-select-content[title="${title}"]`);
+    const modal = (nodes: never[], fetched: boolean) => (
+      <ThemeProvider>
+        <InboundFormModal
+          open
+          mode="edit"
+          dbInbound={buildInbound()}
+          dbInbounds={[]}
+          availableNodes={nodes}
+          availableNodesFetched={fetched}
+          onClose={() => {}}
+          onSaved={() => {}}
+        />
+      </ThemeProvider>
+    );
+
+    // Baseline: nodes already loaded, so the node option is offered and selected.
+    render(modal([node], true));
+    await flush();
+    expect(strategyItem('Node address')).toBeTruthy();
+    cleanup();
+
+    // Race: the modal mounts before /nodes/list resolves (empty placeholder),
+    // then nodes arrive. The persisted 'node' strategy must survive the gap and
+    // stay selected once the option reappears — not silently revert to listen.
+    const { rerender } = render(modal([], false));
+    await flush();
+    rerender(modal([node], true));
+    await flush();
+    expect(strategyItem('Node address')).toBeTruthy();
+    expect(strategyItem('Inbound listen')).toBeFalsy();
+  });
 });

+ 2 - 1
internal/database/index_tags_test.go

@@ -21,7 +21,7 @@ func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) {
 	if err != nil {
 		t.Fatalf("open sqlite: %v", err)
 	}
-	if err := db.AutoMigrate(&model.ClientRecord{}, &xray.ClientTraffic{}); err != nil {
+	if err := db.AutoMigrate(&model.ClientRecord{}, &xray.ClientTraffic{}, &model.ClientGlobalTraffic{}); err != nil {
 		t.Fatalf("automigrate: %v", err)
 	}
 
@@ -32,6 +32,7 @@ func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) {
 		{&model.ClientRecord{}, "idx_client_record_group"},
 		{&xray.ClientTraffic{}, "idx_client_traffics_inbound"},
 		{&xray.ClientTraffic{}, "idx_client_traffics_renew"},
+		{&model.ClientGlobalTraffic{}, "idx_client_global_email"},
 	}
 	for _, c := range cases {
 		if !db.Migrator().HasIndex(c.model, c.index) {

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

@@ -13,7 +13,7 @@ package model
 type ClientGlobalTraffic struct {
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	MasterGuid string `json:"masterGuid" gorm:"uniqueIndex:idx_master_email,priority:1;not null"`
-	Email      string `json:"email" gorm:"uniqueIndex:idx_master_email,priority:2;not null"`
+	Email      string `json:"email" gorm:"uniqueIndex:idx_master_email,priority:2;index:idx_client_global_email;not null"`
 	Up         int64  `json:"up"`
 	Down       int64  `json:"down"`
 	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`

+ 25 - 0
internal/sub/build_urls_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func initSubDB(t *testing.T) {
@@ -60,6 +61,30 @@ func TestBuildURLs_UsesSubscriberDomain(t *testing.T) {
 	}
 }
 
+// A local wildcard inbound (no node, no custom share address, blank/0.0.0.0
+// listen) must not advertise the raw request host when it carries a client IP
+// that leaked in behind NAT/proxy. The admin's configured panel host wins for
+// this last-resort fallback; without a configured host the request host stands.
+func TestResolveInboundAddress_PrefersConfiguredHostOverClientIP(t *testing.T) {
+	initSubDB(t)
+	local := &model.Inbound{Listen: "", ShareAddrStrategy: "node"}
+
+	s := &SubService{}
+	s.PrepareForRequest("192.168.1.50") // a client LAN IP that reached the panel
+	if got := s.resolveInboundAddress(local); got != "192.168.1.50" {
+		t.Fatalf("with no configured host the request host stands, got %q", got)
+	}
+
+	if err := database.GetDB().Create(&model.Setting{Key: "subDomain", Value: "panel.example.com"}).Error; err != nil {
+		t.Fatalf("set subDomain: %v", err)
+	}
+	s2 := &SubService{}
+	s2.PrepareForRequest("192.168.1.50")
+	if got := s2.resolveInboundAddress(local); got != "panel.example.com" {
+		t.Fatalf("configured host must win over the leaked client IP, got %q", got)
+	}
+}
+
 func TestBuildURLs_EmptySubId(t *testing.T) {
 	initSubDB(t)
 	s := &SubService{}

+ 10 - 4
internal/sub/service.go

@@ -929,10 +929,13 @@ func (s *SubService) loadNodes() {
 //   - "node" (default, and any unknown value): the node's address for
 //     node-managed inbounds, then a routable Listen — the pre-strategy order.
 //
-// Every chain ends at the subscriber's request host (s.address). A
-// loopback/wildcard bind or a unix-domain-socket listen is a server-side
-// detail and is never advertised; External Proxy still overrides everything
-// upstream of this call.
+// Every chain ends at the admin's configured public host (Sub/Web domain) and
+// then the subscriber's request host (s.address). Preferring the configured
+// host over the request host for this last resort keeps a wildcard local inbound
+// from advertising a bogus client IP that leaked into the request Host header
+// behind NAT/proxy/CDN (#5425). A loopback/wildcard bind or a unix-domain-socket
+// listen is a server-side detail and is never advertised; External Proxy still
+// overrides everything upstream of this call.
 func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
 	var nodeAddr string
 	if inbound.NodeID != nil && s.nodesByID != nil {
@@ -957,6 +960,9 @@ func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
 			return c
 		}
 	}
+	if d := s.configuredPublicHost(); d != "" {
+		return d
+	}
 	return s.address
 }
 

+ 15 - 1
internal/web/service/client_traffic.go

@@ -101,7 +101,15 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 				}
 				affected += int(res.RowsAffected)
 			}
-			return clearGlobalTraffic(tx, cleanEmails...)
+			if err := clearGlobalTraffic(tx, cleanEmails...); err != nil {
+				return err
+			}
+			for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+				if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+					return err
+				}
+			}
+			return nil
 		})
 	})
 	if err != nil {
@@ -154,6 +162,12 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 			return err
 		}
 
+		for _, batch := range chunkStrings(resetEmails, sqlInChunk) {
+			if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+				return err
+			}
+		}
+
 		inboundWhereText := "id "
 		if id == -1 {
 			inboundWhereText += " > ?"

+ 26 - 0
internal/web/service/global_traffic_test.go

@@ -64,6 +64,32 @@ func TestAcceptGlobalTraffic_OverwriteAndMultiMaster(t *testing.T) {
 	}
 }
 
+func TestDepletedCond_ProbeGuard(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	// No global rows: the cross-panel EXISTS branch is skipped (#5392), but a
+	// client over its local quota is still disabled.
+	if got := depletedCond(db); got != depletedClientsCondLocal {
+		t.Fatalf("empty globals must use the local-only predicate")
+	}
+	seedClientRow(t, "local-cap", 1, 600, 600, 1000)
+	if _, count, _, err := svc.disableInvalidClients(db); err != nil {
+		t.Fatalf("disableInvalidClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("local over-quota client must be disabled, disabled %d", count)
+	}
+
+	// Once a master pushes a global row, the full predicate is used so combined
+	// quota is enforced.
+	if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil {
+		t.Fatalf("AcceptGlobalTraffic: %v", err)
+	}
+	if got := depletedCond(db); got != depletedClientsCond {
+		t.Fatalf("with globals present the cross-panel predicate must be used")
+	}
+}
+
 func TestGlobalUsage_DisablesClient(t *testing.T) {
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}

+ 23 - 2
internal/web/service/inbound_disable.go

@@ -60,13 +60,34 @@ const depletedClientsCond = `((total > 0 AND up + down >= total)
 		WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
 	)))`
 
+// depletedClientsCondLocal is depletedClientsCond without the cross-panel
+// client_global_traffics check. The EXISTS branch is a correlated subquery that
+// turns every traffic poll into a full client_traffics scan; on a panel no
+// master pushes to (the common case) client_global_traffics is empty, so the
+// branch can never match and is pure CPU cost (#5392).
+const depletedClientsCondLocal = `((total > 0 AND up + down >= total)
+	OR (expiry_time > 0 AND expiry_time <= ?))`
+
+// depletedCond returns the local-only predicate unless this panel actually
+// holds global-traffic rows, in which case the cross-panel EXISTS check is
+// needed to enforce combined quota. Both variants take the same single
+// expiry_time placeholder, so callers pass identical args either way.
+func depletedCond(tx *gorm.DB) string {
+	var probe int64
+	if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 {
+		return depletedClientsCond
+	}
+	return depletedClientsCondLocal
+}
+
 func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
 	now := time.Now().Unix() * 1000
 	needRestart := false
+	cond := depletedCond(tx)
 
 	var depletedRows []xray.ClientTraffic
 	err := tx.Model(xray.ClientTraffic{}).
-		Where(depletedClientsCond+" AND enable = ?", now, true).
+		Where(cond+" AND enable = ?", now, true).
 		Find(&depletedRows).Error
 	if err != nil {
 		return false, 0, nil, err
@@ -142,7 +163,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int,
 	}
 
 	result := tx.Model(xray.ClientTraffic{}).
-		Where(depletedClientsCond+" AND enable = ?", now, true).
+		Where(cond+" AND enable = ?", now, true).
 		Update("enable", false)
 	err = result.Error
 	count := result.RowsAffected

+ 2 - 2
internal/web/service/inbound_node.go

@@ -571,10 +571,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			var deltaUp, deltaDown int64
 			if seen {
 				if deltaUp = canon.Up - base.Up; deltaUp < 0 {
-					deltaUp = canon.Up
+					deltaUp = 0
 				}
 				if deltaDown = canon.Down - base.Down; deltaDown < 0 {
-					deltaDown = canon.Down
+					deltaDown = 0
 				}
 			}
 

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

@@ -506,9 +506,12 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
 		if err := clearGlobalTraffic(db, clientEmail); err != nil {
 			return err
 		}
-		return db.Model(xray.ClientTraffic{}).
+		if err := db.Model(xray.ClientTraffic{}).
 			Where("email = ?", clientEmail).
-			Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error
+			Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
+			return err
+		}
+		return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
 	})
 }
 
@@ -602,6 +605,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 	if err := clearGlobalTraffic(db, clientEmail); err != nil {
 		return false, err
 	}
+	if err := db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+		return false, err
+	}
 
 	now := time.Now().UnixMilli()
 	_ = db.Model(model.Inbound{}).

+ 46 - 3
internal/web/service/node_client_traffic_sum_test.go

@@ -169,7 +169,7 @@ func TestGhostData_NoPhantomTraffic(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts")
 }
 
-func TestNodeCounterReset_Clamped(t *testing.T) {
+func TestNodeCounterReset_NoReAdd(t *testing.T) {
 	db := initTrafficTestDB(t)
 	createNodeInbound(t, db, 1, "n1-in", 41001)
 	svc := &InboundService{}
@@ -180,13 +180,19 @@ func TestNodeCounterReset_Clamped(t *testing.T) {
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
 	assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset")
 
-	// Counter resets to 50 (Xray restart). delta=50-950=-900 → clamped → adds 50.
+	// Node reboot drops the counter to 50. delta=50-950=-900 is a counter reset,
+	// not new traffic: add 0 and rebaseline to 50, never re-add the node's full
+	// cumulative counter onto the master total (#5456).
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
 	ct := readTraffic(t, db, email)
 	if ct.Up < 0 || ct.Down < 0 {
 		t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
 	}
-	assertUpDown(t, ct, 100, 100, "after node counter reset (clamped)")
+	assertUpDown(t, ct, 50, 50, "after node counter reset: rebaselined, not re-added")
+
+	// Post-reset accrual resumes from the new baseline: 80-50=30.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 80, Down: 80, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 80, 80, "post-reset delta accrues from rebaselined counter")
 }
 
 func TestCentralReset_NoReAdd(t *testing.T) {
@@ -212,6 +218,43 @@ func TestCentralReset_NoReAdd(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
 }
 
+// A real reset (ResetClientTrafficByEmail) must clear the per-node baseline so
+// the node's pre-reset cumulative — including traffic it counted but had not yet
+// synced — cannot leak back onto the master after the reset (#5476, #5390).
+func TestCentralResetClearsNodeBaseline_NoLeak(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	StartTrafficWriter()
+	svc := &InboundService{}
+
+	const email = "reset-revert"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 200, 200, "before reset")
+
+	if err := svc.ResetClientTrafficByEmail(email); err != nil {
+		t.Fatalf("ResetClientTrafficByEmail: %v", err)
+	}
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "right after reset")
+
+	var baselines int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&baselines).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if baselines != 0 {
+		t.Fatalf("reset must clear node baseline rows, found %d", baselines)
+	}
+
+	// Node still reports its pre-reset cumulative (340 > last synced 300: usage it
+	// had not synced before the reset). It must not revert the reset.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 340, Down: 340, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "stale node counter must not revert reset")
+
+	// Genuine post-reset usage accrues from the rebaselined counter: 370-340=30.
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 370, Down: 370, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 30, 30, "post-reset usage accrues")
+}
+
 func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) {
 	db := initTrafficTestDB(t)
 	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared")

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

@@ -6,7 +6,7 @@
   "cancel": "إلغاء",
   "close": "إغلاق",
   "save": "حفظ",
-  "logout": "تسجيل خروج",
+  "logout": "تسجيل خروج <3",
   "create": "إنشاء",
   "add": "إضافة",
   "remove": "إزالة",
@@ -2054,4 +2054,4 @@
     "statusDown": "غير متصل",
     "statusUp": "متصل"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "Cancel",
   "close": "Close",
   "save": "Save",
-  "logout": "Log Out",
+  "logout": "Log Out <3",
   "create": "Create",
   "add": "Add",
   "remove": "Remove",

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

@@ -6,7 +6,7 @@
   "cancel": "Cancelar",
   "close": "Cerrar",
   "save": "Guardar",
-  "logout": "Cerrar Sesión",
+  "logout": "Cerrar Sesión <3",
   "create": "Crear",
   "add": "Añadir",
   "remove": "Quitar",
@@ -2054,4 +2054,4 @@
     "statusDown": "CAÍDO",
     "statusUp": "ACTIVO"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "انصراف",
   "close": "بستن",
   "save": "ذخیره",
-  "logout": "خروج",
+  "logout": "خروج <3",
   "create": "ایجاد",
   "add": "افزودن",
   "remove": "حذف",
@@ -2054,4 +2054,4 @@
     "statusDown": "قطع",
     "statusUp": "وصل"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "Batal",
   "close": "Tutup",
   "save": "Simpan",
-  "logout": "Keluar",
+  "logout": "Keluar <3",
   "create": "Buat",
   "add": "Tambah",
   "remove": "Hapus",
@@ -2054,4 +2054,4 @@
     "statusDown": "MATI",
     "statusUp": "AKTIF"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "キャンセル",
   "close": "閉じる",
   "save": "保存",
-  "logout": "ログアウト",
+  "logout": "ログアウト <3",
   "create": "作成",
   "add": "追加",
   "remove": "削除",
@@ -2054,4 +2054,4 @@
     "statusDown": "ダウン",
     "statusUp": "アップ"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "Cancelar",
   "close": "Fechar",
   "save": "Salvar",
-  "logout": "Sair",
+  "logout": "Sair <3",
   "create": "Criar",
   "add": "Adicionar",
   "remove": "Remover",
@@ -2054,4 +2054,4 @@
     "statusDown": "INATIVO",
     "statusUp": "ATIVO"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "Отмена",
   "close": "Закрыть",
   "save": "Сохранить",
-  "logout": "Выход",
+  "logout": "Выход <3",
   "create": "Создать",
   "add": "Добавить",
   "remove": "Удалить",
@@ -2054,4 +2054,4 @@
     "statusDown": "НЕДОСТУПЕН",
     "statusUp": "РАБОТАЕТ"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "İptal",
   "close": "Kapat",
   "save": "Kaydet",
-  "logout": "Çıkış Yap",
+  "logout": "Çıkış Yap <3",
   "create": "Oluştur",
   "add": "Ekle",
   "remove": "Kaldır",
@@ -2054,4 +2054,4 @@
     "statusDown": "ÇEVRİMDIŞI",
     "statusUp": "ÇEVRİMİÇİ"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "Скасувати",
   "close": "Закрити",
   "save": "Зберегти",
-  "logout": "Вийти",
+  "logout": "Вийти <3",
   "create": "Створити",
   "add": "Додати",
   "remove": "Видалити",
@@ -2054,4 +2054,4 @@
     "statusDown": "НЕДОСТУПНО",
     "statusUp": "ДОСТУПНО"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "Hủy bỏ",
   "close": "Đóng",
   "save": "Lưu",
-  "logout": "Đăng xuất",
+  "logout": "Đăng xuất <3",
   "create": "Tạo",
   "add": "Thêm",
   "remove": "Xóa",
@@ -2054,4 +2054,4 @@
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusUp": "HOẠT ĐỘNG"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "取消",
   "close": "关闭",
   "save": "保存",
-  "logout": "登出",
+  "logout": "登出 <3",
   "create": "创建",
   "add": "添加",
   "remove": "移除",
@@ -2054,4 +2054,4 @@
     "statusDown": "断开",
     "statusUp": "恢复"
   }
-}
+}

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

@@ -6,7 +6,7 @@
   "cancel": "取消",
   "close": "關閉",
   "save": "儲存",
-  "logout": "登出",
+  "logout": "登出 <3",
   "create": "建立",
   "add": "新增",
   "remove": "移除",
@@ -2054,4 +2054,4 @@
     "statusDown": "中斷",
     "statusUp": "恢復"
   }
-}
+}