From eb669e1d83308970e70f7d55d456a6c45f3cd4f7 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 15 Jul 2026 06:05:37 +0200 Subject: [PATCH] fix(frontend): don't drift a client's byte quota on a no-op save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quota field shows the total in GB rounded to two decimals; editing a client and saving converted that display value straight back to bytes. A byte total not aligned to 0.01 GB — one set via the API or an import — was therefore rewritten to the rounded value on any save that never touched the field, losing a few MB each time. Add resolveTotalBytes: keep the original byte total when the displayed GB still matches it, and only re-derive from GB when the user actually changed the field. --- .../src/pages/clients/ClientFormModal.tsx | 17 ++++++++++++++-- frontend/src/test/client-total-bytes.test.tsx | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 frontend/src/test/client-total-bytes.test.tsx diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index f3314f499..d457215b9 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -146,11 +146,23 @@ function bytesToGB(bytes: number): number { return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100; } -function gbToBytes(gb: number): number { +export function gbToBytes(gb: number): number { if (!gb || gb <= 0) return 0; return Math.round(gb * 1024 * 1024 * 1024); } +// The quota field displays whole/2-decimal GB, so a byte total that isn't +// aligned to 0.01 GB (e.g. one set via the API or an import) would drift on a +// save that never touched the field. Keep the original byte total when the +// displayed GB still matches it, and only re-derive from GB when the user +// actually changed the value. +export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number { + if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) { + return originalBytes; + } + return gbToBytes(displayedGB); +} + export default function ClientFormModal({ open, mode, @@ -491,6 +503,7 @@ export default function ClientFormModal({ const expiryTime = values.delayedStart ? -86400000 * (Number(values.delayedDays) || 0) : (values.expiryDate || 0); + const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB); const clientPayload: Record = { email: values.email.trim(), subId: values.subId, @@ -499,7 +512,7 @@ export default function ClientFormModal({ auth: values.auth, flow: showFlow ? (values.flow || '') : '', security: showSecurity ? (values.security || 'auto') : 'auto', - totalGB: gbToBytes(values.totalGB), + totalGB: totalBytes, expiryTime, reset: Number(values.reset) || 0, limitIp: Number(values.limitIp) || 0, diff --git a/frontend/src/test/client-total-bytes.test.tsx b/frontend/src/test/client-total-bytes.test.tsx new file mode 100644 index 000000000..2bf63bda1 --- /dev/null +++ b/frontend/src/test/client-total-bytes.test.tsx @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; + +import { resolveTotalBytes, gbToBytes } from '@/pages/clients/ClientFormModal'; + +describe('resolveTotalBytes', () => { + it('preserves the original byte total on a no-op save of a non-GB-aligned quota', () => { + const original = 11_505_016_832; + const displayedGB = Math.round((original / 1024 ** 3) * 100) / 100; + expect(resolveTotalBytes(original, displayedGB)).toBe(original); + }); + + it('re-derives bytes from GB when the user changed the quota', () => { + const original = 11_505_016_832; + expect(resolveTotalBytes(original, 20)).toBe(gbToBytes(20)); + }); + + it('uses the GB value directly when there is no original (add mode)', () => { + expect(resolveTotalBytes(null, 5)).toBe(gbToBytes(5)); + }); +});