Compare commits

...
Author SHA1 Message Date
Thomas 9f8cc19a9e Merge branch 'dev' into feat/typescript-7 2026-07-12 15:32:58 +02:00
Thomas 03f68a0421 fix(qbittorrent): sort torrents by progress so active items appear before limit cutoff (#6297) 2026-07-12 14:56:24 +02:00
Bergasha 807e5eb2a9 feat(docker-widget): replace footer timestamp text with compact refresh button (#6307) 2026-07-12 14:51:56 +02:00
Thomas 8e8c29979f fix(system-disks): format absolute storage values with shared byte helper (#6299) 2026-07-12 14:50:58 +02:00
Manuel 7d859c1af0 docs: update pr template (#6147) 2026-07-12 14:49:56 +02:00
homarr-renovate[bot] e9543dc30d chore(deps): update tiptap monorepo to v3.27.3 (#6308)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 09:12:55 +00:00
Nerdy Technician 9f313440ac fix(integrations): update PatchMon API key settings link (#6302) 2026-07-12 08:59:52 +02:00
homarr-renovate[bot] c2508dffea chore(deps): update dependency posthog-js to ^1.396.7 (#6285)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 06:18:17 +00:00
Artur d44bc06a93 feat(notebook): support text direction controls (#6293) 2026-07-12 07:58:02 +02:00
homarr-renovate[bot] c56b91570b chore(deps): update tiptap monorepo to v3.27.2 (#6292)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 03:01:23 +00:00
homarr-renovate[bot] d53c4e6513 chore(deps): update dependency hono@<4.6.5 to >=4.12.28 (#6291)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 02:58:51 +00:00
homarr-renovate[bot] 0426d600cc chore(deps): update dependency oxfmt to ^0.58.0 (#6305)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-12 00:06:59 +00:00
Thomas Camlong 2faa0f018a chore: upgrade typescript from v6 to v7 (tsgo native port)
TypeScript 7 is a native Go port (tsgo) that delivers 8-12x faster
builds and a rebuilt LSP. No tsconfig changes needed — the codebase
was already compatible with all TS7 defaults and deprecations.
2026-07-11 21:41:00 +02:00
Thomas 576856d634 fix(beszel): add reliable live stats and filesystem UI (#6289) 2026-07-11 17:55:04 +02:00
Thomas def3ede3bc fix(db): restore widget_secret migration journal entries accidentally removed by #6206 (#6288) 2026-07-11 15:40:06 +02:00
homarr-releases[bot] d9c503d01b chore: update bug report template (#6281)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-07-10 20:17:43 +00:00
69 changed files with 2736 additions and 1588 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.70.0
- 1.69.2
- 1.69.0
- 1.68.0
+8
View File
@@ -11,3 +11,11 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Screenshots
@@ -1,24 +1,21 @@
---
title: 'Beszel System Stats'
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
title: "Beszel System Stats"
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { beszelSystemStatsWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { beszelSystemStatsWidget } from ".";
import { beszelIntegration } from "@site/docs/integrations/beszel";
import beszelSystemStats from './img/beszel-system-stats.png';
import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader
widget={beszelSystemStatsWidget}
categories={['System Monitoring', 'Charts']}
/>
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
<WidgetIntegrations items={[{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system"
}]} />
<WidgetIntegrations
items={[
{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Supported Integrations
+40 -20
View File
@@ -1,29 +1,25 @@
---
title: 'Notebook'
description: 'A simple notebook widget that supports markdown.'
title: "Notebook"
description: "A simple notebook widget that supports markdown."
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { notebookWidget } from '.';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from ".";
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
<WidgetHeader
widget={notebookWidget}
categories={['Notes']}
/>
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
It is based on Tiptap.dev and supports most of its features.
### Screenshots
import editOff from './img/normal.png';
import editOn from './img/edit.png';
import editOff from "./img/normal.png";
import editOn from "./img/edit.png";
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={notebookWidget.configuration} />
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
![](./img/notebook-enter-editing.webp)
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions
The notebook offers a variaty of functions to edit the text and more.
The notebook offers a variety of functions to edit the text and more.
![](./img/notebook-toolbar.webp)
#### Common text formatting
| Function | Description |
| ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold |
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Text Alignment
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes
![](./img/notebook-toolbar-lists.webp)
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions
##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -102,16 +117,20 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls
![](./img/notebook-image-modal.webp)
##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
![](./img/notebook-table-modal.webp)
@@ -119,6 +138,7 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it.
##### Table controls
![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table.
@@ -134,4 +154,4 @@ All the following ones are for editing the wize of the table:
- Delete Column
- Add row on top
- Add row below
- Delete Row
- Delete Row
@@ -10,6 +10,7 @@ import {
createWSClient,
httpBatchStreamLink,
httpLink,
httpSubscriptionLink,
isNonJsonSerializable,
loggerLink,
splitLink,
@@ -101,9 +102,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
splitLink({
condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
true: splitLink({
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
true: httpSubscriptionLink({
url: getTrpcUrl(),
transformer: superjson,
eventSourceOptions: { withCredentials: true },
}),
false: wsLink<AppRouter>({
client: wsClient,
transformer: superjson,
}),
}),
false: splitLink({
condition: ({ input }) => isNonJsonSerializable(input),
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -40,7 +41,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />,
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -41,15 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => {
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) {
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
{formatBytes(file.size)}
</Text>
</Group>
<Button
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
{formatBytes(bytesUsage)}
</Text>
);
},
@@ -4,7 +4,7 @@ import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
@@ -49,7 +49,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
@@ -276,7 +279,7 @@ const WidgetCacheAge = ({
isFetching,
}: {
queryClient: QueryClient;
queryKey: unknown[];
queryKey: QueryKey;
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
@@ -295,8 +298,8 @@ const WidgetCacheAge = ({
.filter(Boolean);
if (timestamps.length === 0) return null;
const oldest = Math.min(...timestamps);
const seconds = Math.floor((Date.now() - oldest) / 1000);
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
+1
View File
@@ -4,6 +4,7 @@ export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
},
experimental__runtimeEnv: process.env,
});
+1 -1
View File
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const excludedWidgetPaths = new Set(["widget.app.ping"]);
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
+39 -1
View File
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
let emittedEventCount = 0;
logger.debug("Beszel realtime subscription started", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
});
void (async () => {
const integration = ctx.integrations[0];
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (isActive) emit.next(event);
if (!isActive) return;
emittedEventCount += 1;
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
logger.debug("Forwarding Beszel realtime events", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
eventType: event.type,
emittedEventCount,
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
});
}
emit.next(event);
},
controller.signal,
);
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
}
} catch (error) {
if (isActive) {
logger.warn("Beszel realtime subscription failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
});
emit.error(
error instanceof TRPCError
? error
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
}
if (isActive) {
logger.debug("Beszel realtime subscription completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
});
emit.complete();
}
})();
return () => {
isActive = false;
logger.debug("Beszel realtime subscription cancelled", {
userId: ctx.session?.user?.id,
integrationIds: ctx.integrations.map((integration) => integration.id),
systemId: input.systemId,
emittedEventCount,
});
controller.abort();
};
});
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
[[["docker", "getContainers"], { type: "query" }], true],
[[["integration", "byIds"], { type: "query" }], true],
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
+104 -5
View File
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
};
/**
* Number of bytes to si format. (Division by 1024)
* Does not accept floats, size in bytes should be an integer.
* Will return "NaI" and logs a warning if a float is passed.
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
* Returns "∞" if the size is too large to be represented in the current format.
* Number of bytes to a human-readable string using binary (KiB) suffixes.
* Does not accept floats; size in bytes should be an integer.
* Will return "NaI" and log a warning if a float is passed.
* `concat` is appended after the unit so it is omitted when the returned
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
* represented in the current format.
*
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
* paired "used / total" displays, and `formatByteRate` for byte-per-second
* rates. This function is kept for backwards compatibility but should not
* be used in new code.
*/
export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
return "∞";
};
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export type ByteUnitSystem = "binary" | "decimal";
export interface FormatBytesOptions {
/**
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
*/
unit?: ByteUnitSystem;
}
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
let index = 0;
let value = bytes;
while (value >= base && index < lastIndex) {
value /= base;
index++;
}
return index;
};
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
const resolveUnitConfig = (options: FormatBytesOptions) => {
const { unit = "binary" } = options;
return {
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
base: unit === "binary" ? 1024 : 1000,
};
};
/**
* Format a byte value as a human-readable string with a unit suffix.
*
* Picks the largest unit that keeps the scaled value below the base. Use
* `formatBytesPair` when two related values (e.g. used and available) should
* share a common unit.
*
* @example
* formatBytes(0); // "0.0 B"
* formatBytes(1024); // "1.0 KiB"
* formatBytes(985828802560); // "918.1 GiB"
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
*/
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
const { units, base } = resolveUnitConfig(options);
const safe = sanitizeBytes(bytes);
const index = pickUnitIndex(safe, base, units.length - 1);
const scaled = safe / base ** index;
return `${scaled.toFixed(1)} ${units[index]}`;
};
/**
* Format two related byte values (typically `used` and `available`) with a
* shared unit. The unit is picked from the sum of the two values so the
* combined display stays consistent.
*
* @example
* formatBytesPair(985828802560, 2858736793190);
* // { used: "0.9 TiB", available: "2.6 TiB" }
*/
export const formatBytesPair = (
used: number,
available: number,
options: FormatBytesOptions = {},
): { used: string; available: string } => {
const { units, base } = resolveUnitConfig(options);
const safeUsed = sanitizeBytes(used);
const safeAvailable = sanitizeBytes(available);
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
const suffix = units[index];
return {
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
};
};
/**
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
* string with a `/s` suffix. The value is formatted with the same rules as
* `formatBytes` and a trailing `/s` is appended.
*
* @example
* formatByteRate(0); // "0.0 B/s"
* formatByteRate(1024); // "1.0 KiB/s"
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
*/
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
`${formatBytes(bytes, options)}/s`;
const IMPERIAL_MULTIPLIER = 1.609344;
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
describe("formatBytes", () => {
it("returns bytes for small values", () => {
expect(formatBytes(0)).toBe("0.0 B");
expect(formatBytes(1)).toBe("1.0 B");
expect(formatBytes(1023)).toBe("1023.0 B");
});
it("scales binary units correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
});
it("scales decimal units when requested", () => {
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
});
it("formats a typical storage value with one decimal", () => {
expect(formatBytes(985828802560)).toBe("918.1 GiB");
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
});
it("caps at the largest unit to avoid overflow", () => {
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
});
it("returns the zero value for negative or non-finite input", () => {
expect(formatBytes(-1)).toBe("0.0 B");
expect(formatBytes(Number.NaN)).toBe("0.0 B");
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
});
});
describe("formatBytesPair", () => {
it("picks the unit of the larger total and applies it to both values", () => {
const { used, available } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("0.9 TiB");
expect(available).toBe("2.6 TiB");
});
it("uses a smaller unit when both values fit in it", () => {
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("100.0 MiB");
expect(available).toBe("200.0 MiB");
});
it("falls back to zero when both inputs are invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, -1);
expect(used).toBe("0.0 B");
expect(available).toBe("0.0 B");
});
it("uses the same unit across both values when one is invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TiB");
expect(available).toBe("1.0 TiB");
});
it("honours the decimal unit option", () => {
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
expect(used).toBe("0.5 TB");
expect(available).toBe("1.5 TB");
});
});
describe("formatByteRate", () => {
it("appends a /s suffix to the formatted value", () => {
expect(formatByteRate(0)).toBe("0.0 B/s");
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
});
it("honours the decimal unit option", () => {
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
});
it("returns zero for invalid input", () => {
expect(formatByteRate(-1)).toBe("0.0 B/s");
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
});
});
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
};
};
export const createCertificateAgentAsync = async (override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
}) => {
export const createCertificateAgentAsync = async (
override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
) => {
return new UndiciHttpAgent({
...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number },
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
const agent = await createCertificateAgentAsync(
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
);
if (options?.timeout) {
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
...options,
...fetchOptions,
signal,
dispatcher: agent,
}),
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
);
}
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
return fetch(url, {
...options,
...fetchOptions,
dispatcher: agent,
});
};
@@ -295,6 +295,13 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
}
]
}
@@ -57,6 +57,13 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
}
]
}
@@ -281,6 +281,13 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -379,7 +379,7 @@ export const integrationDefs = {
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413,
apiKeySettingsPath: "/settings/api-keys",
apiKeySettingsPath: "/settings/integrations",
},
tracearr: {
name: "Tracearr",
@@ -15,6 +15,12 @@ describe("getIntegrationApiKeyUrl", () => {
);
});
it("builds URL for patchmon", () => {
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
"http://patchmon.local:3413/settings/integrations",
);
});
it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
});
@@ -19,15 +19,66 @@ import type {
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
BeszelSystemStats,
BeszelContainerStats,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
const logger = createLogger({ module: "beszel-integration" });
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const realtimeRetryDelaysMs = [200, 300, 500, 1_000, 1_500, 2_000] as const;
const supportsRealtimeMetrics = (version: string) => {
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
return major > 0 || minor >= 13;
};
const waitForRetryAsync = async (delayMs: number, signal: AbortSignal) =>
await new Promise<void>((resolve) => {
if (signal.aborted) return resolve();
const timeout = setTimeout(done, delayMs);
signal.addEventListener("abort", done, { once: true });
function done() {
clearTimeout(timeout);
signal.removeEventListener("abort", done);
resolve();
}
});
interface BeszelRealtimeSnapshot {
stats?: BeszelSystemStats;
container?: BeszelContainerStats[];
}
export const normalizeRealtimeSnapshot = (
payload: BeszelRealtimeSnapshot,
systemId: string,
receivedAt = new Date(),
): LiveStatsEvent[] => {
const created = receivedAt.toISOString();
const id = `realtime-${receivedAt.getTime()}`;
const events: LiveStatsEvent[] = [];
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
events.push({
type: "system_stats",
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
});
}
if (Array.isArray(payload.container)) {
events.push({
type: "container_stats",
record: { id, system: systemId, stats: payload.container, type: "1m", created, updated: created },
});
}
return events;
};
interface BeszelSession {
token: string;
@@ -285,123 +336,163 @@ export class BeszelIntegration extends Integration {
});
}
/**
* Subscribe to real-time system and container stats via PocketBase SSE.
* Connects to the PocketBase Realtime API and parses incoming SSE events,
* filtering by systemId. This provides live-updating metrics (≤1s latency
* from agent) instead of polling the REST API every 5s.
*/
/** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const session = await this.authenticateAsync();
const realtimeUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection for realtime metrics", {
integrationId: this.integration.id,
systemId,
url: realtimeUrl.pathname,
});
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { Authorization: session.token },
signal,
});
if (!response.ok) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: response.status,
});
throw new ResponseError(response);
const systems = await this.getSystemsAsync();
const system = systems.find((candidate) => candidate.id === systemId);
if (!system) throw new Error("Selected Beszel system was not found");
if (system.status !== "up")
throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
if (!supportsRealtimeMetrics(system.info.v)) {
throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body in SSE stream");
const decoder = new TextDecoder();
let buffer = "";
let subscribed = false;
const processLine = async (line: string) => {
if (!line.startsWith("data: ")) return;
const realtimeUrl = this.url("/api/realtime");
const topic = createRealtimeMetricsTopic(systemId);
let retryAttempt = 0;
let emittedSnapshotCount = 0;
while (!signal.aborted) {
try {
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
// PB_CONNECT delivers the clientId — use it to POST our subscriptions
if (!subscribed && typeof parsed.clientId === "string") {
subscribed = true;
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
let session = await this.authenticateAsync();
const openStream = async () =>
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: {
Accept: "text/event-stream",
Authorization: session.token,
"Content-Type": "application/json",
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal,
bodyTimeout: 0,
});
if (!subscribeResponse.ok) {
logger.warn("Beszel SSE subscription POST failed", {
logger.debug("Opening Beszel rt_metrics SSE connection", {
integrationId: this.integration.id,
systemId,
retryAttempt,
});
let response = await openStream();
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await openStream();
}
if (!response.ok) throw new ResponseError(response);
const reader = response.body?.getReader();
if (!reader) throw new Error("Beszel realtime response has no body");
const decoder = new TextDecoder();
const parser = new PocketBaseSseParser();
let clientId: string | undefined;
const processFrameAsync = async (frame: ReturnType<PocketBaseSseParser["push"]>[number]) => {
if (frame.event === "PB_CONNECT") {
let fallbackClientId: string | undefined;
try {
const connectData = JSON.parse(frame.data) as { clientId?: string };
fallbackClientId = connectData.clientId;
} catch {
// PocketBase normally provides the client ID in the SSE id field.
}
clientId = frame.id || fallbackClientId;
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: { Authorization: session.token, "Content-Type": "application/json" },
body: JSON.stringify({ clientId, subscriptions: [topic] }),
signal,
});
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
logger.debug("Subscribed to Beszel rt_metrics topic", {
integrationId: this.integration.id,
systemId,
status: subscribeResponse.status,
clientId,
});
throw new ResponseError(subscribeResponse);
return;
}
logger.debug("Beszel SSE subscribed to collections", {
if (!frame.event.startsWith("rt_metrics")) return;
try {
const snapshot = JSON.parse(frame.data) as BeszelRealtimeSnapshot;
const events = normalizeRealtimeSnapshot(snapshot, systemId);
if (events.length === 0) return;
emittedSnapshotCount += 1;
retryAttempt = 0;
for (const event of events) onMessage(event);
if (emittedSnapshotCount === 1 || emittedSnapshotCount % 30 === 0) {
logger.debug("Forwarded Beszel rt_metrics snapshots", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
containerCount: snapshot.container?.length ?? 0,
});
}
} catch (error) {
logger.warn("Failed to parse Beszel rt_metrics payload", {
integrationId: this.integration.id,
systemId,
error: error instanceof Error ? error.message : String(error),
});
}
};
logger.debug("Beszel rt_metrics SSE connection established", {
integrationId: this.integration.id,
systemId,
contentType: response.headers.get("content-type"),
});
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
await processFrameAsync(frame);
}
}
for (const frame of parser.push(decoder.decode())) await processFrameAsync(frame);
for (const frame of parser.finish()) await processFrameAsync(frame);
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
} catch (error) {
if (signal.aborted) break;
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
retryAttempt += 1;
logger.debug("Retrying Beszel rt_metrics handshake after reauthentication", {
integrationId: this.integration.id,
systemId,
clientId: parsed.clientId,
});
return;
continue;
}
const record = parsed.record as Record<string, unknown> | undefined;
if (!record || record.system !== systemId) return;
if (Array.isArray(record.stats)) {
onMessage({
type: "container_stats",
record: record as unknown as BeszelContainerStatsRecord,
});
} else if (record.stats && typeof record.stats === "object") {
onMessage({
type: "system_stats",
record: record as unknown as BeszelSystemStatsRecord,
});
}
} catch (error) {
if (error instanceof ResponseError) throw error;
// skip malformed SSE data lines
if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
retryAttempt += 1;
logger.warn("Beszel rt_metrics connection lost; retrying", {
integrationId: this.integration.id,
systemId,
retryAttempt,
delayMs,
error: error instanceof Error ? error.message : String(error),
});
await waitForRetryAsync(delayMs, signal);
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
await processLine(line);
}
}
} finally {
reader.releaseLock();
logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
}
logger.debug("Beszel rt_metrics subscription stopped", {
integrationId: this.integration.id,
systemId,
emittedSnapshotCount,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
/** disk usage (%) */
dp: number;
/** disk read (MB/s) */
dr: number;
dr?: number;
/** disk write (MB/s) */
dw: number;
dw?: number;
/** disk read max (MB/s) */
drm?: number;
/** disk write max (MB/s) */
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
/** disk IOPS max [read, write] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
ns: number;
ns?: number;
/** network received — all interfaces (bytes/s) */
nr: number;
nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
}
export interface BeszelExtraFsStats {
/** total disk (bytes) */
/** total disk (GiB) */
d: number;
/** disk used (bytes) */
/** disk used (GiB) */
du: number;
/** read (bytes/s) */
r: number;
/** write (bytes/s) */
w: number;
/** read max (bytes/s) */
rm: number;
rm?: number;
/** write max (bytes/s) */
wm: number;
wm?: number;
/** read (IOPS) */
rb: number;
rb?: number;
/** write (IOPS) */
wb: number;
wb?: number;
/** read max (IOPS) */
rbm: number;
rbm?: number;
/** write max (IOPS) */
wbm: number;
wbm?: number;
}
export interface BeszelGPUData {
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
memory: number;
/** disk usage (%) */
disk: number;
/** extra filesystem usage (%) keyed by mount path */
extraFilesystems: Record<string, number>;
/** GPU usage (%) */
gpu: number;
/** load average [1m, 5m, 15m] */
@@ -0,0 +1,65 @@
export interface PocketBaseSseFrame {
event: string;
id?: string;
data: string;
}
export const createRealtimeMetricsTopic = (systemId: string) =>
`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: systemId } }))}`;
export class PocketBaseSseParser {
private buffer = "";
private event = "message";
private id: string | undefined;
private dataLines: string[] = [];
public push(chunk: string): PocketBaseSseFrame[] {
this.buffer += chunk;
const frames: PocketBaseSseFrame[] = [];
const lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const frame = this.processLine(line);
if (frame) frames.push(frame);
}
return frames;
}
public finish(): PocketBaseSseFrame[] {
const frames: PocketBaseSseFrame[] = [];
if (this.buffer !== "") {
const frame = this.processLine(this.buffer);
if (frame) frames.push(frame);
this.buffer = "";
}
const trailingFrame = this.dispatch();
if (trailingFrame) frames.push(trailingFrame);
return frames;
}
private processLine(line: string): PocketBaseSseFrame | null {
if (line === "") return this.dispatch();
if (line.startsWith(":")) return null;
const colonIndex = line.indexOf(":");
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
let value = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") this.event = value;
else if (field === "id") this.id = value;
else if (field === "data") this.dataLines.push(value);
return null;
}
private dispatch(): PocketBaseSseFrame | null {
if (this.dataLines.length === 0 && this.event === "message" && this.id === undefined) return null;
const frame = { event: this.event, id: this.id, data: this.dataLines.join("\n") };
this.event = "message";
this.id = undefined;
this.dataLines = [];
return frame;
}
}
@@ -40,7 +40,124 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration } from "../beszel-integration";
import { BeszelIntegration, normalizeRealtimeSnapshot } from "../beszel-integration";
import type { LiveStatsEvent } from "../beszel-types";
import { createRealtimeMetricsTopic } from "../pocketbase-realtime";
describe("normalizeRealtimeSnapshot", () => {
test("normalizes current Beszel host and container snapshots", () => {
const receivedAt = new Date("2026-07-11T13:51:30.217Z");
const events = normalizeRealtimeSnapshot(
{
stats: {
cpu: 18.07,
m: 31.09,
mu: 16.53,
mp: 53.18,
mb: 14.32,
s: 4,
su: 3.9,
d: 938.8,
du: 455.81,
dp: 51.15,
efs: { sda: { d: 1906.8, du: 1656.45, r: 25.63, w: 0 } },
},
container: [{ n: "homarr", c: 1.65, m: 650.56, b: [356066, 19702867] }],
},
"system-1",
receivedAt,
);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
type: "system_stats",
record: { system: "system-1", created: receivedAt.toISOString(), stats: { cpu: 18.07 } },
});
expect(events[1]).toMatchObject({
type: "container_stats",
record: { system: "system-1", stats: [{ n: "homarr", m: 650.56 }] },
});
});
test("ignores snapshots without metric data", () => {
expect(normalizeRealtimeSnapshot({}, "system-1")).toEqual([]);
});
});
describe("subscribeRealtimeMetrics", () => {
test("subscribes to Beszel rt_metrics and forwards one-second snapshots", async () => {
const originalFetch = globalThis.fetch;
const topic = createRealtimeMetricsTopic("system-1");
const subscriptionBodies: unknown[] = [];
const encoder = new TextEncoder();
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/collections/users/auth-with-password")) {
return Response.json({ token: "test-token", record: { id: "user-1" } });
}
if (url.includes("/api/collections/systems/records")) {
return Response.json({
page: 1,
perPage: 500,
totalItems: 1,
totalPages: 1,
items: [
{
id: "system-1",
name: "Server",
host: "localhost",
port: "45876",
status: "up",
info: { cpu: 1, mp: 2, dp: 3, u: 4, v: "0.18.7" },
created: "2026-07-11T00:00:00.000Z",
updated: "2026-07-11T00:00:00.000Z",
},
],
});
}
if (url.endsWith("/api/realtime") && init?.method === "POST") {
subscriptionBodies.push(JSON.parse(String(init.body)));
expect(new Headers(init.headers).get("Authorization")).toBe("test-token");
return new Response(null, { status: 204 });
}
if (url.endsWith("/api/realtime")) {
const snapshot = JSON.stringify({
stats: { cpu: 5, m: 10, mu: 4, mp: 40, mb: 1, s: 0, su: 0, d: 20, du: 8, dp: 40 },
container: [{ n: "homarr", c: 1, m: 256 }],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: PB_CONNECT\nid: client-1\ndata: {}\n\n"));
controller.enqueue(encoder.encode(`event: ${topic}\ndata: ${snapshot}\n\n`));
controller.close();
},
});
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
}
throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`);
}) as typeof fetch;
try {
const integration = createBeszelIntegration();
const controller = new AbortController();
const events: LiveStatsEvent[] = [];
await integration.subscribeRealtimeMetrics(
"system-1",
(event) => {
events.push(event);
if (events.length === 2) controller.abort();
},
controller.signal,
);
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
function createBeszelIntegration() {
return new BeszelIntegration({
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "../pocketbase-realtime";
describe("PocketBaseSseParser", () => {
test("parses PB_CONNECT client IDs and CRLF-delimited frames split across chunks", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: PB_CONNECT\r\nid: client-")).toEqual([]);
expect(parser.push("123\r\ndata: {}\r\n\r\n")).toEqual([{ event: "PB_CONNECT", id: "client-123", data: "{}" }]);
});
test("joins multiline data and ignores keepalive comments", () => {
const parser = new PocketBaseSseParser();
expect(parser.push(': keepalive\n\nevent: rt_metrics\ndata: {"stats":\ndata: {}}\n\n')).toEqual([
{ event: "rt_metrics", id: undefined, data: '{"stats":\n{}}' },
]);
});
test("flushes a trailing frame when the stream ends without a blank line", () => {
const parser = new PocketBaseSseParser();
expect(parser.push("event: rt_metrics\ndata: {}")).toEqual([]);
expect(parser.finish()).toEqual([{ event: "rt_metrics", id: undefined, data: "{}" }]);
});
});
describe("createRealtimeMetricsTopic", () => {
test("matches the PocketBase custom-topic options encoding used by Beszel", () => {
const topic = createRealtimeMetricsTopic("system-1");
expect(topic).toBe(`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: "system-1" } }))}`);
});
});
@@ -1,5 +1,3 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: humanFileSize(storageLoad[index]!),
used: `${storageLoad[index]!}`,
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})),
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit });
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
@@ -226,6 +226,7 @@ export class BeszelMockService {
u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
@@ -1,7 +1,6 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -59,7 +58,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
+1
View File
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
extraFilesystems: info.efs ?? {},
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
+7 -5
View File
@@ -1956,6 +1956,8 @@
"clear": "Clear formatting",
"heading": "Heading {level}",
"align": "Align text: {position}",
"directionLtr": "Set text direction left to right",
"directionRtl": "Set text direction right to left",
"blockquote": "Blockquote",
"horizontalLine": "Horizontal line",
"bulletList": "Bullet list",
@@ -4031,9 +4033,6 @@
},
"showDockerNetwork": {
"label": "Show Docker network chart"
},
"showStorage": {
"label": "Show storage chart"
}
},
"selectSystem": "Select system",
@@ -4060,8 +4059,8 @@
},
"disk": {
"title": "Disk Usage",
"subtitle": "Usage of root partition",
"series": "Used"
"subtitle": "Usage by filesystem",
"series": "Root"
},
"diskIO": {
"title": "Disk I/O",
@@ -5206,6 +5205,9 @@
"title": "Containers",
"table": {
"updated": "Updated {when}",
"refresh": {
"lastUpdated": "Last refresh {when}"
},
"search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers",
@@ -3,7 +3,7 @@
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition";
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
</Stack>
);
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
interface SizeConfig {
iconSize: number;
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 120;
const MIN_CELL_WIDTH = 180;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, itemCount: number): number => {
const getColCount = (width: number, height: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
};
interface MetricRowProps {
@@ -133,20 +133,25 @@ interface MetricRowProps {
}
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{value}
</Text>
{progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={progress.value}
color={progress.color}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
)}
</Group>
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
<Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{label}
</Text>
<MetricValue value={value} progress={progress} size={size} />
@@ -195,14 +200,13 @@ const metricRenderers = [
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="disk"
icon={<HardDrive size={sz.iconSize} />}
label={t("metric.disk")}
value={formatPercent(s.disk)}
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
size={sz}
/>
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
<HardDrive size={sz.iconSize} />
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{t("metric.disk")}
</Text>
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
@@ -399,7 +403,7 @@ export default function BeszelSystemGridWidget({
);
}
const cols = getColCount(width, filteredSystems.length);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,54 +1,21 @@
"use client";
import { useCallback, useMemo } from "react";
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDockerChartData,
useEfsChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
import { useLiveStats } from "../beszel/_shared/use-live-stats";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const gridColumns = [1, 2] as const;
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
if (!show || !data) return undefined;
return data;
}
import { BeszelStatsView } from "../beszel/_shared/stats-view";
export default function BeszelSystemStatsWidget({
options,
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
const systemExists = systems.some((s) => s.value === selectedSystem);
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const isLive = options.timePeriod === "1m";
const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem,
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
},
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = statsResult;
if (isLive) {
activeStats = liveData;
}
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats;
const timePeriod = options.timePeriod as BeszelTimePeriod;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames,
"cpu",
timePeriod,
);
const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames,
"memory",
timePeriod,
);
const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames,
"network",
timePeriod,
);
const efsPaths = useMemo(() => {
if (!systemStats) return [];
const paths = new Set<string>();
for (const record of systemStats) {
if (record.stats.efs) {
for (const path of Object.keys(record.stats.efs)) {
paths.add(path);
}
}
}
return [...paths];
}, [systemStats]);
const storageData = useEfsChartData(statsWhenShown(options.showStorage, systemStats), efsPaths, timePeriod);
const containerSeries = useMemo(
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
[containerNames],
);
const storageSeries = useMemo(
() => efsPaths.map((path, i) => ({ name: path, color: containerColors[i % containerColors.length] as string })),
[efsPaths],
);
const cols = gridColumns[Number(width > 600)];
if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError;
if (systemsPending) {
if (systemsPending)
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (systems.length === 0) {
return (
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
);
}
if (!systemsPending && !systemExists && systems.length > 0) {
if (!systemExists) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
@@ -260,22 +99,6 @@ export default function BeszelSystemStatsWidget({
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
{t("error.switchToHistorical")}
</Button>
</Stack>
</Center>
);
}
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: (isEditMode && "none") || undefined }}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
{systems.map((system) => (
<Menu.Item
key={s.value}
key={system.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
fw={system.value === selectedSystem ? 600 : 400}
c={system.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(system.value)}
>
{s.label}
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
<Center h={CHART_HEIGHT}>
<Loader size="sm" />
</Center>
)}
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
)}
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{options.showStorage && storageData.length > 0 && storageSeries.length > 0 && (
<BeszelChartPanel
title={t("chart.storage.title")}
subtitle={t("chart.storage.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: storageData,
type: "stacked",
series: storageSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
)}
<BeszelStatsView
integrationIds={integrationIds}
systemId={selectedSystem}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
</Stack>
</ScrollArea>
</Box>
@@ -16,6 +16,7 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
showStorage: factory.switch({ defaultValue: true }),
}));
},
errors: {
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import {
formatByteRate,
formatLoadAvg,
formatPercent,
formatTemp,
formatUptime,
getProgressTrackSize,
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -97,11 +105,16 @@ export default function BeszelSystemTableWidget({
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
@@ -156,7 +169,15 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.disk} />,
render: (record) => (
<DiskUsage
system={record}
fontSize={size.fontSize}
progressSize={size.progressSize}
valueMiw={size.valueMiw}
valueGap={8}
/>
),
},
options.showGpu && {
accessor: "gpu",
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
const record = (
created: string,
du: number,
efs: BeszelSystemStatsRecord["stats"]["efs"],
): BeszelSystemStatsRecord => ({
id: created,
system: "system-1",
type: "1m",
created,
updated: created,
stats: {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
s: 0,
su: 0,
d: 938.8,
du,
dp: 51.15,
efs,
},
});
describe("buildDiskChartData", () => {
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:46:00.000Z", 455.81, {
sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
}),
],
["sda", "sdb", "sdc"],
"Root",
"1h",
);
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
});
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
record("2026-07-11T13:46:00.000Z", 1, undefined),
],
["sda"],
"Root",
"1h",
);
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
{ Root: 1, sda: 0 },
{ Root: 2, sda: 4 },
]);
});
});
describe("padLiveTimeGrid", () => {
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
expect(data).toHaveLength(60);
expect(data[0]).not.toHaveProperty("CPU");
expect(data[58]).not.toHaveProperty("CPU");
expect(data[59]).toMatchObject({ CPU: 18.07 });
});
test("leaves null gaps for missing seconds instead of stretching existing points", () => {
const data = padLiveTimeGrid([
{ time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
]);
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
});
});
+62 -14
View File
@@ -30,6 +30,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
const days = periodDays[timePeriod];
if (!days) return data;
@@ -50,6 +51,38 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
if (data.length === 0) return data;
const end = dayjs(
data.reduce((latest, point) => {
const timestamp = new Date(point.rawTime as string).getTime();
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
}, 0),
).startOf("second");
const pointsBySecond = new Map(
data.map(
(point) =>
[
dayjs(point.rawTime as string)
.startOf("second")
.valueOf(),
point,
] as const,
),
);
return Array.from({ length: pointCount }, (_, index) => {
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
rawTime: timestamp.toISOString(),
}
);
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
@@ -174,24 +207,39 @@ const defaultContainerExtractors: Record<string, ContainerExtractor> = {
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useEfsChartData = (
export const useDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) =>
useMemo(() => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
}, [systemStats, efsPaths, timePeriod]);
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
);
export const buildDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
rawTime: record.created,
[rootSeriesName]: record.stats.du,
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
@@ -0,0 +1,105 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
interface DiskUsageProps {
system: BeszelSystemRow;
fontSize: string;
progressSize: "xs" | "sm";
valueMiw: number;
valueGap?: number;
}
const mountLabel = (path: string) =>
path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
};
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
describe("Beszel storage formatting", () => {
test("keeps smaller values in GB", () => {
expect(formatGB(455.81)).toBe("455.81 GB");
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
});
test("promotes large GiB values to TB", () => {
expect(formatGB(3323)).toBe("3.25 TB");
expect(formatGB(3936.86)).toBe("3.84 TB");
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
});
test("maps progress sizes consistently", () => {
expect(getProgressTrackSize("xs")).toBe(6);
expect(getProgressTrackSize("sm")).toBe(9);
});
});
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
gb: (value: number) => {
const numericValue = Number(value);
if (!numericValue) return "0";
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
return `${numericValue.toFixed(0)}G`;
},
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
@@ -0,0 +1,339 @@
"use client";
import { useMemo } from "react";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDiskChartData,
useDockerChartData,
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
export interface BeszelStatsVisibility {
cpu: boolean;
memory: boolean;
disk: boolean;
diskIO: boolean;
network: boolean;
dockerCpu: boolean;
dockerMemory: boolean;
dockerNetwork: boolean;
}
interface BeszelStatsViewProps {
integrationIds: string[];
systemId: string;
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
integrationIds,
systemId,
timePeriod,
visibility,
columns,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = isLive ? liveData : historicalData;
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
for (const record of systemStats ?? []) {
for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
}
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
...efsPaths.map((path, index) => ({
name: path,
color: containerColors[(index + 1) % containerColors.length] as string,
})),
],
[efsPaths, rootSeriesName],
);
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
const containerSeries = useMemo(
() =>
containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
[containerNames],
);
const dockerCpuData = useDockerChartData(
whenVisible(visibility.dockerCpu, containerStats),
containerNames,
"cpu",
timePeriod,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
{onSwitchToHistorical && (
<Button size="xs" variant="light" onClick={onSwitchToHistorical}>
{t("error.switchToHistorical")}
</Button>
)}
</Stack>
</Center>
);
}
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
}
if ("error" in activeStats && activeStats.error) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
);
}
return (
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{visibility.memory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.disk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.diskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{visibility.network && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{visibility.dockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{visibility.dockerMemory && dockerMemoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{visibility.dockerNetwork && dockerNetworkData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
);
}
@@ -1,25 +1,15 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
import { useState } from "react";
import { SegmentedControl, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelTimePeriod } from "./chart";
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
import { makeTooltipProps } from "./tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
import { BeszelStatsView } from "./stats-view";
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1m", label: "Live" },
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
@@ -27,141 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "30d", label: "30D" },
];
const allCharts = {
cpu: true,
memory: true,
disk: true,
diskIO: true,
network: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
} as const;
interface BeszelSystemStatsModalProps {
integrationId: string;
systemId: string;
}
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
const t = useScopedI18n("widget.beszelSystemStats");
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
const { data } = clientApi.widget.beszel.getSystemStats.useQuery({
integrationIds: [innerProps.integrationId],
systemId: innerProps.systemId,
timePeriod,
includeDocker: false,
});
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = data?.systemStats;
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
return (
<Stack gap="md">
<SegmentedControl
size="xs"
value={timePeriod}
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
data={timePeriodOptions}
/>
<SimpleGrid cols={2} spacing="md">
{cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</SimpleGrid>
<BeszelStatsView
integrationIds={[innerProps.integrationId]}
systemId={innerProps.systemId}
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
);
}).withOptions({
@@ -1,12 +1,14 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
// 120 records at ~1 per second = 2 minutes of rolling data for charts
const MAX_BUFFER = 120;
// Beszel emits roughly one record per second. Keep a sliding one-minute window
// without synthesizing points for missed updates.
const MAX_BUFFER = 60;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
@@ -29,9 +31,8 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
{ integrationIds, systemId },
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
{
enabled: enabled && systemId !== "",
onData(event) {
setError(null);
if (event.type === "system_stats") {
@@ -48,7 +49,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}`;
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
+2 -1
View File
@@ -1,6 +1,6 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -69,6 +69,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition {
icon: TablerIcon;
queryKey?: QueryKey;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
maxIntegrations?: number;
+23 -8
View File
@@ -3,13 +3,13 @@
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common";
import { formatBytes, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -110,7 +110,7 @@ const createColumns = (
return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
{formatBytes(bytesUsage)}
</Text>
);
},
@@ -184,7 +184,11 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
const {
data,
refetch,
isFetching,
} = clientApi.docker.getContainers.useQuery(undefined, {
refetchInterval: 30_000,
});
const containers = data?.containers ?? [];
@@ -297,12 +301,23 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.updated", { when: relativeTime })}
</Text>
<Tooltip
label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<ActionIcon
size="sm"
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
)}
+1
View File
@@ -25,6 +25,7 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
queryKey: [["docker", "getContainers"]],
createOptions() {
return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({
+16 -19
View File
@@ -43,7 +43,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { humanFileSize, useIntegrationConnected } from "@homarr/common";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
@@ -386,7 +386,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null;
return downSpeed ? <Text size="xs">{formatByteRate(downSpeed)}</Text> : null;
},
},
{
@@ -466,7 +466,7 @@ export default function DownloadClientsWidget({
...columnsDefBase({ key: "received", showHeader: true }),
Cell: ({ cell }) => {
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
return <Text size="xs">{humanFileSize(received)}</Text>;
return <Text size="xs">{formatBytes(received)}</Text>;
},
},
{
@@ -474,14 +474,14 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
return sent && <Text size="xs">{humanFileSize(sent)}</Text>;
return sent && <Text size="xs">{formatBytes(sent)}</Text>;
},
},
{
...columnsDefBase({ key: "size", showHeader: true }),
Cell: ({ cell }) => {
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
return <Text size="xs">{humanFileSize(size)}</Text>;
return <Text size="xs">{formatBytes(size)}</Text>;
},
},
{
@@ -521,7 +521,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last",
Cell: ({ cell }) => {
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>;
return upSpeed && <Text size="xs">{formatByteRate(upSpeed)}</Text>;
},
},
],
@@ -669,21 +669,21 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
{item.type !== "miscellaneous" && (
<NormalizedLine
itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
values={item.upSpeed === undefined ? undefined : formatByteRate(item.upSpeed)}
/>
)}
<NormalizedLine
itemKey="downSpeed"
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
values={item.downSpeed === undefined ? undefined : formatByteRate(item.downSpeed)}
/>
{item.type !== "miscellaneous" && (
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : formatBytes(item.sent)} />
)}
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
<NormalizedLine itemKey="received" values={formatBytes(item.received)} />
<NormalizedLine itemKey="size" values={formatBytes(item.size)} />
<NormalizedLine
itemKey="progress"
values={new Intl.NumberFormat("en", {
@@ -744,10 +744,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{ paused: [] as string[], active: [] as string[] },
);
const someInteract = clients.some(({ interact }) => interact);
const totalSpeed = humanFileSize(
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
"/s",
);
const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0));
const utils = clientApi.useUtils();
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
@@ -849,21 +846,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{client.status.rates.up !== undefined ? (
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
<Text flex={1} ta="right">
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
{`↑ ${formatByteRate(client.status.rates.up)}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(client.status.totalUp ?? 0)}
{formatBytes(client.status.totalUp ?? 0)}
</Text>
</Group>
) : undefined}
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
<Text flex={1} ta="right">
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
{`↓ ${formatByteRate(client.status.rates.down)}`}
</Text>
<Text>{"-"}</Text>
<Text flex={1} ta="left">
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
{formatBytes(Math.floor(client.status.totalDown ?? 0))}
</Text>
</Group>
</Stack>
@@ -16,7 +16,7 @@ import {
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { capitalize, humanFileSize } from "@homarr/common";
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common";
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
@@ -93,16 +93,18 @@ const RightSection = ({ label, value }: RightSectionProps) => {
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const memory = formatBytesPair(item.memory.used, item.memory.total);
const storage = formatBytesPair(item.storage.used, item.storage.total);
return (
<List>
<List.Item icon={<IconCpu size={16} />}>
{t("cpu")} - {item.cpu.cores}
</List.Item>
<List.Item icon={<IconBrain size={16} />}>
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
{t("memory")} - {memory.used} / {memory.available}
</List.Item>
<List.Item icon={<IconDatabase size={16} />}>
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
{t("storage")} - {storage.used} / {storage.available}
</List.Item>
<List.Item icon={<IconClockHour3 size={16} />}>
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
@@ -121,6 +123,7 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
const storage = formatBytesPair(item.used, item.total);
return (
<Stack gap={0}>
<Center>
@@ -133,7 +136,7 @@ const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
/>
<Group align="center" gap={0}>
<Text>
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
{t("storage")} - {storage.used} / {storage.available}
</Text>
</Group>
</Center>
@@ -152,11 +155,11 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconDatabase size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.storage.write)}</Text>
<Text>{formatBytes(item.storage.write)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.storage.read)}</Text>
<Text>{formatBytes(item.storage.read)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
@@ -172,11 +175,11 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconNetwork size={16} />}>
<Group gap="sm">
<Group gap={0}>
<Text>{humanFileSize(item.network.in)}</Text>
<Text>{formatBytes(item.network.in)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group gap={0}>
<Text>{humanFileSize(item.network.out)}</Text>
<Text>{formatBytes(item.network.out)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
@@ -32,7 +32,7 @@ import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -277,7 +277,7 @@ export const progressColor = (percentage: number) => {
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
const formatFileSize = (value: string) => {
const bytes = Number(value);
return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value;
return Number.isFinite(bytes) ? formatBytes(Math.round(bytes)) : value;
};
interface FileSystem {
@@ -5,7 +5,7 @@ import { Group, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
@@ -46,7 +46,7 @@ export default function ImmichServerStatsWidget({
<StatItem
icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")}
value={humanFileSize(stats.totalLibraryUsageInBytes)}
value={formatBytes(stats.totalLibraryUsageInBytes)}
/>
)}
</Stack>
@@ -14,7 +14,7 @@ import {
} from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TdarrQueue } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
@@ -72,7 +72,7 @@ export function QueuePanel(props: QueuePanelProps) {
</Group>
</TableTd>
<TableTd py={2}>
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
<Text size="xs">{formatBytes(item.fileSize)}</Text>
</TableTd>
</TableTr>
))}
@@ -3,7 +3,7 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytes } from "@homarr/common";
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
@@ -41,7 +41,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
<StatisticItem
icon={IconDatabaseHeart}
label={t("savedSpace")}
value={humanFileSize(Math.floor(allLibs.totalSavedSpace))}
value={formatBytes(Math.floor(allLibs.totalSavedSpace))}
/>
</Group>
<Group justify="center" wrap="wrap" grow>
@@ -39,6 +39,8 @@ import {
IconRowRemove,
IconTableOff,
IconTablePlus,
IconTextDirectionLtr,
IconTextDirectionRtl,
IconX,
} from "@tabler/icons-react";
import { Color } from "@tiptap/extension-color";
@@ -66,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css";
import "./notebook.css";
@@ -171,6 +174,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
}),
TaskList.configure({ itemTypeName: "taskItem" }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
NotebookTextDirection,
TextStyle,
],
shouldRerenderOnTransaction: true,
@@ -347,6 +351,22 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
position: t("widget.notebook.align.right"),
})}
/>
<RichTextEditor.Control
title={tControls("directionLtr")}
aria-label={tControls("directionLtr")}
active={editor?.isActive({ dir: "ltr" })}
onClick={() => editor && setTextDirection(editor, "ltr").run()}
>
<IconTextDirectionLtr {...controlIconProps} />
</RichTextEditor.Control>
<RichTextEditor.Control
title={tControls("directionRtl")}
aria-label={tControls("directionRtl")}
active={editor?.isActive({ dir: "rtl" })}
onClick={() => editor && setTextDirection(editor, "rtl").run()}
>
<IconTextDirectionRtl {...controlIconProps} />
</RichTextEditor.Control>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
@@ -0,0 +1,34 @@
import { Editor } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
describe("notebook text direction", () => {
it("persists right-to-left direction in the notebook HTML", () => {
const editor = new Editor({
content: "<p>יש לי 16GB</p>",
extensions: [StarterKit, NotebookTextDirection],
});
setTextDirection(editor, "rtl").run();
expect(editor.getHTML()).toBe('<p dir="rtl">יש לי 16GB</p>');
editor.destroy();
});
it("reads and changes an existing direction attribute", () => {
const editor = new Editor({
content: '<h2 dir="rtl">Heading</h2>',
extensions: [StarterKit, NotebookTextDirection],
});
expect(editor.getHTML()).toBe('<h2 dir="rtl">Heading</h2>');
editor.commands.setTextSelection(1);
setTextDirection(editor, "ltr").run();
expect(editor.getHTML()).toContain('<h2 dir="ltr">Heading</h2>');
editor.destroy();
});
});
@@ -0,0 +1,28 @@
import { Extension } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
export type TextDirection = "ltr" | "rtl";
export const NotebookTextDirection = Extension.create({
name: "notebookTextDirection",
addGlobalAttributes() {
return [
{
types: ["heading", "paragraph"],
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir"),
renderHTML: ({ dir }: { dir?: TextDirection }) => (dir ? { dir } : {}),
},
},
},
];
},
});
export function setTextDirection(editor: Editor, direction: TextDirection) {
const nodeType = editor.isActive("heading") ? "heading" : "paragraph";
return editor.chain().focus().updateAttributes(nodeType, { dir: direction });
}
@@ -5,7 +5,7 @@ import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantin
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { formatBytesPair } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
@@ -20,11 +20,13 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
case "percentage":
return `${Math.round(item.percentage)}%`;
case "absolute": {
const usedInBytes = Number(item.used);
const availableInBytes = Number(item.available);
const availableText = Number.isFinite(availableInBytes)
? humanFileSize(Math.round(availableInBytes))
: item.available;
return `${item.used} / ${availableText}`;
if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) {
const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
return `${used} / ${available}`;
}
return `${item.used} / ${item.available}`;
}
case "free":
return `${Math.round(100 - item.percentage)}% free`;
@@ -130,16 +132,8 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
throw new NoIntegrationDataError();
}
const fileSystem = filterStorageVolumes(
rawFileSystem,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const smart = filterStorageVolumes(
lastItem.healthInfo.smart,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
if (fileSystem.length === 0) return <WidgetEmptyState />;
@@ -1,7 +1,7 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -47,7 +47,7 @@ export const CombinedNetworkTrafficChart = ({
{payloadData.value === undefined ? (
<>N/A</>
) : (
<>{humanFileSize(Math.round(Number(payloadData.value)))}/s</>
formatByteRate(Math.round(Number(payloadData.value)))
)}
</Text>
</Group>
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatBytesPair } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -41,11 +41,11 @@ export const SystemResourceMemoryChart = ({
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">
{humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} (
{Math.round((value / totalCapacityInBytes) * 100)}%)
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
</Text>
</Paper>
);
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { formatByteRate } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
@@ -32,7 +32,7 @@ export const NetworkTrafficChart = ({
title={isUp ? t("up") : t("down")}
icon={isUp ? IconArrowUp : IconArrowDown}
yAxisProps={{ domain: [0, upperBound] }}
lastValue={`${humanFileSize(Math.round(max))}/s`}
lastValue={formatByteRate(Math.round(max))}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
tooltipProps={{
@@ -41,7 +41,7 @@ export const NetworkTrafficChart = ({
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))}/s
{formatByteRate(Math.round(value))}
</Text>
</Paper>
);
+999 -772
View File
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -53,22 +53,22 @@ catalog:
"@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4
"@tiptap/extension-color": 3.27.1
"@tiptap/extension-highlight": 3.27.1
"@tiptap/extension-image": 3.27.1
"@tiptap/extension-link": ^3.27.1
"@tiptap/extension-placeholder": ^3.27.1
"@tiptap/extension-table": 3.27.1
"@tiptap/extension-table-cell": 3.27.1
"@tiptap/extension-table-header": 3.27.1
"@tiptap/extension-table-row": 3.27.1
"@tiptap/extension-task-item": 3.27.1
"@tiptap/extension-task-list": 3.27.1
"@tiptap/extension-text-align": 3.27.1
"@tiptap/extension-text-style": 3.27.1
"@tiptap/extension-underline": 3.27.1
"@tiptap/react": ^3.27.1
"@tiptap/starter-kit": ^3.27.1
"@tiptap/extension-color": 3.27.3
"@tiptap/extension-highlight": 3.27.3
"@tiptap/extension-image": 3.27.3
"@tiptap/extension-link": ^3.27.3
"@tiptap/extension-placeholder": ^3.27.3
"@tiptap/extension-table": 3.27.3
"@tiptap/extension-table-cell": 3.27.3
"@tiptap/extension-table-header": 3.27.3
"@tiptap/extension-table-row": 3.27.3
"@tiptap/extension-task-item": 3.27.3
"@tiptap/extension-task-list": 3.27.3
"@tiptap/extension-text-align": 3.27.3
"@tiptap/extension-text-style": 3.27.3
"@tiptap/extension-underline": 3.27.3
"@tiptap/react": ^3.27.3
"@tiptap/starter-kit": ^3.27.3
"@trpc/client": ^11.18.0
"@trpc/next": ^11.18.0
"@trpc/react-query": ^11.18.0
@@ -93,7 +93,7 @@ catalog:
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
posthog-js: ^1.396.4
posthog-js: ^1.398.0
posthog-node: ^5.39.2
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
@@ -151,7 +151,7 @@ catalog:
next-intl: 4.13.1
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.57.0
oxfmt: ^0.58.0
oxlint: ^1.72.0
pg: ^8.22.0
postcss-preset-mantine: ^1.18.0
@@ -177,7 +177,7 @@ catalog:
tsdav: ^2.3.0
tsx: 4.20.4
turbo: ^2.10.2
typescript: ^6.0.3
typescript: ^7.0.2
vite: ^8.1.2
undici: 7.28.0
use-deep-compare-effect: ^1.8.1
@@ -218,7 +218,7 @@ overrides:
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
"esbuild@<=0.24.2": ">=0.28.1"
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
"hono@<4.6.5": ">=4.12.27"
"hono@<4.6.5": ">=4.12.28"
"linkifyjs@<4.3.2": ">=4.3.3"
"nanoid@>=4.0.0 <5.0.9": ">=5.1.16"
"prismjs@<1.30.0": ">=1.30.0"