diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx
index 3fa95d2d4..89f061f1e 100644
--- a/apps/docs/docs/management/boards/index.mdx
+++ b/apps/docs/docs/management/boards/index.mdx
@@ -133,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling.
- **Mobile** starts at `0px` and uses three columns on new boards.
-- **Base** is the widest layout and starts at `768px` on new boards.
+- **Base** is the main layout and starts at `768px` on new boards.
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
-rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
-their breakpoints.
+rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
+unused breakpoint above Mobile.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
-Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
-free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails.
+Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
+available breakpoint, and you can then change the breakpoint, columns, and rails.
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
-layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
-custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
+layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
+becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
+Duplicate breakpoints are normalized without discarding saved positions.
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
@@ -182,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### Breakpoint
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
-`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
+`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
+eligible layout with the highest breakpoint, regardless of its role.
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
fallback to select a layout.
@@ -271,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the
### Background
+When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
+
#### Background image URL
Homarr can display a background image behind apps and widgets.
diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx
index 2ed31ea88..9a4599a3d 100644
--- a/apps/docs/docs/widgets/media-request-list/index.mdx
+++ b/apps/docs/docs/widgets/media-request-list/index.mdx
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
+import { seerrIntegration } from "@site/docs/integrations/seerr";
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
{
integration: overseerrIntegration,
},
+ {
+ integration: seerrIntegration,
+ },
]}
/>
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
+- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx
index 7376bb0e4..7a06bf365 100644
--- a/apps/docs/docs/widgets/media-request-stats/index.mdx
+++ b/apps/docs/docs/widgets/media-request-stats/index.mdx
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
+import { seerrIntegration } from "@site/docs/integrations/seerr";
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
{
integration: overseerrIntegration,
},
+ {
+ integration: seerrIntegration,
+ },
]}
/>
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
+- When no requests exist yet, the widget offers media search and explains when statistics will appear.
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx
index 2912946e5..95cf296cb 100644
--- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx
+++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
-Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
+The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
index fa72af452..bf8cfc00f 100644
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
@@ -40,6 +40,8 @@ interface Props {
saveSettingsAsync: () => Promise;
}
+const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
+
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
@@ -101,6 +103,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
+ const displayedLayouts = form.values.layouts
+ .map((layout, index) => ({ layout, index }))
+ .toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
return (
@@ -134,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
- {form.values.layouts.map((layout, index) => {
+ {displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
- const customBreakpoints = form.values.layouts
- .filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
- .map((candidate) => candidate.breakpoint);
- const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
return (
breakpoint + 1))
- : 1
- }
- max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
+ min={layout.role === "mobile" ? 0 : 1}
+ max={32767}
/>
diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
index 452c22699..ad753f8d1 100644
--- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
- const result = createItemCallback({ kind: "mediaMissing" })(board);
+ const result = createItemCallback({ kind: "downloads" })(board);
const createdItem = result.items.at(0);
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
- expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
+ expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
]);
});
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
layoutId: layout.id,
sectionId: "canvas",
width: 1,
- height: 3,
+ height: 2,
xOffset: 0,
yOffset: 1,
}),
diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css
index c00ac34d4..3e30cd129 100644
--- a/apps/nextjs/src/components/board/items/item-content.module.css
+++ b/apps/nextjs/src/components/board/items/item-content.module.css
@@ -1,7 +1,7 @@
.settingsButton {
position: absolute;
- top: -34px;
- right: 14px;
+ top: calc(var(--mantine-spacing-sm) / 2);
+ right: calc(var(--mantine-spacing-sm) / 2);
z-index: 201;
opacity: 0;
pointer-events: none;
@@ -52,7 +52,6 @@
@media (max-width: 768px) {
.settingsButton {
- right: 4px;
opacity: 1;
pointer-events: auto;
}
diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
index 25a8708f9..5d8e95f59 100644
--- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
+++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
@@ -49,12 +49,14 @@
}
.widgetIcon {
+ width: var(--mantine-font-size-sm);
+ height: var(--mantine-font-size-sm);
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
- font-size: 11px;
+ font-size: var(--mantine-font-size-xs);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
@@ -94,7 +96,7 @@
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
- font-size: 10px;
+ font-size: var(--mantine-font-size-xxs);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
index a072d8c74..49f71bf7b 100644
--- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
+++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
@@ -2,7 +2,8 @@
width: 100%;
max-width: 100%;
min-width: 0;
- overflow: clip;
+ overflow-x: clip;
+ overflow-y: visible;
}
.viewport[data-board-hydrated="false"] {
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
index 53079451f..4b7e47054 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within {
- z-index: 10;
+ z-index: calc(var(--homarr-z-index-board-header) - 1);
}
.board-grid-entry[data-grid-preview="true"] {
diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx
index 558ba0c69..bf86ca341 100644
--- a/apps/nextjs/src/components/layout/shell.tsx
+++ b/apps/nextjs/src/components/layout/shell.tsx
@@ -27,6 +27,12 @@ export const ClientShell = ({
return (
doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
+ // background image only covers as much height as the content needs, so collapsing a
+ // container short enough leaves flat page background showing below it instead of the
+ // background continuing to the bottom of the viewport.
+ mih={backgroundProps.bg ? "100dvh" : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index e8aae1b60..e98d9dd4c 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -2814,14 +2814,11 @@ export const boardLayoutsNeedRepair = (
) => {
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
- const baseLayout = baseLayouts.at(0);
return (
mobileLayouts.length !== 1 ||
baseLayouts.length !== 1 ||
mobileLayouts.at(0)?.breakpoint !== 0 ||
- !baseLayout ||
- boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
);
};
diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts
index 4b765792a..5e31efef3 100644
--- a/packages/api/src/router/test/onboard.spec.ts
+++ b/packages/api/src/router/test/onboard.spec.ts
@@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => {
expect(mediaMissing).toBeDefined();
expect(
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
- ).toMatchObject({ width: 4, height: 3 });
+ ).toMatchObject({ width: 2, height: 2 });
});
it("requires an exact board id when more than one board exists", async () => {
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index 3e4cdebf2..e06769cda 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
}
}
- const mobileLayout = normalizedLayouts[0];
- const baseLayout = normalizedLayouts.at(-1);
+ const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile");
+ const baseLayout = normalizedLayouts.find((layout) => layout.role === "base");
if (mobileLayout && baseLayout) {
await insertMissingProjectedPositionsAsync(
db,
diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts
index 3bd5ffdd9..625248a95 100644
--- a/packages/definitions/src/board.ts
+++ b/packages/definitions/src/board.ts
@@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = <
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
- const orderedLayouts =
- mobileLayout && baseLayout
- ? [
- mobileLayout,
- ...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
- baseLayout,
- ]
- : sourceLayouts.toSorted(compareBoardLayouts);
+ const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined;
+ let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts);
+ if (hasProtectedLayouts) {
+ orderedLayouts = [
+ mobileLayout,
+ ...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts),
+ ];
+ }
let previousBreakpoint = -1;
return orderedLayouts.map((layout, index) => {
@@ -49,7 +49,12 @@ export const normalizeBoardLayoutRoles = <
index === 0
? 0
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
- const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
+ let role: LayoutRole = layout.role ?? "custom";
+ if (!hasProtectedLayouts) {
+ if (index === 0) role = "mobile";
+ else if (index === orderedLayouts.length - 1) role = "base";
+ else role = "custom";
+ }
previousBreakpoint = breakpoint;
return { ...layout, breakpoint, role };
});
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index 662ad5e57..320c01c24 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -66,14 +66,20 @@ export const widgetDefaultSizes: Partial layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
- ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
- }
-
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
}
diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css
index f9e9b584b..d41f57deb 100644
--- a/packages/widgets/src/beszel-system-stats/component.module.css
+++ b/packages/widgets/src/beszel-system-stats/component.module.css
@@ -1,11 +1,16 @@
.beszelStatsControls {
+ position: absolute;
+ z-index: 2;
+ top: var(--mantine-spacing-sm);
+ right: var(--mantine-spacing-sm);
+ left: var(--mantine-spacing-sm);
opacity: 0;
transition: opacity 150ms ease-out;
pointer-events: none;
}
-.beszelStatsContainer:hover .beszelStatsControls,
-.beszelStatsContainer:focus-within .beszelStatsControls {
+.beszelStatsRoot:hover .beszelStatsControls,
+.beszelStatsRoot:focus-within .beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx
index 0aca4348f..eb24774eb 100644
--- a/packages/widgets/src/beszel-system-stats/component.tsx
+++ b/packages/widgets/src/beszel-system-stats/component.tsx
@@ -23,11 +23,16 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
+const STACK_PADDING = 24;
+const SCROLL_FIT_BUFFER = 2;
+
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
+ height,
+ displayScale = 1,
boardId,
itemId,
setOptions,
@@ -66,6 +71,12 @@ export default function BeszelSystemStatsWidget({
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
];
+ let responsiveWidth = width;
+ let responsiveHeight = height;
+ if (Number.isFinite(displayScale) && displayScale > 0) {
+ responsiveWidth *= displayScale;
+ responsiveHeight *= displayScale;
+ }
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
@@ -180,82 +191,83 @@ export default function BeszelSystemStatsWidget({
}
return (
-
+
+ {!isEditMode && (
+
+ {systems.length > 1 && (
+
+
+ }
+ className={classes.beszelStatsSystemToggle}
+ disabled={isSelectionSavePending}
+ >
+ {selectedLabel}
+
+
+
+ {systems.map((system) => (
+ handleSelectSystem(system.value)}
+ >
+ {system.label}
+
+ ))}
+
+
+ )}
+ {responsiveWidth >= 560 ? (
+
+ {periodOptions.map((period) => (
+ handleTimePeriod(period.value)}
+ >
+ {period.label}
+
+ ))}
+
+ ) : (
+ value && handleTimePeriod(value)}
+ disabled={isSelectionSavePending}
+ data={periodOptions}
+ className={classes.beszelStatsPeriodToggle}
+ />
+ )}
+
+ )}
-
- {!isEditMode && (
-
- {systems.length > 1 && (
-
-
- }
- className={classes.beszelStatsSystemToggle}
- disabled={isSelectionSavePending}
- >
- {selectedLabel}
-
-
-
- {systems.map((system) => (
- handleSelectSystem(system.value)}
- >
- {system.label}
-
- ))}
-
-
- )}
- {width >= 560 ? (
-
- {periodOptions.map((period) => (
- handleTimePeriod(period.value)}
- >
- {period.label}
-
- ))}
-
- ) : (
- value && handleTimePeriod(value)}
- disabled={isSelectionSavePending}
- data={periodOptions}
- className={classes.beszelStatsPeriodToggle}
- />
- )}
-
- )}
+
600 ? 2 : 1}
+ columns={responsiveWidth > 600 ? 2 : 1}
+ availableHeight={Math.max(0, responsiveHeight - STACK_PADDING - SCROLL_FIT_BUFFER)}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx
index 7a9949b6d..89dacb535 100644
--- a/packages/widgets/src/beszel/_shared/chart.tsx
+++ b/packages/widgets/src/beszel/_shared/chart.tsx
@@ -101,12 +101,12 @@ interface BeszelChartPanelProps {
export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => (
-
-
+
+
{title}
{subtitle && (
-
+
{subtitle}
)}
diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx
index 66a24f38f..91996d266 100644
--- a/packages/widgets/src/beszel/_shared/stats-view.tsx
+++ b/packages/widgets/src/beszel/_shared/stats-view.tsx
@@ -35,13 +35,13 @@ const tooltips = {
bytesTotal: makeTooltipProps(formatStorageBytes, true),
};
-const ChartSkeleton = () => (
+const ChartSkeleton = ({ height }: { height: number }) => (
-
+
);
@@ -62,9 +62,20 @@ interface BeszelStatsViewProps {
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
+ availableHeight?: number;
onSwitchToHistorical?: () => void;
}
+const MIN_CHART_HEIGHT = 100;
+const PANEL_ROW_OVERHEAD = 25;
+const GRID_ROW_GAP = 16;
+
+const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => {
+ if (availableHeight === undefined || rowCount <= 0) return CHART_HEIGHT;
+ const perRow = (availableHeight - (rowCount - 1) * GRID_ROW_GAP) / rowCount - PANEL_ROW_OVERHEAD;
+ return Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, Math.floor(perRow)));
+};
+
const whenVisible = (visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
@@ -73,6 +84,7 @@ export function BeszelStatsView({
timePeriod,
visibility,
columns,
+ availableHeight,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useI18n("widget.beszelSystemStats");
@@ -226,10 +238,13 @@ export function BeszelStatsView({
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
+ const effectiveColumns = Math.min(columns, Math.max(1, visibleChartCount)) as 1 | 2;
+ const rowCount = Math.ceil(visibleChartCount / effectiveColumns);
+ const chartHeight = computeChartHeight(availableHeight, rowCount);
return (
-
+
{Array.from({ length: visibleChartCount }, (_, index) => (
-
+
))}
);
@@ -248,14 +263,29 @@ export function BeszelStatsView({
);
}
+ const renderedPanelCount =
+ (visibility.cpu && cpuData.length > 0 ? 1 : 0) +
+ (visibility.memory && memoryData.length > 0 ? 1 : 0) +
+ (visibility.disk && diskData.length > 0 ? 1 : 0) +
+ (visibility.diskIO && diskIOData.length > 0 ? 1 : 0) +
+ (visibility.network && networkData.length > 0 ? 1 : 0) +
+ (showDocker && containerSeries.length > 0
+ ? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) +
+ (visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) +
+ (visibility.dockerNetwork && dockerNetworkData.length > 0 ? 1 : 0)
+ : 0);
+ const effectiveColumns = Math.min(columns, Math.max(1, renderedPanelCount)) as 1 | 2;
+ const rowCount = Math.ceil(renderedPanelCount / effectiveColumns);
+ const chartHeight = computeChartHeight(availableHeight, rowCount);
+
return (
-
+
{visibility.cpu && cpuData.length > 0 && (
+ dayScaleTiers.find((tier) => minAxisSize >= tier.minAxisSize) ?? fallbackDayScale;
+
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const minAxisSize = Math.min(rootWidth, rootHeight);
- const shouldScaleDown = minAxisSize < 350;
const shouldShowIndicators = rootHeight >= 256;
- const indicatorSize = shouldScaleDown ? 3 : 4;
+ const { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]);
const cell = (
-
+
{date.getDate()}
-
+
);
@@ -70,10 +86,11 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
interface NotificationIndicatorProps {
events: CalendarEvent[];
size: number;
+ marginTop: number;
visible: boolean;
}
-const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => {
+const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
(color): color is string => Boolean(color),
);
@@ -82,7 +99,7 @@ const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorP
return (
{
+ const tSearch = useI18n("search.mode.media");
+
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+ {!isEditMode ? (
+ }
+ onClick={() => openMediaRequestSearch({ integrationIds })}
+ >
+ {tSearch("action.search.label")}
+
+ ) : null}
+
+ );
+};
diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx
index 617b332be..59d2e14be 100644
--- a/packages/widgets/src/media-requests/list/component.tsx
+++ b/packages/widgets/src/media-requests/list/component.tsx
@@ -20,7 +20,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
-import { NoIntegrationDataError } from "../../errors/no-data-integration";
+import { MediaRequestsEmptyState } from "../empty-state";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
@@ -30,6 +30,7 @@ export default function MediaServerWidget({
options,
width,
}: WidgetComponentProps<"mediaRequests-requestList">) {
+ const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
.filter(({ id }) => integrationIds.includes(id))
@@ -48,7 +49,16 @@ export default function MediaServerWidget({
if (isInitialWidgetQueryPending(mediaRequestQuery)) return ;
if (!mediaRequestData) return ;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
- if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
+ if (mediaRequests.length === 0 && failedIntegrations.length === 0) {
+ return (
+
+ );
+ }
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
return (
diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx
index 92aaac4c6..074014264 100644
--- a/packages/widgets/src/media-requests/stats/component.tsx
+++ b/packages/widgets/src/media-requests/stats/component.tsx
@@ -28,7 +28,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
-import { NoIntegrationDataError } from "../../errors/no-data-integration";
+import { MediaRequestsEmptyState } from "../empty-state";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
@@ -54,8 +54,16 @@ export default function MediaServerWidget({
requestStats.users.length === 0 &&
requestStats.stats.length === 0 &&
requestStats.failedIntegrations.length === 0
- )
- throw new NoIntegrationDataError();
+ ) {
+ return (
+
+ );
+ }
const data = [
{
diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx
index 8ccae86e4..efb2b4376 100644
--- a/packages/widgets/src/speedtest-tracker/component.tsx
+++ b/packages/widgets/src/speedtest-tracker/component.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useMemo, useState } from "react";
-import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
+import { useMemo } from "react";
+import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
@@ -21,14 +21,10 @@ export default function SpeedtestTrackerWidget({
options,
integrationIds,
width,
- height,
- displayMode = "compact",
}: WidgetComponentProps<"speedtestTracker">) {
const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
- const [advancedRange, setAdvancedRange] = useState("24");
- const isAdvanced = displayMode === "advanced";
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
const combined = useMemo(
@@ -36,98 +32,24 @@ export default function SpeedtestTrackerWidget({
[successfulDashboards],
);
- const rangeHours = isAdvanced ? Number(advancedRange) : 12;
- const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
+ const cutoff = Date.now() - 12 * 60 * 60 * 1000;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
- const showLatestResult = isAdvanced || options.showLatestResult;
- const showStats = isAdvanced || options.showStats;
- const showRecentResults = isAdvanced || options.showRecentResults;
- const showPingGraph = isAdvanced || options.showPingGraph;
+ const showLatestResult = options.showLatestResult;
+ const showStats = options.showStats;
+ const showRecentResults = options.showRecentResults;
+ const showPingGraph = options.showPingGraph;
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
const hasChart = showRecentResults && recentFiltered.length > 0;
- const noSectionsEnabled =
- !isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
- const advancedSectionWidth = width >= 720 ? width / 2 : width;
+ const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const latest = showLatestResult && combined.latestResult && (
-
+
);
const averages = showStats && combined.stats && (
-
+
);
- if (displayMode === "advanced") {
- const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1;
- const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32);
- const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth;
-
- return (
-
-
-
-
-
-
-
- {successfulDashboards.length === 0 ? (
-
- ) : (
-
- {successfulDashboards.map(({ integrationId, integrationName, dashboard }) => {
- const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff);
- const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null;
- const sourceHasChart = sourceRecent.length > 0;
-
- return (
-
-
-
- {integrationName}
-
- {sourceHasStats && (
- = 720 ? 2 : 1} spacing="md">
- {dashboard.latestResult && (
-
- )}
- {dashboard.stats && }
-
- )}
- {sourceHasChart && (
-
-
-
- )}
- {!sourceHasStats && !sourceHasChart && }
-
-
- );
- })}
-
- )}
-
-
-
- );
- }
-
return (
diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts
index dbc989d99..4ab6118f1 100644
--- a/packages/widgets/src/speedtest-tracker/index.ts
+++ b/packages/widgets/src/speedtest-tracker/index.ts
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
- supportsAdvancedFocus: true,
refetchInterval: null,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/uptime-kuma/component.module.css b/packages/widgets/src/uptime-kuma/component.module.css
index f56a12bda..57fb16aed 100644
--- a/packages/widgets/src/uptime-kuma/component.module.css
+++ b/packages/widgets/src/uptime-kuma/component.module.css
@@ -4,8 +4,8 @@
flex-direction: column;
height: 100%;
min-height: 0;
- padding: clamp(6px, 3cqi, 14px);
- gap: clamp(4px, 2cqi, 10px);
+ padding: clamp(6px, 3cqmin, 14px);
+ gap: clamp(4px, 2cqmin, 10px);
overflow: hidden;
}
@@ -13,11 +13,10 @@
display: flex;
align-items: center;
justify-content: space-between;
- gap: clamp(8px, 3cqi, 18px);
+ gap: clamp(8px, 3cqmin, 18px);
flex-shrink: 0;
- padding-bottom: clamp(4px, 2cqi, 10px);
- border-bottom: 1px solid
- rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
+ padding-bottom: clamp(4px, 2cqmin, 10px);
+ border-bottom: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.heroExpanded {
@@ -37,25 +36,25 @@
}
.heroTextOnly .heroValue {
- font-size: clamp(22px, 10cqi, 36px);
+ font-size: clamp(22px, 10cqmin, 36px);
}
.heroText {
display: flex;
flex-direction: column;
- gap: clamp(2px, 1cqi, 4px);
+ gap: clamp(2px, 1cqmin, 4px);
min-width: 0;
flex: 1;
}
.heroLabel {
- font-size: clamp(9px, 2.8cqi, 13px);
+ font-size: clamp(9px, 2.8cqmin, 13px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
}
.heroValue {
- font-size: clamp(18px, 8cqi, 32px);
+ font-size: clamp(18px, 8cqmin, 32px);
font-weight: 700;
line-height: 1.1;
}
@@ -68,7 +67,7 @@
display: grid;
flex: 1;
min-height: 0;
- gap: clamp(4px, 2cqi, 10px);
+ gap: clamp(4px, 2cqmin, 10px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
@@ -81,11 +80,10 @@
justify-content: center;
min-height: 0;
min-width: 0;
- gap: clamp(2px, 1.5cqi, 5px);
- padding: clamp(4px, 2.5cqi, 10px);
+ gap: clamp(2px, 1.5cqmin, 5px);
+ padding: clamp(4px, 2.5cqmin, 10px);
border-radius: var(--mantine-radius-md);
- border: 1px solid
- rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
+ border: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
overflow: hidden;
}
@@ -95,14 +93,14 @@
}
.statValue {
- font-size: clamp(14px, 5.5cqi, 24px);
+ font-size: clamp(14px, 5.5cqmin, 24px);
font-weight: 700;
line-height: 1.1;
text-align: center;
}
.statLabel {
- font-size: clamp(8px, 2.5cqi, 12px);
+ font-size: clamp(8px, 2.5cqmin, 12px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -119,6 +117,6 @@
justify-content: center;
min-height: 0;
color: var(--mantine-color-dimmed);
- font-size: clamp(11px, 3cqi, 14px);
+ font-size: clamp(11px, 3cqmin, 14px);
text-align: center;
}
diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx
index 908d8ed52..6108fec8f 100644
--- a/packages/widgets/src/uptime-kuma/component.tsx
+++ b/packages/widgets/src/uptime-kuma/component.tsx
@@ -129,8 +129,9 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
);
const uptimeValue = clampPercent(combined.averageUptimePercent);
const uptimeColor = getUptimeColor(uptimeValue);
- const ringSize = getRingSize(width);
- const iconSize = getIconSize(width);
+ const visualScale = Math.min(width, height);
+ const ringSize = getRingSize(visualScale);
+ const iconSize = getIconSize(visualScale);
const gridCols = getGridCols(width);
const isAdvanced = displayMode === "advanced";
@@ -247,7 +248,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
- {summaryContent}
+ {summaryContent}
{monitorList}