Compare commits

..
34 changed files with 377 additions and 471 deletions

No files matched your search

+1 -1
View File
@@ -113,7 +113,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Set desktop and mobile home boards for the current user - Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior - Configure global board defaults such as public home boards and status behavior
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
+8 -12
View File
@@ -133,21 +133,20 @@ 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. 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. - **Mobile** starts at `0px` and uses three columns on new boards.
- **Base** is the main layout and starts at `768px` on new boards. - **Base** is the widest 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 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 at any rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between
unused breakpoint above Mobile. their breakpoints.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in 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 an Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a
available breakpoint, and you can then change the breakpoint, columns, and rails. free breakpoint between Mobile and Base, 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 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 columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain
becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them. custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions.
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. 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.
@@ -183,8 +182,7 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### Breakpoint #### Breakpoint
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the `0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
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 When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
fallback to select a layout. fallback to select a layout.
@@ -273,8 +271,6 @@ Expanding it smoothly restores those bounds and moves following items out of the
### Background ### 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 #### Background image URL
Homarr can display a background image behind apps and widgets. Homarr can display a background image behind apps and widgets.
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from "."; import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} /> <WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
@@ -40,9 +39,6 @@ import mediaRequestList from "./img/list.png";
{ {
integration: overseerrIntegration, integration: overseerrIntegration,
}, },
{
integration: seerrIntegration,
},
]} ]}
/> />
@@ -50,7 +46,6 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon. - 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. - 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. - A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide advancedView={false} /> <WidgetInteractionGuide advancedView={false} />
@@ -16,7 +16,6 @@ import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from "."; import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} /> <WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
@@ -32,9 +31,6 @@ Provides an overview of media requests, including totals, availability, approval
{ {
integration: overseerrIntegration, integration: overseerrIntegration,
}, },
{
integration: seerrIntegration,
},
]} ]}
/> />
@@ -47,7 +43,6 @@ import mediaRequestStats from "./img/stats.png";
### Interactions ### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. - 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. - A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
<WidgetInteractionGuide advancedView={false} /> <WidgetInteractionGuide advancedView={false} />
@@ -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, 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. including the latest result, historical averages, and a live chart of the last 12 hours.
The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected. 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.
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. 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.
@@ -40,8 +40,6 @@ interface Props {
saveSettingsAsync: () => Promise<FormValues | null>; saveSettingsAsync: () => Promise<FormValues | null>;
} }
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const tLayout = useI18n("layout"); const tLayout = useI18n("layout");
@@ -103,9 +101,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); 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 ( return (
<SectionCard title={tBoard("setting.section.layout.title")}> <SectionCard title={tBoard("setting.section.layout.title")}>
@@ -139,9 +134,13 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Button> </Button>
</Group> </Group>
{displayedLayouts.map(({ layout, index }) => { {form.values.layouts.map((layout, index) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); 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 ( return (
<Fieldset <Fieldset
@@ -191,8 +190,12 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
} }
disabled={layout.role === "mobile"} disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }} styles={{ description: { color: "var(--mantine-color-text)" } }}
min={layout.role === "mobile" ? 0 : 1} min={
max={32767} layout.role === "base"
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
: 1
}
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
/> />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
@@ -1,7 +1,21 @@
.refreshIndicator {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 3;
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
pointer-events: none;
}
.settingsButton { .settingsButton {
position: absolute; position: absolute;
top: calc(var(--mantine-spacing-sm) / 2); top: -34px;
right: calc(var(--mantine-spacing-sm) / 2); right: 14px;
z-index: 201; z-index: 201;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@@ -52,6 +66,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.settingsButton { .settingsButton {
right: 4px;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import { Box, Button, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary"; import type { FallbackProps } from "react-error-boundary";
@@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors";
import { import {
createWidgetRuntimeState, createWidgetRuntimeState,
getWidgetQueryKeys, getWidgetQueryKeys,
getWidgetRuntimeQueries,
supportsAdvancedFocus as definitionSupportsAdvancedFocus, supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition"; } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
@@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu"; import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery"; import { removeWidgetDataQueries } from "./widget-query-recovery";
import { matchesWidgetItemQuery } from "./widget-query-scope";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), { const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false, ssr: false,
@@ -156,6 +158,24 @@ const LoadedBoardItemContent = ({
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }), () => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
[definition, item, settings], [definition, item, settings],
); );
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetRefreshing =
useIsFetching({
predicate: (query) =>
query.state.data !== undefined &&
matchesWidgetItemQuery(
query.queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options: menuItem.options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
}) > 0;
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null); const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null); const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus(); const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
@@ -323,6 +343,11 @@ const LoadedBoardItemContent = ({
/> />
</ErrorBoundary> </ErrorBoundary>
</Box> </Box>
{isWidgetRefreshing && (
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
<Loader size="xs" />
</Box>
)}
</WidgetCardShell> </WidgetCardShell>
); );
@@ -49,14 +49,12 @@
} }
.widgetIcon { .widgetIcon {
width: var(--mantine-font-size-sm);
height: var(--mantine-font-size-sm);
flex-shrink: 0; flex-shrink: 0;
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
} }
.name { .name {
font-size: var(--mantine-font-size-xs); font-size: 11px;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
@@ -96,7 +94,7 @@
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: var(--chip-background); background-color: var(--chip-background);
font-size: var(--mantine-font-size-xxs); font-size: 10px;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -2,8 +2,7 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
overflow-x: clip; overflow: clip;
overflow-y: visible;
} }
.viewport[data-board-hydrated="false"] { .viewport[data-board-hydrated="false"] {
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover, .board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within { .board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: var(--homarr-z-index-widget-focus); z-index: 10;
} }
.board-grid-entry[data-grid-preview="true"] { .board-grid-entry[data-grid-preview="true"] {
@@ -27,12 +27,6 @@ export const ClientShell = ({
return ( return (
<AppShell <AppShell
{...backgroundProps} {...backgroundProps}
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
// <main> 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} header={hasHeader ? { height: headerHeight } : undefined}
navbar={ navbar={
hasNavigation hasNavigation
+2
View File
@@ -163,6 +163,7 @@ describe("lazy widget application graph", () => {
}); });
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true); await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
await expect(cachedDownload).toBeVisible({ timeout: 5_000 }); await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
const refreshedDownloadsResponse = page.waitForResponse( const refreshedDownloadsResponse = page.waitForResponse(
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
@@ -170,6 +171,7 @@ describe("lazy widget application graph", () => {
releaseRefresh(); releaseRefresh();
releaseRefresh = undefined; releaseRefresh = undefined;
await refreshedDownloadsResponse; await refreshedDownloadsResponse;
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
expect(hydrationErrors).toEqual([]); expect(hydrationErrors).toEqual([]);
+3
View File
@@ -2776,11 +2776,14 @@ export const boardLayoutsNeedRepair = (
) => { ) => {
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile"); const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base"); const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
const baseLayout = baseLayouts.at(0);
return ( return (
mobileLayouts.length !== 1 || mobileLayouts.length !== 1 ||
baseLayouts.length !== 1 || baseLayouts.length !== 1 ||
mobileLayouts.at(0)?.breakpoint !== 0 || 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 new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
); );
}; };
+2 -2
View File
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
} }
} }
const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile"); const mobileLayout = normalizedLayouts[0];
const baseLayout = normalizedLayouts.find((layout) => layout.role === "base"); const baseLayout = normalizedLayouts.at(-1);
if (mobileLayout && baseLayout) { if (mobileLayout && baseLayout) {
await insertMissingProjectedPositionsAsync( await insertMissingProjectedPositionsAsync(
db, db,
+9 -14
View File
@@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = <
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined; const orderedLayouts =
let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts); mobileLayout && baseLayout
if (hasProtectedLayouts) { ? [
orderedLayouts = [ mobileLayout,
mobileLayout, ...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts), baseLayout,
]; ]
} : sourceLayouts.toSorted(compareBoardLayouts);
let previousBreakpoint = -1; let previousBreakpoint = -1;
return orderedLayouts.map((layout, index) => { return orderedLayouts.map((layout, index) => {
@@ -49,12 +49,7 @@ export const normalizeBoardLayoutRoles = <
index === 0 index === 0
? 0 ? 0
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
let role: LayoutRole = layout.role ?? "custom"; const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
if (!hasProtectedLayouts) {
if (index === 0) role = "mobile";
else if (index === orderedLayouts.length - 1) role = "base";
else role = "custom";
}
previousBreakpoint = breakpoint; previousBreakpoint = breakpoint;
return { ...layout, breakpoint, role }; return { ...layout, breakpoint, role };
}); });
+1 -7
View File
@@ -63,19 +63,13 @@ export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = { export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
airQuality: { width: 2, height: 1 }, airQuality: { width: 2, height: 1 },
calendar: { width: 2, height: 2 },
countdown: { width: 2, height: 1 }, countdown: { width: 2, height: 1 },
downloads: { width: 4, height: 2 },
indexerManager: { width: 2, height: 3 },
timer: { width: 2, height: 1 }, timer: { width: 2, height: 1 },
uptimeKuma: { width: 2, height: 3 }, uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 }, audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 }, paperlessNgx: { width: 2, height: 2 },
patchmon: { width: 2, height: 2 }, patchmon: { width: 2, height: 2 },
mediaServer: { width: 3, height: 2 }, mediaMissing: { width: 4, height: 3 },
"mediaRequests-requestList": { width: 3, height: 2 },
"mediaRequests-requestStats": { width: 2, height: 2 },
mediaMissing: { width: 2, height: 2 },
bazarr: { width: 2, height: 2 }, bazarr: { width: 2, height: 2 },
assistant: { width: 10, height: 4 }, assistant: { width: 10, height: 4 },
}; };
+9 -12
View File
@@ -4227,11 +4227,7 @@
}, },
"mediaRequests-requestList": { "mediaRequests-requestList": {
"name": "Media Requests List", "name": "Media Requests List",
"description": "See a list of all media requests from your Seerr, Overseerr, or Jellyseerr instance", "description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
"empty": {
"title": "No media requests yet",
"description": "Search for a movie or show to start a request."
},
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Open links in new tab" "label": "Open links in new tab"
@@ -4271,10 +4267,6 @@
"mediaRequests-requestStats": { "mediaRequests-requestStats": {
"name": "Media Requests Stats", "name": "Media Requests Stats",
"description": "Statistics about your media requests", "description": "Statistics about your media requests",
"empty": {
"title": "No request activity yet",
"description": "Stats will appear after the first media request."
},
"titles": { "titles": {
"stats": { "stats": {
"main": "Media Stats", "main": "Media Stats",
@@ -4680,6 +4672,12 @@
"noSectionsEnabled": "Enable at least one section in widget options.", "noSectionsEnabled": "Enable at least one section in widget options.",
"error": { "error": {
"internalServerError": "Failed to fetch speedtest data" "internalServerError": "Failed to fetch speedtest data"
},
"range": {
"twelveHours": "12h",
"oneDay": "24h",
"all": "All",
"lastThirty": "Last 30"
} }
}, },
"uptimeKuma": { "uptimeKuma": {
@@ -4853,8 +4851,7 @@
"memory": "MEM", "memory": "MEM",
"network": "NET", "network": "NET",
"up": "UP", "up": "UP",
"down": "DOWN", "down": "DOWN"
"memoryTooltip": "{used} / {available} ({percent}%)"
} }
}, },
"systemDisks": { "systemDisks": {
@@ -5426,7 +5423,7 @@
"title": "Layout", "title": "Layout",
"responsive": { "responsive": {
"title": "Responsive layouts", "title": "Responsive layouts",
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.", "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.",
"action": { "action": {
"add": "Add layout" "add": "Add layout"
} }
+4
View File
@@ -116,6 +116,10 @@ export const responsiveBoardLayoutsSchema = z
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" }); ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
} }
if (layouts.some((layout) => 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) { if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" }); ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
} }
@@ -23,16 +23,11 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
const CONTROLS_ROW_HEIGHT = 36;
const CONTROLS_ROW_GAP = 16;
const STACK_PADDING = 24;
export default function BeszelSystemStatsWidget({ export default function BeszelSystemStatsWidget({
options, options,
integrationIds, integrationIds,
isEditMode, isEditMode,
width, width,
height,
boardId, boardId,
itemId, itemId,
setOptions, setOptions,
@@ -261,10 +256,6 @@ export default function BeszelSystemStatsWidget({
systemId={selectedSystem?.systemId ?? ""} systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod} timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1} columns={width > 600 ? 2 : 1}
availableHeight={Math.max(
0,
height - STACK_PADDING - (isEditMode ? 0 : CONTROLS_ROW_HEIGHT + CONTROLS_ROW_GAP),
)}
visibility={{ visibility={{
cpu: options.showCpu, cpu: options.showCpu,
memory: options.showMemory, memory: options.showMemory,
@@ -62,20 +62,9 @@ interface BeszelStatsViewProps {
timePeriod: BeszelTimePeriod; timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility; visibility: BeszelStatsVisibility;
columns: 1 | 2; columns: 1 | 2;
availableHeight?: number;
onSwitchToHistorical?: () => void; onSwitchToHistorical?: () => void;
} }
const MIN_CHART_HEIGHT = 100;
const PANEL_ROW_OVERHEAD = 28;
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 = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined); const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({ export function BeszelStatsView({
@@ -84,7 +73,6 @@ export function BeszelStatsView({
timePeriod, timePeriod,
visibility, visibility,
columns, columns,
availableHeight,
onSwitchToHistorical, onSwitchToHistorical,
}: BeszelStatsViewProps) { }: BeszelStatsViewProps) {
const t = useI18n("widget.beszelSystemStats"); const t = useI18n("widget.beszelSystemStats");
@@ -260,29 +248,14 @@ 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 ( return (
<SimpleGrid cols={effectiveColumns} spacing="md"> <SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && ( {visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel <BeszelChartPanel
title={t("chart.cpu.title")} title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")} subtitle={t("chart.cpu.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: cpuData, data: cpuData,
series: series.cpu, series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent, yAxisFormatter: chartAxisFormatters.percent,
@@ -296,7 +269,7 @@ export function BeszelStatsView({
title={t("chart.memory.title")} title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")} subtitle={t("chart.memory.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: memoryData, data: memoryData,
type: "stacked", type: "stacked",
series: series.memory, series: series.memory,
@@ -310,7 +283,7 @@ export function BeszelStatsView({
title={t("chart.disk.title")} title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")} subtitle={t("chart.disk.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: diskData, data: diskData,
type: "stacked", type: "stacked",
series: diskSeries, series: diskSeries,
@@ -324,7 +297,7 @@ export function BeszelStatsView({
title={t("chart.diskIO.title")} title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")} subtitle={t("chart.diskIO.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: diskIOData, data: diskIOData,
series: series.diskIO, series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate, yAxisFormatter: chartAxisFormatters.rate,
@@ -337,7 +310,7 @@ export function BeszelStatsView({
title={t("chart.network.title")} title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")} subtitle={t("chart.network.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: networkData, data: networkData,
series: series.network, series: series.network,
yAxisFormatter: chartAxisFormatters.rate, yAxisFormatter: chartAxisFormatters.rate,
@@ -352,7 +325,7 @@ export function BeszelStatsView({
title={t("chart.dockerCpu.title")} title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")} subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: dockerCpuData, data: dockerCpuData,
type: "stacked", type: "stacked",
series: containerSeries, series: containerSeries,
@@ -366,7 +339,7 @@ export function BeszelStatsView({
title={t("chart.dockerMemory.title")} title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")} subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: dockerMemoryData, data: dockerMemoryData,
type: "stacked", type: "stacked",
series: containerSeries, series: containerSeries,
@@ -380,7 +353,7 @@ export function BeszelStatsView({
title={t("chart.dockerNetwork.title")} title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")} subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{ chartProps={{
h: chartHeight, h: CHART_HEIGHT,
data: dockerNetworkData, data: dockerNetworkData,
series: containerSeries, series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate, yAxisFormatter: chartAxisFormatters.rate,
+7 -24
View File
@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core"; import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -14,25 +13,15 @@ interface CalendarDayProps {
rootHeight: number; rootHeight: number;
} }
const fallbackDayScale = { textSize: "xs" as const, indicatorSize: 3, indicatorMarginTop: 2 };
const dayScaleTiers = [
{ minAxisSize: 700, textSize: "lg" as const, indicatorSize: 6, indicatorMarginTop: 6 },
{ minAxisSize: 450, textSize: "md" as const, indicatorSize: 5, indicatorMarginTop: 4 },
{ minAxisSize: 250, textSize: "sm" as const, indicatorSize: 4, indicatorMarginTop: 3 },
{ minAxisSize: 0, ...fallbackDayScale },
];
const getDayScale = (minAxisSize: number) =>
dayScaleTiers.find((tier) => minAxisSize >= tier.minAxisSize) ?? fallbackDayScale;
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => { export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const mantineTheme = useMantineTheme(); const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius]; const actualItemRadius = mantineTheme.radius[board.itemRadius];
const minAxisSize = Math.min(rootWidth, rootHeight); const minAxisSize = Math.min(rootWidth, rootHeight);
const shouldScaleDown = minAxisSize < 350;
const shouldShowIndicators = rootHeight >= 256; const shouldShowIndicators = rootHeight >= 256;
const { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]); const indicatorSize = shouldScaleDown ? 3 : 4;
const cell = ( const cell = (
<Container <Container
@@ -50,15 +39,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
justifyContent: "center", justifyContent: "center",
}} }}
> >
<Text ta={"center"} size={textSize} lh={1}> <Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()} {date.getDate()}
</Text> </Text>
<NotificationIndicator <NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} />
events={events}
size={indicatorSize}
marginTop={indicatorMarginTop}
visible={shouldShowIndicators}
/>
</Container> </Container>
); );
@@ -86,20 +70,19 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
interface NotificationIndicatorProps { interface NotificationIndicatorProps {
events: CalendarEvent[]; events: CalendarEvent[];
size: number; size: number;
marginTop: number;
visible: boolean; visible: boolean;
} }
const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => { const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter( const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
(color): color is string => Boolean(color), (color): color is string => Boolean(color),
); );
if (!visible || notificationEvents.length === 0) return null; if (!visible) return null;
return ( return (
<Flex <Flex
mt={marginTop} mt={3}
w="fit-content" w="fit-content"
maw="75%" maw="75%"
h={size} h={size}
@@ -212,7 +212,6 @@ const CalendarBase = ({
monthCell: { monthCell: {
textAlign: "center", textAlign: "center",
position: "relative", position: "relative",
padding: 3,
}, },
day: { day: {
borderRadius: actualItemRadius, borderRadius: actualItemRadius,
@@ -1,49 +0,0 @@
"use client";
import { Button, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconMovieOff, IconSearch } from "@tabler/icons-react";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
interface MediaRequestsEmptyStateProps {
title: string;
description: string;
integrationIds: string[];
isEditMode: boolean;
}
export const MediaRequestsEmptyState = ({
title,
description,
integrationIds,
isEditMode,
}: MediaRequestsEmptyStateProps) => {
const tSearch = useI18n("search.mode.media");
return (
<Stack h="100%" align="center" justify="center" gap="xs" p="md">
<ThemeIcon variant="light" size="xl" radius="xl">
<IconMovieOff />
</ThemeIcon>
<Stack gap={2} align="center">
<Text fw={600} ta="center">
{title}
</Text>
<Text c="dimmed" size="sm" ta="center" maw="28ch">
{description}
</Text>
</Stack>
{!isEditMode ? (
<Button
variant="light"
size="compact-sm"
leftSection={<IconSearch size="var(--mantine-font-size-md)" />}
onClick={() => openMediaRequestSearch({ integrationIds })}
>
{tSearch("action.search.label")}
</Button>
) : null}
</Stack>
);
};
@@ -20,7 +20,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css"; import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { MediaRequestsEmptyState } from "../empty-state"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css"; import classes from "./component.module.css";
import searchClasses from "../search-button.module.css"; import searchClasses from "../search-button.module.css";
@@ -30,7 +30,6 @@ export default function MediaServerWidget({
options, options,
width, width,
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set( const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess() useIntegrationsWithInteractAccess()
.filter(({ id }) => integrationIds.includes(id)) .filter(({ id }) => integrationIds.includes(id))
@@ -49,16 +48,7 @@ export default function MediaServerWidget({
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />; if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
if (!mediaRequestData) return <WidgetEmptyState />; if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData; const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) { if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
return (
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1; const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
return ( return (
@@ -28,7 +28,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css"; import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import { MediaRequestsEmptyState } from "../empty-state"; import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css"; import classes from "./component.module.css";
import searchClasses from "../search-button.module.css"; import searchClasses from "../search-button.module.css";
@@ -54,16 +54,8 @@ export default function MediaServerWidget({
requestStats.users.length === 0 && requestStats.users.length === 0 &&
requestStats.stats.length === 0 && requestStats.stats.length === 0 &&
requestStats.failedIntegrations.length === 0 requestStats.failedIntegrations.length === 0
) { )
return ( throw new NoIntegrationDataError();
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
const data = [ const data = [
{ {
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Box, Group, Stack, Text } from "@mantine/core"; import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -21,10 +21,14 @@ export default function SpeedtestTrackerWidget({
options, options,
integrationIds, integrationIds,
width, width,
height,
displayMode = "compact",
}: WidgetComponentProps<"speedtestTracker">) { }: WidgetComponentProps<"speedtestTracker">) {
const t = useI18n("widget.speedtestTracker"); const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData; const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
const [advancedRange, setAdvancedRange] = useState("24");
const isAdvanced = displayMode === "advanced";
const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]); const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]);
const combined = useMemo( const combined = useMemo(
@@ -32,24 +36,98 @@ export default function SpeedtestTrackerWidget({
[successfulDashboards], [successfulDashboards],
); );
const cutoff = Date.now() - 12 * 60 * 60 * 1000; const rangeHours = isAdvanced ? Number(advancedRange) : 12;
const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff); const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const showLatestResult = options.showLatestResult; const showLatestResult = isAdvanced || options.showLatestResult;
const showStats = options.showStats; const showStats = isAdvanced || options.showStats;
const showRecentResults = options.showRecentResults; const showRecentResults = isAdvanced || options.showRecentResults;
const showPingGraph = options.showPingGraph; const showPingGraph = isAdvanced || options.showPingGraph;
const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null); const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null);
const hasChart = showRecentResults && recentFiltered.length > 0; const hasChart = showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults; const noSectionsEnabled =
!isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults;
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = showLatestResult && combined.latestResult && ( const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={width} compactSurface /> <LatestResultSection
result={combined.latestResult}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
); );
const averages = showStats && combined.stats && ( const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={width} compactSurface /> <AveragesSection
stats={combined.stats}
width={isAdvanced ? advancedSectionWidth : width}
compactSurface={!isAdvanced}
/>
); );
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 (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
</Group>
<ScrollArea h="100%">
<Stack gap="md" p="md">
<SegmentedControl
size="xs"
value={advancedRange}
onChange={setAdvancedRange}
data={[
{ value: "12", label: t("range.twelveHours") },
{ value: "24", label: t("range.oneDay") },
{ value: "0", label: t("range.lastThirty") },
]}
/>
{successfulDashboards.length === 0 ? (
<WidgetEmptyState />
) : (
<SimpleGrid cols={sourceColumns} spacing="md">
{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 (
<Card key={integrationId} data-integration-id={integrationId} p="sm" withBorder>
<Stack gap="md">
<Text size="sm" fw={600} truncate>
{integrationName}
</Text>
{sourceHasStats && (
<SimpleGrid cols={sourcePanelWidth >= 720 ? 2 : 1} spacing="md">
{dashboard.latestResult && (
<LatestResultSection result={dashboard.latestResult} width={sourceSectionWidth} />
)}
{dashboard.stats && <AveragesSection stats={dashboard.stats} width={sourceSectionWidth} />}
</SimpleGrid>
)}
{sourceHasChart && (
<Box h={Math.max(260, Math.min(420, height / 2))}>
<RecentResultsSection results={sourceRecent} showPingGraph />
</Box>
)}
{!sourceHasStats && !sourceHasChart && <WidgetEmptyState />}
</Stack>
</Card>
);
})}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
</Box>
);
}
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}> <Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat, icon: IconSpeedboat,
supportsAdvancedFocus: true,
refetchInterval: null, refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
@@ -1,4 +1,4 @@
import { Box, Group, Stack, Text } from "@mantine/core"; import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react"; import { IconNetwork } from "@tabler/icons-react";
import { formatByteRate } from "@homarr/common"; import { formatByteRate } from "@homarr/common";
@@ -21,43 +21,9 @@ export const CombinedNetworkTrafficChart = ({
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
}) => { }) => {
const chartData = usageOverTime.map((usage, index) => ({ const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
index,
up: usage.up,
down: usage.down,
}));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
const tooltipLabel = (index: number) => {
const point = usageOverTime[index];
return (
<Stack gap={2}>
<Group gap={4} wrap="nowrap">
<Box
bg="orange.5"
w={8}
h={8}
style={{ borderRadius: 99, flexShrink: 0 }}
/>
<Text size="xs">
{t("up")}: {formatByteRate(Math.round(point?.up ?? 0))}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Box
bg="yellow.5"
w={8}
h={8}
style={{ borderRadius: 99, flexShrink: 0 }}
/>
<Text size="xs">
{t("down")}: {formatByteRate(Math.round(point?.down ?? 0))}
</Text>
</Group>
</Stack>
);
};
return ( return (
<CommonChart <CommonChart
data={chartData} data={chartData}
@@ -72,7 +38,28 @@ export const CombinedNetworkTrafficChart = ({
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipLabel={tooltipLabel} tooltipProps={{
content: ({ payload }) => {
return (
<Paper px={3} py={2} shadow="md">
<Stack gap={0}>
{payload.map((payloadData) => (
<Group key={payloadData.key} gap={4}>
<Box bg={payloadData.color} w={10} h={10} style={{ borderRadius: 99 }}></Box>
<Text c="dimmed" size="xs">
{payloadData.value === undefined ? (
<>N/A</>
) : (
formatByteRate(Math.round(Number(payloadData.value)))
)}
</Text>
</Group>
))}
</Stack>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,24 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { import type { CSSProperties, ReactNode } from "react";
CSSProperties,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import { useState } from "react";
import type { AreaChartSeries } from "@mantine/charts"; import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts";
import { import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
Card,
Center,
Group,
Stack,
Text,
Tooltip,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { YAxisProps } from "recharts"; import type { TooltipProps, YAxisProps } from "recharts";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { zoomCompensatedSize } from "@homarr/ui"; import { zoomCompensatedSize } from "@homarr/ui";
@@ -33,7 +19,7 @@ export const CommonChart = ({
title, title,
icon: Icon, icon: Icon,
labelDisplayMode, labelDisplayMode,
tooltipLabel, tooltipProps,
yAxisProps, yAxisProps,
lastValue, lastValue,
chartType = "line", chartType = "line",
@@ -45,10 +31,7 @@ export const CommonChart = ({
title: ReactNode; title: ReactNode;
icon: TablerIcon; icon: TablerIcon;
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
// What to show on hover. Defaults to lastValue when omitted. Pass a function to reflect tooltipProps?: TooltipProps<number, any>;
// the datum under the cursor as it moves across multi-point data (e.g. memory's
// used/available at that point), instead of always showing the latest value.
tooltipLabel?: ReactNode | ((index: number) => ReactNode);
yAxisProps?: Omit<YAxisProps, "ref">; yAxisProps?: Omit<YAxisProps, "ref">;
lastValue?: string; lastValue?: string;
chartType?: "line" | "area"; chartType?: "line" | "area";
@@ -62,80 +45,63 @@ export const CommonChart = ({
const ref = useMergedRef(elementSizeRef, hoverRef); const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100; const opacity = board.opacity / 100;
let backgroundColor = let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
colorScheme === "dark"
? `rgba(57, 57, 57, ${opacity})`
: `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" }; const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = { const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] };
padding: 5,
borderRadius: theme.radius[board.itemRadius],
};
if (advanced) { if (advanced) {
backgroundColor = backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border = cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; "1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor; chartRootStyle.backgroundColor = backgroundColor;
} }
const ChartComponent = chartType === "line" ? LineChart : AreaChart; const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showText = labelDisplayMode === "text" || 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.
const [hoveredIndex, setHoveredIndex] = useState(data.length - 1);
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (data.length <= 1) return;
const rect = event.currentTarget.getBoundingClientRect();
if (rect.width === 0) return;
const ratio = (event.clientX - rect.left) / rect.width;
const index = Math.round(ratio * (data.length - 1));
setHoveredIndex(Math.min(data.length - 1, Math.max(0, index)));
};
// 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 resolvedTooltipLabel =
typeof tooltipLabel === "function"
? tooltipLabel(clampedHoveredIndex)
: (tooltipLabel ?? lastValue);
return ( return (
<Tooltip.Floating <Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
label={resolvedTooltipLabel} {data.length > 1 && height > 40 && !hovered && (
disabled={data.length <= 1 || !resolvedTooltipLabel} <Group
> pos={"absolute"}
<Card top={0}
ref={ref} left={0}
h={"100%"} p={8}
pos={"relative"} pt={6}
style={cardStyle} gap={5}
p={0} wrap={"nowrap"}
bg={backgroundColor} style={{ zIndex: 2, pointerEvents: "none" }}
radius={board.itemRadius} align="center"
onPointerMove={handlePointerMove} >
> {showIcon && (
{data.length > 1 && height > 40 && !hovered && ( <Icon
<Group color="var(--mantine-color-dimmed)"
pos={"absolute"} style={zoomCompensatedSize(height > 100 ? 20 : 14)}
top={0} stroke={1.5}
left={0} />
p={8} )}
pt={6} {showText && (
gap={5} <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
wrap={"nowrap"} {title}
style={{ zIndex: 2, pointerEvents: "none" }} </Text>
align="center" )}
> {lastValue && (
<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>
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
{showIcon && ( {showIcon && (
<Icon <Icon
color="var(--mantine-color-dimmed)" color="var(--mantine-color-dimmed)"
@@ -144,85 +110,43 @@ export const CommonChart = ({
/> />
)} )}
{showText && ( {showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}> <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title} {title}
</Text> </Text>
)} )}
{lastValue && ( {advanced && lastValue && (
<Text <Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
lineClamp={1}
>
{lastValue} {lastValue}
</Text> </Text>
)} )}
</Group> </Stack>
)} </Center>
{advanced && ) : (
data.length > 1 && <ChartComponent
height > 0 && data={data}
height <= 40 && dataKey={dataKey}
lastValue && h={"100%"}
!hovered && ( series={series}
<Center curveType="monotone"
pos="absolute" tickLine="none"
w="100%" gridAxis="none"
h="100%" withXAxis={false}
style={{ zIndex: 2, pointerEvents: "none" }} withYAxis={false}
> withDots={false}
<Text size="xs" fw={600}> bg={backgroundColor}
{lastValue} styles={{ root: chartRootStyle }}
</Text> tooltipAnimationDuration={200}
</Center> tooltipProps={{
)} // Render via portal so the tooltip isn't clipped by the card's overflow:hidden
{data.length <= 1 ? ( // (needed to stop the chart line itself bleeding past its rounded corners).
<Center pos="absolute" w="100%" h="100%"> portal: typeof document !== "undefined" ? document.body : undefined,
<Stack px={"xs"} align={"center"} gap={4}> ...tooltipProps,
{showIcon && ( }}
<Icon withTooltip={height >= 64}
color="var(--mantine-color-dimmed)" yAxisProps={yAxisProps}
style={zoomCompensatedSize(height > 100 ? 20 : 14)} fillOpacity={chartType === "area" ? 0.3 : undefined}
stroke={1.5} />
/> )}
)} </Card>
{showText && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
fw={"bold"}
ta="center"
>
{title}
</Text>
)}
{advanced && lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
{lastValue}
</Text>
)}
</Stack>
</Center>
) : (
<ChartComponent
data={data}
dataKey={dataKey}
h={"100%"}
series={series}
curveType="monotone"
tickLine="none"
gridAxis="none"
withXAxis={false}
withYAxis={false}
withDots={false}
bg={backgroundColor}
styles={{ root: chartRootStyle }}
withTooltip={false}
yAxisProps={yAxisProps}
fillOpacity={chartType === "area" ? 0.3 : undefined}
/>
)}
</Card>
</Tooltip.Floating>
); );
}; };
@@ -1,3 +1,4 @@
import { Paper, Text } from "@mantine/core";
import { IconCpu } from "@tabler/icons-react"; import { IconCpu } from "@tabler/icons-react";
import { invariantTechnicalLabels } from "@homarr/definitions"; import { invariantTechnicalLabels } from "@homarr/definitions";
@@ -26,16 +27,25 @@ export const SystemResourceCPUChart = ({
title={invariantTechnicalLabels.cpu} title={invariantTechnicalLabels.cpu}
icon={IconCpu} icon={IconCpu}
lastValue={ lastValue={
cpuUsageOverTime.length > 0 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined
`${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%`
: undefined
} }
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }} yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipLabel={(index) => `${Math.round(cpuUsageOverTime[index] ?? 0)}%`} tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{value.toFixed(0)}%
</Text>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,3 +1,4 @@
import { Paper, Text } from "@mantine/core";
import { IconDeviceDesktop } from "@tabler/icons-react"; import { IconDeviceDesktop } from "@tabler/icons-react";
import { invariantTechnicalLabels } from "@homarr/definitions"; import { invariantTechnicalLabels } from "@homarr/definitions";
@@ -26,16 +27,25 @@ export const SystemResourceGPUChart = ({
title={invariantTechnicalLabels.gpu} title={invariantTechnicalLabels.gpu}
icon={IconDeviceDesktop} icon={IconDeviceDesktop}
lastValue={ lastValue={
gpuUsageOverTime.length > 0 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion gpuUsageOverTime.length > 0 ? `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` : undefined
`${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%`
: undefined
} }
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }} yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipLabel={(index) => `${Math.round(gpuUsageOverTime[index] ?? 0)}%`} tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{value.toFixed(0)}%
</Text>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,3 +1,4 @@
import { Paper, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react"; import { IconBrain } from "@tabler/icons-react";
import { formatBytesPair } from "@homarr/common"; import { formatBytesPair } from "@homarr/common";
@@ -19,33 +20,15 @@ export const SystemResourceMemoryChart = ({
labelDisplayMode: LabelDisplayModeOption; labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean; advanced?: boolean;
}) => { }) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({ const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
index,
usage,
}));
const t = useI18n("widget.systemResources.card"); const t = useI18n("widget.systemResources.card");
const percentageUsed = const percentageUsed =
memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0 memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
totalCapacityInBytes
: undefined; : undefined;
const tooltipLabel = (index: number) => {
const used = memoryUsageOverTime[index] ?? 0;
const memory = formatBytesPair(Math.round(used), totalCapacityInBytes);
const percent =
totalCapacityInBytes > 0
? Math.round((used / totalCapacityInBytes) * 100)
: 0;
return t("memoryTooltip", {
used: memory.used,
available: memory.available,
percent,
});
};
return ( return (
<CommonChart <CommonChart
data={chartData} data={chartData}
@@ -56,13 +39,22 @@ export const SystemResourceMemoryChart = ({
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
yAxisProps={{ domain: [0, totalCapacityInBytes] }} yAxisProps={{ domain: [0, totalCapacityInBytes] }}
lastValue={ lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
percentageUsed !== undefined
? `${Math.round(percentageUsed * 100)}%`
: undefined
}
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
tooltipLabel={tooltipLabel} tooltipProps={{
content: ({ payload }) => {
const value = payload[0] ? Number(payload[0].value) : 0;
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{memory.used} / {memory.available} (
{totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%)
</Text>
</Paper>
);
},
}}
/> />
); );
}; };
@@ -1,3 +1,4 @@
import { Paper, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react"; import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { formatByteRate } from "@homarr/common"; import { formatByteRate } from "@homarr/common";
@@ -38,9 +39,18 @@ export const NetworkTrafficChart = ({
chartType={hasShadow ? "area" : "line"} chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode} labelDisplayMode={labelDisplayMode}
advanced={advanced} advanced={advanced}
tooltipLabel={(index) => tooltipProps={{
formatByteRate(Math.round(usageOverTime[index] ?? 0)) content: ({ payload }) => {
} const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{formatByteRate(Math.round(value))}
</Text>
</Paper>
);
},
}}
/> />
); );
}; };