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 { 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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user