feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantine/core";
|
||||
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useMantineColorScheme } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -10,12 +10,15 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { filterStorageVolumes } from "../filter-storage-volumes";
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
|
||||
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
||||
|
||||
const getDisplayText = (item: { used: string; available: string; percentage: number }, displayMode: DisplayMode) => {
|
||||
export const getDisplayText = (
|
||||
item: { used: string; available: string; percentage: number },
|
||||
displayMode: DisplayMode,
|
||||
) => {
|
||||
switch (displayMode) {
|
||||
case "percentage":
|
||||
return `${Math.round(item.percentage)}%`;
|
||||
@@ -35,6 +38,16 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
|
||||
}
|
||||
};
|
||||
|
||||
export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
|
||||
|
||||
const getAbsoluteText = (item: { used: string; available: string }) => {
|
||||
const usedInBytes = Number(item.used);
|
||||
const availableInBytes = Number(item.available);
|
||||
if (!Number.isFinite(usedInBytes) || !Number.isFinite(availableInBytes)) return `${item.used} / ${item.available}`;
|
||||
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
|
||||
return `${used} / ${available}`;
|
||||
};
|
||||
|
||||
interface SystemDiskCardProps {
|
||||
deviceName: string;
|
||||
percentage: number;
|
||||
@@ -42,6 +55,9 @@ interface SystemDiskCardProps {
|
||||
temperature: number | null | undefined;
|
||||
healthy: boolean;
|
||||
showBackgroundBar: boolean;
|
||||
integrationName?: string;
|
||||
secondaryText?: string;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
const SystemDiskCard = ({
|
||||
@@ -51,6 +67,9 @@ const SystemDiskCard = ({
|
||||
temperature,
|
||||
healthy,
|
||||
showBackgroundBar,
|
||||
integrationName,
|
||||
secondaryText,
|
||||
isAdvanced,
|
||||
}: SystemDiskCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const scheme = useMantineColorScheme();
|
||||
@@ -80,27 +99,39 @@ const SystemDiskCard = ({
|
||||
return (
|
||||
<Tooltip
|
||||
label={healthy ? displayText : `${displayText} (${unhealthyLabel})`}
|
||||
disabled={valueFits}
|
||||
disabled={valueFits || isAdvanced}
|
||||
position="top"
|
||||
withinPortal
|
||||
>
|
||||
<Card
|
||||
radius={board.itemRadius}
|
||||
py={"xs"}
|
||||
py="xs"
|
||||
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
|
||||
style={{ overflow: "hidden", position: "relative" }}
|
||||
>
|
||||
<Group justify="space-between" style={{ zIndex: 1 }}>
|
||||
<div>
|
||||
<p style={{ margin: 0 }}>
|
||||
<b>{deviceName}</b>
|
||||
</p>
|
||||
<Text fw={700} size={isAdvanced ? "sm" : undefined} truncate="end">
|
||||
{deviceName}
|
||||
</Text>
|
||||
{integrationName && (
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<p ref={valueRef} style={{ margin: 0, visibility: valueFits ? "visible" : "hidden" }}>
|
||||
<span>{displayText}</span>
|
||||
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
|
||||
</p>
|
||||
{isAdvanced && secondaryText && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{secondaryText}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{temperature !== null && temperature !== undefined ? <p style={{ margin: 0 }}>{temperature}°C</p> : null}
|
||||
</div>
|
||||
<div>{temperature ? <p style={{ margin: 0 }}>{temperature}°C</p> : null}</div>
|
||||
</Group>
|
||||
<Box
|
||||
bg={healthy ? "green" : "red"}
|
||||
@@ -108,7 +139,7 @@ const SystemDiskCard = ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${percentage}%`,
|
||||
width: `${clampPercentage(percentage)}%`,
|
||||
height: "100%",
|
||||
zIndex: 0,
|
||||
display: showBackgroundBar ? "block" : "none",
|
||||
@@ -119,41 +150,58 @@ const SystemDiskCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
|
||||
export default function SystemResources({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
displayMode: surfaceMode,
|
||||
}: WidgetComponentProps<"systemDisks">) {
|
||||
const queryInput = { integrationIds };
|
||||
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
|
||||
|
||||
const lastItem = data.at(-1);
|
||||
if (data.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
if (!lastItem) return <WidgetEmptyState />;
|
||||
const disks = data.flatMap((entry) => {
|
||||
const fileSystem = filterStorageVolumes(
|
||||
entry.healthInfo.fileSystem,
|
||||
options.visibleStorageVolumes,
|
||||
entry.integrationId,
|
||||
);
|
||||
const smart = filterStorageVolumes(entry.healthInfo.smart, options.visibleStorageVolumes, entry.integrationId);
|
||||
return fileSystem.map((item) => ({
|
||||
integrationId: entry.integrationId,
|
||||
integrationName: entry.integrationName,
|
||||
item,
|
||||
smartItem: smart.find(
|
||||
(candidate) => normalizeStorageDeviceName(candidate.deviceName) === normalizeStorageDeviceName(item.deviceName),
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
const rawFileSystem = lastItem.healthInfo.fileSystem;
|
||||
if (rawFileSystem.length === 0) {
|
||||
throw new NoIntegrationDataError();
|
||||
}
|
||||
if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError();
|
||||
if (disks.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
|
||||
const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
|
||||
|
||||
if (fileSystem.length === 0) return <WidgetEmptyState />;
|
||||
const isAdvanced = surfaceMode === "advanced";
|
||||
const columns = isAdvanced ? Math.max(1, Math.min(disks.length, Math.floor(width / 320))) : 1;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" p="xs" h="100%">
|
||||
{fileSystem.map((item) => {
|
||||
const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={columns} spacing="xs" p="xs">
|
||||
{disks.map(({ integrationId, integrationName, item, smartItem }) => (
|
||||
<SystemDiskCard
|
||||
key={`disk-${item.deviceName}`}
|
||||
key={`${integrationId}:${item.deviceName}`}
|
||||
deviceName={item.deviceName}
|
||||
percentage={item.percentage}
|
||||
displayText={getDisplayText(item, options.displayMode)}
|
||||
temperature={options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
|
||||
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
|
||||
showBackgroundBar={options.showBackgroundBar}
|
||||
integrationName={isAdvanced || data.length > 1 ? integrationName : undefined}
|
||||
secondaryText={getAbsoluteText(item)}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { clampPercentage, getDisplayText } from "./component";
|
||||
|
||||
describe("system disk display", () => {
|
||||
test("clamps malformed utilization before sizing the background", () => {
|
||||
expect(clampPercentage(-4)).toBe(0);
|
||||
expect(clampPercentage(108)).toBe(100);
|
||||
});
|
||||
|
||||
test("falls back to provider values when sizes are already formatted", () => {
|
||||
expect(getDisplayText({ used: "5 GiB", available: "10 GiB", percentage: 50 }, "absolute")).toBe("5 GiB / 10 GiB");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user