{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
-
-
-
{value}
-
{t(statKey as never)}
+
+
+ {value}
+
+ {t(statKey as never)}
+
))}
diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts
index 77534fec7..1d6d4b2a7 100644
--- a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts
+++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
-import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
+import { getGridCols, getIconSize, getVisibleStats } from "./shared";
describe("getGridCols", () => {
test("caps columns to visible stat count", () => {
@@ -37,17 +37,6 @@ describe("getIconSize", () => {
});
});
-describe("getVisibleStatLimit", () => {
- test("keeps compact metrics legible when height is constrained", () => {
- expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
- expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
- });
-
- test("preserves every configured metric outside compact mode", () => {
- expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
- });
-});
-
describe("getVisibleStats", () => {
const navidromeStats: NavidromeDashboardData = {
artistCount: 100,
@@ -117,21 +106,3 @@ describe("getVisibleStats", () => {
expect(typeof result[0]?.value).toBe("string");
});
});
-
-describe("prioritizeVisibleStats", () => {
- test("puts an active session first only on compact surfaces", () => {
- const stats = getVisibleStats(
- "audiobookshelf",
- { showLibraryCount: true, showActiveSessions: true },
- {
- libraryCount: 3,
- totalAudiobooks: 150,
- totalPodcasts: 20,
- totalListeningTimeSeconds: 86400,
- activeSessions: 2,
- },
- );
- expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
- expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
- });
-});
diff --git a/packages/widgets/src/audio-stats/component.module.css b/packages/widgets/src/audio-stats/component.module.css
index 4a16abfb6..95bfd1af7 100644
--- a/packages/widgets/src/audio-stats/component.module.css
+++ b/packages/widgets/src/audio-stats/component.module.css
@@ -14,6 +14,11 @@
gap: clamp(2px, 1cqi, 4px);
}
+.rootShort {
+ padding: 4px;
+ gap: 2px;
+}
+
.grid {
display: grid;
flex: 1;
@@ -29,6 +34,10 @@
gap: clamp(2px, 1cqi, 4px);
}
+.gridShort {
+ gap: 2px;
+}
+
.statTile {
display: flex;
flex-direction: column;
@@ -40,16 +49,31 @@
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
- background-color: transparent;
+ background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
+.statTileAdvanced {
+ background-color: transparent;
+}
+
.statTileCompact {
gap: clamp(1px, 0.8cqi, 2px);
padding: clamp(2px, 1.2cqi, 5px);
border-radius: var(--mantine-radius-sm);
}
+.statTileShort {
+ gap: 2px;
+ padding: 3px;
+}
+
+.statTileNarrowShort {
+ flex-direction: row;
+ gap: 2px;
+ padding: 1px 3px;
+}
+
.statIcon {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
@@ -57,7 +81,12 @@
}
.statLabel {
- font-size: clamp(8px, 2.5cqi, 11px);
+ flex-shrink: 0;
+ font-size: clamp(
+ calc(8px * var(--board-canvas-ui-scale, 1)),
+ calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
+ calc(11px * var(--board-canvas-ui-scale, 1))
+ );
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -68,11 +97,32 @@
}
.statTileCompact .statLabel {
- font-size: clamp(7px, 2cqi, 9px);
+ font-size: clamp(
+ calc(7px * var(--board-canvas-ui-scale, 1)),
+ calc(2cqi * var(--board-canvas-ui-scale, 1)),
+ calc(9px * var(--board-canvas-ui-scale, 1))
+ );
+}
+
+.statLabelShort {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
}
.statValue {
- font-size: clamp(13px, 5cqi, 20px);
+ flex-shrink: 0;
+ font-size: clamp(
+ calc(13px * var(--board-canvas-ui-scale, 1)),
+ calc(5cqi * var(--board-canvas-ui-scale, 1)),
+ calc(20px * var(--board-canvas-ui-scale, 1))
+ );
font-weight: 700;
color: var(--mantine-primary-color-filled);
line-height: 1.1;
@@ -84,7 +134,16 @@
}
.statTileCompact .statValue {
- font-size: clamp(11px, 4cqi, 16px);
+ font-size: clamp(
+ calc(11px * var(--board-canvas-ui-scale, 1)),
+ calc(4cqi * var(--board-canvas-ui-scale, 1)),
+ calc(16px * var(--board-canvas-ui-scale, 1))
+ );
+}
+
+.statValueNarrowShort {
+ font-size: calc(11px * var(--board-canvas-ui-scale, 1));
+ line-height: 1;
}
.emptyState {
diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx
index f4aaca4a8..4571f5533 100644
--- a/packages/widgets/src/audio-stats/component.tsx
+++ b/packages/widgets/src/audio-stats/component.tsx
@@ -18,6 +18,7 @@ export default function AudioStatsWidget({
options,
width,
height,
+ displayScale = 1,
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
@@ -39,14 +40,22 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? [];
if (!response) return
;
+ let responsiveWidth = width;
+ let responsiveHeight = height;
+ if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
+ responsiveWidth *= displayScale;
+ responsiveHeight *= displayScale;
+ }
+
const summary = (
);
if (displayMode === "compact") {
diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts
index 75a875b6a..f31f55592 100644
--- a/packages/widgets/src/audio-stats/shared.ts
+++ b/packages/widgets/src/audio-stats/shared.ts
@@ -156,23 +156,6 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
return Math.min(maxCols, Math.max(visibleCount, 1));
};
-export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
- if (!compact) return visibleCount;
-
- const columns = getGridCols(width, visibleCount, true);
- const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
- return Math.min(visibleCount, columns * rows);
-};
-
-export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
- if (!compact) return stats;
- return stats.toSorted(
- (left, right) =>
- Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
- Number(left.statKey === "activeSessions" && Number(left.value) > 0),
- );
-};
-
const fallbackIconSize = { true: 14, false: 16 } as const;
export const getIconSize = (width: number, compact: boolean): number => {
diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx
index 083d63ee6..94e32c52a 100644
--- a/packages/widgets/src/beszel-alerts/component.tsx
+++ b/packages/widgets/src/beszel-alerts/component.tsx
@@ -1,20 +1,7 @@
"use client";
import { useMemo } from "react";
-import {
- Badge,
- Box,
- Center,
- Divider,
- Group,
- Loader,
- ScrollArea,
- Stack,
- Text,
- ThemeIcon,
- Tooltip,
- VisuallyHidden,
-} from "@mantine/core";
+import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import dayjs from "dayjs";
@@ -53,7 +40,6 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
- height,
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelAlerts">) {
@@ -109,9 +95,7 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
- const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260;
- const showHistory = isAdvanced || (options.showHistory && height >= 360);
- const showAlertDescriptions = isAdvanced || height >= 190;
+ const showHistory = isAdvanced || options.showHistory;
if (isPending) {
return (
@@ -145,7 +129,7 @@ export default function BeszelAlertsWidget({
-
+
{t("status.triggered")} ({triggeredAlerts.length})
@@ -158,19 +142,19 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered
- showDescription={showAlertDescriptions}
+ isAdvanced={isAdvanced}
/>
))}
)}
- {triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 &&
}
+ {triggeredAlerts.length > 0 && okAlerts.length > 0 &&
}
- {showOkAlerts && okAlerts.length > 0 && (
+ {okAlerts.length > 0 && (
-
+
{t("status.ok")} ({okAlerts.length})
@@ -183,7 +167,7 @@ export default function BeszelAlertsWidget({
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
triggered={false}
- showDescription={showAlertDescriptions}
+ isAdvanced={isAdvanced}
/>
))}
@@ -195,7 +179,7 @@ export default function BeszelAlertsWidget({
-
+
{t("history")}
@@ -250,7 +234,7 @@ interface AlertRowProps {
systemName: string;
integrationName?: string;
triggered: boolean;
- showDescription: boolean;
+ isAdvanced: boolean;
}
const unitSuffixMap: Record = {
@@ -268,49 +252,46 @@ const unitSuffixMap: Record = {
Status: "",
};
-function formatAlertDescription(name: string, value: number, min: number): string {
- const suffix = unitSuffixMap[name] ?? "";
- if (name === "Status") return `down for ${min} min`;
- return `exceeds ${value}${suffix} over ${min} min`;
-}
-
-function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) {
+function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) {
+ const t = useI18n("widget.beszelAlerts");
const Icon = alertIconMap[name] ?? Server;
- const description = formatAlertDescription(name, value, min);
+ const suffix = unitSuffixMap[name] ?? "";
+ let description = t("alertDescription.threshold", { value: `${value}${suffix}`, minutes: min });
+ if (name === "Status") {
+ description = t("alertDescription.statusDuration", { minutes: min });
+ }
+ const accentColor = triggered ? "red" : "green";
+ const borderColor = triggered ? "red" : "gray";
+ const rowStyle = isAdvanced
+ ? {
+ borderRadius: "var(--mantine-radius-sm)",
+ border: `1px solid var(--mantine-color-${borderColor}-4)`,
+ backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
+ }
+ : {
+ borderRadius: "var(--mantine-radius-sm)",
+ borderLeft: `3px solid var(--mantine-color-${accentColor}-6)`,
+ backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
+ };
return (
-
-
-
-
-
-
- {integrationName ? `${systemName} · ${integrationName}` : systemName}
-
-
- ·
-
-
- {name}
-
-
- {showDescription && (
-
- {description}
-
- )}
- {!showDescription && {description}}
-
-
-
+
+
+
+
+
+ {integrationName ? `${systemName} · ${integrationName}` : systemName}
+
+
+ ·
+
+
+ {name}
+
+
+
+ {description}
+
+
+
);
}
diff --git a/packages/widgets/src/common/compact-stat-layout.ts b/packages/widgets/src/common/compact-stat-layout.ts
new file mode 100644
index 000000000..f644ab795
--- /dev/null
+++ b/packages/widgets/src/common/compact-stat-layout.ts
@@ -0,0 +1,74 @@
+const SHORT_HEIGHT_BREAKPOINT = 120;
+const NARROW_SHORT_WIDTH_BREAKPOINT = 120;
+const NARROW_SHORT_ICON_SIZE = 12;
+const SHORT_GRID_HORIZONTAL_INSET = 8;
+const SHORT_GRID_GAP = 2;
+const SHORT_ICON_WIDTH_RATIO = 0.44;
+const SHORT_ICON_SIZE_CAP = 40;
+
+export type CompactStatLayoutState = "default" | "short" | "narrowShort";
+
+interface CompactStatLayoutInput {
+ width: number;
+ height: number;
+ visibleCount: number;
+ /** Compact board display mode, independent of widget-specific compact styling options. */
+ compactDisplay: boolean;
+ defaultColumns: number;
+ defaultIconSize: number;
+}
+
+interface CompactStatLayout {
+ state: CompactStatLayoutState;
+ columns: number;
+ iconSize: number;
+}
+
+export function getCompactStatLayout({
+ width,
+ height,
+ visibleCount,
+ compactDisplay,
+ defaultColumns,
+ defaultIconSize,
+}: CompactStatLayoutInput): CompactStatLayout {
+ if (!compactDisplay) {
+ return {
+ state: "default",
+ columns: defaultColumns,
+ iconSize: defaultIconSize,
+ };
+ }
+
+ if (width < NARROW_SHORT_WIDTH_BREAKPOINT) {
+ return {
+ state: "narrowShort",
+ columns: 1,
+ iconSize: NARROW_SHORT_ICON_SIZE,
+ };
+ }
+
+ if (height >= SHORT_HEIGHT_BREAKPOINT) {
+ return {
+ state: "default",
+ columns: defaultColumns,
+ iconSize: defaultIconSize,
+ };
+ }
+
+ return {
+ state: "short",
+ columns: Math.max(visibleCount, 1),
+ iconSize: getShortIconSize(width, visibleCount, defaultIconSize),
+ };
+}
+
+function getShortIconSize(width: number, visibleCount: number, defaultIconSize: number): number {
+ const statCount = Math.max(visibleCount, 1);
+ const totalGridGap = Math.max(statCount - 1, 0) * SHORT_GRID_GAP;
+ const availableWidth = Math.max(width - SHORT_GRID_HORIZONTAL_INSET - totalGridGap, 0);
+ const availableWidthPerStat = availableWidth / statCount;
+ const proportionalIconSize = Math.round(availableWidthPerStat * SHORT_ICON_WIDTH_RATIO);
+ const cappedIconSize = Math.min(proportionalIconSize, SHORT_ICON_SIZE_CAP);
+ return Math.max(defaultIconSize, cappedIconSize);
+}
diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx
index 9fc46f402..7751ae61b 100644
--- a/packages/widgets/src/coolify/component.tsx
+++ b/packages/widgets/src/coolify/component.tsx
@@ -17,7 +17,6 @@ export default function CoolifyWidget({
options,
integrationIds,
width,
- height,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useI18n("widget.coolify");
@@ -38,7 +37,6 @@ export default function CoolifyWidget({
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
- height={height}
isAdvanced={isAdvanced}
/>
);
@@ -48,11 +46,10 @@ interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
- height: number;
isAdvanced: boolean;
}
-function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
+function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const t = useI18n("common");
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
@@ -64,8 +61,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
);
const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null);
- const isTiny = width < 256 || height < 144;
- const hideFooter = height < 112;
+ const isTiny = width < 256;
const [firstInstance] = successfulInstances;
const widgetKey = createWidgetKey(integrationIds);
@@ -100,7 +96,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
- hideFooter={!isAdvanced && hideFooter}
/>
@@ -120,7 +115,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
- hideFooter={!isAdvanced && hideFooter}
/>
))}
diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx
index d12de63e5..740a10945 100644
--- a/packages/widgets/src/coolify/instance-card.tsx
+++ b/packages/widgets/src/coolify/instance-card.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
+import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
@@ -22,12 +22,10 @@ interface InstanceCardProps {
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
- hideFooter: boolean;
}
-export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
+export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey }: InstanceCardProps) {
const t = useI18n("widget.coolify");
- const tCommon = useI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`,
@@ -53,32 +51,10 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
const runningServices = instance.instanceInfo.services.filter(
(s) => parseStatus(s.status ?? "") === "running",
).length;
- const healthyResources =
- (options.showServers ? onlineServers : 0) +
- (options.showApplications ? runningApps : 0) +
- (options.showServices ? runningServices : 0);
- const totalResources =
- (options.showServers ? instance.instanceInfo.servers.length : 0) +
- (options.showApplications ? instance.instanceInfo.applications.length : 0) +
- (options.showServices ? instance.instanceInfo.services.length : 0);
- const resourceSummary = [
- options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
- options.showApplications
- ? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
- : null,
- options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
- ]
- .filter(Boolean)
- .join(" · ");
return (
);
}
diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx
index 7120275ce..9f1656e90 100644
--- a/packages/widgets/src/coolify/resource-row.tsx
+++ b/packages/widgets/src/coolify/resource-row.tsx
@@ -114,7 +114,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
)}
- {!isTiny && logsUrl && (
+ {logsUrl && (
diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx
index 49edbb03b..f7efba90e 100644
--- a/packages/widgets/src/coolify/single-instance-layout.tsx
+++ b/packages/widgets/src/coolify/single-instance-layout.tsx
@@ -22,17 +22,9 @@ interface SingleInstanceLayoutProps {
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
- hideFooter: boolean;
}
-export function SingleInstanceLayout({
- instance,
- options,
- isTiny,
- isAdvanced,
- widgetKey,
- hideFooter,
-}: SingleInstanceLayoutProps) {
+export function SingleInstanceLayout({ instance, options, isTiny, isAdvanced, widgetKey }: SingleInstanceLayoutProps) {
const t = useI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -115,7 +107,7 @@ export function SingleInstanceLayout({
)}
- {!hideFooter &&
);
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index 6ef443999..6ec7f7336 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -180,6 +180,7 @@ export type WidgetComponentProps