fix(system-disks): format absolute storage values with shared byte helper (#6299)
This commit is contained in:
1 parent
7d859c1af0
commit
8e8c29979f
19 files changed
+259
-79
No files matched your search
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||
{humanFileSize(file.size)}
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
|
||||
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { createLocalImageUrl } from "@homarr/icons/local";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||
<TableTd>{formatBytes(media.size)}</TableTd>
|
||||
<TableTd>
|
||||
{media.creator ? (
|
||||
<Group gap="sm">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
|
||||
return (
|
||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||
{humanFileSize(bytesUsage)}
|
||||
{formatBytes(bytesUsage)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of bytes to si format. (Division by 1024)
|
||||
* Does not accept floats, size in bytes should be an integer.
|
||||
* Will return "NaI" and logs a warning if a float is passed.
|
||||
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
|
||||
* Returns "∞" if the size is too large to be represented in the current format.
|
||||
* Number of bytes to a human-readable string using binary (KiB) suffixes.
|
||||
* Does not accept floats; size in bytes should be an integer.
|
||||
* Will return "NaI" and log a warning if a float is passed.
|
||||
* `concat` is appended after the unit so it is omitted when the returned
|
||||
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
|
||||
* represented in the current format.
|
||||
*
|
||||
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
|
||||
* paired "used / total" displays, and `formatByteRate` for byte-per-second
|
||||
* rates. This function is kept for backwards compatibility but should not
|
||||
* be used in new code.
|
||||
*/
|
||||
export const humanFileSize = (size: number, concat = ""): string => {
|
||||
//64bit limit for Number stops at EiB
|
||||
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
|
||||
return "∞";
|
||||
};
|
||||
|
||||
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
|
||||
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
|
||||
|
||||
export type ByteUnitSystem = "binary" | "decimal";
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
/**
|
||||
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
|
||||
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
|
||||
*/
|
||||
unit?: ByteUnitSystem;
|
||||
}
|
||||
|
||||
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
||||
let index = 0;
|
||||
let value = bytes;
|
||||
while (value >= base && index < lastIndex) {
|
||||
value /= base;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
|
||||
|
||||
const resolveUnitConfig = (options: FormatBytesOptions) => {
|
||||
const { unit = "binary" } = options;
|
||||
return {
|
||||
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
|
||||
base: unit === "binary" ? 1024 : 1000,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a byte value as a human-readable string with a unit suffix.
|
||||
*
|
||||
* Picks the largest unit that keeps the scaled value below the base. Use
|
||||
* `formatBytesPair` when two related values (e.g. used and available) should
|
||||
* share a common unit.
|
||||
*
|
||||
* @example
|
||||
* formatBytes(0); // "0.0 B"
|
||||
* formatBytes(1024); // "1.0 KiB"
|
||||
* formatBytes(985828802560); // "918.1 GiB"
|
||||
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
|
||||
*/
|
||||
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
|
||||
const { units, base } = resolveUnitConfig(options);
|
||||
const safe = sanitizeBytes(bytes);
|
||||
const index = pickUnitIndex(safe, base, units.length - 1);
|
||||
const scaled = safe / base ** index;
|
||||
return `${scaled.toFixed(1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format two related byte values (typically `used` and `available`) with a
|
||||
* shared unit. The unit is picked from the sum of the two values so the
|
||||
* combined display stays consistent.
|
||||
*
|
||||
* @example
|
||||
* formatBytesPair(985828802560, 2858736793190);
|
||||
* // { used: "0.9 TiB", available: "2.6 TiB" }
|
||||
*/
|
||||
export const formatBytesPair = (
|
||||
used: number,
|
||||
available: number,
|
||||
options: FormatBytesOptions = {},
|
||||
): { used: string; available: string } => {
|
||||
const { units, base } = resolveUnitConfig(options);
|
||||
const safeUsed = sanitizeBytes(used);
|
||||
const safeAvailable = sanitizeBytes(available);
|
||||
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
|
||||
const suffix = units[index];
|
||||
return {
|
||||
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
|
||||
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
|
||||
* string with a `/s` suffix. The value is formatted with the same rules as
|
||||
* `formatBytes` and a trailing `/s` is appended.
|
||||
*
|
||||
* @example
|
||||
* formatByteRate(0); // "0.0 B/s"
|
||||
* formatByteRate(1024); // "1.0 KiB/s"
|
||||
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
|
||||
*/
|
||||
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
|
||||
`${formatBytes(bytes, options)}/s`;
|
||||
|
||||
const IMPERIAL_MULTIPLIER = 1.609344;
|
||||
|
||||
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("returns bytes for small values", () => {
|
||||
expect(formatBytes(0)).toBe("0.0 B");
|
||||
expect(formatBytes(1)).toBe("1.0 B");
|
||||
expect(formatBytes(1023)).toBe("1023.0 B");
|
||||
});
|
||||
|
||||
it("scales binary units correctly", () => {
|
||||
expect(formatBytes(1024)).toBe("1.0 KiB");
|
||||
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
|
||||
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
|
||||
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
|
||||
});
|
||||
|
||||
it("scales decimal units when requested", () => {
|
||||
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
|
||||
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
|
||||
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
|
||||
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
|
||||
});
|
||||
|
||||
it("formats a typical storage value with one decimal", () => {
|
||||
expect(formatBytes(985828802560)).toBe("918.1 GiB");
|
||||
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
|
||||
});
|
||||
|
||||
it("caps at the largest unit to avoid overflow", () => {
|
||||
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
|
||||
});
|
||||
|
||||
it("returns the zero value for negative or non-finite input", () => {
|
||||
expect(formatBytes(-1)).toBe("0.0 B");
|
||||
expect(formatBytes(Number.NaN)).toBe("0.0 B");
|
||||
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesPair", () => {
|
||||
it("picks the unit of the larger total and applies it to both values", () => {
|
||||
const { used, available } = formatBytesPair(985828802560, 2858736793190);
|
||||
expect(used).toBe("0.9 TiB");
|
||||
expect(available).toBe("2.6 TiB");
|
||||
});
|
||||
|
||||
it("uses a smaller unit when both values fit in it", () => {
|
||||
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
|
||||
expect(used).toBe("100.0 MiB");
|
||||
expect(available).toBe("200.0 MiB");
|
||||
});
|
||||
|
||||
it("falls back to zero when both inputs are invalid", () => {
|
||||
const { used, available } = formatBytesPair(Number.NaN, -1);
|
||||
expect(used).toBe("0.0 B");
|
||||
expect(available).toBe("0.0 B");
|
||||
});
|
||||
|
||||
it("uses the same unit across both values when one is invalid", () => {
|
||||
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
|
||||
expect(used).toBe("0.0 TiB");
|
||||
expect(available).toBe("1.0 TiB");
|
||||
});
|
||||
|
||||
it("honours the decimal unit option", () => {
|
||||
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
|
||||
expect(used).toBe("0.5 TB");
|
||||
expect(available).toBe("1.5 TB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatByteRate", () => {
|
||||
it("appends a /s suffix to the formatted value", () => {
|
||||
expect(formatByteRate(0)).toBe("0.0 B/s");
|
||||
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
|
||||
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
|
||||
});
|
||||
|
||||
it("honours the decimal unit option", () => {
|
||||
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
|
||||
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
|
||||
});
|
||||
|
||||
it("returns zero for invalid input", () => {
|
||||
expect(formatByteRate(-1)).toBe("0.0 B/s");
|
||||
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
|
||||
import "@homarr/redis";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
||||
.map((storage, index) => ({
|
||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
used: humanFileSize(storageLoad[index]!),
|
||||
used: `${storageLoad[index]!}`,
|
||||
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
|
||||
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
||||
})),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { fetch as undiciFetch } from "undici/types/fetch";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -59,7 +58,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
||||
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
|
||||
fileSystem: systemInfo.array.disks.map((disk) => ({
|
||||
deviceName: disk.name,
|
||||
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
|
||||
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
|
||||
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
|
||||
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
|
||||
})),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
|
||||
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||
@@ -9,7 +9,7 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -110,7 +110,7 @@ const createColumns = (
|
||||
|
||||
return (
|
||||
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||
{humanFileSize(bytesUsage)}
|
||||
{formatBytes(bytesUsage)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
@@ -297,7 +297,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
||||
</Text>
|
||||
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
|
||||
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
|
||||
@@ -43,7 +43,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { humanFileSize, useIntegrationConnected } from "@homarr/common";
|
||||
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -386,7 +386,7 @@ export default function DownloadClientsWidget({
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
|
||||
return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null;
|
||||
return downSpeed ? <Text size="xs">{formatByteRate(downSpeed)}</Text> : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -466,7 +466,7 @@ export default function DownloadClientsWidget({
|
||||
...columnsDefBase({ key: "received", showHeader: true }),
|
||||
Cell: ({ cell }) => {
|
||||
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
|
||||
return <Text size="xs">{humanFileSize(received)}</Text>;
|
||||
return <Text size="xs">{formatBytes(received)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -474,14 +474,14 @@ export default function DownloadClientsWidget({
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
|
||||
return sent && <Text size="xs">{humanFileSize(sent)}</Text>;
|
||||
return sent && <Text size="xs">{formatBytes(sent)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "size", showHeader: true }),
|
||||
Cell: ({ cell }) => {
|
||||
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
|
||||
return <Text size="xs">{humanFileSize(size)}</Text>;
|
||||
return <Text size="xs">{formatBytes(size)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -521,7 +521,7 @@ export default function DownloadClientsWidget({
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
|
||||
return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>;
|
||||
return upSpeed && <Text size="xs">{formatByteRate(upSpeed)}</Text>;
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -669,21 +669,21 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
|
||||
{item.type !== "miscellaneous" && (
|
||||
<NormalizedLine
|
||||
itemKey="upSpeed"
|
||||
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
|
||||
values={item.upSpeed === undefined ? undefined : formatByteRate(item.upSpeed)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NormalizedLine
|
||||
itemKey="downSpeed"
|
||||
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
|
||||
values={item.downSpeed === undefined ? undefined : formatByteRate(item.downSpeed)}
|
||||
/>
|
||||
|
||||
{item.type !== "miscellaneous" && (
|
||||
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
|
||||
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : formatBytes(item.sent)} />
|
||||
)}
|
||||
|
||||
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
|
||||
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
|
||||
<NormalizedLine itemKey="received" values={formatBytes(item.received)} />
|
||||
<NormalizedLine itemKey="size" values={formatBytes(item.size)} />
|
||||
<NormalizedLine
|
||||
itemKey="progress"
|
||||
values={new Intl.NumberFormat("en", {
|
||||
@@ -744,10 +744,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
|
||||
{ paused: [] as string[], active: [] as string[] },
|
||||
);
|
||||
const someInteract = clients.some(({ interact }) => interact);
|
||||
const totalSpeed = humanFileSize(
|
||||
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
|
||||
"/s",
|
||||
);
|
||||
const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0));
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
|
||||
@@ -849,21 +846,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
|
||||
{client.status.rates.up !== undefined ? (
|
||||
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
|
||||
<Text flex={1} ta="right">
|
||||
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
|
||||
{`↑ ${formatByteRate(client.status.rates.up)}`}
|
||||
</Text>
|
||||
<Text>{"-"}</Text>
|
||||
<Text flex={1} ta="left">
|
||||
{humanFileSize(client.status.totalUp ?? 0)}
|
||||
{formatBytes(client.status.totalUp ?? 0)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : undefined}
|
||||
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
|
||||
<Text flex={1} ta="right">
|
||||
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
|
||||
{`↓ ${formatByteRate(client.status.rates.down)}`}
|
||||
</Text>
|
||||
<Text>{"-"}</Text>
|
||||
<Text flex={1} ta="left">
|
||||
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
|
||||
{formatBytes(Math.floor(client.status.totalDown ?? 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
import { capitalize, humanFileSize } from "@homarr/common";
|
||||
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
|
||||
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -93,16 +93,18 @@ const RightSection = ({ label, value }: RightSectionProps) => {
|
||||
|
||||
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||
const memory = formatBytesPair(item.memory.used, item.memory.total);
|
||||
const storage = formatBytesPair(item.storage.used, item.storage.total);
|
||||
return (
|
||||
<List>
|
||||
<List.Item icon={<IconCpu size={16} />}>
|
||||
{t("cpu")} - {item.cpu.cores}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconBrain size={16} />}>
|
||||
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
|
||||
{t("memory")} - {memory.used} / {memory.available}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
|
||||
{t("storage")} - {storage.used} / {storage.available}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconClockHour3 size={16} />}>
|
||||
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
|
||||
@@ -121,6 +123,7 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
||||
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
|
||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
|
||||
const storage = formatBytesPair(item.used, item.total);
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Center>
|
||||
@@ -133,7 +136,7 @@ const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
|
||||
/>
|
||||
<Group align="center" gap={0}>
|
||||
<Text>
|
||||
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
|
||||
{t("storage")} - {storage.used} / {storage.available}
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
@@ -152,11 +155,11 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.storage.write)}</Text>
|
||||
<Text>{formatBytes(item.storage.write)}</Text>
|
||||
<IconArrowNarrowDown size={14} />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.storage.read)}</Text>
|
||||
<Text>{formatBytes(item.storage.read)}</Text>
|
||||
<IconArrowNarrowUp size={14} />
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -172,11 +175,11 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
|
||||
<List.Item icon={<IconNetwork size={16} />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.network.in)}</Text>
|
||||
<Text>{formatBytes(item.network.in)}</Text>
|
||||
<IconArrowNarrowDown size={14} />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.network.out)}</Text>
|
||||
<Text>{formatBytes(item.network.out)}</Text>
|
||||
<IconArrowNarrowUp size={14} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -32,7 +32,7 @@ import duration from "dayjs/plugin/duration";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -277,7 +277,7 @@ export const progressColor = (percentage: number) => {
|
||||
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
|
||||
const formatFileSize = (value: string) => {
|
||||
const bytes = Number(value);
|
||||
return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value;
|
||||
return Number.isFinite(bytes) ? formatBytes(Math.round(bytes)) : value;
|
||||
};
|
||||
|
||||
interface FileSystem {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
@@ -46,7 +46,7 @@ export default function ImmichServerStatsWidget({
|
||||
<StatItem
|
||||
icon={<IconDatabase size={20} />}
|
||||
label={t("widget.immich-serverStats.storage")}
|
||||
value={humanFileSize(stats.totalLibraryUsageInBytes)}
|
||||
value={formatBytes(stats.totalLibraryUsageInBytes)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { TdarrQueue } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -72,7 +72,7 @@ export function QueuePanel(props: QueuePanelProps) {
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd py={2}>
|
||||
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
|
||||
<Text size="xs">{formatBytes(item.fileSize)}</Text>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
|
||||
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
@@ -41,7 +41,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
<StatisticItem
|
||||
icon={IconDatabaseHeart}
|
||||
label={t("savedSpace")}
|
||||
value={humanFileSize(Math.floor(allLibs.totalSavedSpace))}
|
||||
value={formatBytes(Math.floor(allLibs.totalSavedSpace))}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="center" wrap="wrap" grow>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantin
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytesPair } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
@@ -20,11 +20,13 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
|
||||
case "percentage":
|
||||
return `${Math.round(item.percentage)}%`;
|
||||
case "absolute": {
|
||||
const usedInBytes = Number(item.used);
|
||||
const availableInBytes = Number(item.available);
|
||||
const availableText = Number.isFinite(availableInBytes)
|
||||
? humanFileSize(Math.round(availableInBytes))
|
||||
: item.available;
|
||||
return `${item.used} / ${availableText}`;
|
||||
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
|
||||
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
|
||||
return `${used} / ${available}`;
|
||||
}
|
||||
return `${item.used} / ${item.available}`;
|
||||
}
|
||||
case "free":
|
||||
return `${Math.round(100 - item.percentage)}% free`;
|
||||
@@ -130,16 +132,8 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
||||
throw new NoIntegrationDataError();
|
||||
}
|
||||
|
||||
const fileSystem = filterStorageVolumes(
|
||||
rawFileSystem,
|
||||
options.visibleStorageVolumes,
|
||||
lastItem.integrationId,
|
||||
);
|
||||
const smart = filterStorageVolumes(
|
||||
lastItem.healthInfo.smart,
|
||||
options.visibleStorageVolumes,
|
||||
lastItem.integrationId,
|
||||
);
|
||||
const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
|
||||
const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
|
||||
|
||||
if (fileSystem.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { LabelDisplayModeOption } from "..";
|
||||
@@ -47,7 +47,7 @@ export const CombinedNetworkTrafficChart = ({
|
||||
{payloadData.value === undefined ? (
|
||||
<>N/A</>
|
||||
) : (
|
||||
<>{humanFileSize(Math.round(Number(payloadData.value)))}/s</>
|
||||
formatByteRate(Math.round(Number(payloadData.value)))
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconBrain } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytesPair } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { LabelDisplayModeOption } from "..";
|
||||
@@ -41,11 +41,11 @@ export const SystemResourceMemoryChart = ({
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} (
|
||||
{Math.round((value / totalCapacityInBytes) * 100)}%)
|
||||
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { LabelDisplayModeOption } from "..";
|
||||
@@ -32,7 +32,7 @@ export const NetworkTrafficChart = ({
|
||||
title={isUp ? t("up") : t("down")}
|
||||
icon={isUp ? IconArrowUp : IconArrowDown}
|
||||
yAxisProps={{ domain: [0, upperBound] }}
|
||||
lastValue={`${humanFileSize(Math.round(max))}/s`}
|
||||
lastValue={formatByteRate(Math.round(max))}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
tooltipProps={{
|
||||
@@ -41,7 +41,7 @@ export const NetworkTrafficChart = ({
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{humanFileSize(Math.round(value))}/s
|
||||
{formatByteRate(Math.round(value))}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user