fix: finish widget modernization audit

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:50 +02:00
1 parent bbcfc505bc
commit 747ccdfa9e
27 files changed
+340 -115

No files matched your search

@@ -0,0 +1,11 @@
.target {
min-width: 24px;
min-height: 24px;
}
@media (pointer: coarse) {
.target {
min-width: 44px;
min-height: 44px;
}
}
@@ -1,8 +1,12 @@
import { Box, getDefaultZIndex, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import { useEffect, useState } from "react";
import { Box, Group, Popover, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
import classes from "./disk-usage.module.css";
interface DiskUsageProps {
system: BeszelSystemRow;
@@ -17,34 +21,44 @@ const mountLabel = (path: string) =>
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const t = useScopedI18n("common");
const [opened, setOpened] = useState(false);
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const filesystemUsageLabel = t("filesystemUsage", { count: filesystems.length + 1 });
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
useEffect(() => {
if (filesystems.length === 0) setOpened(false);
}, [filesystems.length]);
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
zIndex={getDefaultZIndex("modal") - 3}
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
{filesystems.length > 0 ? (
<Popover
opened={opened}
onChange={setOpened}
zIndex="var(--homarr-z-index-widget-background-tooltip)"
position="right"
withArrow
shadow="md"
>
<Popover.Target>
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
aria-label={filesystemUsageLabel}
className={classes.target}
onMouseEnter={() => setOpened(true)}
onMouseLeave={() => setOpened(false)}
onBlur={() => setOpened(false)}
onClick={() => setOpened(true)}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
@@ -75,32 +89,32 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Popover.Target>
<Popover.Dropdown aria-label={filesystemUsageLabel} p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</Popover.Dropdown>
</Popover>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</Group>
);
};
@@ -20,16 +20,23 @@ import {
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import type { BeszelTooltipLayer } from "./tooltip";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const makeTooltipSet = (layer: BeszelTooltipLayer) => ({
percent: makeTooltipProps(formatPercent, false, layer),
gigabytes: makeTooltipProps(formatGB, true, layer),
rate: makeTooltipProps(formatByteRate, true, layer),
percentTotal: makeTooltipProps(formatPercent, true, layer),
bytesTotal: makeTooltipProps(formatStorageBytes, true, layer),
});
const tooltipSets = {
background: makeTooltipSet("background"),
modal: makeTooltipSet("modal"),
};
const ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
@@ -59,6 +66,7 @@ interface BeszelStatsViewProps {
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
tooltipLayer?: BeszelTooltipLayer;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
@@ -70,11 +78,13 @@ export function BeszelStatsView({
visibility,
columns,
onSwitchToHistorical,
tooltipLayer = "background",
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const tooltips = tooltipSets[tooltipLayer];
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
@@ -256,7 +266,7 @@ export function BeszelStatsView({
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
tooltipProps: tooltips.percent,
}}
/>
)}
@@ -270,7 +280,7 @@ export function BeszelStatsView({
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
tooltipProps: tooltips.gigabytes,
}}
/>
)}
@@ -284,7 +294,7 @@ export function BeszelStatsView({
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
tooltipProps: tooltips.gigabytes,
}}
/>
)}
@@ -297,7 +307,7 @@ export function BeszelStatsView({
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -310,7 +320,7 @@ export function BeszelStatsView({
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -326,7 +336,7 @@ export function BeszelStatsView({
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
tooltipProps: tooltips.percentTotal,
}}
/>
)}
@@ -340,7 +350,7 @@ export function BeszelStatsView({
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
tooltipProps: tooltips.bytesTotal,
}}
/>
)}
@@ -353,7 +363,7 @@ export function BeszelStatsView({
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
tooltipProps: tooltips.rate,
}}
/>
)}
@@ -50,6 +50,7 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
tooltipLayer="modal"
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
@@ -1,6 +1,5 @@
"use client";
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import {
Avatar,
@@ -46,7 +45,7 @@ export default function DnsHoleSummaryWidget({
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const data = summaries.flatMap(({ summary }) => summary);
const layoutProps = boxPropsByLayout(options.layout);
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
@@ -120,7 +119,7 @@ const stats = [
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
color: "rgba(240, 82, 60, 0.4)", // RED
color: "var(--mantine-color-red-light)",
},
{
icon: IconPercentage,
@@ -130,7 +129,7 @@ const stats = [
return `${formatNumber(totalCount === 0 ? 0 : (blocked / totalCount) * 100, size === "sm" ? 0 : 2)}%`;
},
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
color: "rgba(255, 165, 20, 0.4)", // YELLOW
color: "var(--mantine-color-yellow-light)",
},
{
icon: IconSearch,
@@ -140,7 +139,7 @@ const stats = [
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
color: "rgba(0, 175, 218, 0.4)", // BLUE
color: "var(--mantine-color-cyan-light)",
},
{
icon: IconWorldWww,
@@ -156,7 +155,7 @@ const stats = [
},
tooltip: (data, t) => (data.length >= 2 ? t("widget.dnsHoleSummary.domainsTooltip") : undefined),
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
color: "rgba(0, 176, 96, 0.4)", // GREEN
color: "var(--mantine-color-green-light)",
},
] satisfies StatItem[];
@@ -76,7 +76,7 @@ export default function NetworkControllerSummaryWidget({
/>
}
>
WAN
{t("widget.networkControllerSummary.card.wan")}
</List.Item>
<List.Item
icon={
@@ -88,7 +88,7 @@ export default function NetworkControllerSummaryWidget({
}
>
<Text>
WWW
{t("widget.networkControllerSummary.card.web")}
<Text c="dimmed" size="sm" ms="xs" span>
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
</Text>
@@ -103,7 +103,8 @@ export default function NetworkControllerSummaryWidget({
/>
}
>
Wi-Fi{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
{t("widget.networkControllerSummary.card.wifi")}
{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
</List.Item>
{showDetails && (
<List.Item
@@ -115,7 +116,7 @@ export default function NetworkControllerSummaryWidget({
/>
}
>
LAN · {summary.lan.users + summary.lan.guests}
{t("widget.networkControllerSummary.card.lan")} · {summary.lan.users + summary.lan.guests}
</List.Item>
)}
<List.Item
@@ -128,9 +129,9 @@ export default function NetworkControllerSummaryWidget({
}
>
<Text>
VPN
{t("widget.networkControllerSummary.card.vpn.label")}
<Text c="dimmed" size="sm" ms="xs" span>
{t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${summary.vpn.users}` })}
{t("widget.networkControllerSummary.card.vpn.countConnected", { count: summary.vpn.users })}
</Text>
</Text>
</List.Item>
@@ -11,6 +11,8 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
import { getEntityStateLabel } from "./state";
export default function SmartHomeEntityStateWidget({
options,
@@ -95,7 +97,7 @@ export default function SmartHomeEntityStateWidget({
if (entityError && entity === undefined) throw entityError;
const isTiny = width < 128 || height < 96;
const isTiny = isSmartHomeTiny(width, height);
const advancedAttributes = [
{ key: "unit" as const, value: entity?.attributes.unit_of_measurement },
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
@@ -105,7 +107,13 @@ export default function SmartHomeEntityStateWidget({
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
? entity.attributes.friendly_name
: options.displayName;
const state = entity?.state ?? "—";
const knownStates = {
on: t("state.on"),
off: t("state.off"),
unavailable: t("state.unavailable"),
unknown: t("state.unknown"),
};
const state = getEntityStateLabel(entity?.state, knownStates);
return (
<UnstyledButton
@@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { getEntityStateLabel } from "./state";
const labels = { on: "On", off: "Off", unavailable: "Unavailable", unknown: "Unknown" };
describe("getEntityStateLabel", () => {
test("localizes known states and preserves custom states", () => {
expect(getEntityStateLabel("on", labels)).toBe("On");
expect(getEntityStateLabel("heating", labels)).toBe("heating");
});
test.each(["__proto__", "constructor", "toString"])("preserves prototype-like state %s", (state) => {
expect(getEntityStateLabel(state, labels)).toBe(state);
});
});
@@ -0,0 +1,11 @@
export interface LocalizedEntityStates {
on: string;
off: string;
unavailable: string;
unknown: string;
}
export const getEntityStateLabel = (state: string | undefined, labels: LocalizedEntityStates) => {
if (!state) return "—";
return Object.hasOwn(labels, state) ? labels[state as keyof LocalizedEntityStates] : state;
};
@@ -21,6 +21,7 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
export default function SmartHomeTriggerAutomationWidget({
options,
@@ -83,7 +84,7 @@ export default function SmartHomeTriggerAutomationWidget({
[canInteract, handleClick, integrationId, isPending, options.automationId, options.displayName],
);
const isTiny = width < 128 || height < 96;
const isTiny = isSmartHomeTiny(width, height);
return (
<Box pos="relative" w="100%" h="100%">
@@ -0,0 +1,14 @@
import { describe, expect, test } from "vitest";
import { isSmartHomeTiny } from "./layout";
describe("smart home layout", () => {
test.each([
{ width: 127, height: 96, expected: true },
{ width: 128, height: 95, expected: true },
{ width: 128, height: 96, expected: false },
{ width: 256, height: 192, expected: false },
])("classifies $width x $height", ({ width, height, expected }) => {
expect(isSmartHomeTiny(width, height)).toBe(expected);
});
});
@@ -0,0 +1 @@
export const isSmartHomeTiny = (width: number, height: number) => width < 128 || height < 96;
+21 -21
View File
@@ -9,6 +9,7 @@ import type { UmamiVisitorStats } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { UmamiEventsContent } from "./umami-events-content";
import { getUmamiLayout } from "./umami-layout";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
@@ -96,11 +97,11 @@ export function UmamiContent({
const isOverlay = chartStyle === "overlay" && hasEventSeries;
const overlayBarSize = 16;
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && height < 120;
const showXAxis = isAdvanced || height >= 140;
const showSecondaryStats = isAdvanced || height >= 96;
const showDetailedStats = isAdvanced || (width >= 260 && height >= 150);
const { isDense, showXAxis, showSecondaryStats, showDetailedStats, stackAdvancedContent } = getUmamiLayout({
width,
height,
displayMode,
});
const selectedView =
viewMode === "events" ? (
@@ -181,22 +182,21 @@ export function UmamiContent({
</SimpleGrid>
);
const advancedContent =
width < 900 ? (
<ScrollArea h="100%">
<Stack gap="md">
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
{advancedLists}
</Stack>
</ScrollArea>
) : (
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box h="100%" mih={260}>
{selectedView}
</Box>
<ScrollArea h="100%">{advancedLists}</ScrollArea>
</SimpleGrid>
);
const advancedContent = stackAdvancedContent ? (
<ScrollArea h="100%">
<Stack gap="md">
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
{advancedLists}
</Stack>
</ScrollArea>
) : (
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box h="100%" mih={260}>
{selectedView}
</Box>
<ScrollArea h="100%">{advancedLists}</ScrollArea>
</SimpleGrid>
);
return (
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%" pos="relative">
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { getUmamiLayout } from "./umami-layout";
describe("Umami layout", () => {
test.each([
{ width: 260, height: 119, property: "isDense" as const, expected: true },
{ width: 260, height: 120, property: "isDense" as const, expected: false },
{ width: 260, height: 139, property: "showXAxis" as const, expected: false },
{ width: 260, height: 140, property: "showXAxis" as const, expected: true },
{ width: 260, height: 95, property: "showSecondaryStats" as const, expected: false },
{ width: 260, height: 96, property: "showSecondaryStats" as const, expected: true },
{ width: 259, height: 150, property: "showDetailedStats" as const, expected: false },
{ width: 260, height: 149, property: "showDetailedStats" as const, expected: false },
{ width: 260, height: 150, property: "showDetailedStats" as const, expected: true },
])("sets $property to $expected at $width x $height", ({ width, height, property, expected }) => {
expect(getUmamiLayout({ width, height, displayMode: "compact" })[property]).toBe(expected);
});
test("keeps advanced details visible and changes columns at 900px", () => {
expect(getUmamiLayout({ width: 899, height: 80, displayMode: "advanced" })).toEqual({
isDense: false,
showXAxis: true,
showSecondaryStats: true,
showDetailedStats: true,
stackAdvancedContent: true,
});
expect(getUmamiLayout({ width: 900, height: 80, displayMode: "advanced" }).stackAdvancedContent).toBe(false);
});
});
@@ -0,0 +1,16 @@
interface UmamiLayoutInput {
width: number;
height: number;
displayMode: "compact" | "advanced";
}
export const getUmamiLayout = ({ width, height, displayMode }: UmamiLayoutInput) => {
const isAdvanced = displayMode === "advanced";
return {
isDense: !isAdvanced && height < 120,
showXAxis: isAdvanced || height >= 140,
showSecondaryStats: isAdvanced || height >= 96,
showDetailedStats: isAdvanced || (width >= 260 && height >= 150),
stackAdvancedContent: isAdvanced && width < 900,
};
};
+2 -1
View File
@@ -35,6 +35,7 @@ export default function WeatherWidget({
height,
displayMode,
}: WidgetComponentProps<"weather">) {
const t = useScopedI18n("widget.weather");
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -75,7 +76,7 @@ export default function WeatherWidget({
{content}
{weatherQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={weatherQuery.error} label="Weather" />
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("name")} />
</Box>
)}
</Box>