OMV reports filesystem devices (sda1) without the /dev/ prefix, so the partition-suffix normalization never applied and filesystem-to-SMART matching returned no temperature. Strip partition suffixes from unprefixed sd/vd, nvme and mmcblk names, and use the shared normalized matcher in both the health monitoring and system disks widgets.
160 lines
5.6 KiB
TypeScript
160 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantine/core";
|
|
|
|
import { clientApi } from "@homarr/api/client";
|
|
import { useRequiredBoard } from "@homarr/boards/context";
|
|
import { formatBytesPair } from "@homarr/common";
|
|
import { useI18n } from "@homarr/translation/client";
|
|
|
|
import { WidgetEmptyState } from "../common/empty-state";
|
|
import type { WidgetComponentProps } from "../definition";
|
|
import { filterStorageVolumes, storageDeviceNamesMatch } 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) => {
|
|
switch (displayMode) {
|
|
case "percentage":
|
|
return `${Math.round(item.percentage)}%`;
|
|
case "absolute": {
|
|
const usedInBytes = Number(item.used);
|
|
const availableInBytes = Number(item.available);
|
|
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`;
|
|
default:
|
|
return `${Math.round(item.percentage)}%`;
|
|
}
|
|
};
|
|
|
|
interface SystemDiskCardProps {
|
|
deviceName: string;
|
|
percentage: number;
|
|
displayText: string;
|
|
temperature: number | null | undefined;
|
|
healthy: boolean;
|
|
showBackgroundBar: boolean;
|
|
}
|
|
|
|
const SystemDiskCard = ({
|
|
deviceName,
|
|
percentage,
|
|
displayText,
|
|
temperature,
|
|
healthy,
|
|
showBackgroundBar,
|
|
}: SystemDiskCardProps) => {
|
|
const board = useRequiredBoard();
|
|
const scheme = useMantineColorScheme();
|
|
const t = useI18n();
|
|
const valueRef = useRef<HTMLParagraphElement>(null);
|
|
const [valueFits, setValueFits] = useState(true);
|
|
|
|
// When the card is squeezed (small widget), the value line is clipped by the card's `overflow: hidden`.
|
|
// Detect that and hide it, surfacing the value in a tooltip instead. The value keeps its layout space
|
|
// (visibility, not display) so toggling it does not change the measurement and flap. The ref lives on
|
|
// the value (not the Card) so the Tooltip can attach its own ref to the Card.
|
|
useEffect(() => {
|
|
const value = valueRef.current;
|
|
const card = value?.offsetParent as HTMLElement | null; // the position:relative Card
|
|
if (!value || !card) return;
|
|
const measure = () => {
|
|
setValueFits(value.getBoundingClientRect().bottom <= card.getBoundingClientRect().bottom + 1);
|
|
};
|
|
measure();
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(card);
|
|
return () => observer.disconnect();
|
|
}, [displayText, healthy]);
|
|
|
|
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
|
|
|
|
return (
|
|
<Tooltip
|
|
label={healthy ? displayText : `${displayText} (${unhealthyLabel})`}
|
|
disabled={valueFits}
|
|
position="top"
|
|
withinPortal
|
|
>
|
|
<Card
|
|
radius={board.itemRadius}
|
|
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>
|
|
<p ref={valueRef} style={{ margin: 0, visibility: valueFits ? "visible" : "hidden" }}>
|
|
<span>{displayText}</span>
|
|
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
|
|
</p>
|
|
</div>
|
|
<div>{temperature ? <p style={{ margin: 0 }}>{temperature}°C</p> : null}</div>
|
|
</Group>
|
|
<Box
|
|
bg={healthy ? "green" : "red"}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: `${percentage}%`,
|
|
height: "100%",
|
|
zIndex: 0,
|
|
display: showBackgroundBar ? "block" : "none",
|
|
}}
|
|
></Box>
|
|
</Card>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) {
|
|
const queryInput = { integrationIds };
|
|
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
|
|
|
|
const lastItem = data.at(-1);
|
|
|
|
if (!lastItem) return <WidgetEmptyState />;
|
|
|
|
const rawFileSystem = lastItem.healthInfo.fileSystem;
|
|
if (rawFileSystem.length === 0) {
|
|
throw new NoIntegrationDataError();
|
|
}
|
|
|
|
const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
|
|
const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
|
|
|
|
if (fileSystem.length === 0) return <WidgetEmptyState />;
|
|
|
|
return (
|
|
<Stack gap="xs" p="xs" h="100%">
|
|
{fileSystem.map((item) => {
|
|
const smartItem = smart.find((smart) => storageDeviceNamesMatch(smart.deviceName, item.deviceName));
|
|
|
|
return (
|
|
<SystemDiskCard
|
|
key={`disk-${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}
|
|
/>
|
|
);
|
|
})}
|
|
</Stack>
|
|
);
|
|
}
|