feat: refine mobile dashboard experience

This commit is contained in:
ajnart committed 2026-08-13 19:55:11 +02:00
1 parent 2e3dca3e87
commit 84db2f9d49
68 files changed
+1425 -1311

No files matched your search

+43 -103
View File
@@ -9,21 +9,16 @@ import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
export const getDisplayText = (
item: { used: string; available: string; percentage: number },
displayMode: DiskDisplayMode,
translatedFreeText?: string,
displayMode: DisplayMode,
) => {
switch (displayMode) {
case "percentage":
@@ -32,26 +27,27 @@ export const getDisplayText = (
const usedInBytes = Number(item.used);
const availableInBytes = Number(item.available);
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
const { used, available: total } = formatBytesPair(usedInBytes, usedInBytes + availableInBytes);
return `${used} / ${total}`;
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
return `${used} / ${available}`;
}
return `${item.used} / ${item.available}`;
}
case "free":
return translatedFreeText ?? `${Math.round(100 - clampPercentage(item.percentage))}% free`;
return `${Math.round(100 - item.percentage)}% free`;
default:
return `${Math.round(item.percentage)}%`;
}
};
export const getAdvancedDisplayTexts = (
item: { used: string; available: string; percentage: number },
translatedFreeText?: string,
) => ({
percentage: getDisplayText(item, "percentage"),
absolute: getDisplayText(item, "absolute"),
free: getDisplayText(item, "free", translatedFreeText),
});
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;
@@ -62,9 +58,6 @@ interface SystemDiskCardProps {
showBackgroundBar: boolean;
integrationName?: string;
secondaryText?: string;
freeText?: string;
smartStatus?: string;
isAdvanced: boolean;
showSecondaryText: boolean;
showTemperature: boolean;
}
@@ -78,9 +71,6 @@ const SystemDiskCard = ({
showBackgroundBar,
integrationName,
secondaryText,
freeText,
smartStatus,
isAdvanced,
showSecondaryText,
showTemperature,
}: SystemDiskCardProps) => {
@@ -108,9 +98,7 @@ const SystemDiskCard = ({
}, [displayText, healthy]);
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
const hasTemperature = temperature !== null && temperature !== undefined;
const temperatureText = hasTemperature ? `${temperature}°C` : "—°C";
const hasHiddenTemperature = hasTemperature && !showTemperature;
const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText);
const tooltipLabel = [
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
@@ -152,19 +140,11 @@ const SystemDiskCard = ({
{secondaryText}
</Text>
)}
{isAdvanced && freeText && freeText !== displayText && (
<Text size="xs" c="dimmed">
{freeText}
</Text>
)}
{isAdvanced && (
<Text size="xs" c={healthy ? "dimmed" : "red"}>
{t("widget.systemDisks.status.smart")}: {smartStatus?.trim() || "—"}
</Text>
)}
</div>
<div style={{ flexShrink: 0 }}>
{showTemperature && (hasTemperature || isAdvanced) ? <Text size="sm">{temperatureText}</Text> : null}
{showTemperature && temperature !== null && temperature !== undefined ? (
<Text size="sm">{temperature}°C</Text>
) : null}
</div>
</Group>
<Box
@@ -189,32 +169,12 @@ export default function SystemResources({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"systemDisks">) {
const t = useI18n();
const queryInput = { integrationIds };
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
const results = getUsableWidgetQueryData(healthQuery) ?? [];
const data = results.filter(
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
);
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.systemDisks.name")} />
</Group>
);
const emptyState = (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<WidgetEmptyState />
</Box>
);
const data =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? [];
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
if (data.length === 0) return emptyState;
if (data.length === 0) return <WidgetEmptyState />;
const disks = data.flatMap((entry) => {
const fileSystem = filterStorageVolumes(
@@ -233,52 +193,32 @@ export default function SystemResources({
}));
});
const hasNoFileSystems = data.every((entry) => entry.healthInfo.fileSystem.length === 0);
const hasFailedSource = results.some((entry) => Boolean(entry.error));
if (hasNoFileSystems && !hasFailedSource && !healthQuery.error) throw new NoIntegrationDataError();
if (hasNoFileSystems || disks.length === 0) return emptyState;
if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError();
if (disks.length === 0) return <WidgetEmptyState />;
const isAdvanced = displayMode === "advanced";
const minimumCardWidth = 260;
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
const cellWidth = width / columns;
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p="xs">
{disks.map(({ integrationId, integrationName, item, smartItem }) => {
const freeText = t("widget.systemDisks.status.free", {
percentage: String(Math.round(100 - clampPercentage(item.percentage))),
});
const advancedDisplayTexts = getAdvancedDisplayTexts(item, freeText);
return (
<SystemDiskCard
key={`${integrationId}:${item.deviceName}`}
deviceName={item.deviceName}
percentage={item.percentage}
displayText={
isAdvanced ? advancedDisplayTexts.percentage : getDisplayText(item, options.displayMode, freeText)
}
temperature={isAdvanced || options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
showBackgroundBar={isAdvanced || options.showBackgroundBar}
integrationName={isAdvanced || data.length > 1 ? integrationName : undefined}
secondaryText={advancedDisplayTexts.absolute}
freeText={advancedDisplayTexts.free}
smartStatus={smartItem?.overallStatus}
isAdvanced={isAdvanced}
showSecondaryText={isAdvanced || (height >= 160 && cellWidth >= 260)}
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)}
/>
);
})}
</SimpleGrid>
</ScrollArea>
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p="xs">
{disks.map(({ integrationId, integrationName, item, smartItem }) => (
<SystemDiskCard
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={data.length > 1 ? integrationName : undefined}
secondaryText={getAbsoluteText(item)}
showSecondaryText={height >= 160 && cellWidth >= 260}
showTemperature={height >= 100 && cellWidth >= 220}
/>
))}
</SimpleGrid>
</ScrollArea>
);
}
@@ -7,6 +7,13 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
supportsAdvancedFocus: true,
icon: IconServer2,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],