feat: improve admin panel (#14180)

This commit is contained in:
DarkSky
2025-12-30 05:22:54 +08:00
committed by GitHub
parent d6b380aee5
commit 95a5e941e7
94 changed files with 3146 additions and 1114 deletions
@@ -0,0 +1,172 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { AccountIcon, LinkIcon } from '@blocksuite/icons/rc';
import type { ColumnDef } from '@tanstack/react-table';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useMemo } from 'react';
import type { WorkspaceListItem } from '../schema';
import { formatBytes } from '../utils';
import { DataTableRowActions } from './data-table-row-actions';
export const useColumns = () => {
const columns: ColumnDef<WorkspaceListItem>[] = useMemo(() => {
return [
{
accessorKey: 'workspace',
header: () => <div className="text-xs font-medium">Workspace</div>,
cell: ({ row }) => {
const workspace = row.original;
return (
<div className="flex flex-col gap-1 max-w-[40vw] min-w-0 overflow-hidden">
<div className="flex items-center gap-2 text-sm font-medium overflow-hidden">
<span className="truncate">
{workspace.name || workspace.id}
</span>
{workspace.public ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
<LinkIcon fontSize={14} />
Public
</span>
) : null}
</div>
<div
className="text-xs font-mono truncate w-full"
style={{ color: cssVarV2('text/secondary') }}
>
{workspace.id}
</div>
<div className="flex flex-wrap gap-2 text-[11px]">
{workspace.features.length ? (
workspace.features.map(feature => (
<span
key={feature}
className="px-2 py-0.5 rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
{feature}
</span>
))
) : (
<span style={{ color: cssVarV2('text/secondary') }}>
No features
</span>
)}
</div>
</div>
);
},
},
{
accessorKey: 'owner',
header: () => <div className="text-xs font-medium">Owner</div>,
cell: ({ row }) => {
const owner = row.original.owner;
if (!owner) {
return (
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Unknown
</div>
);
}
return (
<div className="flex items-center gap-3 min-w-[180px] min-w-0">
<Avatar className="w-9 h-9">
<AvatarImage src={owner.avatarUrl ?? undefined} />
<AvatarFallback>
<AccountIcon fontSize={16} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col overflow-hidden min-w-0">
<div className="text-sm font-medium truncate">{owner.name}</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
{owner.email}
</div>
</div>
</div>
);
},
},
{
accessorKey: 'usage',
header: () => <div className="text-xs font-medium">Usage</div>,
cell: ({ row }) => {
const ws = row.original;
return (
<div className="flex flex-col gap-1 text-xs">
<div className="flex gap-3">
<span>Snapshot {formatBytes(ws.snapshotSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
({ws.snapshotCount})
</span>
</div>
<div className="flex gap-3">
<span>Blobs {formatBytes(ws.blobSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
({ws.blobCount})
</span>
</div>
</div>
);
},
},
{
accessorKey: 'members',
header: () => <div className="text-xs font-medium">Members</div>,
cell: ({ row }) => {
const ws = row.original;
return (
<div className="flex flex-col text-xs gap-1">
<div className="flex gap-2">
<span className="font-medium">{ws.memberCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
members
</span>
</div>
<div className="flex gap-2">
<span className="font-medium">{ws.publicPageCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
shared pages
</span>
</div>
</div>
);
},
},
{
id: 'actions',
meta: {
className: 'w-[80px] justify-end',
},
header: () => (
<div className="text-xs font-medium text-right">Actions</div>
),
cell: ({ row }) => (
<div className="flex justify-end w-full">
<DataTableRowActions workspace={row.original} />
</div>
),
},
];
}, []);
return columns;
};
@@ -0,0 +1,76 @@
import { Button } from '@affine/admin/components/ui/button';
import { EditIcon } from '@blocksuite/icons/rc';
import { useCallback, useState } from 'react';
import { DiscardChanges } from '../../../components/shared/discard-changes';
import { useRightPanel } from '../../panel/context';
import type { WorkspaceListItem } from '../schema';
import { WorkspacePanel } from './workspace-panel';
export function DataTableRowActions({
workspace,
}: {
workspace: WorkspaceListItem;
}) {
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const {
setPanelContent,
openPanel,
isOpen,
closePanel,
hasDirtyChanges,
setHasDirtyChanges,
} = useRightPanel();
const handleConfirm = useCallback(() => {
setHasDirtyChanges(false);
setPanelContent(
<WorkspacePanel workspaceId={workspace.id} onClose={closePanel} />
);
if (!isOpen) {
openPanel();
}
}, [
closePanel,
isOpen,
openPanel,
setHasDirtyChanges,
setPanelContent,
workspace.id,
]);
const handleEdit = useCallback(() => {
if (hasDirtyChanges) {
setDiscardDialogOpen(true);
return;
}
handleConfirm();
}, [handleConfirm, hasDirtyChanges]);
const handleDiscardConfirm = useCallback(() => {
setDiscardDialogOpen(false);
setHasDirtyChanges(false);
handleConfirm();
}, [handleConfirm, setHasDirtyChanges]);
return (
<>
<Button
variant="ghost"
size="sm"
className="px-2 h-8 flex items-center gap-2"
onClick={handleEdit}
>
<EditIcon fontSize={18} />
<span>Edit</span>
</Button>
<DiscardChanges
open={discardDialogOpen}
onOpenChange={setDiscardDialogOpen}
onClose={() => setDiscardDialogOpen(false)}
onConfirm={handleDiscardConfirm}
description="Changes to this workspace will not be saved."
/>
</>
);
}
@@ -0,0 +1,121 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import type { Table } from '@tanstack/react-table';
import {
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../../components/ui/popover';
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
import { useServerConfig } from '../../common';
interface DataTableToolbarProps<TData> {
table?: Table<TData>;
keyword: string;
onKeywordChange: (keyword: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
}
const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' },
{ value: AdminWorkspaceSort.BlobCount, label: 'Blob count' },
{ value: AdminWorkspaceSort.BlobSize, label: 'Blob size' },
{ value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
];
export function DataTableToolbar<TData>({
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
sort,
onSortChange,
}: DataTableToolbarProps<TData>) {
const [value, setValue] = useState(keyword);
const debouncedValue = useDebouncedValue(value, 400);
const serverConfig = useServerConfig();
const availableFeatures = serverConfig.availableWorkspaceFeatures ?? [];
useEffect(() => {
setValue(keyword);
}, [keyword]);
useEffect(() => {
onKeywordChange(debouncedValue.trim());
}, [debouncedValue, onKeywordChange]);
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
}, []);
const handleSortChange = useCallback(
(value: AdminWorkspaceSort) => {
onSortChange(value);
},
[onSortChange]
);
const selectedSortLabel = useMemo(
() =>
sortOptions.find(option => option.value === sort)?.label ??
'Created time',
[sort]
);
return (
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
<FeatureFilterPopover
selectedFeatures={selectedFeatures}
availableFeatures={availableFeatures}
onChange={onFeaturesChange}
align="start"
/>
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 px-2 lg:px-3">
Sort: {selectedSortLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-2">
<div className="flex flex-col gap-1">
{sortOptions.map(option => (
<Button
key={option.value}
variant="ghost"
className="justify-start"
size="sm"
onClick={() => handleSortChange(option.value)}
>
{option.label}
</Button>
))}
</div>
</PopoverContent>
</Popover>
<div className="flex">
<Input
placeholder="Search Workspace / Owner"
value={value}
onChange={onValueChange}
className="h-8 w-[150px] lg:w-[250px]"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import type { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react';
import { SharedDataTable } from '../../../components/shared/data-table';
import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination: PaginationState;
workspacesCount: number;
keyword: string;
onKeywordChange: (value: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
sort: AdminWorkspaceSort | undefined;
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
onPaginationChange: Dispatch<
SetStateAction<{
pageIndex: number;
pageSize: number;
}>
>;
}
export function DataTable<TData extends { id: string }, TValue>({
columns,
data,
pagination,
workspacesCount,
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
sort,
onSortChange,
onPaginationChange,
}: DataTableProps<TData, TValue>) {
return (
<SharedDataTable
columns={columns}
data={data}
totalCount={workspacesCount}
pagination={pagination}
onPaginationChange={onPaginationChange}
resetFiltersDeps={[keyword, selectedFeatures, sort]}
renderToolbar={table => (
<DataTableToolbar
table={table}
keyword={keyword}
onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange}
sort={sort}
onSortChange={onSortChange}
/>
)}
/>
);
}
@@ -0,0 +1,357 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import {
adminUpdateWorkspaceMutation,
adminWorkspaceQuery,
adminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
import { AccountIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
import { useMutateQueryResource, useMutation } from '../../../use-mutation';
import { useQuery } from '../../../use-query';
import { useServerConfig } from '../../common';
import { RightPanelHeader } from '../../header';
import { useRightPanel } from '../../panel/context';
import type { WorkspaceDetail } from '../schema';
import { formatBytes } from '../utils';
export function WorkspacePanel({
workspaceId,
onClose,
}: {
workspaceId: string;
onClose: () => void;
}) {
const { data } = useQuery({
query: adminWorkspaceQuery,
variables: {
id: workspaceId,
memberSkip: 0,
memberTake: 20,
},
});
const workspace = data.adminWorkspace;
if (!workspace) {
return (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Workspace"
handleClose={onClose}
handleConfirm={onClose}
canSave={false}
/>
<div
className="p-6 text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace not found.
</div>
</div>
);
}
return <WorkspacePanelContent workspace={workspace} onClose={onClose} />;
}
function WorkspacePanelContent({
workspace,
onClose,
}: {
workspace: WorkspaceDetail;
onClose: () => void;
}) {
const serverConfig = useServerConfig();
const { setHasDirtyChanges } = useRightPanel();
const revalidate = useMutateQueryResource();
const { trigger: updateWorkspace, isMutating } = useMutation({
mutation: adminUpdateWorkspaceMutation,
});
const normalizedWorkspace = useMemo(
() => ({
features: [...workspace.features],
flags: {
public: workspace.public,
enableAi: workspace.enableAi,
enableUrlPreview: workspace.enableUrlPreview,
enableDocEmbedding: workspace.enableDocEmbedding,
name: workspace.name ?? '',
},
}),
[workspace]
);
const [featureSelection, setFeatureSelection] = useState<FeatureType[]>(
normalizedWorkspace.features
);
const [flags, setFlags] = useState(normalizedWorkspace.flags);
const [baseline, setBaseline] = useState(normalizedWorkspace);
useEffect(() => {
setFeatureSelection(normalizedWorkspace.features);
setFlags(normalizedWorkspace.flags);
setBaseline(normalizedWorkspace);
}, [normalizedWorkspace]);
const hasChanges = useMemo(() => {
return (
flags.public !== baseline.flags.public ||
flags.enableAi !== baseline.flags.enableAi ||
flags.enableUrlPreview !== baseline.flags.enableUrlPreview ||
flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding ||
flags.name !== baseline.flags.name ||
featureSelection.length !== baseline.features.length ||
featureSelection.some(f => !baseline.features.includes(f))
);
}, [baseline, featureSelection, flags]);
useEffect(() => {
setHasDirtyChanges(hasChanges);
}, [hasChanges, setHasDirtyChanges]);
const handleFeaturesChange = useCallback((features: FeatureType[]) => {
setFeatureSelection(features);
}, []);
const handleSave = useCallback(() => {
const update = async () => {
try {
await updateWorkspace({
input: {
id: workspace.id,
public: flags.public,
enableAi: flags.enableAi,
enableUrlPreview: flags.enableUrlPreview,
enableDocEmbedding: flags.enableDocEmbedding,
name: flags.name || null,
features: featureSelection,
},
});
await Promise.all([
revalidate(adminWorkspacesQuery),
revalidate(adminWorkspaceQuery, vars => vars?.id === workspace.id),
]);
toast.success('Workspace updated successfully');
setBaseline({
flags: { ...flags },
features: [...featureSelection],
});
setHasDirtyChanges(false);
onClose();
} catch (e) {
toast.error(`Failed to update workspace: ${(e as Error).message}`);
}
};
update().catch(() => {});
}, [
featureSelection,
flags,
onClose,
revalidate,
setBaseline,
setHasDirtyChanges,
updateWorkspace,
workspace.id,
]);
const memberList = workspace.members ?? [];
return (
<div className="flex flex-col h-full">
<RightPanelHeader
title="Update Workspace"
handleClose={onClose}
handleConfirm={handleSave}
canSave={hasChanges && !isMutating}
/>
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
<div className="border rounded-md p-3 space-y-2">
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace ID
</div>
<div className="text-sm font-mono break-all">{workspace.id}</div>
<div className="flex flex-col gap-1">
<Label
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Name
</Label>
<Input
value={flags.name}
onChange={e =>
setFlags(prev => ({ ...prev, name: e.target.value }))
}
placeholder="Workspace name"
/>
</div>
</div>
<div className="border rounded-md">
<FlagItem
label="Public"
description="Allow public access to workspace pages"
checked={flags.public}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, public: value }))
}
/>
<Separator />
<FlagItem
label="Enable AI"
description="Allow AI features in this workspace"
checked={flags.enableAi}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableAi: value }))
}
/>
<Separator />
<FlagItem
label="Enable URL Preview"
description="Allow URL previews in shared pages"
checked={flags.enableUrlPreview}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableUrlPreview: value }))
}
/>
<Separator />
<FlagItem
label="Enable Doc Embedding"
description="Allow document embedding for search"
checked={flags.enableDocEmbedding}
onCheckedChange={value =>
setFlags(prev => ({ ...prev, enableDocEmbedding: value }))
}
/>
</div>
<div className="border rounded-md p-3 space-y-3">
<div className="text-sm font-medium">Features</div>
<FeatureToggleList
features={serverConfig.availableWorkspaceFeatures ?? []}
selected={featureSelection}
onChange={handleFeaturesChange}
className="grid grid-cols-1 gap-2"
control="checkbox"
controlPosition="left"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="Snapshot Size"
value={formatBytes(workspace.snapshotSize)}
/>
<MetricCard
label="Snapshot Count"
value={`${workspace.snapshotCount}`}
/>
<MetricCard
label="Blob Size"
value={formatBytes(workspace.blobSize)}
/>
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
<MetricCard label="Members" value={`${workspace.memberCount}`} />
<MetricCard
label="Shared Pages"
value={`${workspace.publicPageCount}`}
/>
</div>
<div className="border rounded-md">
<div className="px-3 py-2 text-sm font-medium">Members</div>
<Separator />
<div className="flex flex-col divide-y">
{memberList.length === 0 ? (
<div
className="px-3 py-3 text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
No members.
</div>
) : (
memberList.map(member => (
<div
key={member.id}
className="flex items-center gap-3 px-3 py-2"
>
<Avatar className="w-9 h-9">
<AvatarImage src={member.avatarUrl ?? undefined} />
<AvatarFallback>
<AccountIcon fontSize={16} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col overflow-hidden">
<div className="text-sm font-medium truncate">
{member.name || member.email}
</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
{member.email}
</div>
</div>
<div className="ml-auto text-xs px-2 py-1 rounded border">
{member.role}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
function FlagItem({
label,
description,
checked,
onCheckedChange,
}: {
label: string;
description: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-2 p-3">
<div className="flex flex-col">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{description}
</div>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-md p-3 flex flex-col gap-1">
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{label}
</div>
<div className="text-sm font-semibold">{value}</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import { useState } from 'react';
import { Header } from '../header';
import { useColumns } from './components/columns';
import { DataTable } from './components/data-table';
import { useWorkspaceList } from './use-workspace-list';
export function WorkspacePage() {
const [keyword, setKeyword] = useState('');
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
const [sort, setSort] = useState<AdminWorkspaceSort | undefined>(
AdminWorkspaceSort.CreatedAt
);
const { workspaces, pagination, setPagination, workspacesCount } =
useWorkspaceList({
keyword,
features: featureFilters,
orderBy: sort,
});
const columns = useColumns();
return (
<div className="h-screen flex-1 flex-col flex">
<Header title="Workspaces" />
<DataTable
data={workspaces}
columns={columns}
pagination={pagination}
workspacesCount={workspacesCount}
onPaginationChange={setPagination}
keyword={keyword}
onKeywordChange={setKeyword}
selectedFeatures={featureFilters}
onFeaturesChange={setFeatureFilters}
sort={sort}
onSortChange={setSort}
/>
</div>
);
}
export { WorkspacePage as Component };
@@ -0,0 +1,17 @@
import type {
AdminUpdateWorkspaceMutation,
AdminWorkspaceQuery,
AdminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
export type WorkspaceListItem = AdminWorkspacesQuery['adminWorkspaces'][0];
export type WorkspaceDetail = NonNullable<
AdminWorkspaceQuery['adminWorkspace']
>;
export type WorkspaceMember = WorkspaceDetail['members'][0];
export type WorkspaceUpdateInput =
AdminUpdateWorkspaceMutation['adminUpdateWorkspace'];
export type WorkspaceFeatureFilter = FeatureType[];
@@ -0,0 +1,80 @@
import { useQuery } from '@affine/admin/use-query';
import {
adminWorkspacesCountQuery,
AdminWorkspaceSort,
adminWorkspacesQuery,
FeatureType,
} from '@affine/graphql';
import { useEffect, useMemo, useState } from 'react';
export const useWorkspaceList = (filter?: {
keyword?: string;
features?: FeatureType[];
orderBy?: AdminWorkspaceSort;
}) => {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const filterKey = useMemo(
() =>
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
.sort()
.join(',')}-${filter?.orderBy ?? ''}`,
[filter?.features, filter?.keyword, filter?.orderBy]
);
useEffect(() => {
setPagination(prev => ({ ...prev, pageIndex: 0 }));
}, [filterKey]);
const variables = useMemo(
() => ({
filter: {
first: pagination.pageSize,
skip: pagination.pageIndex * pagination.pageSize,
keyword: filter?.keyword || undefined,
features:
filter?.features && filter.features.length > 0
? filter.features
: undefined,
orderBy: filter?.orderBy,
},
}),
[
filter?.features,
filter?.keyword,
filter?.orderBy,
pagination.pageIndex,
pagination.pageSize,
]
);
const { data: listData } = useQuery(
{
query: adminWorkspacesQuery,
variables,
},
{
keepPreviousData: true,
}
);
const { data: countData } = useQuery(
{
query: adminWorkspacesCountQuery,
variables,
},
{
keepPreviousData: true,
}
);
return {
workspaces: listData?.adminWorkspaces ?? [],
workspacesCount: countData?.adminWorkspacesCount ?? 0,
pagination,
setPagination,
};
};
@@ -0,0 +1,14 @@
export function formatBytes(bytes: number) {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const digits = value >= 10 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}