diff --git a/src/components/common/SizeDisplay.tsx b/src/components/common/SizeDisplay.tsx new file mode 100644 index 0000000..fcca6ce --- /dev/null +++ b/src/components/common/SizeDisplay.tsx @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { Info } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { ReactNode } from 'react'; + +import { formatSize } from '@/lib/durationFormat'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +interface SizeDisplayProps { + bytes: number; + className?: string; +} + +/** + * Formats a byte size. Negative values (stale Stalwart quota counters) are + * shown in red with an info tooltip pointing admins at recalculateQuota. + */ +export function SizeDisplay({ bytes, className }: SizeDisplayProps): ReactNode { + const { t } = useTranslation(); + const label = Number.isFinite(bytes) ? formatSize(bytes) : formatSize(0); + + if (!Number.isFinite(bytes) || bytes >= 0) { + return {label}; + } + + return ( + + {label} + + + + + + + {t( + 'list.negativeQuotaTooltip', + 'This disk-usage counter is out of sync (often after a migration or reset). Schedule a task: Perform account maintenance operations → Recalculate storage quota usage for the account. Or for all accounts: Perform store maintenance operations → Reset all user quotas.', + )} + + + + + ); +} diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index ec12c87..2bc1e9a 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -29,13 +29,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { ExpressionEditor } from '@/components/expression/ExpressionEditor'; import { OtpAuthField } from '@/components/forms/OtpAuthField'; +import { SizeDisplay } from '@/components/common/SizeDisplay'; import { bytesToHuman, humanToBytes, msToHuman, humanToMs, - formatSize, formatDuration, SIZE_UNITS, DURATION_UNITS, @@ -651,7 +651,11 @@ function SizeInput({ value, onChange, readOnly, nullable }: SizeInputProps) { if (readOnly) { if (value == null) return {t('field.notSet', 'Not set')}; - return {formatSize(value as number)}; + return ( + + + + ); } return ; } diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 37c11fd..d8bfa62 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -31,6 +31,7 @@ import { Combobox } from '@/components/ui/combobox'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat'; +import { SizeDisplay } from '@/components/common/SizeDisplay'; import { DropdownMenu, DropdownMenuTrigger, @@ -247,15 +248,25 @@ function formatUserRole(item: Record, schema: Schema): React.Re return type; } -function formatQuotaUsage(item: Record, t: TFn): string { - const used = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0; +function renderQuotaUsage(item: Record, t: TFn): React.ReactNode { + const rawUsed = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0; + const used = Number.isFinite(rawUsed) ? rawUsed : 0; const quotas = item.quotas as Record | undefined; - const limit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0; - const usedLabel = formatSize(used); - if (!limit) { - return `${usedLabel} / ${t('list.unlimitedQuota', 'Unlimited')}`; + const rawLimit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0; + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0; + const limitLabel = limit ? formatSize(limit) : t('list.unlimitedQuota', 'Unlimited'); + + if (used >= 0) { + return `${formatSize(used)} / ${limitLabel}`; } - return `${usedLabel} / ${formatSize(limit)}`; + + return ( + + + / + {limitLabel} + + ); } function getFieldsRecord(resolvedSchema: ResolvedSchema): Record { @@ -1644,7 +1655,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ) : isAccountsList && col.name === 'roles' ? ( formatUserRole(item, schema!) ) : isAccountsList && col.name === 'quotaUsage' ? ( - formatQuotaUsage(item, t) + renderQuotaUsage(item, t) ) : isMailboxList && col.name === 'name' ? ( (() => { const depth = mailboxDepths.get(item.id as string) ?? 0; diff --git a/src/components/views/DynamicView.tsx b/src/components/views/DynamicView.tsx index 502a044..f99302b 100644 --- a/src/components/views/DynamicView.tsx +++ b/src/components/views/DynamicView.tsx @@ -14,7 +14,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver'; import { useObjectList, useObjectLabel } from '@/lib/objectOptions'; -import { formatSize, formatDuration } from '@/lib/durationFormat'; +import { formatDuration } from '@/lib/durationFormat'; +import { SizeDisplay } from '@/components/common/SizeDisplay'; import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema'; export interface DynamicViewProps { @@ -224,7 +225,7 @@ function NumberValue({ value, format }: { value: unknown; format: string }) { switch (format) { case 'size': - return {formatSize(num)}; + return ; case 'duration': return {formatDuration(num)}; default: diff --git a/src/i18n/en.json b/src/i18n/en.json index 09eafaf..732a6fb 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -317,7 +317,9 @@ "showingItems": "Showing {{count}} items", "sort": "Sort", "unknownError": "Unknown error", - "unlimitedQuota": "Unlimited" + "unlimitedQuota": "Unlimited", + "negativeQuotaInfoAria": "Why is disk usage negative?", + "negativeQuotaTooltip": "This disk-usage counter is out of sync (often after a migration or reset). Schedule a task: Perform account maintenance operations → Recalculate storage quota usage for the account. Or for all accounts: Perform store maintenance operations → Reset all user quotas." }, "login": { "continue": "Continue", diff --git a/src/lib/durationFormat.test.ts b/src/lib/durationFormat.test.ts index 50c7373..aa24eea 100644 --- a/src/lib/durationFormat.test.ts +++ b/src/lib/durationFormat.test.ts @@ -68,6 +68,15 @@ describe('bytesToHuman', () => { it('should convert 1099511627776 bytes to 1 TB', () => { expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' }); }); + + it('should format negative byte counts with a minus sign', () => { + expect(bytesToHuman(-9515272)).toEqual({ value: -9.07, unit: 'MB' }); + }); + + it('should treat non-finite byte counts as empty usage', () => { + expect(bytesToHuman(Number.NaN)).toEqual({ value: 0, unit: 'B' }); + expect(bytesToHuman(Number.POSITIVE_INFINITY)).toEqual({ value: 0, unit: 'B' }); + }); }); describe('humanToBytes', () => { @@ -108,6 +117,10 @@ describe('formatSize', () => { it('should format large values in TB', () => { expect(formatSize(1099511627776)).toBe('1 TB'); }); + + it('should format negative byte counts with a minus sign', () => { + expect(formatSize(-9515272)).toBe('-9.07 MB'); + }); }); describe('msToHuman', () => { diff --git a/src/lib/durationFormat.ts b/src/lib/durationFormat.ts index 1c540a7..aa8f390 100644 --- a/src/lib/durationFormat.ts +++ b/src/lib/durationFormat.ts @@ -15,15 +15,18 @@ const SIZE_FACTORS: Record = { }; export function bytesToHuman(bytes: number): { value: number; unit: string } { - if (bytes === 0) return { value: 0, unit: 'B' }; + if (!Number.isFinite(bytes) || bytes === 0) return { value: 0, unit: 'B' }; + + const sign = bytes < 0 ? -1 : 1; + const abs = Math.abs(bytes); for (let i = SIZE_UNITS.length - 1; i >= 0; i--) { const unit = SIZE_UNITS[i]; const factor = SIZE_FACTORS[unit]; - const v = bytes / factor; + const v = abs / factor; if (v >= 1) { const rounded = Math.round(v * 100) / 100; - return { value: rounded, unit }; + return { value: sign * rounded, unit }; } }