Revert "fix: address mobile dashboard review findings"

This reverts commit 1039bb18b3.
This commit is contained in:
ajnart committed 2026-07-29 20:06:59 +02:00
1 parent 43e95ff185
commit 38a710efe3
21 files changed
+45 -175

No files matched your search

+1 -1
View File
@@ -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 friends.
This can be really useful if you want to have public boards in your company or for family and fiends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
@@ -13,7 +13,7 @@
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@@ -47,7 +47,6 @@ export const beszelRouter = createTRPCRouter({
};
},
{
throwOnAllFailure: true,
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
@@ -120,7 +119,6 @@ export const beszelRouter = createTRPCRouter({
};
},
{
throwOnAllFailure: true,
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
@@ -23,22 +23,6 @@ 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(
@@ -51,21 +35,6 @@ 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(
@@ -13,7 +13,6 @@ 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<string, LucideIcon> = {
@@ -44,13 +43,7 @@ export default function BeszelAlertsWidget({
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
);
const {
data: results = [],
error: alertsError,
isPending,
isLoadingError: alertsLoadingError,
isRefetchError: alertsRefetchError,
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
@@ -79,7 +72,7 @@ export default function BeszelAlertsWidget({
const failedResults = results.filter((result) => "error" in result);
if (alertsLoadingError) throw alertsError;
if (alertsError) throw alertsError;
if (results.length > 0 && failedResults.length === results.length) {
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
}
@@ -100,7 +93,7 @@ export default function BeszelAlertsWidget({
value={triggeredAlerts.length}
label={t("status.triggered")}
description={`${alerts.length} ${t("name")}`}
isStale={hasStaleIntegrationData(alertsRefetchError, failedResults)}
isStale={failedResults.length > 0}
/>
);
}
@@ -108,7 +101,7 @@ export default function BeszelAlertsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} isStale={alertsRefetchError} />
<BeszelIntegrationErrorIndicator results={results} />
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
@@ -398,13 +398,11 @@ function BeszelSystemGridContent({
data: results = [],
error: systemsError,
isPending,
isLoadingError: systemsLoadingError,
isRefetchError: systemsRefetchError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
if (systemsLoadingError) throw systemsError;
if (systemsError) throw systemsError;
if (isPending) {
return (
@@ -417,7 +415,7 @@ function BeszelSystemGridContent({
if (filteredSystems.length === 0) {
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} isStale={systemsRefetchError} />
<BeszelIntegrationErrorIndicator results={results} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -441,7 +439,7 @@ function BeszelSystemGridContent({
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} isStale={systemsRefetchError} />
<BeszelIntegrationErrorIndicator results={results} />
<Box
h="100%"
style={{
@@ -47,8 +47,6 @@ function BeszelSystemStatsContent({
data: systemsResult = [],
isPending: systemsPending,
error: systemsError,
isLoadingError: systemsLoadingError,
isRefetchError: systemsRefetchError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
@@ -80,7 +78,7 @@ function BeszelSystemStatsContent({
[setOptions, isReadOnly, hasChangeAccess, boardId, itemId, saveItemOptions],
);
if (systemsLoadingError) throw systemsError;
if (systemsError) throw systemsError;
if (systemsPending)
return (
<Center h="100%">
@@ -91,7 +89,7 @@ function BeszelSystemStatsContent({
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<BeszelIntegrationErrorIndicator results={systemsResult} isStale={systemsRefetchError} />
<BeszelIntegrationErrorIndicator results={systemsResult} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -127,7 +125,7 @@ function BeszelSystemStatsContent({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={systemsResult} isStale={systemsRefetchError} />
<BeszelIntegrationErrorIndicator results={systemsResult} />
</Box>
<ScrollArea
h="100%"
@@ -99,8 +99,6 @@ function BeszelSystemTableContent({
data: results = [],
error: systemsError,
isPending,
isLoadingError: systemsLoadingError,
isRefetchError: systemsRefetchError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const size = getSizeConfig(width);
@@ -304,7 +302,7 @@ function BeszelSystemTableContent({
openModal({ integrationId, systemId: record.id }, { title: record.name });
};
if (systemsLoadingError) throw systemsError;
if (systemsError) throw systemsError;
if (isPending) {
return (
@@ -317,7 +315,7 @@ function BeszelSystemTableContent({
return (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} isStale={systemsRefetchError} />
<BeszelIntegrationErrorIndicator results={results} />
</div>
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
@@ -3,8 +3,6 @@
import { Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
@@ -13,25 +11,15 @@ interface IntegrationResult {
interface BeszelIntegrationErrorIndicatorProps {
results: IntegrationResult[];
isStale?: boolean;
}
export function BeszelIntegrationErrorIndicator({ results, isStale = false }: BeszelIntegrationErrorIndicatorProps) {
const t = useI18n();
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
const failed = results.filter((r) => "error" in r && r.error);
if (failed.length === 0 && !isStale) return null;
const label =
failed.length > 0
? failed.map((r) => r.integrationName ?? r.integrationId).join(", ")
: t("board.mobile.dataWarning");
if (failed.length === 0) return null;
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
return (
<Tooltip label={label} position="left" withArrow>
<IconAlertTriangle
size={14}
color="var(--mantine-color-orange-6)"
style={{ cursor: "help", flexShrink: 0 }}
aria-label={label}
/>
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
</Tooltip>
);
}
@@ -5,7 +5,6 @@ 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[];
@@ -19,18 +18,12 @@ export const BeszelSystemsMobileSummary = ({
statusFilter,
}: BeszelSystemsMobileSummaryProps) => {
const t = useI18n();
const {
data: results = [],
error,
isPending,
isLoadingError,
isRefetchError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useBeszelFilteredSystems(results, statusFilter);
const failedResults = results.filter((result) => "error" in result);
if (isPending) return <WidgetMobileLoading />;
if (isLoadingError) throw error;
if (error) throw error;
if (results.length > 0 && failedResults.length === results.length) {
throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
}
@@ -45,7 +38,7 @@ export const BeszelSystemsMobileSummary = ({
up,
attention: systems.length - up,
})}
isStale={hasStaleIntegrationData(isRefetchError, failedResults)}
isStale={failedResults.length > 0}
/>
);
};
@@ -1,11 +0,0 @@
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);
});
});
@@ -1,2 +0,0 @@
export const hasStaleIntegrationData = (isRefetchError: boolean, failedResults: readonly unknown[]) =>
isRefetchError || failedResults.length > 0;
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Box, Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -19,7 +19,6 @@ 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";
@@ -75,12 +74,7 @@ export function BeszelStatsView({
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const {
data: historicalData,
error: historicalError,
isLoadingError: historicalLoadingError,
isRefetchError: historicalRefetchError,
} = clientApi.widget.beszel.getSystemStats.useQuery(
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
@@ -181,7 +175,7 @@ export function BeszelStatsView({
timePeriod,
);
if (!isLive && historicalLoadingError) throw historicalError;
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
@@ -226,10 +220,7 @@ export function BeszelStatsView({
}
return (
<SimpleGrid cols={columns} spacing="md" pos="relative">
<Box pos="absolute" top={0} right={0} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={[]} isStale={historicalRefetchError} />
</Box>
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
import {
filterStorageVolumes,
normalizeStorageDeviceName,
storageDeviceNamesMatch,
toScopedStorageVolumeValue,
} from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
@@ -31,14 +30,6 @@ describe("normalizeStorageDeviceName", () => {
});
});
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");
@@ -28,14 +28,18 @@ export const toScopedStorageVolumeValue = (integrationId: string, value: string)
return `${integrationId}:${volumeName}`;
};
export const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
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);
@@ -10,10 +10,9 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import { filterStorageVolumes, storageDeviceNamesMatch } from "../filter-storage-volumes";
import type { WidgetComponentProps } from "../definition";
import { formatDiskTemperature } from "./mobile-summary";
import { filterStorageVolumes } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
@@ -82,7 +81,6 @@ const SystemDiskCard = ({
}, [displayText, healthy]);
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
const temperatureLabel = formatDiskTemperature(temperature, true);
return (
<Tooltip
@@ -107,7 +105,7 @@ const SystemDiskCard = ({
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
</p>
</div>
<div>{temperatureLabel ? <p style={{ margin: 0 }}>{temperatureLabel}</p> : null}</div>
<div>{temperature ? <p style={{ margin: 0 }}>{temperature}°C</p> : null}</div>
</Group>
<Box
bg={healthy ? "green" : "red"}
@@ -157,9 +155,10 @@ export default function SystemResources({
const disk = fileSystem.toSorted((diskA, diskB) => diskB.percentage - diskA.percentage)[0];
if (!disk) return <WidgetEmptyState />;
const smartItem = smart.find((item) => storageDeviceNamesMatch(item.deviceName, disk.deviceName));
const smartItem = smart.find((item) => item.deviceName === disk.deviceName);
const isHealthy = smartItem?.healthy ?? true;
const temperature = formatDiskTemperature(smartItem?.temperature, options.showTemperatureIfAvailable);
const temperature =
options.showTemperatureIfAvailable && smartItem?.temperature ? `${smartItem.temperature}°C` : undefined;
return (
<WidgetMobileSummary
@@ -174,7 +173,7 @@ export default function SystemResources({
return (
<Stack gap="xs" p="xs" h="100%">
{fileSystem.map((item) => {
const smartItem = smart.find((smartEntry) => storageDeviceNamesMatch(smartEntry.deviceName, item.deviceName));
const smartItem = smart.find((smartEntry) => smartEntry.deviceName === item.deviceName);
return (
<SystemDiskCard
@@ -1,15 +0,0 @@
import { describe, expect, test } from "vitest";
import { formatDiskTemperature } from "./mobile-summary";
describe("formatDiskTemperature", () => {
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();
});
});
@@ -1,2 +0,0 @@
export const formatDiskTemperature = (temperature: number | null | undefined, shouldShow: boolean) =>
shouldShow && temperature != null ? `${temperature}°C` : undefined;
+7 -1
View File
@@ -14,7 +14,6 @@ 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 = {
@@ -248,3 +247,10 @@ function Forecast({ weather, options }: WeatherProps) {
</Group>
);
}
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`
: "?";
@@ -1,20 +0,0 @@
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");
});
});
@@ -1,6 +0,0 @@
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`
: "?";