feat(accounts): highlight negative disk usage with recalculate hint
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <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} />;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>, schema: Schema): React.Re
|
||||
return type;
|
||||
}
|
||||
|
||||
function formatQuotaUsage(item: Record<string, unknown>, t: TFn): string {
|
||||
const used = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
|
||||
function renderQuotaUsage(item: Record<string, unknown>, 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<string, unknown> | 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 (
|
||||
<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> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <span>{formatSize(num)}</span>;
|
||||
return <SizeDisplay bytes={num} />;
|
||||
case 'duration':
|
||||
return <span>{formatDuration(num)}</span>;
|
||||
default:
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -15,15 +15,18 @@ const SIZE_FACTORS: Record<string, number> = {
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user