Compare commits

...
76 changed files with 694 additions and 220 deletions

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
- Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/`
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
When a change affects setup, usage, troubleshooting, or the accuracy of existing docs, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
+13 -5
View File
@@ -1,13 +1,21 @@
---
name: documentation-sync
description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand.
description: Keep Homarr's Docusaurus documentation useful and accurate. Use when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate.
---
# Documentation Sync
Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together.
Update `apps/docs/` when users need new or corrected guidance to configure, use, or troubleshoot Homarr. Keep documentation straight to the point.
## Map the change
## Decide whether documentation is needed
Identify the user action, decision, prerequisite, limitation, or troubleshooting step that changes. Update the relevant docs when that information is missing or inaccurate. If nothing users need to know changes, leave the docs untouched.
Routine visual fixes such as icon sizing, spacing, alignment, responsive layout, and animation usually need no documentation. Describe them in the PR when useful for review. Document a visual change only when it changes a user workflow, setting, or meaningful limitation.
Write the minimum information needed to complete the user's task. Inspect adjacent pages and the docs types before editing.
## Map changes that need documentation
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
@@ -44,8 +52,8 @@ For a widget:
## Completion criteria
1. Describe the shipped behavior, not an implementation plan.
1. Every addition helps a user configure, use, or troubleshoot Homarr; omit incidental rendering and implementation details.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance.
3. Correct affected guidance and remove superseded instructions. A code change alone does not require a docs edit.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
5. Treat broken links and anchors as failures; Docusaurus checks them strictly.
@@ -1,4 +1,4 @@
interface:
display_name: "Homarr Documentation Sync"
short_description: "Keep user-facing docs aligned with code"
default_prompt: "Use $documentation-sync to map this Homarr code change to the required documentation updates."
short_description: "Keep user docs concise, useful, and accurate"
default_prompt: "Use $documentation-sync to decide whether this Homarr change needs user guidance and update only what users need to know."
+1 -1
View File
@@ -10,7 +10,7 @@
- [ ] Pull request targets the intended base branch (`dev` by default; `release/v2` for v2 release work)
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (for example, `x`, `y`, `i`, or abbreviations)
- [ ] User-facing changes include matching documentation updates under `apps/docs`
- [ ] Changes to setup, usage, troubleshooting, or existing guidance include relevant documentation updates under `apps/docs` when needed
- [ ] When using AI, no temporary files are checked in and the code style follows the rest of the project
**When contributing a new integration or widget:**
+2 -2
View File
@@ -59,7 +59,7 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
When modifying user-facing code, you MUST also update the corresponding documentation:
Update documentation when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate. Follow `.agents/skills/documentation-sync/SKILL.md` to decide whether an update is needed and keep it concise. For changes that need documentation:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -99,6 +99,6 @@ When modifying user-facing code, you MUST also update the corresponding document
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — required user-facing documentation updates
- `documentation-sync` — concise, relevant user guidance
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
+2 -1
View File
@@ -12,7 +12,8 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
check from the tile's edit actions.
check from the tile's edit actions. Very small tiles shorten the title and description to leave room for the icon;
enlarge the tile to show more text.
### Adding the widget
+2 -1
View File
@@ -2304,7 +2304,8 @@
"traefik",
"customApi",
"assistant",
"wud"
"wud",
"llamacpp"
]
},
"options": {
@@ -27,7 +27,7 @@ import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-s
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useBoardContentScale } from "~/components/board/layout/scaled-board-canvas";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -148,7 +148,7 @@ const LoadedBoardItemContent = ({
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const board = useRequiredBoard();
const boardCanvasScale = useBoardCanvasScale();
const boardContentScale = useBoardContentScale();
const t = useI18n();
const [isEditMode] = useEditMode();
const settings = useSettings();
@@ -163,8 +163,8 @@ const LoadedBoardItemContent = ({
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = getWidgetName(item.kind, t);
const previewDimensions = useMemo(
() => ({ width, height, scale: boardCanvasScale }),
[boardCanvasScale, height, width],
() => ({ width, height, scale: boardContentScale }),
[boardContentScale, height, width],
);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
@@ -314,7 +314,7 @@ const LoadedBoardItemContent = ({
item={item}
width={width}
height={height}
displayScale={boardCanvasScale}
displayScale={boardContentScale}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
@@ -17,7 +17,10 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
}
.canvas,
.contentScale {
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
@@ -8,6 +8,10 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
// Widget content can also be scaled by containers inside the board canvas.
const BoardContentScaleContext = createContext(1);
export const BoardContentScaleProvider = BoardContentScaleContext.Provider;
export const useBoardContentScale = () => useContext(BoardContentScaleContext);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -86,7 +90,9 @@ export const ScaledBoardCanvas = ({
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
<BoardCanvasScaleContext.Provider value={scale}>
<BoardContentScaleProvider value={scale}>{children}</BoardContentScaleProvider>
</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -18,7 +18,13 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import {
BoardContentScaleProvider,
calculateBoardUiScale,
useBoardCanvasScale,
useBoardContentScale,
} from "~/components/board/layout/scaled-board-canvas";
import canvasClasses from "~/components/board/layout/scaled-board-canvas.module.css";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -50,6 +56,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const parentContentScale = useBoardContentScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -144,10 +151,16 @@ export const SectionGrid = ({
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
let effectiveParentContentScale = effectiveCanvasScale;
if (Number.isFinite(parentContentScale) && parentContentScale > 0) {
effectiveParentContentScale = parentContentScale;
}
// Compensate every ancestor container while retaining the board's enlargement above 100%.
const parentUiScale =
(calculateBoardUiScale(effectiveCanvasScale) * effectiveCanvasScale) / effectiveParentContentScale;
// Match the label's Mantine height in its parent's logical coordinate system.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT * parentUiScale : 0;
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
@@ -172,7 +185,8 @@ export const SectionGrid = ({
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const combinedUiScale = parentUiScale / containerContentScale;
const contentScale = effectiveParentContentScale * containerContentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -282,21 +296,23 @@ export const SectionGrid = ({
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
>
<Box
className={classes.staticGrid}
className={combineClasses(classes.staticGrid, canvasClasses.contentScale)}
style={
{
width: fullGridWidth,
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
"--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
<BoardContentScaleProvider value={contentScale}>
<SectionContent />
</BoardContentScaleProvider>
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
+14 -1
View File
@@ -8,6 +8,7 @@ import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -27,7 +28,19 @@ import {
getUvCategory,
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
export default function AirQualityWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"airQuality">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
+11 -2
View File
@@ -11,6 +11,7 @@ import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -37,10 +38,18 @@ const isForbiddenError = (error: unknown): boolean => {
export default function AnchorNoteWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"anchorNote">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
+9 -1
View File
@@ -1,8 +1,16 @@
.appText {
min-width: 0;
min-height: 0;
flex-shrink: 1;
overflow-wrap: anywhere;
overflow: hidden;
}
.appIcon {
max-height: 100%;
max-width: 100%;
overflow: auto;
flex: 1 !important;
flex: 1;
object-fit: contain;
scale: 0.8;
transition: scale 0.2s ease-in-out;
+44 -11
View File
@@ -1,8 +1,8 @@
"use client";
import type { PropsWithChildren } from "react";
import type { CSSProperties, PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -12,6 +12,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { getWidgetDisplayScale } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -21,7 +22,14 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
export default function AppWidget({
options,
isEditMode,
height,
width,
displayScale = 1,
displayMode,
}: WidgetComponentProps<"app">) {
const tCommon = useI18n("common");
const settings = useSettings();
const board = useRequiredBoard();
@@ -33,8 +41,31 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
// Readable board tokens stay fixed on screen, but must yield space to the icon
// when the tile itself becomes small. Below 100px, scale the whole composition.
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const maximumContentScale = Math.min(width, height) / 100;
let uiScale = "var(--board-canvas-ui-scale, 1)";
if (displayMode === "advanced") uiScale = "1";
const textSize = `min(calc(14px * ${uiScale}), ${14 * maximumContentScale}px)`;
const spacing = `min(calc(12px * ${uiScale}), ${12 * maximumContentScale}px)`;
const rowGap = `min(calc(6px * ${uiScale}), ${6 * maximumContentScale}px)`;
const isColumnLayout = options.layout.startsWith("column");
const isTiny = Math.min(width, height) * scale < 100;
const textStyle: CSSProperties = {};
let titleLineClamp: number | undefined;
let descriptionLineClamp = 4;
if (isTiny) {
titleLineClamp = 2;
descriptionLineClamp = 2;
textStyle.flexShrink = 1;
textStyle.maxWidth = "50%";
textStyle.maxHeight = "100%";
if (isColumnLayout) {
textStyle.maxWidth = "100%";
textStyle.maxHeight = "50%";
}
}
return (
<Box h="100%" w="100%" pos="relative">
@@ -44,22 +75,22 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
style={{ padding: spacing, gap: isColumnLayout ? 0 : rowGap }}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
<Stack gap={0} className={classes.appText} style={textStyle}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
lineClamp={titleLineClamp}
style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
@@ -68,10 +99,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
lineClamp={descriptionLineClamp}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -88,9 +119,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
flex: isColumnLayout ? "1 0 25%" : "0 0 50%",
height: "100%",
width: "100%",
minWidth: "20%",
minWidth: 0,
minHeight: 0,
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
@@ -7,15 +7,24 @@ import { getIconUrl } from "@homarr/definitions";
import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0];
if (!integrationId) {
@@ -8,6 +8,7 @@ import { formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { useByteFormatter } from "@homarr/settings";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -42,12 +43,12 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />;
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const summary = (
<AudioStatsContent
+11 -2
View File
@@ -9,6 +9,7 @@ import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
@@ -58,10 +59,18 @@ const iconSizeByWidth = [
export default function BazarrWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"bazarr">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.bazarr");
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
@@ -37,6 +37,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
@@ -456,10 +457,17 @@ export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"beszelSystemGrid">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.beszel");
const tCommon = useI18n("common");
const board = useRequiredBoard();
@@ -520,7 +528,9 @@ export default function BeszelSystemGridWidget({
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
gridTemplateRows: scrollEnabled
? `repeat(${rows}, ${minimumCellHeight / getWidgetDisplayScale({ displayScale, displayMode })}px)`
: `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
@@ -13,6 +13,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
@@ -33,6 +34,7 @@ export default function BeszelSystemStatsWidget({
width,
height,
displayScale = 1,
displayMode,
boardId,
itemId,
setOptions,
@@ -71,12 +73,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;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -112,12 +113,16 @@ export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
width,
width: logicalWidth,
boardId,
itemId,
setOptions,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"beszelSystemTable">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.beszelSystemTable");
const tBeszel = useI18n("widget.beszel");
const tCommon = useI18n("common");
@@ -25,6 +25,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -68,12 +69,12 @@ export default function BookmarksWidget({
});
const data = [...configuredItems, ...legacyItems];
let responsiveWidth = width;
let responsiveHeight = height;
if (!advanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const contentHeight = Math.max(0, responsiveHeight - (options.title.length > 0 ? 36 : 0));
let layoutWidth = responsiveWidth;
if (options.layout === "gridHorizontal") layoutWidth = width;
+20 -2
View File
@@ -13,6 +13,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -68,7 +69,15 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
displayMode,
displayScale,
}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth() + 1,
@@ -111,11 +120,13 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
setMonth={setMonth}
options={options}
displayMode={displayMode}
displayScale={displayScale}
/>
);
};
interface CalendarBaseProps {
displayScale?: number;
isEditMode: boolean;
events: CalendarEventWithSource[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
@@ -135,13 +146,20 @@ const CalendarBase = ({
setMonth,
options,
displayMode,
displayScale,
}: CalendarBaseProps) => {
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const { ref, width: logicalWidth, height: logicalHeight } = useElementSize();
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isSmall = width < 256;
const normalizedEvents = useMemo(
+14 -1
View File
@@ -4,6 +4,7 @@ import { Box, Center, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { AdvancedClockView } from "./advanced-view";
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
@@ -13,7 +14,19 @@ import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
dayjs.extend(advancedFormat);
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
export default function ClockWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"clock">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced";
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
const time = useCurrentTime({ showSeconds });
@@ -0,0 +1,22 @@
interface WidgetDisplay {
displayScale?: number;
displayMode?: "compact" | "advanced";
}
export const getWidgetDisplayScale = ({ displayScale = 1, displayMode }: WidgetDisplay): number => {
// Advanced surfaces are outside the scaled board, even when their source tile is not.
if (displayMode === "advanced" || !Number.isFinite(displayScale) || displayScale <= 0) return 1;
return displayScale;
};
/** Visible dimensions for density, breakpoints, and content budgets.
* Keep the original logical dimensions for canvas/SVG geometry and CSS pixel sizes.
*/
export const getWidgetLayoutSize = ({
width,
height,
...display
}: WidgetDisplay & { width: number; height: number }) => {
const scale = getWidgetDisplayScale(display);
return { width: width * scale, height: height * scale };
};
+6 -1
View File
@@ -5,6 +5,7 @@ import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -16,9 +17,13 @@ import type { InstanceData } from "./types";
export default function CoolifyWidget({
options,
integrationIds,
width,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"coolify">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.coolify");
if (integrationIds.length === 0) {
+2
View File
@@ -175,8 +175,10 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
isEditMode: boolean;
displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
/** Logical pixels for rendering geometry. Use getWidgetLayoutSize for responsive decisions. */
width: number;
height: number;
/** Board scale; advanced surfaces render outside the board at scale 1. */
displayScale?: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
@@ -38,6 +38,7 @@ import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerPopover from "./timer-popover";
@@ -58,9 +59,12 @@ export default function DnsHoleControlsWidget({
options,
integrationIds,
isEditMode,
width,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<typeof widgetKind>) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
@@ -29,6 +29,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
@@ -38,9 +39,18 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
export default function DnsHoleSummaryWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
@@ -67,7 +77,14 @@ export default function DnsHoleSummaryWidget({
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
<StatCard
displayScale={scale}
key={item.color}
item={item}
usePiHoleColors={options.usePiHoleColors}
data={data}
t={t}
/>
))
) : (
<Stack
@@ -171,13 +188,15 @@ interface StatItem {
}
interface StatCardProps {
displayScale: number;
item: StatItem;
data: DnsHoleSummary[];
usePiHoleColors: boolean;
t: TranslationFunction;
}
const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const { ref, height, width } = useElementSize();
const StatCard = ({ item, data, usePiHoleColors, t, displayScale }: StatCardProps) => {
const { ref, height: logicalHeight, width: logicalWidth } = useElementSize();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const isLong = width > height + 20;
const canStackText = height > 32;
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
+6 -1
View File
@@ -46,6 +46,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
@@ -241,13 +242,17 @@ const useContainerAction = (action: ContainerAction) => {
export default function DockerWidget({
options,
width,
width: logicalWidth,
isEditMode,
displayMode,
boardId,
itemId,
setOptions,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"dockerContainers">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("docker");
const tCommon = useI18n("common");
const tWidget = useI18n("widget.dockerContainers");
+11 -2
View File
@@ -53,6 +53,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -302,10 +303,18 @@ export default function DownloadClientsWidget({
setOptions,
boardId,
itemId,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"downloads">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { formatByteRate, formatBytes, formatBytesPair } = useByteFormatter();
const board = useOptionalBoard();
const { data: session } = useSession();
+11 -2
View File
@@ -25,6 +25,7 @@ import type { FirewallInterfacesSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
@@ -60,11 +61,19 @@ export const hasFirewallPartialFailure = (firewallId: string, queries: readonly
export default function FirewallWidget({
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
itemId,
displayMode,
displayScale,
}: WidgetComponentProps<"firewall">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180);
@@ -8,6 +8,7 @@ import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition";
@@ -38,6 +39,8 @@ export const ClusterHealthMonitoring = ({
integrationId,
options,
width,
height,
displayScale,
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n("widget.healthMonitoring");
@@ -68,7 +71,8 @@ export const ClusterHealthMonitoring = ({
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
const isAdvanced = displayMode === "advanced";
const isTiny = displayMode !== "advanced" && width < 256;
const { width: responsiveWidth } = getWidgetLayoutSize({ width, height, displayScale, displayMode });
const isTiny = displayMode !== "advanced" && responsiveWidth < 256;
return (
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
{(isAdvanced || options.showUptime) && !isTiny && (
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const CpuRing = ({
cpuUtilization,
@@ -17,6 +18,7 @@ export const CpuRing = ({
}) => {
return (
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu"
aria-label={ariaLabel}
roundCaps
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
const CPU_TEMPERATURE_MIN_CELSIUS = 0;
const CPU_TEMPERATURE_MAX_CELSIUS = 100;
@@ -31,6 +32,7 @@ export const CpuTempRing = ({
return (
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu-temperature"
aria-label={ariaLabel}
roundCaps
@@ -5,6 +5,7 @@ import { IconDeviceDesktop } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
interface GpuRingProps {
gpu: {
@@ -43,6 +44,7 @@ export const GpuRing = ({ gpu, isTiny, fahrenheit, ariaLabel }: GpuRingProps) =>
multiline
>
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className={`health-monitoring-gpu health-monitoring-gpu-${gpu.gpuId}`}
aria-label={ariaLabel}
roundCaps
@@ -6,6 +6,7 @@ import { useByteFormatter } from "@homarr/settings";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const MemoryRing = ({
available,
@@ -24,6 +25,7 @@ export const MemoryRing = ({
return (
<Tooltip label={`${memoryUsage.memUsed.percent}%`}>
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-memory"
aria-label={ariaLabel}
roundCaps
@@ -33,3 +33,12 @@
transition: none;
}
}
/* Gauge labels live inside an SVG foreignObject. The SVG already scales its
* coordinate system to the compensated gauge size; compensating again makes
* the label grow as the board shrinks. */
.gaugeLabel {
--mantine-scale: 1;
--board-canvas-ui-scale: 1;
--mantine-font-size-xs: 0.75rem;
}
@@ -42,6 +42,7 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -61,6 +62,8 @@ export const SystemHealthMonitoring = ({
options,
integrationIds,
width,
height,
displayScale = 1,
displayMode,
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
@@ -75,7 +78,13 @@ export const SystemHealthMonitoring = ({
const { formatBytes, formatBytesPair } = useByteFormatter();
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const isTiny = !isAdvanced && (responsiveWidth < 256 || responsiveHeight < 160);
const showCpu = isAdvanced || options.cpu;
const showMemory = isAdvanced || options.memory;
const showGpu = isAdvanced || options.gpu;
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -45,12 +46,20 @@ const statusPresentation = {
export default function IndexerManagerWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
isEditMode,
widgetRuntimeRef,
displayMode,
displayScale,
}: WidgetComponentProps<"indexerManager">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.indexerManager");
const tCommon = useI18n("common");
const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
+6 -4
View File
@@ -20,11 +20,11 @@ import { formatBytes, formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
// Board grid: a widget N cells wide is N*200 + (N-1)*12 px (1→200, 2→412, 3→624, 4→836).
// Width drives how much info fits side-by-side; height only matters for the
// Visible width drives how much info fits side-by-side; height only matters for the
// single-row (short) case where the widget must drop down to essentials.
const isNarrowWidth = (width: number) => width < 300; // 1 cell (200px)
const isWideWidth = (width: number) => width >= 550; // 3+ cells (624px+)
@@ -43,9 +43,11 @@ export default function LlamacppWidget({
options,
width,
height,
displayScale,
displayMode = "compact",
}: WidgetComponentProps<"llamacpp">) {
const integrationId = integrationIds[0];
const layoutSize = getWidgetLayoutSize({ width, height, displayScale, displayMode });
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
@@ -54,8 +56,8 @@ export default function LlamacppWidget({
<LlamacppContent
integrationId={integrationId}
options={options}
width={width}
height={height}
width={layoutSize.width}
height={layoutSize.height}
displayMode={displayMode}
/>
);
@@ -26,6 +26,7 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -66,10 +67,13 @@ const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: b
export default function MediaMissingWidget({
integrationIds,
options,
width,
width: logicalWidth,
displayMode,
widgetRuntimeRef,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaMissing">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const showMissing = isAdvanced || options.showMissing;
@@ -13,6 +13,7 @@ import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -28,8 +29,13 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
options,
width,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
@@ -21,6 +21,7 @@ import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -38,9 +39,18 @@ const JELLYSEERR_COLOR = "#6677CC";
export default function MediaServerWidget({
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.mediaRequests-requestStats");
const tCommon = useI18n("common");
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
@@ -38,6 +38,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -169,10 +170,13 @@ function StreamTableHeader({
export default function MediaServerWidget({
options,
integrationIds,
width,
width: logicalWidth,
isEditMode,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaServer">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
@@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -51,11 +52,19 @@ const getTranscodingWidthLayout = (width: number) =>
export default function MediaTranscodingWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
widgetRuntimeRef,
displayScale,
}: WidgetComponentProps<"mediaTranscoding">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({
@@ -9,14 +9,22 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { WidgetEmptyState } from "../../common/empty-state";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n("widget.minecraftServerStatus");
const tCommon = useI18n("common");
@@ -5,6 +5,7 @@ import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text }
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
@@ -41,12 +42,12 @@ export default function NetworkControllerNetworkStatusWidget({
const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
let responsiveWidth = width;
let responsiveHeight = height;
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const layout = getNetworkControllerStatusLayout({
width: responsiveWidth,
height: responsiveHeight,
+6 -1
View File
@@ -58,6 +58,7 @@ import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
@@ -88,9 +89,13 @@ export function Notebook({
isEditMode,
boardId,
itemId,
height,
height: logicalHeight,
displayMode = "compact",
width: logicalWidth,
displayScale,
}: WidgetComponentProps<"notebook">) {
const { height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(options.content);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -149,12 +150,12 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const advanced = displayMode === "advanced";
const layout = getCompactStatLayout({
width: responsiveWidth,
+11 -2
View File
@@ -20,6 +20,7 @@ import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -70,10 +71,18 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
export default function PatchMonWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"patchmon">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
+14 -1
View File
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -43,7 +44,19 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
};
};
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"rssFeed">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const feed = useLiveFeed({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -10,6 +10,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
import { getEntityStateLabel } from "./state";
@@ -18,10 +19,18 @@ export default function SmartHomeEntityStateWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-entityState">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.smartHome-entityState");
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
@@ -21,6 +21,7 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
@@ -28,10 +29,17 @@ export default function SmartHomeTriggerAutomationWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-executeAutomation">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0];
const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const [lastExecutedAt, setLastExecutedAt] = React.useState<Date | null>(null);
@@ -14,10 +14,12 @@ export function AveragesSection({
stats,
width = 500,
compactSurface = false,
displayScale,
}: {
stats: SpeedtestTrackerStats;
width?: number;
compactSurface?: boolean;
displayScale?: number;
}) {
const t = useI18n("widget.speedtestTracker");
@@ -28,6 +30,7 @@ export function AveragesSection({
</SectionLabel>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown}
color="blue"
value={formatStatsSpeed(stats.download)}
@@ -36,6 +39,7 @@ export function AveragesSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp}
color="teal"
value={formatStatsSpeed(stats.upload)}
@@ -44,6 +48,7 @@ export function AveragesSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine}
color="orange"
value={`${stats.ping.avg.toFixed(1)} ms`}
@@ -6,6 +6,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -20,8 +21,13 @@ const emptyDashboardData = [] as const;
export default function SpeedtestTrackerWidget({
options,
integrationIds,
width,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"speedtestTracker">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
@@ -44,10 +50,20 @@ export default function SpeedtestTrackerWidget({
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={width} compactSurface />
<LatestResultSection
result={combined.latestResult}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
);
const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={width} compactSurface />
<AveragesSection
stats={combined.stats}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
);
return (
@@ -14,10 +14,12 @@ export function LatestResultSection({
result,
width = 500,
compactSurface = false,
displayScale,
}: {
result: SpeedtestTrackerResult;
width?: number;
compactSurface?: boolean;
displayScale?: number;
}) {
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
@@ -40,6 +42,7 @@ export function LatestResultSection({
</Group>
<SimpleGrid cols={cols} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown}
color="blue"
value={formatResultSpeed(result, "download")}
@@ -47,6 +50,7 @@ export function LatestResultSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp}
color="teal"
value={formatResultSpeed(result, "upload")}
@@ -54,6 +58,7 @@ export function LatestResultSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine}
color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
@@ -62,6 +67,7 @@ export function LatestResultSection({
/>
{result.healthy !== null && (
<SpeedStatCard
displayScale={displayScale}
icon={result.healthy ? IconCircleCheck : IconCircleX}
color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")}
@@ -1,5 +1,6 @@
"use client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -14,6 +15,7 @@ export interface SpeedStatCardProps {
label: string;
compact?: boolean;
legacySurface?: boolean;
displayScale?: number;
}
export function SpeedStatCard({
@@ -23,8 +25,10 @@ export function SpeedStatCard({
label,
compact = false,
legacySurface = false,
displayScale,
}: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>();
const { ref, height: logicalHeight, width: logicalWidth } = useElementSize<HTMLDivElement>();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const board = useRequiredBoard();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
+10 -2
View File
@@ -7,6 +7,7 @@ import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -71,10 +72,17 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
export default function StockPriceWidget({
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
displayScale,
}: WidgetComponentProps<"stockPrice">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.stockPrice");
const tCommon = useI18n("common");
const locale = useCurrentIntlLocale();
@@ -12,6 +12,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
usageOverTime: {
up: number;
@@ -20,6 +21,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = usageOverTime.map((usage, index) => ({
index,
@@ -51,6 +53,7 @@ export const CombinedNetworkTrafficChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[
@@ -1,22 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
CSSProperties,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { useState } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import {
Card,
Center,
Group,
Stack,
Text,
Tooltip,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import { Card, Center, Group, Stack, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { YAxisProps } from "recharts";
@@ -24,6 +11,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale } from "../../common/widget-layout-size";
import type { LabelDisplayModeOption } from "..";
export const CommonChart = ({
@@ -38,6 +26,7 @@ export const CommonChart = ({
lastValue,
chartType = "line",
advanced = false,
displayScale,
}: {
data: Record<string, any>[];
dataKey: string;
@@ -53,8 +42,11 @@ export const CommonChart = ({
lastValue?: string;
chartType?: "line" | "area";
advanced?: boolean;
displayScale?: number;
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const { ref: elementSizeRef, height: logicalHeight } = useElementSize();
const height =
logicalHeight * getWidgetDisplayScale({ displayScale, displayMode: advanced ? "advanced" : "compact" });
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard();
@@ -62,28 +54,22 @@ export const CommonChart = ({
const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100;
let backgroundColor =
colorScheme === "dark"
? `rgba(57, 57, 57, ${opacity})`
: `rgba(246, 247, 248, ${opacity})`;
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
};
if (advanced) {
backgroundColor =
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor;
}
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon =
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText =
labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
// Track which data point the cursor is over so the tooltip can reflect that point
// instead of always showing the latest value while scrubbing across the chart.
@@ -100,20 +86,12 @@ export const CommonChart = ({
// Clamp on read rather than syncing via an effect - handles data growing/shrinking
// between renders (e.g. history resetting on integration change) without ever
// handing a stale or out-of-range index to tooltipLabel.
const clampedHoveredIndex = Math.max(
0,
Math.min(hoveredIndex, data.length - 1),
);
const clampedHoveredIndex = Math.max(0, Math.min(hoveredIndex, data.length - 1));
const resolvedTooltipLabel =
typeof tooltipLabel === "function"
? tooltipLabel(clampedHoveredIndex)
: (tooltipLabel ?? lastValue);
typeof tooltipLabel === "function" ? tooltipLabel(clampedHoveredIndex) : (tooltipLabel ?? lastValue);
return (
<Tooltip.Floating
label={resolvedTooltipLabel}
disabled={data.length <= 1 || !resolvedTooltipLabel}
>
<Tooltip.Floating label={resolvedTooltipLabel} disabled={data.length <= 1 || !resolvedTooltipLabel}>
<Card
ref={ref}
h={"100%"}
@@ -149,33 +127,19 @@ export const CommonChart = ({
</Text>
)}
{lastValue && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
lineClamp={1}
>
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
{lastValue}
</Text>
)}
</Group>
)}
{advanced &&
data.length > 1 &&
height > 0 &&
height <= 40 &&
lastValue &&
!hovered && (
<Center
pos="absolute"
w="100%"
h="100%"
style={{ zIndex: 2, pointerEvents: "none" }}
>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
@@ -187,12 +151,7 @@ export const CommonChart = ({
/>
)}
{showText && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
fw={"bold"}
ta="center"
>
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title}
</Text>
)}
@@ -10,16 +10,19 @@ export const SystemResourceCPUChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
cpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "blue.5" }]}
@@ -10,16 +10,19 @@ export const SystemResourceGPUChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
gpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "grape.5" }]}
@@ -12,12 +12,14 @@ export const SystemResourceMemoryChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
memoryUsageOverTime: number[];
totalCapacityInBytes: number;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({
index,
@@ -45,6 +47,7 @@ export const SystemResourceMemoryChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "red.6" }]}
@@ -12,12 +12,14 @@ export const NetworkTrafficChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
usageOverTime: number[];
isUp: boolean;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card");
@@ -29,6 +31,7 @@ export const NetworkTrafficChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "yellow.5" }]}
@@ -5,6 +5,7 @@ import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -89,6 +90,7 @@ export default function SystemResources({
options,
width,
height,
displayScale,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
@@ -150,6 +152,7 @@ export default function SystemResources({
width={availableWidth}
height={availableHeight}
isAdvanced={isAdvanced}
displayScale={displayScale}
showTitle={isAdvanced || data.length > 1}
/>
);
@@ -211,6 +214,7 @@ interface SystemChartsProps {
height: number;
isAdvanced: boolean;
showTitle: boolean;
displayScale?: number;
}
const SystemCharts = ({
@@ -223,13 +227,20 @@ const SystemCharts = ({
height,
isAdvanced,
showTitle,
displayScale,
}: SystemChartsProps) => {
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode: isAdvanced ? "advanced" : "compact",
});
const networkItems = getNetworkHistory(items);
const visibleCharts = getVisibleSystemCharts({
configuredCharts: options.visibleCharts,
hasGpu,
hasNetwork: networkItems.length > 0,
height,
height: responsiveHeight,
isAdvanced,
});
const showNetwork = visibleCharts.includes("network");
@@ -270,6 +281,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
@@ -281,6 +293,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
@@ -291,11 +304,12 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
{showNetwork &&
(width >= 300 ? (
(responsiveWidth >= 300 ? (
<Group h="100%" gap="xs" grow wrap="nowrap">
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.down)}
@@ -303,6 +317,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)}
@@ -310,6 +325,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Group>
) : (
@@ -319,6 +335,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
))}
+11 -2
View File
@@ -29,6 +29,7 @@ import {
requestBrowserNotificationPermission,
showBrowserNotification,
} from "../common/browser-alert";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { useLocalWidgetState } from "../common/use-local-widget-state";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
@@ -52,12 +53,20 @@ const completionSoundIntervalMs = 300;
export default function TimerWidget({
boardId,
displayMode,
height,
height: logicalHeight,
isEditMode,
itemId,
options,
width,
width: logicalWidth,
displayScale,
}: WidgetComponentProps<"timer">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const now = useWidgetNow("second");
const t = useI18n("widget.timer");
const reducedMotion = useReducedMotion();
+11 -2
View File
@@ -4,6 +4,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import { formatLocalizedTime } from "../common/locale";
import type { WidgetComponentProps } from "../definition";
@@ -11,10 +12,18 @@ import type { WidgetComponentProps } from "../definition";
export default function TimetableWidget({
options,
displayMode = "compact",
width,
height,
width: logicalWidth,
height: logicalHeight,
itemId,
displayScale,
}: WidgetComponentProps<"timetable">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.timetable");
if (!options.station) {
+6 -1
View File
@@ -6,6 +6,7 @@ import { clientApi } from "@homarr/api/client";
import type { TracearrDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -36,9 +37,13 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is
export default function TracearrWidget({
options,
integrationIds,
width,
width: logicalWidth,
displayMode = "compact",
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"tracearr">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
+5 -1
View File
@@ -16,6 +16,7 @@ import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSumma
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -103,9 +104,12 @@ export default function TraefikWidget(props: WidgetComponentProps<"traefik">) {
function TraefikWidgetContent({
integrationIds,
options,
width,
width: logicalWidth,
displayMode = "compact",
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"traefik">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.traefik");
const tCommon = useI18n("common");
const dashboardQuery = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
+10 -2
View File
@@ -6,6 +6,7 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { UmamiContent } from "./umami-content";
@@ -13,11 +14,18 @@ import { UmamiContent } from "./umami-content";
export default function UmamiWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
widgetRuntimeRef,
displayScale,
}: WidgetComponentProps<"umami">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0] ?? "";
const commonTopInput = {
integrationId,
+13 -6
View File
@@ -20,6 +20,7 @@ import type { UpsStatus, UpsSummary } from "@homarr/integrations/types";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -34,20 +35,26 @@ const statusColors: Record<UpsStatus, string> = {
unknown: "gray",
};
const neutralSurfaceBackground =
"rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))";
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const neutralSurfaceBackground = "rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))";
const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
type UpsLayout = "mini" | "compact" | "full";
export default function UpsWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
displayScale,
}: WidgetComponentProps<"ups">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError();
}
+10 -2
View File
@@ -9,6 +9,7 @@ import { formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -82,10 +83,17 @@ const heroVariantByRing = {
export default function UptimeKumaWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
displayScale,
}: WidgetComponentProps<"uptimeKuma">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
+15 -1
View File
@@ -5,11 +5,25 @@ import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { VpnIntegrationCard } from "./vpn-card";
export default function VpnWidget({ options, integrationIds, width, height }: WidgetComponentProps<"vpn">) {
export default function VpnWidget({
options,
integrationIds,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"vpn">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const summariesQuery = clientApi.widget.vpn.getSummaries.useQuery({
...options,
integrationIds,
+10 -2
View File
@@ -5,6 +5,7 @@ import { Box, Center, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -15,10 +16,17 @@ import { CompactWeather } from "./compact";
export default function WeatherWidget({
isEditMode,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"weather">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.weather");
const input = {
latitude: options.location.latitude,
+11 -8
View File
@@ -11,12 +11,21 @@ import type { WudContainerUpdate } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function WudWidget({ integrationIds, options, width, displayMode }: WidgetComponentProps<"wud">) {
export default function WudWidget({
integrationIds,
options,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"wud">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const integrationId = integrationIds[0];
if (!integrationId) return null;
return (
@@ -208,13 +217,7 @@ const UpdateCard = ({
const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText;
return (
<Card
className={combineClasses(className)}
radius={radius}
p="xs"
bg="transparent"
style={{ overflow: "visible" }}
>
<Card className={combineClasses(className)} radius={radius} p="xs" bg="transparent" style={{ overflow: "visible" }}>
<Group justify="space-between" wrap="nowrap" gap="xs" miw={0}>
<Text size="xs" fw={500} lineClamp={1} miw={0}>
{update.name}