fix: finish widget modernization audit
This commit is contained in:
28 files changed
+336
-112
No files matched your search
@@ -76,7 +76,7 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
</BoardMantineProvider>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
.surfaceHostAdvanced {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: calc(var(--mantine-z-index-modal) - 1);
|
||||
z-index: var(--homarr-z-index-widget-focus);
|
||||
width: auto;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -229,6 +229,19 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
if (isEditMode && activeRef.current) close(false);
|
||||
}, [close, isEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active?.activation !== "manual") return;
|
||||
const background = document.querySelectorAll<HTMLElement>("[data-advanced-focus-background]");
|
||||
background.forEach((element) => {
|
||||
element.inert = true;
|
||||
});
|
||||
return () => {
|
||||
background.forEach((element) => {
|
||||
element.inert = false;
|
||||
});
|
||||
};
|
||||
}, [active?.activation]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ active, open, close, dismiss, hover, leave }),
|
||||
[active, close, dismiss, hover, leave, open],
|
||||
@@ -244,7 +257,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
data-advanced-focus-overlay
|
||||
backgroundOpacity={0.38}
|
||||
blur={0}
|
||||
zIndex="calc(var(--mantine-z-index-modal) - 2)"
|
||||
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
|
||||
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
@@ -59,7 +59,7 @@ export const AdvancedFocusManualSurface = ({
|
||||
radius={radius}
|
||||
padding={0}
|
||||
shadow="none"
|
||||
zIndex="calc(var(--mantine-z-index-modal) - 1)"
|
||||
zIndex="var(--homarr-z-index-widget-focus)"
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
lockScroll
|
||||
|
||||
@@ -18,7 +18,12 @@ interface Props {
|
||||
|
||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
return (
|
||||
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
|
||||
<AppShellHeader
|
||||
maw="100vw"
|
||||
zIndex="var(--homarr-z-index-board-header)"
|
||||
style={{ overflowX: "hidden" }}
|
||||
data-advanced-focus-background
|
||||
>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <ClientBurger />}
|
||||
|
||||
@@ -75,6 +75,8 @@ describe("Board advanced interactions", () => {
|
||||
const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" });
|
||||
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
|
||||
const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
|
||||
const header = page.locator("header[data-advanced-focus-background]");
|
||||
const boardMain = page.locator("main[data-advanced-focus-background]");
|
||||
|
||||
await compactSurface.evaluate((element) => {
|
||||
element.setAttribute("data-lifecycle-probe", "same-instance");
|
||||
@@ -93,6 +95,8 @@ describe("Board advanced interactions", () => {
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
|
||||
await expect(otherWidget).toBeVisible();
|
||||
await expect(widget).toBeFocused();
|
||||
await expect(header).not.toHaveAttribute("inert", "");
|
||||
await expect(boardMain).not.toHaveAttribute("inert", "");
|
||||
|
||||
await previewSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
|
||||
@@ -136,6 +140,8 @@ describe("Board advanced interactions", () => {
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(manualSurface).toBeVisible();
|
||||
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
|
||||
await expect(header).toHaveAttribute("inert", "");
|
||||
await expect(boardMain).toHaveAttribute("inert", "");
|
||||
expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull();
|
||||
await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto");
|
||||
@@ -210,6 +216,8 @@ describe("Board advanced interactions", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(manualSurface).toBeHidden();
|
||||
await expect(widget).toBeFocused();
|
||||
await expect(header).not.toHaveAttribute("inert", "");
|
||||
await expect(boardMain).not.toHaveAttribute("inert", "");
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden");
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
|
||||
@@ -1405,6 +1405,8 @@
|
||||
"success": "Erfolgreich",
|
||||
"beta": "Beta",
|
||||
"error": "Fehler",
|
||||
"total": "Gesamt",
|
||||
"filesystemUsage": "{count, plural, one {Auslastung für # Dateisystem anzeigen} other {Auslastung für # Dateisysteme anzeigen}}",
|
||||
"action": {
|
||||
"add": "Hinzufügen",
|
||||
"apply": "Übernehmen",
|
||||
@@ -2108,6 +2110,12 @@
|
||||
"clickable": {
|
||||
"label": "Anklickbar"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"on": "Ein",
|
||||
"off": "Aus",
|
||||
"unavailable": "Nicht verfügbar",
|
||||
"unknown": "Unbekannt"
|
||||
}
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
@@ -3547,8 +3555,13 @@
|
||||
"networkControllerSummary": {
|
||||
"option": {},
|
||||
"card": {
|
||||
"wan": "WAN",
|
||||
"web": "Web",
|
||||
"wifi": "WLAN",
|
||||
"lan": "LAN",
|
||||
"vpn": {
|
||||
"countConnected": "{count} verbunden"
|
||||
"label": "VPN",
|
||||
"countConnected": "{count, plural, one {# verbunden} other {# verbunden}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1410,6 +1410,8 @@
|
||||
"loading": "Loading widget data",
|
||||
"stale": "Could not refresh {widget}; showing saved data"
|
||||
},
|
||||
"total": "Total",
|
||||
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
|
||||
"action": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
@@ -2179,6 +2181,12 @@
|
||||
"label": "Clickable"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"unavailable": "Unavailable",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"advanced": {
|
||||
"entityId": "Entity ID: {id}",
|
||||
"lastUpdated": "Updated {date}",
|
||||
@@ -3740,8 +3748,13 @@
|
||||
"networkControllerSummary": {
|
||||
"option": {},
|
||||
"card": {
|
||||
"wan": "WAN",
|
||||
"web": "Web",
|
||||
"wifi": "Wi-Fi",
|
||||
"lan": "LAN",
|
||||
"vpn": {
|
||||
"countConnected": "{count} connected"
|
||||
"label": "VPN",
|
||||
"countConnected": "{count, plural, one {# connected} other {# connected}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1405,6 +1405,8 @@
|
||||
"success": "Succès",
|
||||
"beta": "Bêta",
|
||||
"error": "Erreur",
|
||||
"total": "Total",
|
||||
"filesystemUsage": "{count, plural, one {Afficher l’utilisation de # système de fichiers} other {Afficher l’utilisation de # systèmes de fichiers}}",
|
||||
"action": {
|
||||
"add": "Ajouter",
|
||||
"apply": "Appliquer",
|
||||
@@ -2108,6 +2110,12 @@
|
||||
"clickable": {
|
||||
"label": "Cliquable"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"on": "Activé",
|
||||
"off": "Désactivé",
|
||||
"unavailable": "Indisponible",
|
||||
"unknown": "Inconnu"
|
||||
}
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
@@ -3547,8 +3555,13 @@
|
||||
"networkControllerSummary": {
|
||||
"option": {},
|
||||
"card": {
|
||||
"wan": "WAN",
|
||||
"web": "Web",
|
||||
"wifi": "Wi-Fi",
|
||||
"lan": "LAN",
|
||||
"vpn": {
|
||||
"countConnected": "{count} connecté"
|
||||
"label": "VPN",
|
||||
"countConnected": "{count, plural, one {# connecté} other {# connectés}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1405,6 +1405,8 @@
|
||||
"success": "הצלחה",
|
||||
"beta": "בטא",
|
||||
"error": "שגיאה",
|
||||
"total": "סה״כ",
|
||||
"filesystemUsage": "{count, plural, one {הצגת השימוש במערכת קבצים אחת} other {הצגת השימוש ב-# מערכות קבצים}}",
|
||||
"action": {
|
||||
"add": "הוסף",
|
||||
"apply": "החל",
|
||||
@@ -2108,6 +2110,12 @@
|
||||
"clickable": {
|
||||
"label": "ניתן ללחיצה"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"on": "פעיל",
|
||||
"off": "כבוי",
|
||||
"unavailable": "לא זמין",
|
||||
"unknown": "לא ידוע"
|
||||
}
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
@@ -3547,8 +3555,13 @@
|
||||
"networkControllerSummary": {
|
||||
"option": {},
|
||||
"card": {
|
||||
"wan": "WAN",
|
||||
"web": "אינטרנט",
|
||||
"wifi": "אלחוטי",
|
||||
"lan": "LAN",
|
||||
"vpn": {
|
||||
"countConnected": "{count} מחובר"
|
||||
"label": "VPN",
|
||||
"countConnected": "{count, plural, one {אחד מחובר} other {# מחוברים}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -3,3 +3,11 @@
|
||||
@import "@mantine/dates/styles.css";
|
||||
@import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||
@import "mantine-react-table/styles.css";
|
||||
|
||||
:root {
|
||||
--homarr-z-index-widget-background-tooltip: calc(var(--mantine-z-index-modal) - 4);
|
||||
--homarr-z-index-widget-preview-backdrop: calc(var(--mantine-z-index-modal) - 3);
|
||||
--homarr-z-index-board-header: calc(var(--mantine-z-index-modal) - 2);
|
||||
--homarr-z-index-widget-focus: calc(var(--mantine-z-index-modal) - 1);
|
||||
--homarr-z-index-modal-tooltip: calc(var(--mantine-z-index-modal) + 2);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getDefaultZIndex } from "@mantine/core";
|
||||
|
||||
import { useCurrentIntlLocale } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedDate } from "../../common/locale";
|
||||
|
||||
@@ -54,15 +52,17 @@ interface PortalTooltipProps {
|
||||
payload?: TooltipPayloadItem[];
|
||||
formatter: (value: number) => string;
|
||||
showTotal?: boolean;
|
||||
layer: BeszelTooltipLayer;
|
||||
}
|
||||
|
||||
const MARGIN = 14;
|
||||
const BACKGROUND_TOOLTIP_Z_INDEX = getDefaultZIndex("modal") - 3;
|
||||
export type BeszelTooltipLayer = "background" | "modal";
|
||||
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||
const MARGIN = 14;
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal, layer }: PortalTooltipProps) => {
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("common");
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
const [pos, setPos] = useState({ x: 0, y: 0, isAdvanced: false });
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const rafRef = useRef(0);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -73,10 +73,8 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
const el = tooltipRef.current;
|
||||
const mx = mouseRef.current.x;
|
||||
const my = mouseRef.current.y;
|
||||
const isAdvanced = Boolean(document.elementFromPoint(mx, my)?.closest("[data-advanced-focus-surface]"));
|
||||
|
||||
if (!el) {
|
||||
setPos({ x: mx + MARGIN, y: my, isAdvanced });
|
||||
setPos({ x: mx + MARGIN, y: my });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,7 +89,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
if (y < MARGIN) y = MARGIN;
|
||||
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
|
||||
|
||||
setPos({ x, y, isAdvanced });
|
||||
setPos({ x, y });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -126,7 +124,8 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
position: "fixed",
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
zIndex: pos.isAdvanced ? getDefaultZIndex("modal") : BACKGROUND_TOOLTIP_Z_INDEX,
|
||||
zIndex:
|
||||
layer === "modal" ? "var(--homarr-z-index-modal-tooltip)" : "var(--homarr-z-index-widget-background-tooltip)",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
@@ -143,7 +142,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
))}
|
||||
{showTotal && sorted.length > 1 && (
|
||||
<div style={{ ...styles.row, ...styles.separator }}>
|
||||
<span style={styles.name}>Total</span>
|
||||
<span style={styles.name}>{t("total")}</span>
|
||||
<span style={styles.value}>{formatter(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -154,7 +153,11 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
|
||||
export const makeTooltipProps = (
|
||||
formatter: (v: number) => string,
|
||||
showTotal = false,
|
||||
layer: BeszelTooltipLayer = "background",
|
||||
) => ({
|
||||
content: (props: any) => (
|
||||
<PortalTooltipContent
|
||||
active={props.active}
|
||||
@@ -162,6 +165,7 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
|
||||
payload={props.payload}
|
||||
formatter={formatter}
|
||||
showTotal={showTotal}
|
||||
layer={layer}
|
||||
/>
|
||||
),
|
||||
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user