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
homarr-crowdin[bot] db8440bddf chore(lang): updated translations from crowdin (#5875) 2026-06-14 16:59:34 +02:00
43 changed files with 47023 additions and 555 deletions

No files matched your search

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