Compare commits

...
Author SHA1 Message Date
Thomas Camlong def3ef8e15 fix: prevent icon selection on mobile when editing dashboard
Add user-select: none and -webkit-touch-callout: none to app icon
to prevent the browser from selecting the image when long-pressing,
which interferes with drag-to-move in edit mode on mobile devices.

Closes #2970
2026-06-14 17:20:46 +02:00
Thomas Camlong 2e709b102f feat(overlay): add integration avatars back to WidgetHoverOverlay 2026-06-14 17:18:44 +02:00
Thomas Camlong 875310f4d9 refactor(overlay): simplify WidgetHoverOverlay to bare minimum 2026-06-14 17:18:04 +02:00
Thomas Camlong 82064b2874 refactor(overlay): strip WidgetHoverOverlay to name-only 2026-06-14 17:17:31 +02:00
Thomas Camlong f1bed389e3 fix(overlay): make WidgetHoverOverlay purely visual with pointer-events none 2026-06-14 17:16:18 +02:00
ajnart b6c817123f style: auto-fix lint and format 2026-06-14 15:07:23 +00:00
Thomas 062ab1ad34 Merge branch 'dev' into feat/various-fixes 2026-06-14 17:06:10 +02:00
Thomas Camlong b7b7105f2d fix(overlay): show WidgetHoverOverlay only during edit mode 2026-06-14 17:05:40 +02:00
Thomas Camlong f0e20eb3da fix(beszel): correct unit scaling for system stats and container charts
Beszel API returns pre-scaled values, not raw bytes as the types claimed:
- Storage (du/mu/mb): GB — remove /BYTES_TO_GB division
- Disk I/O (dr/dw): MB/s — multiply by 1024² for formatters
- Container memory (m): MB — multiply by 1024² for formatters
- Container network (b/ns/nr): bytes/s — passthrough, unchanged

Also fix Recharts width(-1)/height(-1) warnings by adding w=100%
and minHeight:0 to BeszelAreaChart.
2026-06-14 17:05:34 +02:00
8 changed files with 72 additions and 79 deletions

No files matched your search

