feat(accounts): highlight negative disk usage with recalculate hint

This commit is contained in:
Steven RYDELL
2026-07-30 19:54:46 +02:00
parent 1ab574a42a
commit 0dec14c3f8
7 changed files with 106 additions and 16 deletions
+56
View File
@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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 <span className={className}>{label}</span>;
}
return (
<span className={`inline-flex items-center gap-1.5 ${className ?? ''}`.trim()}>
<span className="font-medium text-destructive">{label}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 text-destructive hover:text-destructive/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t('list.negativeQuotaInfoAria', 'Why is disk usage negative?')}
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-left">
{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.',
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
);
}
+6 -2
View File
@@ -29,13 +29,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { ExpressionEditor } from '@/components/expression/ExpressionEditor'; import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
import { OtpAuthField } from '@/components/forms/OtpAuthField'; import { OtpAuthField } from '@/components/forms/OtpAuthField';
import { SizeDisplay } from '@/components/common/SizeDisplay';
import { import {
bytesToHuman, bytesToHuman,
humanToBytes, humanToBytes,
msToHuman, msToHuman,
humanToMs, humanToMs,
formatSize,
formatDuration, formatDuration,
SIZE_UNITS, SIZE_UNITS,
DURATION_UNITS, DURATION_UNITS,
@@ -651,7 +651,11 @@ function SizeInput({ value, onChange, readOnly, nullable }: SizeInputProps) {
if (readOnly) { if (readOnly) {
if (value == null) if (value == null)
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>; return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
return <span className="text-sm">{formatSize(value as number)}</span>; return (
<span className="text-sm">
<SizeDisplay bytes={value as number} />
</span>
);
} }
return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />; return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />;
} }
+19 -8
View File
@@ -31,6 +31,7 @@ import { Combobox } from '@/components/ui/combobox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat'; import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
import { SizeDisplay } from '@/components/common/SizeDisplay';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -247,15 +248,25 @@ function formatUserRole(item: Record<string, unknown>, schema: Schema): React.Re
return type; return type;
} }
function formatQuotaUsage(item: Record<string, unknown>, t: TFn): string { function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNode {
const used = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0; const rawUsed = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
const used = Number.isFinite(rawUsed) ? rawUsed : 0;
const quotas = item.quotas as Record<string, unknown> | undefined; const quotas = item.quotas as Record<string, unknown> | undefined;
const limit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0; const rawLimit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0;
const usedLabel = formatSize(used); const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
if (!limit) { const limitLabel = limit ? formatSize(limit) : t('list.unlimitedQuota', 'Unlimited');
return `${usedLabel} / ${t('list.unlimitedQuota', 'Unlimited')}`;
if (used >= 0) {
return `${formatSize(used)} / ${limitLabel}`;
} }
return `${usedLabel} / ${formatSize(limit)}`;
return (
<span className="inline-flex items-center gap-1.5">
<SizeDisplay bytes={used} />
<span className="text-muted-foreground">/</span>
<span>{limitLabel}</span>
</span>
);
} }
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> { function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
@@ -1644,7 +1655,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
) : isAccountsList && col.name === 'roles' ? ( ) : isAccountsList && col.name === 'roles' ? (
formatUserRole(item, schema!) formatUserRole(item, schema!)
) : isAccountsList && col.name === 'quotaUsage' ? ( ) : isAccountsList && col.name === 'quotaUsage' ? (
formatQuotaUsage(item, t) renderQuotaUsage(item, t)
) : isMailboxList && col.name === 'name' ? ( ) : isMailboxList && col.name === 'name' ? (
(() => { (() => {
const depth = mailboxDepths.get(item.id as string) ?? 0; const depth = mailboxDepths.get(item.id as string) ?? 0;
+3 -2
View File
@@ -14,7 +14,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver'; import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
import { useObjectList, useObjectLabel } from '@/lib/objectOptions'; 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'; import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema';
export interface DynamicViewProps { export interface DynamicViewProps {
@@ -224,7 +225,7 @@ function NumberValue({ value, format }: { value: unknown; format: string }) {
switch (format) { switch (format) {
case 'size': case 'size':
return <span>{formatSize(num)}</span>; return <SizeDisplay bytes={num} />;
case 'duration': case 'duration':
return <span>{formatDuration(num)}</span>; return <span>{formatDuration(num)}</span>;
default: default:
+3 -1
View File
@@ -317,7 +317,9 @@
"showingItems": "Showing {{count}} items", "showingItems": "Showing {{count}} items",
"sort": "Sort", "sort": "Sort",
"unknownError": "Unknown error", "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": { "login": {
"continue": "Continue", "continue": "Continue",
+13
View File
@@ -68,6 +68,15 @@ describe('bytesToHuman', () => {
it('should convert 1099511627776 bytes to 1 TB', () => { it('should convert 1099511627776 bytes to 1 TB', () => {
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: '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', () => { describe('humanToBytes', () => {
@@ -108,6 +117,10 @@ describe('formatSize', () => {
it('should format large values in TB', () => { it('should format large values in TB', () => {
expect(formatSize(1099511627776)).toBe('1 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', () => { describe('msToHuman', () => {
+6 -3
View File
@@ -15,15 +15,18 @@ const SIZE_FACTORS: Record<string, number> = {
}; };
export function bytesToHuman(bytes: number): { value: number; unit: string } { 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--) { for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
const unit = SIZE_UNITS[i]; const unit = SIZE_UNITS[i];
const factor = SIZE_FACTORS[unit]; const factor = SIZE_FACTORS[unit];
const v = bytes / factor; const v = abs / factor;
if (v >= 1) { if (v >= 1) {
const rounded = Math.round(v * 100) / 100; const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit }; return { value: sign * rounded, unit };
} }
} }