From 1039bb18b3c79868f470b721c3e38f47d33dabd3 Mon Sep 17 00:00:00 2001
From: ajnart
Date: Tue, 28 Jul 2026 15:25:44 +0200
Subject: [PATCH] fix: address mobile dashboard review findings
---
apps/docs/docs/management/boards/index.mdx | 2 +-
.../board/mobile/mobile-board.module.css | 2 +-
packages/api/src/router/widgets/beszel.ts | 2 ++
packages/api/src/settle-integrations.spec.ts | 31 +++++++++++++++++++
.../widgets/src/beszel-alerts/component.tsx | 15 ++++++---
.../src/beszel-system-grid/component.tsx | 8 +++--
.../src/beszel-system-stats/component.tsx | 8 +++--
.../src/beszel-system-table/component.tsx | 6 ++--
.../src/beszel/_shared/error-indicator.tsx | 20 +++++++++---
.../src/beszel/_shared/mobile-summary.tsx | 13 ++++++--
.../src/beszel/_shared/query-status.spec.ts | 11 +++++++
.../src/beszel/_shared/query-status.ts | 2 ++
.../widgets/src/beszel/_shared/stats-view.tsx | 17 +++++++---
.../src/filter-storage-volumes.spec.ts | 9 ++++++
.../widgets/src/filter-storage-volumes.ts | 8 ++---
.../widgets/src/system-disks/component.tsx | 15 ++++-----
.../src/system-disks/mobile-summary.spec.ts | 15 +++++++++
.../src/system-disks/mobile-summary.ts | 2 ++
packages/widgets/src/weather/component.tsx | 8 +----
.../widgets/src/weather/temperature.spec.ts | 20 ++++++++++++
packages/widgets/src/weather/temperature.ts | 6 ++++
21 files changed, 175 insertions(+), 45 deletions(-)
create mode 100644 packages/widgets/src/beszel/_shared/query-status.spec.ts
create mode 100644 packages/widgets/src/beszel/_shared/query-status.ts
create mode 100644 packages/widgets/src/system-disks/mobile-summary.spec.ts
create mode 100644 packages/widgets/src/system-disks/mobile-summary.ts
create mode 100644 packages/widgets/src/weather/temperature.spec.ts
create mode 100644 packages/widgets/src/weather/temperature.ts
diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx
index b6cefdac0..a9e7bb187 100644
--- a/apps/docs/docs/management/boards/index.mdx
+++ b/apps/docs/docs/management/boards/index.mdx
@@ -40,7 +40,7 @@ The column count defines how many horizontal columns are available to drag and d
### Public
This will enable public access for everyone - regardless of whether they are logged in or not.
-This can be really useful if you want to have public boards in your company or for family and fiends.
+This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
index 78026b2f1..80783dd08 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
@@ -13,7 +13,7 @@
padding: 0;
margin: -1px;
overflow: hidden;
- clip: rect(0, 0, 0, 0);
+ clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts
index 738adecf7..16e3e7cb2 100644
--- a/packages/api/src/router/widgets/beszel.ts
+++ b/packages/api/src/router/widgets/beszel.ts
@@ -47,6 +47,7 @@ export const beszelRouter = createTRPCRouter({
};
},
{
+ throwOnAllFailure: true,
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
@@ -119,6 +120,7 @@ export const beszelRouter = createTRPCRouter({
};
},
{
+ throwOnAllFailure: true,
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts
index 52f0d3697..ca032b700 100644
--- a/packages/api/src/settle-integrations.spec.ts
+++ b/packages/api/src/settle-integrations.spec.ts
@@ -23,6 +23,22 @@ describe("settleIntegrationQueries", () => {
expect(results).toEqual(["Second"]);
});
+ it("retains fallback and successful results for a partial failure", async () => {
+ const results = await settleIntegrationQueries(
+ integrations,
+ vi.fn(async (integration) => {
+ if (integration.id === "first") throw new Error("offline");
+ return integration.name;
+ }),
+ {
+ throwOnAllFailure: true,
+ fallback: (integration) => `${integration.name} unavailable`,
+ },
+ );
+
+ expect(results).toEqual(["First unavailable", "Second"]);
+ });
+
it("throws when every integration fails and throwOnAllFailure is enabled", async () => {
await expect(
settleIntegrationQueries(
@@ -35,6 +51,21 @@ describe("settleIntegrationQueries", () => {
).rejects.toThrow("offline");
});
+ it("throws on total failure even when fallbacks are configured", async () => {
+ await expect(
+ settleIntegrationQueries(
+ integrations,
+ vi.fn(async () => {
+ throw new Error("offline");
+ }),
+ {
+ throwOnAllFailure: true,
+ fallback: (integration) => `${integration.name} unavailable`,
+ },
+ ),
+ ).rejects.toThrow("offline");
+ });
+
it("does not treat an empty integration list as a failure", async () => {
await expect(
settleIntegrationQueries(
diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx
index 708094f8c..bc3510ac1 100644
--- a/packages/widgets/src/beszel-alerts/component.tsx
+++ b/packages/widgets/src/beszel-alerts/component.tsx
@@ -13,6 +13,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
+import { hasStaleIntegrationData } from "../beszel/_shared/query-status";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
const alertIconMap: Record = {
@@ -43,7 +44,13 @@ export default function BeszelAlertsWidget({
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
);
- const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
+ const {
+ data: results = [],
+ error: alertsError,
+ isPending,
+ isLoadingError: alertsLoadingError,
+ isRefetchError: alertsRefetchError,
+ } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const systemNameMap = useMemo(() => {
const map: Record = {};
@@ -72,7 +79,7 @@ export default function BeszelAlertsWidget({
const failedResults = results.filter((result) => "error" in result);
- if (alertsError) throw alertsError;
+ if (alertsLoadingError) throw alertsError;
if (results.length > 0 && failedResults.length === results.length) {
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
}
@@ -93,7 +100,7 @@ export default function BeszelAlertsWidget({
value={triggeredAlerts.length}
label={t("status.triggered")}
description={`${alerts.length} ${t("name")}`}
- isStale={failedResults.length > 0}
+ isStale={hasStaleIntegrationData(alertsRefetchError, failedResults)}
/>
);
}
@@ -101,7 +108,7 @@ export default function BeszelAlertsWidget({
return (
-
+
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx
index 4e1dc26b2..310e83c45 100644
--- a/packages/widgets/src/beszel-system-grid/component.tsx
+++ b/packages/widgets/src/beszel-system-grid/component.tsx
@@ -390,11 +390,13 @@ function BeszelSystemGridContent({
data: results = [],
error: systemsError,
isPending,
+ isLoadingError: systemsLoadingError,
+ isRefetchError: systemsRefetchError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
- if (systemsError) throw systemsError;
+ if (systemsLoadingError) throw systemsError;
if (isPending) {
return (
@@ -407,7 +409,7 @@ function BeszelSystemGridContent({
if (filteredSystems.length === 0) {
return (
-
+
@@ -431,7 +433,7 @@ function BeszelSystemGridContent({
return (
-
+
@@ -89,7 +91,7 @@ function BeszelSystemStatsContent({
if (systems.length === 0) {
return (
-
+
@@ -125,7 +127,7 @@ function BeszelSystemStatsContent({
return (
-
+
-
+
"error" in r && r.error);
- if (failed.length === 0) return null;
- const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
+ if (failed.length === 0 && !isStale) return null;
+ const label =
+ failed.length > 0
+ ? failed.map((r) => r.integrationName ?? r.integrationId).join(", ")
+ : t("board.mobile.dataWarning");
return (
-
+
);
}
diff --git a/packages/widgets/src/beszel/_shared/mobile-summary.tsx b/packages/widgets/src/beszel/_shared/mobile-summary.tsx
index 7f1caa6a0..d573b50e7 100644
--- a/packages/widgets/src/beszel/_shared/mobile-summary.tsx
+++ b/packages/widgets/src/beszel/_shared/mobile-summary.tsx
@@ -5,6 +5,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
import { useBeszelFilteredSystems } from "./hooks";
+import { hasStaleIntegrationData } from "./query-status";
interface BeszelSystemsMobileSummaryProps {
integrationIds: string[];
@@ -18,12 +19,18 @@ export const BeszelSystemsMobileSummary = ({
statusFilter,
}: BeszelSystemsMobileSummaryProps) => {
const t = useI18n();
- const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
+ const {
+ data: results = [],
+ error,
+ isPending,
+ isLoadingError,
+ isRefetchError,
+ } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useBeszelFilteredSystems(results, statusFilter);
const failedResults = results.filter((result) => "error" in result);
if (isPending) return ;
- if (error) throw error;
+ if (isLoadingError) throw error;
if (results.length > 0 && failedResults.length === results.length) {
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
}
@@ -38,7 +45,7 @@ export const BeszelSystemsMobileSummary = ({
up,
attention: systems.length - up,
})}
- isStale={failedResults.length > 0}
+ isStale={hasStaleIntegrationData(isRefetchError, failedResults)}
/>
);
};
diff --git a/packages/widgets/src/beszel/_shared/query-status.spec.ts b/packages/widgets/src/beszel/_shared/query-status.spec.ts
new file mode 100644
index 000000000..7663babb7
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/query-status.spec.ts
@@ -0,0 +1,11 @@
+import { describe, expect, test } from "vitest";
+
+import { hasStaleIntegrationData } from "./query-status";
+
+describe("Beszel query status", () => {
+ test("marks cached data stale after outer or per-integration failures", () => {
+ expect(hasStaleIntegrationData(true, [])).toBe(true);
+ expect(hasStaleIntegrationData(false, [{ error: "offline" }])).toBe(true);
+ expect(hasStaleIntegrationData(false, [])).toBe(false);
+ });
+});
diff --git a/packages/widgets/src/beszel/_shared/query-status.ts b/packages/widgets/src/beszel/_shared/query-status.ts
new file mode 100644
index 000000000..4d4827486
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/query-status.ts
@@ -0,0 +1,2 @@
+export const hasStaleIntegrationData = (isRefetchError: boolean, failedResults: readonly unknown[]) =>
+ isRefetchError || failedResults.length > 0;
diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx
index 48706d4ad..272b47487 100644
--- a/packages/widgets/src/beszel/_shared/stats-view.tsx
+++ b/packages/widgets/src/beszel/_shared/stats-view.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
-import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
+import { Box, Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -19,6 +19,7 @@ import {
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
+import { BeszelIntegrationErrorIndicator } from "./error-indicator";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
@@ -74,7 +75,12 @@ export function BeszelStatsView({
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
- const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
+ const {
+ data: historicalData,
+ error: historicalError,
+ isLoadingError: historicalLoadingError,
+ isRefetchError: historicalRefetchError,
+ } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
@@ -175,7 +181,7 @@ export function BeszelStatsView({
timePeriod,
);
- if (historicalError) throw historicalError;
+ if (!isLive && historicalLoadingError) throw historicalError;
if (isLive && liveError) {
return (
@@ -220,7 +226,10 @@ export function BeszelStatsView({
}
return (
-
+
+
+
+
{visibility.cpu && cpuData.length > 0 && (
{
});
});
+describe("storageDeviceNamesMatch", () => {
+ test("matches partitions to their base SMART device without merging unrelated devices", () => {
+ expect(storageDeviceNamesMatch("/dev/sda1", "/dev/sda")).toBe(true);
+ expect(storageDeviceNamesMatch("/dev/nvme0n1p2", "/dev/nvme0n1")).toBe(true);
+ expect(storageDeviceNamesMatch("/dev/md0", "/dev/md1")).toBe(false);
+ });
+});
+
describe("toScopedStorageVolumeValue", () => {
test("scopes unscoped volume names to the query integration", () => {
expect(toScopedStorageVolumeValue("nas-a", "volume_1")).toBe("nas-a:volume_1");
diff --git a/packages/widgets/src/filter-storage-volumes.ts b/packages/widgets/src/filter-storage-volumes.ts
index 5e88584be..f5d294508 100644
--- a/packages/widgets/src/filter-storage-volumes.ts
+++ b/packages/widgets/src/filter-storage-volumes.ts
@@ -28,18 +28,14 @@ export const toScopedStorageVolumeValue = (integrationId: string, value: string)
return `${integrationId}:${volumeName}`;
};
-const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
+export const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
return (
leftDeviceName === rightDeviceName ||
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
);
};
-const matchesVisibleStorageVolume = (
- visibleVolume: string,
- integrationId: string,
- deviceName: string,
-): boolean => {
+const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx
index 8db1129aa..19a09ccf7 100644
--- a/packages/widgets/src/system-disks/component.tsx
+++ b/packages/widgets/src/system-disks/component.tsx
@@ -10,9 +10,10 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
-import type { WidgetComponentProps } from "../definition";
-import { filterStorageVolumes } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
+import { filterStorageVolumes, storageDeviceNamesMatch } from "../filter-storage-volumes";
+import type { WidgetComponentProps } from "../definition";
+import { formatDiskTemperature } from "./mobile-summary";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
@@ -81,6 +82,7 @@ const SystemDiskCard = ({
}, [displayText, healthy]);
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
+ const temperatureLabel = formatDiskTemperature(temperature, true);
return (
{unhealthyLabel}}
- {temperature ?
{temperature}°C
: null}
+ {temperatureLabel ?
{temperatureLabel}
: null}
diskB.percentage - diskA.percentage)[0];
if (!disk) return ;
- const smartItem = smart.find((item) => item.deviceName === disk.deviceName);
+ const smartItem = smart.find((item) => storageDeviceNamesMatch(item.deviceName, disk.deviceName));
const isHealthy = smartItem?.healthy ?? true;
- const temperature =
- options.showTemperatureIfAvailable && smartItem?.temperature ? `${smartItem.temperature}°C` : undefined;
+ const temperature = formatDiskTemperature(smartItem?.temperature, options.showTemperatureIfAvailable);
return (
{fileSystem.map((item) => {
- const smartItem = smart.find((smartEntry) => smartEntry.deviceName === item.deviceName);
+ const smartItem = smart.find((smartEntry) => storageDeviceNamesMatch(smartEntry.deviceName, item.deviceName));
return (
{
+ test("preserves a valid zero-degree reading", () => {
+ expect(formatDiskTemperature(0, true)).toBe("0°C");
+ });
+
+ test("omits unavailable or disabled readings", () => {
+ expect(formatDiskTemperature(null, true)).toBeUndefined();
+ expect(formatDiskTemperature(undefined, true)).toBeUndefined();
+ expect(formatDiskTemperature(20, false)).toBeUndefined();
+ });
+});
diff --git a/packages/widgets/src/system-disks/mobile-summary.ts b/packages/widgets/src/system-disks/mobile-summary.ts
new file mode 100644
index 000000000..123d0dd7d
--- /dev/null
+++ b/packages/widgets/src/system-disks/mobile-summary.ts
@@ -0,0 +1,2 @@
+export const formatDiskTemperature = (temperature: number | null | undefined, shouldShow: boolean) =>
+ shouldShow && temperature != null ? `${temperature}°C` : undefined;
diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx
index 366fc6827..b25a70a25 100644
--- a/packages/widgets/src/weather/component.tsx
+++ b/packages/widgets/src/weather/component.tsx
@@ -14,6 +14,7 @@ import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import { WeatherDescription } from "./icon";
+import { getPreferredUnit } from "./temperature";
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
const input = {
@@ -247,10 +248,3 @@ function Forecast({ weather, options }: WeatherProps) {
);
}
-
-const getPreferredUnit = (value?: number, isFahrenheit = false, disableTemperatureDecimals = false): string =>
- value
- ? isFahrenheit
- ? `${(value * (9 / 5) + 32).toFixed(disableTemperatureDecimals ? 0 : 1)}°F`
- : `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C`
- : "?";
diff --git a/packages/widgets/src/weather/temperature.spec.ts b/packages/widgets/src/weather/temperature.spec.ts
new file mode 100644
index 000000000..d22b96619
--- /dev/null
+++ b/packages/widgets/src/weather/temperature.spec.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "vitest";
+
+import { getPreferredUnit } from "./temperature";
+
+describe("getPreferredUnit", () => {
+ test("preserves zero in Celsius and Fahrenheit", () => {
+ expect(getPreferredUnit(0)).toBe("0.0°C");
+ expect(getPreferredUnit(0, true)).toBe("32.0°F");
+ });
+
+ test("only uses the unavailable placeholder for missing values", () => {
+ expect(getPreferredUnit(undefined)).toBe("?");
+ });
+
+ test("respects the decimal preference", () => {
+ expect(getPreferredUnit(0, false, true)).toBe("0°C");
+ expect(getPreferredUnit(0, true, true)).toBe("32°F");
+ expect(getPreferredUnit(12.6, false, true)).toBe("13°C");
+ });
+});
diff --git a/packages/widgets/src/weather/temperature.ts b/packages/widgets/src/weather/temperature.ts
new file mode 100644
index 000000000..73edf7f87
--- /dev/null
+++ b/packages/widgets/src/weather/temperature.ts
@@ -0,0 +1,6 @@
+export const getPreferredUnit = (value?: number, isFahrenheit = false, disableTemperatureDecimals = false): string =>
+ value !== undefined
+ ? isFahrenheit
+ ? `${(value * (9 / 5) + 32).toFixed(disableTemperatureDecimals ? 0 : 1)}°F`
+ : `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C`
+ : "?";