@@ -5,7 +5,7 @@
bottom: calc(100% - 10px); bottom: calc(100% - 10px);
margin-bottom: 1px; margin-bottom: 1px;
z-index: 200; z-index: 200;
pointer-events: auto; pointer-events: none;
opacity: 0; opacity: 0;
transform: translateY(6px); transform: translateY(6px);
transition: transition:
@@ -13,8 +13,10 @@
transform 150ms ease; transform 150ms ease;
} }
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper, :global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper { .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
.wrapper {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
@@ -77,6 +79,8 @@
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
margin-left: auto; margin-left: auto;
flex-wrap: nowrap;
overflow: hidden;
} }
.metadataItem { .metadataItem {
@@ -1,17 +1,14 @@
"use client"; "use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core"; import { Avatar, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions"; import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css"; import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps { interface WidgetHoverOverlayProps {
@@ -21,27 +18,17 @@ interface WidgetHoverOverlayProps {
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => { export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n(); const t = useI18n();
const { definition } = widgetImports[item.kind]; const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon; const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator; const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) => const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id), item.integrationIds.includes(integration.id),
); );
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) { if (!isEditMode) {
return null; return null;
} }
@@ -55,31 +42,15 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
</Text> </Text>
</div> </div>
{hasIntegrations && ( {connectedIntegrations.length > 0 && (
<Avatar.Group spacing="xs" className={classes.integrations}> <Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => ( {connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top"> <Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar <Avatar src={getIconUrl(integration.kind)} size={18} radius="sm" className={classes.integrationAvatar} />
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip> </Tooltip>
))} ))}
</Avatar.Group> </Avatar.Group>
)} )}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div> </div>
</div> </div>
); );
@@ -2,6 +2,7 @@
// it uses the sitemap.xml to generate the HomarrDocumentationPath type // it uses the sitemap.xml to generate the HomarrDocumentationPath type
export type HomarrDocumentationPath = export type HomarrDocumentationPath =
| "/about-us" | "/about-us"
| "/api-reference"
| "/blog" | "/blog"
| "/blog/2023/01/11/version0.11" | "/blog/2023/01/11/version0.11"
| "/blog/2023/04/16/version0.12-more-widgets" | "/blog/2023/04/16/version0.12-more-widgets"
@@ -42,9 +43,11 @@ export type HomarrDocumentationPath =
| "/docs/tags/active-directory" | "/docs/tags/active-directory"
| "/docs/tags/administration" | "/docs/tags/administration"
| "/docs/tags/advanced" | "/docs/tags/advanced"
| "/docs/tags/ai"
| "/docs/tags/analytics" | "/docs/tags/analytics"
| "/docs/tags/api" | "/docs/tags/api"
| "/docs/tags/apps" | "/docs/tags/apps"
| "/docs/tags/automation"
| "/docs/tags/background" | "/docs/tags/background"
| "/docs/tags/backup" | "/docs/tags/backup"
| "/docs/tags/boards" | "/docs/tags/boards"
@@ -55,9 +58,10 @@ export type HomarrDocumentationPath =
| "/docs/tags/community" | "/docs/tags/community"
| "/docs/tags/configuration" | "/docs/tags/configuration"
| "/docs/tags/connections" | "/docs/tags/connections"
| "/docs/tags/custom" | "/docs/tags/custom-widgets"
| "/docs/tags/customization" | "/docs/tags/customization"
| "/docs/tags/data-sources" | "/docs/tags/data-sources"
| "/docs/tags/data"
| "/docs/tags/database" | "/docs/tags/database"
| "/docs/tags/developer" | "/docs/tags/developer"
| "/docs/tags/development" | "/docs/tags/development"
@@ -78,9 +82,11 @@ export type HomarrDocumentationPath =
| "/docs/tags/integrations" | "/docs/tags/integrations"
| "/docs/tags/interface" | "/docs/tags/interface"
| "/docs/tags/jobs" | "/docs/tags/jobs"
| "/docs/tags/jsx"
| "/docs/tags/layout" | "/docs/tags/layout"
| "/docs/tags/ldap" | "/docs/tags/ldap"
| "/docs/tags/management" | "/docs/tags/management"
| "/docs/tags/mcp"
| "/docs/tags/media" | "/docs/tags/media"
| "/docs/tags/oidc" | "/docs/tags/oidc"
| "/docs/tags/open-collective" | "/docs/tags/open-collective"
@@ -217,10 +223,12 @@ export type HomarrDocumentationPath =
| "/docs/integrations/uptime-kuma" | "/docs/integrations/uptime-kuma"
| "/docs/management/api" | "/docs/management/api"
| "/docs/management/apps" | "/docs/management/apps"
| "/docs/management/backup"
| "/docs/management/boards" | "/docs/management/boards"
| "/docs/management/certificates" | "/docs/management/certificates"
| "/docs/management/custom-widgets" | "/docs/management/custom-widgets"
| "/docs/management/integrations" | "/docs/management/integrations"
| "/docs/management/mcp"
| "/docs/management/media" | "/docs/management/media"
| "/docs/management/search-engines" | "/docs/management/search-engines"
| "/docs/management/settings" | "/docs/management/settings"
@@ -94,8 +94,8 @@ export interface BeszelSystemDetails {
* *
* Units: * Units:
* - CPU/GPU/memory/disk percentages: 0–100 * - CPU/GPU/memory/disk percentages: 0–100
* - Memory/disk absolute values (mu, du, etc.): bytes * - Memory/disk absolute values (mu, du, m, d, etc.): GB
* - Disk I/O (dr, dw): bytes/s * - Disk I/O (dr, dw): MB/s
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges) * - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts * - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
*/ */
@@ -110,35 +110,35 @@ export interface BeszelSystemStats {
cpus?: number[]; cpus?: number[];
/** load average [1m, 5m, 15m] */ /** load average [1m, 5m, 15m] */
la?: [number, number, number]; la?: [number, number, number];
/** total memory (bytes) */ /** total memory (GB) */
m: number; m: number;
/** memory used (bytes) */ /** memory used (GB) */
mu: number; mu: number;
/** memory usage (%) */ /** memory usage (%) */
mp: number; mp: number;
/** memory buffer/cache (bytes) */ /** memory buffer/cache (GB) */
mb: number; mb: number;
/** memory max used (bytes) */ /** memory max used (GB) */
mm?: number; mm?: number;
/** memory zfs arc (bytes) */ /** memory zfs arc (GB) */
mz?: number; mz?: number;
/** total swap (bytes) */ /** total swap (GB) */
s: number; s: number;
/** swap used (bytes) */ /** swap used (GB) */
su: number; su: number;
/** total disk (bytes) */ /** total disk (GB) */
d: number; d: number;
/** disk used (bytes) */ /** disk used (GB) */
du: number; du: number;
/** disk usage (%) */ /** disk usage (%) */
dp: number; dp: number;
/** disk read (bytes/s) */ /** disk read (MB/s) */
dr: number; dr: number;
/** disk write (bytes/s) */ /** disk write (MB/s) */
dw: number; dw: number;
/** disk read max (bytes/s) */ /** disk read max (MB/s) */
drm?: number; drm?: number;
/** disk write max (bytes/s) */ /** disk write max (MB/s) */
dwm?: number; dwm?: number;
/** disk IOPS [read, write] */ /** disk IOPS [read, write] */
dio?: [number, number]; dio?: [number, number];
@@ -236,13 +236,14 @@ export interface BeszelContainer {
* One entry per container in a BeszelContainerStatsRecord. * One entry per container in a BeszelContainerStatsRecord.
* *
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr. * Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
* Units: m = MB, c = %, b/ns/nr = bytes/s
*/ */
export interface BeszelContainerStats { export interface BeszelContainerStats {
/** container name */ /** container name */
n: string; n: string;
/** CPU usage (%) */ /** CPU usage (%) */
c: number; c: number;
/** memory usage (bytes) */ /** memory usage (MB) */
m: number; m: number;
/** network sent (bytes/s) — legacy, use b when available */ /** network sent (bytes/s) — legacy, use b when available */
ns?: number; ns?: number;
+18 -14
View File
@@ -10,7 +10,6 @@ import type {
} from "../../beszel/beszel-types"; } from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024; const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
const KB = 1024; const KB = 1024;
interface MockSystemDef { interface MockSystemDef {
@@ -144,26 +143,27 @@ function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSys
const netSendWalk = smoothWalk(500, 200, count, 10, 8000); const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000); const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
const memTotal = sys.mem;
const diskTotal = rand(200, 2000) * GB; const diskTotal = rand(200, 2000) * GB;
return cpuWalk.map((cpu, i) => { return cpuWalk.map((cpu, i) => {
const memPct = memPctWalk[i] ?? sys.baseMem; const memPct = memPctWalk[i] ?? sys.baseMem;
const diskPct = diskPctWalk[i] ?? sys.baseDisk; const diskPct = diskPctWalk[i] ?? sys.baseDisk;
const memTotalGB = sys.mem / GB;
const diskTotalGB = diskTotal / GB;
return { return {
cpu, cpu,
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number], la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
m: memTotal, m: memTotalGB,
mu: (memTotal * memPct) / 100, mu: (memTotalGB * memPct) / 100,
mp: memPct, mp: memPct,
mb: rand(0.5, 4) * GB, mb: rand(0.5, 4),
s: 4 * GB, s: 4,
su: rand(0, 2) * GB, su: rand(0, 2),
d: diskTotal, d: diskTotalGB,
du: (diskTotal * diskPct) / 100, du: (diskTotalGB * diskPct) / 100,
dp: diskPct, dp: diskPct,
dr: (diskReadWalk[i] ?? 5) * MB, dr: diskReadWalk[i] ?? 5,
dw: (diskWriteWalk[i] ?? 3) * MB, dw: diskWriteWalk[i] ?? 3,
ns: (netSendWalk[i] ?? 500) * KB, ns: (netSendWalk[i] ?? 500) * KB,
nr: (netRecvWalk[i] ?? 800) * KB, nr: (netRecvWalk[i] ?? 800) * KB,
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number], b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
@@ -183,7 +183,7 @@ function generateContainerStatsSeries(count: number, profiles: typeof containerP
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({ walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
n: profile.name, n: profile.name,
c: cpuWalk[i] ?? profile.cpuBase, c: cpuWalk[i] ?? profile.cpuBase,
m: (memWalk[i] ?? profile.memMB) * MB, m: memWalk[i] ?? profile.memMB,
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number], b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
})), })),
); );
@@ -264,13 +264,15 @@ export class BeszelMockService {
const sys = resolveSystem(systemId); const sys = resolveSystem(systemId);
const intervalMinutes = typeToMinutes[type] ?? 1; const intervalMinutes = typeToMinutes[type] ?? 1;
const statsSeries = generateSystemStatsSeries(perPage, sys); const statsSeries = generateSystemStatsSeries(perPage, sys);
return buildTimeSeries( const result = buildTimeSeries(
systemId, systemId,
perPage, perPage,
intervalMinutes, intervalMinutes,
type, type,
statsSeries.map((stats) => ({ stats })), statsSeries.map((stats) => ({ stats })),
); );
return result;
} }
public async getContainerStatsAsync( public async getContainerStatsAsync(
@@ -284,13 +286,15 @@ export class BeszelMockService {
.map((i) => containerProfiles[i]) .map((i) => containerProfiles[i])
.filter(Boolean) as typeof containerProfiles; .filter(Boolean) as typeof containerProfiles;
const series = generateContainerStatsSeries(perPage, selectedProfiles); const series = generateContainerStatsSeries(perPage, selectedProfiles);
return buildTimeSeries( const result = buildTimeSeries(
systemId, systemId,
perPage, perPage,
intervalMinutes, intervalMinutes,
type, type,
series.map((stats) => ({ stats })), series.map((stats) => ({ stats })),
); );
return result;
} }
public async getAlertsAsync(): Promise<BeszelAlert[]> { public async getAlertsAsync(): Promise<BeszelAlert[]> {
+2
View File
@@ -6,6 +6,8 @@
object-fit: contain; object-fit: contain;
scale: 0.8; scale: 0.8;
transition: scale 0.2s ease-in-out; transition: scale 0.2s ease-in-out;
user-select: none;
-webkit-touch-callout: none;
} }
.appWithUrl:hover > .appIcon { .appWithUrl:hover > .appIcon {
@@ -63,23 +63,23 @@ export default function BeszelSystemStatsWidget({
{ staleTime: 30 * 1000 }, { staleTime: 30 * 1000 },
); );
// BeszelSystemStats fields are documented in beszel-types.ts. // BeszelSystemStats fields: storage values (m, mu, mb, d, du, s, su) are already in GB.
// mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s // Disk I/O (dr, dw) is in MB/s — scale to bytes/s for display formatters.
const BYTES_TO_GB = 1024 * 1024 * 1024; // Network (ns, nr, b) is in bytes/s. Container memory (m) is in MB.
const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]); const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]);
const memoryMap = useCallback( const memoryMap = useCallback(
(s: { mu: number; mb: number }) => ({ (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu / BYTES_TO_GB, [t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: (s.mb ?? 0) / BYTES_TO_GB, [t("chart.memory.cache")]: s.mb ?? 0,
}), }),
[t], [t],
); );
const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du / BYTES_TO_GB }), [t]); const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du }), [t]);
const diskIOMap = useCallback( const diskIOMap = useCallback(
(s: { dr?: number; dw?: number }) => ({ (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: s.dr ?? 0, [t("chart.diskIO.read")]: (s.dr ?? 0) * (1024 * 1024),
[t("chart.diskIO.write")]: s.dw ?? 0, [t("chart.diskIO.write")]: (s.dw ?? 0) * (1024 * 1024),
}), }),
[t], [t],
); );
@@ -46,7 +46,8 @@ export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style
withDots={false} withDots={false}
withXAxis withXAxis
withYAxis withYAxis
style={{ minWidth: 0, ...style }} w="100%"
style={{ minWidth: 0, minHeight: 0, ...style }}
yAxisProps={{ yAxisProps={{
...yAxisBase, ...yAxisBase,
...(yAxisDomain ? { domain: yAxisDomain } : {}), ...(yAxisDomain ? { domain: yAxisDomain } : {}),
@@ -83,10 +84,12 @@ export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] |
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number; type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
// c = CPU (%), m = memory (bytes), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s) // c = CPU (%), m = memory (MB), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s)
const MB = 1024 * 1024;
const defaultContainerExtractors: Record<string, ContainerExtractor> = { const defaultContainerExtractors: Record<string, ContainerExtractor> = {
cpu: (c) => c?.c ?? 0, cpu: (c) => c?.c ?? 0,
memory: (c) => c?.m ?? 0, memory: (c) => (c?.m ?? 0) * MB,
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)), network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
}; };