Compare commits

...
29 Commits
Author SHA1 Message Date
Thomas 3b1b4c76a4 Merge branch 'dev' into feat/recover-beszel 2026-06-10 18:26:53 +02:00
Thomas Camlong 78f2a25f2b fix(widgets): restore lucide-react and mantine-datatable dependencies
Beszel widgets import these packages directly but they were missing
from package.json while still listed in pnpm-lock.yaml, causing
frozen-lockfile Docker builds to fail.
2026-06-10 18:21:16 +02:00
Thomas Camlong 13c9f768fd chore: update pnpm-lock.yaml 2026-06-10 18:13:58 +02:00
Thomas Camlong 8920be7fd3 fix(i18n): disable createMessagesDeclaration to fix typecheck
The auto-generated en.d.json.ts was stale (missing beszel keys),
causing 67 TS2345 errors. Additionally, with ~2,367 leaf keys at
depth 10, the generated declaration triggers TS type complexity
limits (known next-intl issue #2296).

Disable the feature in next.config.ts, delete the stale file,
and remove its .gitignore entry. Key/namespace validation is
preserved via raw JSON import types.
2026-06-10 18:13:51 +02:00
Thomas Camlong 4b0c69e6a3 fix(beszel): resolve lint errors in widget and API files
Remove unused BeszelStatsData import from beszel router.
Replace non-null assertions with safe fallbacks across beszel
widget files (format.ts, chart.tsx, system-grid, system-stats).
2026-06-10 18:13:38 +02:00
Thomas Camlong 79b3e5cc18 fix: narrow possibly undefined system in beszel test
TypeScript reports 'system' is possibly undefined since it comes from
array indexing. Replace expect().toBeDefined() with an explicit throw
guard so the type narrows for subsequent property accesses.
2026-06-08 23:57:44 +02:00
Thomas Camlong 27521a6cfd fix(beszel): resolve lint errors in integration test
Remove unused BeszelSystem import and replace non-null assertions
with optional chaining in test expectations.
2026-06-08 22:42:12 +02:00
Thomas Camlong 5e4867edaf refactor(beszel): replace hand-rolled formatUptime with dayjs humanize 2026-06-08 22:40:04 +02:00
Thomas Camlong 540ad5a2c1 refactor(beszel): remove dead exports and deduplicate formatters
- Remove unused getBandwidthRates, getContainerNetworkRate, formatMB
- Remove unused alertColorMap from colors.ts
- Remove duplicate chartAxisFormatters.percentPrecise (identical to .percent)
- Make chartAxisFormatters.percent reference formatPercent directly
2026-06-08 22:34:21 +02:00
Thomas Camlong 5588761db7 fix(beszel): remove non-null assertion in stats request handler 2026-06-08 16:29:34 +02:00
ajnart 4d886bf980 style: auto-fix lint and format 2026-06-08 14:20:38 +00:00
Thomas Camlong dfec430b86 fix(beszel): escape backslashes before quotes in filter values
Fixes CodeQL warning: incomplete string escaping. Backslashes must be
escaped first to prevent `\'` injection via `\` in user input.
2026-06-08 16:19:11 +02:00
Thomas Camlong 466c439ba2 chore: remove beszel dev environment files from PR 2026-06-08 16:17:10 +02:00
Thomas Camlong 4c36c9cff1 fix(beszel): prevent recharts negative dimension warnings
Add minWidth: 0 and overflow: hidden to ChartPanel (CSS Grid item)
and minWidth: 0 to BeszelAreaChart. Prevents ResponsiveContainer from
computing negative width/height in grid/flex layouts before first paint.
2026-06-08 16:16:18 +02:00
Thomas Camlong cfa510dbf9 fix(beszel-mock): improve mock data fidelity and demo coverage
- Use correct time intervals per period (not always 1min)
- Return newest-first sort order matching real Beszel API
- Pass systemId into time series records
- Stable container sets per system (deterministic from system def)
- Fix pi-cluster arch to aarch64
- Clamp cpu/mem percentages to [0, 100]
- Add beszelSystemStats widget to demo seed
2026-06-08 16:14:22 +02:00
ajnart 52d5c050b6 style: auto-fix lint and format 2026-06-08 14:09:13 +00:00
Thomas Camlong 43f1613215 feat(beszel): add mock integration support for demo mode
Wire Beszel widgets into the mock integration pipeline so they work
with UNSAFE_ENABLE_MOCK_INTEGRATION and DEMO_MODE for demos.
2026-06-08 16:06:49 +02:00
Thomas Camlong bb96ca354f docs(beszel): document Beszel API types and field semantics
Add JSDoc comments to all Beszel PocketBase API types explaining the
cryptic short field names (cpu, mu, du, dr, ns, b, etc.), their units
(bytes, bytes/s, percentages), and the distinction between ns/nr
(all-interface traffic) vs b (public interface bandwidth).

Also documents the mapToSystemRow mapping and chart data extractors
so future readers understand the data flow without needing Beszel
source code.
2026-06-08 16:00:15 +02:00
ajnart c1bd6cccaa style: auto-fix lint and format 2026-06-08 13:58:40 +00:00
Thomas Camlong 2bf014ba43 fix(beszel): use bandwidth field (b) for public interface traffic
In Beszel, ns/nr are all-interface traffic (loopback, docker bridges)
while b is public interface bandwidth. Use b with ns/nr fallback for
both system stats and container network charts to match what the
grid/table display via netBytes.
2026-06-08 15:57:18 +02:00
ajnart 515b285ef7 style: auto-fix lint and format 2026-06-08 13:54:14 +00:00
Thomas Camlong 5eebfb7719 chore(dev): update beszel dev environment config
Update docker-compose and gitignore for beszel development setup.
2026-06-08 15:51:49 +02:00
Thomas Camlong 7f1638fec6 chore(beszel): add translations and dependency updates
Add i18n keys for beszel system stats chart labels (memory cache,
disk IO series) and update workspace dependencies.
2026-06-08 15:51:39 +02:00
Thomas Camlong 6b1cc0fd6b refactor(beszel): migrate widgets to shared utilities and fix perf
Update all four Beszel widgets to use extracted shared hooks, chart
helpers, colors, and formatters. Fix chart data regressions: restore
byte-to-GB conversion for memory/disk, use raw byte values for diskIO
instead of incorrect multiplication, and use ns/nr fields for network
instead of the optional b field.

Add drag-and-drop performance fixes: disable pointer events and
WebSocket subscriptions during edit mode to prevent re-renders while
dragging widgets on the board.
2026-06-08 15:51:30 +02:00
Thomas Camlong 3131687229 refactor(beszel): extract shared hooks, chart helpers, and tooltip
Create useBeszelSystemsSubscription and useBeszelFilteredSystems hooks
to eliminate duplicated subscription + cache-update + filtering logic
across grid/table/alerts. Add BeszelAreaChart wrapper and chart data
hooks (useSystemChartData, useDockerChartData) to reduce ~90 lines of
repetitive useMemo transforms in system-stats. Add portal tooltip with
mousemove listener gated on active state to prevent unnecessary
re-renders during drag-and-drop.
2026-06-08 15:51:17 +02:00
Thomas Camlong 59d44933cf refactor(beszel): centralize shared constants and formatters
Move alertColorMap and containerColors to _shared/colors.ts, extract
statusOptions to _shared/options.ts, and rename formatBytes to
formatByteRate for clarity since it formats byte rates not raw bytes.
2026-06-08 15:51:05 +02:00
Thomas Camlong 5cf9b1b752 refactor(beszel): deduplicate BeszelSystemRow type definition
Consolidate BeszelSystemRow into @homarr/integrations as the single
source of truth. Both request-handler and widgets now re-export from
there instead of maintaining separate identical copies that could
silently diverge.
2026-06-08 15:50:55 +02:00
Thomas Camlong 48dcf65c4e chore(dev): colocate beszel agent data with docker-compose
Move beszel_agent_data, beszel_data, and beszel_socket directories
into development/ alongside beszel.docker-compose.yml. Add .gitignore
for volatile data (dbs, sockets) while keeping the fingerprint.
2026-06-08 12:56:16 +02:00
Thomas Camlong 6757fbe956 feat(integrations): recover beszel integration from worktree
Recovered all beszel integration files from the local worktree
at /Users/ajnart/.cursor/worktrees/homarr/pwxp that were built
and tested with the homarr:beszel docker image.
2026-06-08 12:54:30 +02:00
41 changed files with 3474 additions and 14 deletions

No files matched your search

+1 -2
View File
@@ -66,8 +66,7 @@ e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
# next-intl
en.d.json.ts
# personal agent config
AGENTS.md
-3
View File
@@ -22,9 +22,6 @@ const homarrVersion = (() => {
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({
experimental: {
createMessagesDeclaration: "../../packages/translation/src/lang/en.json",
},
requestConfig: "../../packages/translation/src/request.ts",
});
+2
View File
@@ -80,6 +80,8 @@
"isomorphic-dompurify": "catalog:",
"jszip": "catalog:",
"jotai": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
+1
View File
@@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "mantine-datatable/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
+5 -5
View File
@@ -613,7 +613,7 @@ export const boardRouter = createTRPCRouter({
boardId: board.id,
});
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
const layoutToClone =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -1215,7 +1215,7 @@ export const boardRouter = createTRPCRouter({
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -1223,7 +1223,7 @@ export const boardRouter = createTRPCRouter({
user,
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupBoardPermission
@@ -1234,7 +1234,7 @@ export const boardRouter = createTRPCRouter({
},
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
@@ -1586,7 +1586,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
...otherBoardProperties,
layouts: layouts
.map(({ boardId: _, ...layout }) => layout)
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
sections: sections.map(({ collapseStates, ...section }) =>
parseSection({
...section,
+135
View File
@@ -0,0 +1,135 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSystems: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
return innerHandler.subscribe((systems) => {
emit.next({
integrationId: integration.id,
systems,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((fn) => fn());
};
});
}),
getAlerts: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
const [alertsResult, systemsResult] = await Promise.all([
alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
]);
const systemNameMap: Record<string, string> = {};
for (const system of systemsResult.data) {
systemNameMap[system.id] = system.name;
}
return {
integrationId: integration.id,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
updatedAt: alertsResult.timestamp,
};
}),
);
return results;
}),
subscribeAlerts: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().min(1).max(100).default(10),
}),
)
.subscription(({ ctx, input }) => {
return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
return innerHandler.subscribe((data) => {
emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
});
});
return () => {
unsubscribes.forEach((fn) => fn());
};
});
}),
getSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
includeDocker: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) => {
const integration = ctx.integrations[0];
if (!integration) return null;
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
});
+1
View File
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
@@ -10,6 +10,9 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind === "beszel") {
return null as never;
}
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
+8 -3
View File
@@ -41,8 +41,7 @@ import {
} from "../schema";
import type { Integration } from "../schema";
const isTruthyEnv = (value: string | undefined) =>
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
@@ -410,7 +409,13 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
// Row 9: beszelSystemGrid + beszelAlerts = 12
{ kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
{ kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
// Row 10: beszelSystemTable + beszelSystemStats = 12
{ kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
{ kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
// Row 11: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
+8
View File
@@ -433,6 +433,14 @@ export const integrationDefs = {
category: ["ups"],
documentationUrl: null,
},
beszel: {
name: "Beszel",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
category: ["healthMonitoring"],
documentationUrl: null,
defaultPort: 8090,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
+4
View File
@@ -39,5 +39,9 @@ export const widgetKinds = [
"speedtestTracker",
"umami",
"ups",
"beszelSystemTable",
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -1,6 +1,7 @@
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
@@ -125,6 +126,7 @@ export const integrationCreators = {
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -0,0 +1,269 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelAuthResponse,
BeszelContainer,
BeszelContainerStatsRecord,
BeszelSmartDevice,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
CreateAlertInput,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
interface BeszelSession {
token: string;
userId: string;
}
export class BeszelIntegration extends Integration {
private readonly sessionStore: SessionStore<BeszelSession>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync();
if (existingSession) {
return existingSession;
}
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) {
throw new ResponseError(response);
}
const data = (await response.json()) as BeszelAuthResponse;
const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session);
return session;
}
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
let session = await this.authenticateAsync();
const doFetch = (token: string) =>
fetchWithTrustedCertificatesAsync(url, {
method: options.method ?? "GET",
headers: {
Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}),
},
body: options.body,
});
let response = await doFetch(session.token);
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await doFetch(session.token);
}
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
public async getSystemsAsync(): Promise<BeszelSystem[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/systems/records", { perPage: "500", sort: "-updated" }),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystem>;
return data.items;
}
public async getSystemDetailsAsync(systemId: string): Promise<BeszelSystemDetails> {
const response = await this.fetchWithAuthAsync(
this.url(`/api/collections/system_details/records/${systemId}` as `/${string}`),
);
return (await response.json()) as BeszelSystemDetails;
}
public async getSystemStatsAsync(systemId: string, type = "1m", perPage = 60): Promise<BeszelSystemStatsRecord[]> {
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/system_stats/records", {
filter,
sort: "-created",
perPage: String(perPage),
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemStatsRecord>;
return data.items;
}
public async getContainersAsync(systemId: string): Promise<BeszelContainer[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/containers/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
sort: "-updated",
perPage: "500",
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelContainer>;
return data.items;
}
public async getContainerStatsAsync(
systemId: string,
type = "1m",
perPage = 60,
): Promise<BeszelContainerStatsRecord[]> {
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/container_stats/records", {
filter,
sort: "-created",
perPage: String(perPage),
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelContainerStatsRecord>;
return data.items;
}
public async getSmartDevicesAsync(systemId: string): Promise<BeszelSmartDevice[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/smart_devices/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSmartDevice>;
return data.items;
}
public async getSystemdServicesAsync(systemId: string): Promise<BeszelSystemdService[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/systemd_services/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
sort: "name",
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemdService>;
return data.items;
}
public async getAlertsAsync(systemId?: string): Promise<BeszelAlert[]> {
const params: Record<string, string> = { perPage: "500" };
if (systemId) {
params.filter = `system='${escapeFilterValue(systemId)}'`;
}
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records", params));
const data = (await response.json()) as PocketBaseListResponse<BeszelAlert>;
return data.items;
}
public async getAlertHistoryAsync(systemId?: string, perPage = 50): Promise<BeszelAlertHistory[]> {
const params: Record<string, string> = {
sort: "-created",
perPage: String(perPage),
};
if (systemId) {
params.filter = `system='${escapeFilterValue(systemId)}'`;
}
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts_history/records", params));
const data = (await response.json()) as PocketBaseListResponse<BeszelAlertHistory>;
return data.items;
}
public async createAlertAsync(systemId: string, input: CreateAlertInput): Promise<BeszelAlert> {
const session = await this.authenticateAsync();
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records"), {
method: "POST",
body: JSON.stringify({
system: systemId,
user: session.userId,
name: input.name,
value: input.value,
min: input.min ?? 0,
triggered: false,
}),
});
return (await response.json()) as BeszelAlert;
}
public async updateAlertAsync(alertId: string, input: UpdateAlertInput): Promise<BeszelAlert> {
const response = await this.fetchWithAuthAsync(
this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`),
{
method: "PATCH",
body: JSON.stringify(input),
},
);
return (await response.json()) as BeszelAlert;
}
public async deleteAlertAsync(alertId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`), {
method: "DELETE",
});
}
public async pauseSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
method: "PATCH",
body: JSON.stringify({ status: "paused" }),
});
}
public async resumeSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
method: "PATCH",
body: JSON.stringify({ status: "pending" }),
});
}
public async deleteSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
method: "DELETE",
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) {
return TestConnectionError.StatusResult({ status: response.status, url: response.url });
}
const data = (await response.json()) as BeszelAuthResponse;
if (!data.token) {
return TestConnectionError.UnauthorizedResult(401);
}
return { success: true };
}
}
@@ -0,0 +1,389 @@
export interface BeszelAuthResponse {
token: string;
record: {
id: string;
email: string;
username: string;
verified: boolean;
};
}
export type BeszelSystemStatus = "up" | "down" | "paused" | "pending";
/**
* Real-time system info snapshot from Beszel agent.
* Returned as `system.info` on each BeszelSystem record.
* All resource values are percentages or already-scaled summaries.
*/
export interface BeszelSystemInfo {
/** hostname */
h?: string;
/** CPU usage (%) */
cpu: number;
/** temperature (°C), overall */
t?: number;
/** CPU core count */
c?: number;
/** CPU thread count */
ct?: number;
/** CPU model string */
m?: string;
/** load average [1m, 5m, 15m] */
la?: [number, number, number];
/** OS identifier string */
o?: string;
/** uptime (seconds) */
u: number;
/** memory usage (%) */
mp: number;
/** disk usage (%) */
dp: number;
/** battery [level%, charging state] */
bat?: [number, number];
/** bandwidth — public interface (Mbps, legacy). Use bb when available */
b?: number;
/** bandwidth — public interface (bytes/s, newer format). Preferred over b */
bb?: number;
/** agent version */
v: string;
/** podman mode */
p?: boolean;
/** GPU usage (%) */
g?: number;
/** disk temperature (°C) */
dt?: number;
/** OS type identifier */
os?: number;
/** extra filesystem usage (%) keyed by mount path */
efs?: Record<string, number>;
/** systemd services [running, total] */
sv?: [number, number];
}
export interface BeszelSystem {
id: string;
name: string;
host: string;
port: string;
status: BeszelSystemStatus;
info: BeszelSystemInfo;
users?: string[];
created: string;
updated: string;
}
export interface BeszelSystemDetails {
id: string;
system: string;
hostname: string;
kernel: string;
cores: number;
threads: number;
cpu: string;
os: number;
os_name: string;
arch: string;
memory: number;
podman: boolean;
updated: string;
}
/**
* Historical system stats record from Beszel PocketBase.
* Stored per-interval (1m, 10m, 20m, 120m, 480m depending on time range).
*
* Units:
* - CPU/GPU/memory/disk percentages: 0–100
* - Memory/disk absolute values (mu, du, etc.): bytes
* - Disk I/O (dr, dw): bytes/s
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
*/
export interface BeszelSystemStats {
/** CPU usage (%) */
cpu: number;
/** CPU usage max (%) */
cpum?: number;
/** per-core busy (%) */
cpub?: number[];
/** per-core speed (MHz) */
cpus?: number[];
/** load average [1m, 5m, 15m] */
la?: [number, number, number];
/** total memory (bytes) */
m: number;
/** memory used (bytes) */
mu: number;
/** memory usage (%) */
mp: number;
/** memory buffer/cache (bytes) */
mb: number;
/** memory max used (bytes) */
mm?: number;
/** memory zfs arc (bytes) */
mz?: number;
/** total swap (bytes) */
s: number;
/** swap used (bytes) */
su: number;
/** total disk (bytes) */
d: number;
/** disk used (bytes) */
du: number;
/** disk usage (%) */
dp: number;
/** disk read (bytes/s) */
dr: number;
/** disk write (bytes/s) */
dw: number;
/** disk read max (bytes/s) */
drm?: number;
/** disk write max (bytes/s) */
dwm?: number;
/** disk IOPS [read, write] */
dio?: [number, number];
/** disk IOPS max [read, write] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
ns: number;
/** network received — all interfaces (bytes/s) */
nr: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
nsm?: number;
/** network received max (bytes/s) */
nrm?: number;
/** bandwidth max [sent, recv] (bytes/s) */
bm?: [number, number];
/** temperatures keyed by sensor name (°C) */
t?: Record<string, number>;
/** extra filesystem stats keyed by mount path */
efs?: Record<string, BeszelExtraFsStats>;
/** GPU stats keyed by device id */
g?: Record<string, BeszelGPUData>;
/** battery [level%, charging state] */
bat?: [number, number];
/** network interfaces [rx bytes/s, tx bytes/s, rx max, tx max] */
ni?: Record<string, [number, number, number, number]>;
}
export interface BeszelExtraFsStats {
/** total disk (bytes) */
d: number;
/** disk used (bytes) */
du: number;
/** read (bytes/s) */
r: number;
/** write (bytes/s) */
w: number;
/** read max (bytes/s) */
rm: number;
/** write max (bytes/s) */
wm: number;
/** read (IOPS) */
rb: number;
/** write (IOPS) */
wb: number;
/** read max (IOPS) */
rbm: number;
/** write max (IOPS) */
wbm: number;
}
export interface BeszelGPUData {
/** GPU name/model */
n: string;
/** memory used (bytes) */
mu?: number;
/** memory total (bytes) */
mt?: number;
/** GPU utilization (%) */
u: number;
/** power draw (W) */
p?: number;
/** power peak (W) */
pp?: number;
/** encoder utilization keyed by engine name (%) */
e?: Record<string, number>;
}
export interface BeszelSystemStatsRecord {
id: string;
system: string;
stats: BeszelSystemStats;
type: string;
created: string;
updated: string;
}
export interface BeszelContainer {
id: string;
system: string;
name: string;
image: string;
status: string;
health: number;
cpu: number;
memory: number;
net: number;
ports?: string;
updated: number;
}
/**
* Per-container resource stats from Beszel agent.
* One entry per container in a BeszelContainerStatsRecord.
*
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
*/
export interface BeszelContainerStats {
/** container name */
n: string;
/** CPU usage (%) */
c: number;
/** memory usage (bytes) */
m: number;
/** network sent (bytes/s) — legacy, use b when available */
ns?: number;
/** network received (bytes/s) — legacy, use b when available */
nr?: number;
/** network bandwidth [sent, recv] (bytes/s) — preferred over ns/nr */
b?: [number, number];
}
export interface BeszelContainerStatsRecord {
id: string;
system: string;
stats: BeszelContainerStats[];
type: string;
created: string;
updated: string;
}
export interface BeszelSmartDevice {
id: string;
system: string;
name: string;
model: string;
state: string;
capacity: number;
temp: number;
firmware: string;
serial: string;
type: string;
hours: number;
cycles: number;
attributes: BeszelSmartAttribute[];
updated: string;
}
export interface BeszelSmartAttribute {
/** attribute ID */
id?: number;
/** attribute name */
n: string;
/** current value */
v: number;
/** worst value */
w?: number;
/** threshold */
t?: number;
/** raw value (numeric) */
rv?: number;
/** raw value (string) */
rs?: string;
/** when failed */
wf?: string;
}
export interface BeszelSystemdService {
id: string;
system: string;
name: string;
state: number;
sub: number;
cpu: number;
cpuPeak: number;
memory: number;
memPeak: number;
updated: number;
}
export interface BeszelAlert {
id: string;
user: string;
system: string;
name: string;
triggered: boolean;
value: number;
min: number;
}
export interface BeszelAlertHistory {
id: string;
alert: string;
user: string;
system: string;
name: string;
val: number;
created: string;
resolved?: string | null;
}
export interface CreateAlertInput {
name: string;
value: number;
min?: number;
}
export interface UpdateAlertInput {
value?: number;
min?: number;
}
export interface PocketBaseListResponse<T> {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: T[];
}
/**
* Normalized view model for a Beszel-monitored system.
* Constructed by mapToSystemRow() in request-handler from BeszelSystem + BeszelSystemDetails.
* This is the shape consumed by grid/table widgets — all values are pre-scaled for display.
*/
export interface BeszelSystemRow {
id: string;
name: string;
status: BeszelSystemStatus;
/** CPU usage (%) */
cpu: number;
/** memory usage (%) */
memory: number;
/** disk usage (%) */
disk: number;
/** GPU usage (%) */
gpu: number;
/** load average [1m, 5m, 15m] */
loadAvg: [number, number, number] | null;
/** public interface bandwidth (bytes/s). From info.bb or info.b * 1M */
netBytes: number;
/** temperature (°C) */
temp: number | null;
/** battery [level%, charging state] */
battery: [number, number] | null;
/** running systemd services count */
services: number;
/** uptime (seconds) */
uptime: number;
agentVersion: string;
hostname: string;
cpuModel: string;
cores: number;
/** total memory (bytes) */
memoryTotal: number;
osName: string;
}
@@ -0,0 +1,226 @@
// @vitest-environment node
import { beforeAll, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
process.env.ENABLE_DNS_CACHING = "false";
});
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
vi.mock("@homarr/redis", () => ({
createGetSetChannel: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
removeAsync: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
ErrorWithMetadata: class extends Error {},
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration } from "../beszel-integration";
function createBeszelIntegration() {
return new BeszelIntegration({
id: "test-beszel",
name: "Test Beszel",
url: BESZEL_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: BESZEL_EMAIL },
{ kind: "password", value: BESZEL_PASSWORD },
],
});
}
const shouldRun = BESZEL_EMAIL && BESZEL_PASSWORD;
describe.skipIf(!shouldRun)("BeszelIntegration (real instance)", () => {
let integration: BeszelIntegration;
let systemId: string;
beforeAll(() => {
integration = createBeszelIntegration();
});
describe("getSystemsAsync", () => {
test("returns an array of systems", async () => {
const systems = await integration.getSystemsAsync();
expect(systems).toBeInstanceOf(Array);
expect(systems.length).toBeGreaterThan(0);
});
test("each system has required fields", async () => {
const systems = await integration.getSystemsAsync();
const system = systems[0];
if (!system) {
throw new Error("Expected at least one system");
}
systemId = system.id;
expect(system.id).toBeDefined();
expect(system.name).toBeDefined();
expect(system.status).toMatch(/^(up|down|paused|pending)$/);
expect(system.info).toBeDefined();
expect(typeof system.info.cpu).toBe("number");
expect(typeof system.info.mp).toBe("number");
expect(typeof system.info.dp).toBe("number");
});
});
describe("getSystemDetailsAsync", () => {
test("returns system details with hardware info", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const details = await integration.getSystemDetailsAsync(systemId);
expect(details.hostname).toBeDefined();
expect(details.cpu).toBeDefined();
expect(typeof details.cores).toBe("number");
expect(details.cores).toBeGreaterThan(0);
expect(typeof details.memory).toBe("number");
expect(details.memory).toBeGreaterThan(0);
expect(details.os_name).toBeDefined();
});
});
describe("getSystemStatsAsync", () => {
test("returns time-series stats records", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const stats = await integration.getSystemStatsAsync(systemId, "1m", 5);
expect(stats).toBeInstanceOf(Array);
if (stats.length === 0) return;
const record = stats[0];
expect(record?.stats).toBeDefined();
expect(typeof record?.stats.cpu).toBe("number");
expect(typeof record?.stats.mp).toBe("number");
expect(typeof record?.stats.dp).toBe("number");
expect(record?.created).toBeDefined();
});
});
describe("getContainersAsync", () => {
test("returns container list", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const containers = await integration.getContainersAsync(systemId);
expect(containers).toBeInstanceOf(Array);
if (containers.length > 0) {
const container = containers[0];
expect(container?.name).toBeDefined();
expect(container?.image).toBeDefined();
expect(container?.status).toBeDefined();
expect(typeof container?.cpu).toBe("number");
expect(typeof container?.memory).toBe("number");
}
});
});
describe("alert CRUD", () => {
let alertId: string;
test("creates an alert", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const alert = await integration.createAlertAsync(systemId, {
name: "CPU",
value: 90,
min: 1,
});
expect(alert.id).toBeDefined();
expect(alert.name).toBe("CPU");
expect(alert.value).toBe(90);
expect(alert.system).toBe(systemId);
alertId = alert.id;
});
test("lists alerts including the created one", async () => {
const alerts = await integration.getAlertsAsync(systemId);
const found = alerts.find((a) => a.id === alertId);
expect(found).toBeDefined();
expect(found?.name).toBe("CPU");
});
test("updates alert threshold", async () => {
const updated = await integration.updateAlertAsync(alertId, { value: 95 });
expect(updated.value).toBe(95);
});
test("deletes alert", async () => {
await integration.deleteAlertAsync(alertId);
const alerts = await integration.getAlertsAsync(systemId);
const found = alerts.find((a) => a.id === alertId);
expect(found).toBeUndefined();
});
});
describe("system actions", () => {
test("pauses and resumes a system", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
await integration.pauseSystemAsync(systemId);
const afterPause = await integration.getSystemsAsync();
const paused = afterPause.find((s) => s.id === systemId);
expect(paused?.status).toBe("paused");
await integration.resumeSystemAsync(systemId);
const afterResume = await integration.getSystemsAsync();
const resumed = afterResume.find((s) => s.id === systemId);
expect(resumed?.status).toMatch(/^(up|pending)$/);
});
});
describe("authentication", () => {
test("authenticates with valid credentials", async () => {
const systems = await integration.getSystemsAsync();
expect(systems.length).toBeGreaterThan(0);
});
test("fails with invalid credentials", async () => {
const badIntegration = new BeszelIntegration({
id: "test-bad",
name: "Bad Beszel",
url: BESZEL_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "wrong@example.com" },
{ kind: "password", value: "wrongpassword" },
],
});
await expect(badIntegration.getSystemsAsync()).rejects.toThrow();
});
});
});
@@ -0,0 +1,343 @@
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelContainerStats,
BeszelContainerStatsRecord,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemStats,
BeszelSystemStatsRecord,
} from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
const KB = 1024;
interface MockSystemDef {
name: string;
host: string;
os: string;
cpu: string;
cores: number;
mem: number;
arch: string;
baseCpu: number;
baseMem: number;
baseDisk: number;
containers: number[];
}
const mockSystems: MockSystemDef[] = [
{
name: "web-server",
host: "192.168.1.10",
os: "Ubuntu 24.04",
cpu: "AMD Ryzen 9 7950X",
cores: 16,
mem: 64 * GB,
arch: "amd64",
baseCpu: 35,
baseMem: 55,
baseDisk: 42,
containers: [0, 1, 2, 3, 5],
},
{
name: "nas-01",
host: "192.168.1.20",
os: "Debian 12",
cpu: "Intel Xeon E-2388G",
cores: 8,
mem: 32 * GB,
arch: "amd64",
baseCpu: 12,
baseMem: 70,
baseDisk: 68,
containers: [1, 2, 6],
},
{
name: "pi-cluster-1",
host: "192.168.1.30",
os: "Raspberry Pi OS",
cpu: "ARM Cortex-A76",
cores: 4,
mem: 8 * GB,
arch: "aarch64",
baseCpu: 45,
baseMem: 82,
baseDisk: 35,
containers: [0, 2, 7],
},
{
name: "media-server",
host: "192.168.1.40",
os: "Proxmox VE 8.2",
cpu: "Intel i7-13700K",
cores: 16,
mem: 128 * GB,
arch: "amd64",
baseCpu: 22,
baseMem: 48,
baseDisk: 55,
containers: [0, 1, 2, 3, 4, 5, 6, 7],
},
{
name: "backup-node",
host: "192.168.1.50",
os: "TrueNAS SCALE",
cpu: "Intel Xeon E5-2680",
cores: 12,
mem: 48 * GB,
arch: "amd64",
baseCpu: 8,
baseMem: 30,
baseDisk: 78,
containers: [1, 6],
},
];
const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [
{ name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 },
{ name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 },
{ name: "redis", cpuBase: 3, memMB: 180, netKB: 500 },
{ name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 },
{ name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 },
{ name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 },
{ name: "minio", cpuBase: 3, memMB: 200, netKB: 600 },
{ name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 },
];
const rand = (min: number, max: number) => min + Math.random() * (max - min);
const randInt = (min: number, max: number) => Math.floor(rand(min, max));
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
function smoothWalk(base: number, amplitude: number, steps: number, lo: number, hi: number): number[] {
const values: number[] = [];
let current = base + rand(-amplitude * 0.3, amplitude * 0.3);
for (let i = 0; i < steps; i++) {
const spike = Math.random() < 0.08 ? rand(-amplitude * 2, amplitude * 3) : 0;
current += rand(-amplitude, amplitude) + spike;
current = clamp(current, lo, hi);
values.push(current);
}
return values;
}
function resolveSystem(systemId: string): MockSystemDef {
const idx = Number(systemId.replace("mock-sys-", "")) - 1;
return mockSystems[idx] ?? mockSystems[0] ?? (mockSystems.find(Boolean) as MockSystemDef);
}
const typeToMinutes: Record<string, number> = {
"1m": 1,
"10m": 10,
"20m": 20,
"120m": 120,
"480m": 480,
};
function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSystemStats[] {
const cpuWalk = smoothWalk(sys.baseCpu, 3, count, 1, 98);
const memPctWalk = smoothWalk(sys.baseMem, 1.5, count, 10, 95);
const diskPctWalk = smoothWalk(sys.baseDisk, 0.2, count, 5, 95);
const diskReadWalk = smoothWalk(5, 3, count, 0, 80);
const diskWriteWalk = smoothWalk(3, 2, count, 0, 50);
const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
const memTotal = sys.mem;
const diskTotal = rand(200, 2000) * GB;
return cpuWalk.map((cpu, i) => {
const memPct = memPctWalk[i] ?? sys.baseMem;
const diskPct = diskPctWalk[i] ?? sys.baseDisk;
return {
cpu,
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
m: memTotal,
mu: (memTotal * memPct) / 100,
mp: memPct,
mb: rand(0.5, 4) * GB,
s: 4 * GB,
su: rand(0, 2) * GB,
d: diskTotal,
du: (diskTotal * diskPct) / 100,
dp: diskPct,
dr: (diskReadWalk[i] ?? 5) * MB,
dw: (diskWriteWalk[i] ?? 3) * MB,
ns: (netSendWalk[i] ?? 500) * KB,
nr: (netRecvWalk[i] ?? 800) * KB,
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
};
});
}
function generateContainerStatsSeries(count: number, profiles: typeof containerProfiles): BeszelContainerStats[][] {
const walks = profiles.map((p) => ({
profile: p,
cpuWalk: smoothWalk(p.cpuBase, 1.5, count, 0, 50),
memWalk: smoothWalk(p.memMB, p.memMB * 0.1, count, p.memMB * 0.5, p.memMB * 2),
netWalk: smoothWalk(p.netKB, p.netKB * 0.3, count, 0, p.netKB * 4),
}));
return Array.from({ length: count }, (_, i) =>
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
n: profile.name,
c: cpuWalk[i] ?? profile.cpuBase,
m: (memWalk[i] ?? profile.memMB) * MB,
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
})),
);
}
function buildTimeSeries<T>(
systemId: string,
count: number,
intervalMinutes: number,
type: string,
dataPoints: T[],
): (T & { id: string; system: string; type: string; created: string; updated: string })[] {
const now = Date.now();
const items = dataPoints.slice(0, count).map((data, i) => {
const ts = new Date(now - (count - i) * intervalMinutes * 60_000).toISOString();
return { id: `mock-stat-${i}`, system: systemId, type, created: ts, updated: ts, ...data };
});
return items.toReversed();
}
const statusByIndex: Record<number, "up" | "paused" | "down"> = { 2: "paused", 4: "down" };
export class BeszelMockService {
public async getSystemsAsync(): Promise<BeszelSystem[]> {
return mockSystems.map((sys, i) => ({
id: `mock-sys-${i + 1}`,
name: sys.name,
host: sys.host,
port: "45876",
status: statusByIndex[i] ?? "up",
info: {
h: sys.name,
cpu: clamp(sys.baseCpu + rand(-5, 10), 0, 100),
c: sys.cores,
ct: sys.cores * 2,
m: sys.cpu,
la: [sys.baseCpu / 20, sys.baseCpu / 25, sys.baseCpu / 30] as [number, number, number],
o: sys.os,
u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
bb: rand(200, 3000) * KB,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
dt: rand(32, 58),
sv: [randInt(8, 25), randInt(25, 40)] as [number, number],
},
users: [],
created: new Date().toISOString(),
updated: new Date().toISOString(),
}));
}
public async getSystemDetailsAsync(systemId: string): Promise<BeszelSystemDetails> {
const sys = resolveSystem(systemId);
return {
id: systemId,
system: systemId,
hostname: sys.name,
kernel: "6.8.0-40-generic",
cores: sys.cores,
threads: sys.cores * 2,
cpu: sys.cpu,
os: 1,
os_name: sys.os,
arch: sys.arch,
memory: sys.mem,
podman: false,
updated: new Date().toISOString(),
};
}
public async getSystemStatsAsync(
systemId: string,
type: string,
perPage: number,
): Promise<BeszelSystemStatsRecord[]> {
const sys = resolveSystem(systemId);
const intervalMinutes = typeToMinutes[type] ?? 1;
const statsSeries = generateSystemStatsSeries(perPage, sys);
return buildTimeSeries(
systemId,
perPage,
intervalMinutes,
type,
statsSeries.map((stats) => ({ stats })),
);
}
public async getContainerStatsAsync(
systemId: string,
type: string,
perPage: number,
): Promise<BeszelContainerStatsRecord[]> {
const sys = resolveSystem(systemId);
const intervalMinutes = typeToMinutes[type] ?? 1;
const selectedProfiles = sys.containers
.map((i) => containerProfiles[i])
.filter(Boolean) as typeof containerProfiles;
const series = generateContainerStatsSeries(perPage, selectedProfiles);
return buildTimeSeries(
systemId,
perPage,
intervalMinutes,
type,
series.map((stats) => ({ stats })),
);
}
public async getAlertsAsync(): Promise<BeszelAlert[]> {
return [
{ id: "mock-alert-1", user: "mock-user", system: "mock-sys-1", name: "CPU", triggered: true, value: 90, min: 5 },
{
id: "mock-alert-2",
user: "mock-user",
system: "mock-sys-2",
name: "Memory",
triggered: false,
value: 80,
min: 10,
},
{
id: "mock-alert-3",
user: "mock-user",
system: "mock-sys-1",
name: "Disk",
triggered: false,
value: 85,
min: 15,
},
{
id: "mock-alert-4",
user: "mock-user",
system: "mock-sys-4",
name: "Temperature",
triggered: true,
value: 75,
min: 3,
},
];
}
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise<BeszelAlertHistory[]> {
const now = Date.now();
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
return Array.from({ length: Math.min(perPage, 5) }, (_, i) => ({
id: `mock-history-${i}`,
alert: `mock-alert-${(i % 4) + 1}`,
user: "mock-user",
system: `mock-sys-${(i % 5) + 1}`,
name: names[i % names.length] ?? "CPU",
val: rand(60, 95),
created: new Date(now - (i + 1) * 3_600_000).toISOString(),
resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null,
}));
}
}
@@ -29,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
import { NotificationsMockService } from "./data/notifications";
import { SmartHomeMockService } from "./data/smart-home";
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
import { BeszelMockService } from "./data/beszel";
import { UpsSummaryMockService } from "./data/ups-summary";
export class MockIntegration
@@ -61,6 +62,7 @@ export class MockIntegration
private static readonly networkController = new NetworkControllerSummaryMockService();
private static readonly notifications = new NotificationsMockService();
private static readonly smartHome = new SmartHomeMockService();
private static readonly beszel = new BeszelMockService();
private static readonly upsSummary = new UpsSummaryMockService();
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
@@ -130,4 +132,12 @@ export class MockIntegration
// UpsSummaryIntegration
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
// BeszelIntegration
getSystemsAsync = MockIntegration.beszel.getSystemsAsync.bind(MockIntegration.beszel);
getSystemDetailsAsync = MockIntegration.beszel.getSystemDetailsAsync.bind(MockIntegration.beszel);
getSystemStatsAsync = MockIntegration.beszel.getSystemStatsAsync.bind(MockIntegration.beszel);
getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
}
+1
View File
@@ -18,3 +18,4 @@ export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./umami/umami-types";
export * from "./beszel/beszel-types";
+122
View File
@@ -0,0 +1,122 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelContainerStatsRecord,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemRow,
BeszelSystemStatsRecord,
} from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export type { BeszelSystemRow } from "@homarr/integrations/types";
/**
* Maps raw Beszel API responses (BeszelSystem + BeszelSystemDetails) into a
* normalized BeszelSystemRow for widget consumption.
*
* BeszelSystemInfo uses short field names (see beszel-types.ts for full docs):
* cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version,
* bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery,
* sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads
*/
function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
const info = system.info;
return {
id: system.id,
name: system.name,
status: system.status,
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
netBytes: info.bb ?? (info.b ?? 0) * 1_000_000,
temp: info.dt ?? null,
battery: info.bat ?? null,
services: info.sv?.[0] ?? 0,
uptime: info.u,
agentVersion: info.v,
hostname: details?.hostname ?? info.h ?? system.name,
cpuModel: details?.cpu ?? info.m ?? "Unknown",
cores: details?.cores ?? info.ct ?? info.c ?? 1,
memoryTotal: details?.memory ?? 0,
osName: details?.os_name ?? "",
};
}
export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
BeszelSystemRow[],
"beszel" | "mock",
Record<string, never>
>({
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
systems.map(async (system) => {
const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
return mapToSystemRow(system, details);
}),
);
return enriched;
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "beszelSystems",
});
export interface BeszelAlertsData {
alerts: BeszelAlert[];
history: BeszelAlertHistory[];
}
export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
BeszelAlertsData,
"beszel" | "mock",
{ includeHistory: boolean; maxHistoryItems: number }
>({
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
const alerts = await instance.getAlertsAsync();
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
return { alerts, history };
},
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "120m", perPage: 84 },
"30d": { type: "480m", perPage: 90 },
};
export interface BeszelStatsData {
systemStats: BeszelSystemStatsRecord[];
containerStats: BeszelContainerStatsRecord[];
}
export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
BeszelStatsData,
"beszel" | "mock",
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
: [];
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "beszelStats",
});
@@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
Record<string, never>
>({
async requestAsync(integration, _input) {
+213
View File
@@ -3104,6 +3104,219 @@
"status": {
"unhealthy": "Unhealthy"
}
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
"option": {
"statusFilter": {
"label": "Status filter"
},
"sortBy": {
"label": "Sort by"
},
"sortDirection": {
"label": "Sort direction"
},
"showCpu": {
"label": "Show CPU"
},
"showMemory": {
"label": "Show memory"
},
"showDisk": {
"label": "Show disk"
},
"showGpu": {
"label": "Show GPU"
},
"showLoadAvg": {
"label": "Show load average"
},
"showNet": {
"label": "Show network"
},
"showTemp": {
"label": "Show temperature"
},
"showBattery": {
"label": "Show battery"
},
"showServices": {
"label": "Show services"
},
"showUptime": {
"label": "Show uptime"
},
"showAgent": {
"label": "Show agent version"
}
},
"noRecords": "No systems found",
"column": {
"system": "System",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"gpu": "GPU",
"loadAvg": "Load Avg",
"net": "Net",
"temp": "Temp",
"battery": "Bat",
"services": "Services",
"uptime": "Uptime",
"agent": "Agent"
}
},
"beszelSystemGrid": {
"name": "Beszel Systems (Grid)",
"description": "Card grid view of all Beszel-monitored systems with real-time metrics",
"option": {
"statusFilter": {
"label": "Status filter"
},
"showCpu": {
"label": "Show CPU"
},
"showMemory": {
"label": "Show memory"
},
"showDisk": {
"label": "Show disk"
},
"showGpu": {
"label": "Show GPU"
},
"showLoadAvg": {
"label": "Show load average"
},
"showNet": {
"label": "Show network"
},
"showTemp": {
"label": "Show temperature"
},
"showBattery": {
"label": "Show battery"
},
"showServices": {
"label": "Show services"
},
"showUptime": {
"label": "Show uptime"
},
"showAgent": {
"label": "Show agent version"
}
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"gpu": "GPU",
"loadAvg": "Load Avg",
"net": "Net",
"temp": "Temp",
"battery": "Bat",
"services": "Services",
"uptime": "Uptime",
"agent": "Agent"
}
},
"beszelAlerts": {
"name": "Beszel Alerts",
"description": "View Beszel alert configurations and history",
"option": {
"showHistory": {
"label": "Show alert history"
},
"maxHistoryItems": {
"label": "Max history items"
}
},
"empty": "No alerts configured",
"history": "History",
"resolved": "Resolved",
"status": {
"triggered": "Triggered",
"ok": "OK"
}
},
"beszelSystemStats": {
"name": "Beszel System Stats",
"description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics",
"option": {
"timePeriod": {
"label": "Time period"
},
"showCpu": {
"label": "Show CPU chart"
},
"showMemory": {
"label": "Show memory chart"
},
"showDisk": {
"label": "Show disk chart"
},
"showDiskIO": {
"label": "Show disk I/O chart"
},
"showNetwork": {
"label": "Show network chart"
},
"showDockerCpu": {
"label": "Show Docker CPU chart"
},
"showDockerMemory": {
"label": "Show Docker memory chart"
},
"showDockerNetwork": {
"label": "Show Docker network chart"
}
},
"selectSystem": "Select system",
"chart": {
"cpu": {
"title": "CPU Usage",
"subtitle": "Average system-wide CPU utilization",
"series": "CPU"
},
"memory": {
"title": "Memory Usage",
"subtitle": "Precise utilization at the recorded time",
"series": "Used",
"cache": "Cache/Buffers"
},
"disk": {
"title": "Disk Usage",
"subtitle": "Usage of root partition",
"series": "Used"
},
"diskIO": {
"title": "Disk I/O",
"subtitle": "Throughput of root filesystem",
"read": "Read",
"write": "Write"
},
"network": {
"title": "Bandwidth",
"subtitle": "Network traffic of public interfaces",
"sent": "Sent",
"recv": "Received"
},
"dockerCpu": {
"title": "Docker CPU Usage",
"subtitle": "Average CPU utilization of containers"
},
"dockerMemory": {
"title": "Docker Memory Usage",
"subtitle": "Memory usage of docker containers"
},
"dockerNetwork": {
"title": "Docker Network I/O",
"subtitle": "Network traffic of docker containers"
}
}
}
},
"widgetPreview": {
+2
View File
@@ -70,6 +70,8 @@
"@tiptap/starter-kit": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -0,0 +1,264 @@
"use client";
import { useMemo } from "react";
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { LucideIcon } from "lucide-react";
import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
Memory: MemoryStick,
Disk: HardDrive,
Bandwidth: Network,
Temperature: Thermometer,
"Load Average": Activity,
LoadAvg1: Activity,
LoadAvg5: Activity,
LoadAvg15: Activity,
Status: Server,
GPU: Monitor,
Battery: Battery,
};
dayjs.extend(relativeTime);
export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({
integrationIds,
includeHistory: options.showHistory,
maxHistoryItems: options.maxHistoryItems,
});
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeAlerts.useSubscription(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
{
enabled: !isEditMode,
onData(data) {
utils.widget.beszel.getAlerts.setData(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
(prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId
? { ...r, alerts: data.alerts.alerts, history: data.alerts.history, updatedAt: data.timestamp }
: r,
);
},
);
},
},
);
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
for (const r of results) {
if (r.systemNameMap) {
Object.assign(map, r.systemNameMap);
}
}
return map;
}, [results]);
const alerts = useMemo(
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
[results],
);
const history = useMemo(() => {
const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` })));
return all
.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
.slice(0, options.maxHistoryItems);
}, [results, options.maxHistoryItems]);
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
{alerts.length === 0 && (
<Stack align="center" justify="center" py="xl" gap="xs">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconBellOff size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{t("empty")}
</Text>
</Stack>
)}
{triggeredAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="red">
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
{triggeredAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
/>
))}
</Stack>
)}
{options.showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}>
<Group gap={6}>
<IconHistory size={14} opacity={0.5} />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("history")}
</Text>
</Group>
{history.map((entry) => {
const systemName = systemNameMap[entry.system] ?? entry.system;
const isResolved = !!entry.resolved;
const HistoryIcon = alertIconMap[entry.name] ?? Server;
return (
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
h={24}
style={{ borderRadius: 2, flexShrink: 0 }}
bg={isResolved ? "green.6" : "red.6"}
/>
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate>
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemName}
</Text>
</Stack>
</Group>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
{isResolved ? t("resolved") : t("status.triggered")}
</Badge>
<Text size="10px" c="dimmed">
{dayjs(entry.created).fromNow()}
</Text>
</Stack>
</Group>
);
})}
</Stack>
</>
)}
</Stack>
</ScrollArea>
);
}
interface AlertRowProps {
name: string;
value: number;
min: number;
systemName: string;
triggered: boolean;
}
const unitSuffixMap: Record<string, string> = {
CPU: "%",
Memory: "%",
Disk: "%",
GPU: "%",
Battery: "%",
Temperature: "°",
Bandwidth: " MB/s",
"Load Average": "",
LoadAvg1: "",
LoadAvg5: "",
LoadAvg15: "",
Status: "",
};
function formatAlertDescription(name: string, value: number, min: number): string {
const suffix = unitSuffixMap[name] ?? "";
if (name === "Status") return `down for ${min} min`;
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
const Icon = alertIconMap[name] ?? Server;
return (
<Group
wrap="nowrap"
gap="xs"
py={4}
pr={8}
pl={12}
style={{
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{formatAlertDescription(name, value, min)}
</Text>
</Stack>
</Group>
);
}
@@ -0,0 +1,17 @@
import { IconBell } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showHistory: factory.switch({ defaultValue: true }),
maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,378 @@
"use client";
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
import {
Activity,
Battery,
BatteryCharging,
Cpu,
HardDrive,
MemoryStick,
Monitor,
Network,
Server,
Thermometer,
Wifi,
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
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 { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
interface SizeConfig {
iconSize: number;
fontSize: string;
progressSize: "xs" | "sm";
labelMiw: number;
valueMiw: number;
rowHeight: number;
cardPadding: number;
badgeHeight: number;
badgeSize: "xs" | "sm";
gap: number;
}
const defaultSizeConfig: SizeConfig = {
iconSize: 10,
fontSize: "10px",
progressSize: "xs",
labelMiw: 40,
valueMiw: 30,
rowHeight: 18,
cardPadding: 4,
badgeHeight: 20,
badgeSize: "xs",
gap: 2,
};
const sizeThresholds: [number, SizeConfig][] = [
[0, defaultSizeConfig],
[
160,
{
iconSize: 12,
fontSize: "xs",
progressSize: "xs",
labelMiw: 50,
valueMiw: 34,
rowHeight: 22,
cardPadding: 6,
badgeHeight: 22,
badgeSize: "xs",
gap: 6,
},
],
[
280,
{
iconSize: 14,
fontSize: "xs",
progressSize: "sm",
labelMiw: 55,
valueMiw: 38,
rowHeight: 24,
cardPadding: 8,
badgeHeight: 26,
badgeSize: "sm",
gap: 8,
},
],
];
const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => {
const basis = Math.min(cellWidth, cellHeight);
let config = defaultSizeConfig;
for (const [threshold, cfg] of sizeThresholds) {
if (basis >= threshold) config = cfg;
}
return config;
};
const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap;
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 120;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: 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;
};
interface MetricRowProps {
icon: React.ReactNode;
label: string;
value: string;
progress?: { value: number; color: string };
size: SizeConfig;
}
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">
{value}
</Text>
{progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
)}
</Group>
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
{label}
</Text>
<MetricValue value={value} progress={progress} size={size} />
</Group>
);
interface SystemCardProps {
system: BeszelSystemRow & { _key: string };
options: WidgetComponentProps<"beszelSystemGrid">["options"];
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
size: SizeConfig;
maxMetrics: number;
}
const metricRenderers = [
{
key: "showCpu",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="cpu"
icon={<Cpu size={sz.iconSize} />}
label={t("metric.cpu")}
value={formatPercent(s.cpu)}
progress={{ value: s.cpu, color: thresholdColor(s.cpu) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu,
},
{
key: "showMemory",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="mem"
icon={<MemoryStick size={sz.iconSize} />}
label={t("metric.memory")}
value={formatPercent(s.memory)}
progress={{ value: s.memory, color: thresholdColor(s.memory) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory,
},
{
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}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
{
key: "showGpu",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="gpu"
icon={<Monitor size={sz.iconSize} />}
label={t("metric.gpu")}
value={formatPercent(s.gpu)}
progress={{ value: s.gpu, color: thresholdColor(s.gpu) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0,
},
{
key: "showLoadAvg",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="la"
icon={<Activity size={sz.iconSize} />}
label={t("metric.loadAvg")}
value={formatLoadAvg(s.loadAvg)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null,
},
{
key: "showNet",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="net"
icon={<Network size={sz.iconSize} />}
label={t("metric.net")}
value={formatByteRate(s.netBytes)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet,
},
{
key: "showTemp",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="temp"
icon={<Thermometer size={sz.iconSize} />}
label={t("metric.temp")}
value={formatTemp(s.temp, false)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null,
},
{
key: "showBattery",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => {
const batteryIcons = [Battery, BatteryCharging] as const;
const Icon = batteryIcons[Math.min(1, s.battery?.[1] ?? 0)] ?? Battery;
return (
<MetricRow
key="bat"
icon={<Icon size={sz.iconSize} />}
label={t("metric.battery")}
value={`${s.battery?.[0] ?? 0}%`}
size={sz}
/>
);
},
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null,
},
{
key: "showServices",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="svc"
icon={<Server size={sz.iconSize} />}
label={t("metric.services")}
value={String(s.services)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices,
},
{
key: "showUptime",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="up"
icon={<Activity size={sz.iconSize} />}
label={t("metric.uptime")}
value={formatUptime(s.uptime)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime,
},
{
key: "showAgent",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="agent"
icon={<Wifi size={sz.iconSize} />}
label={t("metric.agent")}
value={s.agentVersion}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent,
},
] as const;
const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
return (
<Card
padding={size.cardPadding}
radius="sm"
withBorder
h="100%"
style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }}
>
<Group gap="xs" mb={2}>
<Badge
size={size.badgeSize}
variant="dot"
color={statusColorMap[system.status]}
styles={{ root: { textTransform: "none" } }}
>
{system.name}
</Badge>
</Group>
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
</Card>
);
};
export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
width,
height,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const cols = getColCount(width, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box
h="100%"
style={{
pointerEvents: isEditMode ? "none" : undefined,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
>
{filteredSystems.map((system) => (
<SystemCard key={system._key} system={system} options={options} t={t} size={size} maxMetrics={maxMetrics} />
))}
</Box>
);
}
@@ -0,0 +1,27 @@
import { IconLayoutGrid } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,248 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import {
BeszelAreaChart,
ChartPanel,
useContainerNames,
useDockerChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
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;
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
[systemsResult],
);
const [selectedSystem, setSelectedSystem] = useState<string | null>(systems[0]?.value ?? null);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({
integrationIds,
systemId: selectedSystem ?? systems[0]?.value ?? "",
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
});
// BeszelSystemStats fields are documented in beszel-types.ts.
// mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s
const BYTES_TO_GB = 1024 * 1024 * 1024;
const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]);
const memoryMap = useCallback(
(s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu / BYTES_TO_GB,
[t("chart.memory.cache")]: (s.mb ?? 0) / BYTES_TO_GB,
}),
[t],
);
const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du / BYTES_TO_GB }), [t]);
const diskIOMap = useCallback(
(s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: s.dr ?? 0,
[t("chart.diskIO.write")]: s.dw ?? 0,
}),
[t],
);
// b = public interface bandwidth (preferred), ns/nr = all-interface traffic (fallback)
const networkMap = useCallback(
(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 cpuData = useSystemChartData(statsResult?.systemStats, cpuMap);
const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap);
const diskData = useSystemChartData(statsResult?.systemStats, diskMap);
const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap);
const networkData = useSystemChartData(statsResult?.systemStats, networkMap);
const containerNames = useContainerNames(statsResult?.containerStats);
const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu");
const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory");
const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network");
const containerSeries = containerNames.map((name, i) => ({
name,
color: containerColors[i % containerColors.length] as string,
}));
const cols = width > 600 ? 2 : 1;
return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="md" p="sm">
<Select
size="xs"
data={systems}
value={selectedSystem}
onChange={setSelectedSystem}
placeholder={t("selectSystem")}
/>
{statsResult && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={cpuData}
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.percent}
yAxisDomain={[0, "auto"]}
tooltipProps={tooltipPercent}
/>
</ChartPanel>
)}
{options.showMemory && memoryData.length > 0 && (
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={memoryData}
series={[
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
withLegend
/>
</ChartPanel>
)}
{options.showDisk && diskData.length > 0 && (
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskData}
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
</ChartPanel>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskIOData}
series={[
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
)}
{options.showNetwork && networkData.length > 0 && (
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={networkData}
series={[
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerCpuData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.percent}
tooltipProps={tooltipPercentTotal}
/>
</ChartPanel>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerMemData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.bytes}
tooltipProps={tooltipBytesTotal}
/>
</ChartPanel>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerNetData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
</ChartPanel>
)}
</>
)}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
);
}
@@ -0,0 +1,31 @@
import { IconChartAreaLine } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const timePeriodOptions = [
{ value: "1h", label: "1 Hour" },
{ value: "12h", label: "12 Hours" },
{ value: "24h", label: "24 Hours" },
{ value: "1w", label: "1 Week" },
{ value: "30d", label: "30 Days" },
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showDiskIO: factory.switch({ defaultValue: true }),
showNetwork: factory.switch({ defaultValue: true }),
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,260 @@
"use client";
import "./styles.css";
import { useMemo, useState } from "react";
import { Group, Indicator, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
Activity,
Battery,
Cpu,
HardDrive,
MemoryStick,
Monitor,
Network,
Server,
Thermometer,
Wifi,
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
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 { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
interface SizeConfig {
iconSize: number;
fontSize: "xs" | "10px";
progressSize: "xs" | "sm";
cellPadding: number;
valueMiw: number;
}
const getSizeConfig = (width: number): SizeConfig => {
if (width < 400) {
return { iconSize: 10, fontSize: "10px", progressSize: "xs", cellPadding: 2, valueMiw: 30 };
}
return { iconSize: 14, fontSize: "xs", progressSize: "sm", cellPadding: 4, valueMiw: 38 };
};
export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const size = getSizeConfig(width);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc",
});
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...filteredSystems].toSorted((a, b) => {
const aVal = a[accessor] ?? 0;
const bVal = b[accessor] ?? 0;
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
return 0;
});
}, [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" }}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
</Group>
);
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
accessor: "name",
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.system")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
</Text>
</Group>
),
},
options.showCpu && {
accessor: "cpu",
title: (
<Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} />
<Text inherit>{t("column.cpu")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
},
options.showMemory && {
accessor: "memory",
title: (
<Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} />
<Text inherit>{t("column.memory")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
},
options.showDisk && {
accessor: "disk",
title: (
<Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} />
<Text inherit>{t("column.disk")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.disk} />,
},
options.showGpu && {
accessor: "gpu",
title: (
<Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} />
<Text inherit>{t("column.gpu")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
},
options.showLoadAvg && {
accessor: "loadAvg",
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.loadAvg")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
</Group>
),
},
options.showNet && {
accessor: "netBytes",
title: (
<Group gap={4} wrap="nowrap">
<Network size={size.iconSize} />
<Text inherit>{t("column.net")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Text size={size.fontSize} style={{ whiteSpace: "nowrap" }}>
{formatByteRate(record.netBytes)}
</Text>
),
},
options.showTemp && {
accessor: "temp",
title: (
<Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} />
<Text inherit>{t("column.temp")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
accessor: "battery",
title: (
<Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} />
<Text inherit>{t("column.battery")}</Text>
</Group>
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
},
options.showServices && {
accessor: "services",
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.services")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
accessor: "uptime",
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.uptime")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
accessor: "agentVersion",
title: (
<Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} />
<Text inherit>{t("column.agent")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.agentVersion}</Text>,
},
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]);
return (
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
fz={size.fontSize}
records={sortedSystems}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
height="100%"
className="beszel-table"
/>
);
}
@@ -0,0 +1,48 @@
import { IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
const sortOptions = [
{ value: "name", label: "System" },
{ value: "cpu", label: "CPU" },
{ value: "memory", label: "Memory" },
{ value: "disk", label: "Disk" },
{ value: "gpu", label: "GPU" },
{ value: "loadAvg", label: "Load Avg" },
{ value: "netBytes", label: "Net" },
{ value: "temp", label: "Temp" },
{ value: "services", label: "Services" },
{ value: "uptime", label: "Uptime" },
{ value: "agentVersion", label: "Agent" },
];
const sortDirectionOptions = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
sortDirection: factory.select({ defaultValue: "asc", options: sortDirectionOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,24 @@
.beszel-table th {
white-space: nowrap;
}
.beszel-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.beszel-table td .mantine-Text-root,
.beszel-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.beszel-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
@@ -0,0 +1,109 @@
"use client";
import { useMemo } from "react";
import { Group, Stack, Text } from "@mantine/core";
import type { AreaChartProps } from "@mantine/charts";
import { AreaChart } from "@mantine/charts";
import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
interface ChartPanelProps {
title: string;
subtitle?: string;
children: React.ReactNode;
}
export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
<Stack gap={4} style={{ minWidth: 0, overflow: "hidden" }}>
<Group gap="xs">
<Text size="sm" fw={600}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</Group>
{children}
</Stack>
);
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
yAxisFormatter: (value: number) => string;
yAxisDomain?: [number, string];
};
export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => (
<AreaChart
dataKey="time"
curveType="monotone"
withDots={false}
withXAxis
withYAxis
style={{ minWidth: 0, ...style }}
yAxisProps={{
...yAxisBase,
...(yAxisDomain ? { domain: yAxisDomain } : {}),
tickFormatter: yAxisFormatter,
...yAxisProps,
}}
{...props}
/>
);
export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
) =>
useMemo(() => {
if (!systemStats) return [];
return [...systemStats].toReversed().map((r) => ({
time: formatTime(r.created),
...mapFn(r.stats),
}));
}, [systemStats, mapFn]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) =>
useMemo(() => {
if (!containerStats?.length) return [];
const names = new Set<string>();
for (const record of containerStats) {
for (const c of record.stats) {
names.add(c.n);
}
}
return [...names].slice(0, max);
}, [containerStats, max]);
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
// c = CPU (%), m = memory (bytes), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s)
const defaultContainerExtractors: Record<string, ContainerExtractor> = {
cpu: (c) => c?.c ?? 0,
memory: (c) => c?.m ?? 0,
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
containerNames: string[],
metric: "cpu" | "memory" | "network",
) =>
useMemo(() => {
if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric];
if (!extract) return [];
return [...containerStats].toReversed().map((record) => {
const point: Record<string, unknown> = { time: formatTime(record.created) };
for (const name of containerNames) {
point[name] = extract(record.stats.find((c) => c.n === name));
}
return point;
});
}, [containerStats, containerNames, metric]);
@@ -0,0 +1,39 @@
import type { BeszelSystemStatus } from "./types";
export const thresholdColor = (value: number): string => {
if (value >= 80) return "red";
if (value >= 50) return "yellow";
return "green";
};
export const loadAvgColor = (la1: number, cores: number): string => {
const ratio = la1 / cores;
if (ratio >= 1.5) return "red";
if (ratio >= 0.8) return "yellow";
return "green";
};
export const statusColorMap: Record<BeszelSystemStatus, string> = {
up: "green",
down: "red",
paused: "gray",
pending: "yellow",
};
export const containerColors = [
"blue.6",
"teal.6",
"orange.6",
"grape.6",
"cyan.6",
"pink.6",
"lime.6",
"violet.6",
"indigo.6",
"yellow.6",
"red.6",
"green.6",
"blue.4",
"teal.4",
"orange.4",
];
@@ -0,0 +1,56 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(duration);
dayjs.extend(relativeTime);
const byteUnits = ["B", "KB", "MB", "GB", "TB"] as const;
const rateUnits = ["B/s", "KB/s", "MB/s", "GB/s"] as const;
const formatScaled = (value: number, units: readonly string[], zeroLabel: string): string => {
if (!value || !Number.isFinite(value)) return zeroLabel;
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
const scaled = value / 1024 ** i;
const decimals = scaled < 10 ? 2 : 1;
return `${scaled.toFixed(decimals)} ${units[i] ?? ""}`;
};
const formatScaledCompact = (value: number, units: readonly string[], zeroLabel: string): string => {
if (!value || !Number.isFinite(value)) return zeroLabel;
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
const scaled = value / 1024 ** i;
return `${scaled < 10 ? scaled.toFixed(1) : Math.round(scaled)}${units[i] ?? ""}`;
};
export const formatByteRate = (value: number): string => formatScaled(value, rateUnits, "0 B/s");
export const formatStorageBytes = (bytes: number): string => formatScaled(bytes, byteUnits, "0 B");
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).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"),
} as const;
export const formatUptime = (seconds: number): string => dayjs.duration(seconds, "seconds").humanize();
export const formatTemp = (celsius: number | null, fahrenheit: boolean): string => {
if (celsius === null) return "—";
if (fahrenheit) return `${((celsius * 9) / 5 + 32).toFixed(1)} °F`;
return `${celsius.toFixed(2)} °C`;
};
export const formatLoadAvg = (la: [number, number, number] | null): string => {
if (!la) return "—";
return la.map((v) => v.toFixed(2)).join(" ");
};
@@ -0,0 +1,42 @@
"use client";
import { useMemo } from "react";
import { clientApi } from "@homarr/api/client";
import type { BeszelSystemRow } from "./types";
type SystemRowWithKey = BeszelSystemRow & { _key: string };
export const useBeszelSystemsSubscription = (integrationIds: string[], enabled = true) => {
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeSystems.useSubscription(
{ integrationIds },
{
enabled,
onData(data) {
utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r,
);
});
},
},
);
};
export const useBeszelFilteredSystems = (
results: { integrationId: string; systems: BeszelSystemRow[] }[],
statusFilter: string,
): SystemRowWithKey[] => {
const allSystems = useMemo(
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
[results],
);
return useMemo(() => {
if (statusFilter === "all") return allSystems;
return allSystems.filter((s) => s.status === statusFilter);
}, [allSystems, statusFilter]);
};
@@ -0,0 +1,6 @@
export const statusOptions = [
{ value: "all", label: "All Systems" },
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
{ value: "paused", label: "Paused" },
];
@@ -0,0 +1,132 @@
"use client";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
const styles: Record<string, CSSProperties> = {
wrapper: {
background: "var(--mantine-color-dark-7)",
border: "1px solid var(--mantine-color-dark-4)",
borderRadius: 6,
padding: "6px 10px",
fontSize: 12,
lineHeight: 1.5,
pointerEvents: "none",
maxHeight: 320,
overflowY: "auto",
overflowX: "hidden",
},
header: { marginBottom: 4, fontWeight: 500, color: "var(--mantine-color-gray-2)" },
row: { display: "flex", alignItems: "center", gap: 6, padding: "1px 0" },
indicator: { width: 3, height: 14, borderRadius: 1, flexShrink: 0 },
name: {
flex: 1,
color: "var(--mantine-color-gray-3)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 140,
},
value: {
fontWeight: 700,
color: "var(--mantine-color-gray-1)",
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
},
separator: { borderTop: "1px solid var(--mantine-color-dark-4)", marginTop: 3, paddingTop: 3 },
};
export interface TooltipPayloadItem {
name: string;
value: number;
color: string;
dataKey: string;
}
interface PortalTooltipProps {
active?: boolean;
label?: string;
payload?: TooltipPayloadItem[];
formatter: (value: number) => string;
showTotal?: boolean;
}
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const mouseRef = useRef({ x: 0, y: 0 });
const [mouse, setMouse] = useState({ x: 0, y: 0 });
const rafRef = useRef(0);
const handleMouse = useCallback((e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setMouse(mouseRef.current);
});
}, []);
const isActive = active && (payload?.length ?? 0) > 0;
useEffect(() => {
if (!isActive) return;
document.addEventListener("mousemove", handleMouse);
return () => {
document.removeEventListener("mousemove", handleMouse);
cancelAnimationFrame(rafRef.current);
};
}, [isActive, handleMouse]);
if (!isActive) return null;
const sorted = [...(payload ?? [])].filter((p) => p.value > 0).toSorted((a, b) => b.value - a.value);
if (!sorted.length) return null;
const total = sorted.reduce((sum, p) => sum + p.value, 0);
const portalStyle: CSSProperties = {
position: "fixed",
left: mouse.x + 14,
top: mouse.y,
transform: "translateY(-50%)",
zIndex: 10000,
pointerEvents: "none",
};
return createPortal(
<div style={portalStyle}>
<div style={styles.wrapper}>
<div style={styles.header}>{label}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
</div>,
document.body,
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
content: (props: any) => (
<PortalTooltipContent
active={props.active}
label={props.label != null ? String(props.label) : undefined}
payload={props.payload}
formatter={formatter}
showTotal={showTotal}
/>
),
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
isAnimationActive: false,
});
@@ -0,0 +1 @@
export type { BeszelSystemRow, BeszelSystemStatus } from "@homarr/integrations/types";
+8
View File
@@ -48,6 +48,10 @@ import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
import * as beszelSystemTable from "./beszel-system-table";
import * as beszelSystemGrid from "./beszel-system-grid";
import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
@@ -95,6 +99,10 @@ export const widgetImports = {
speedtestTracker,
umami,
ups,
beszelSystemTable,
beszelSystemGrid,
beszelAlerts,
beszelSystemStats,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
+36
View File
@@ -393,6 +393,12 @@ catalogs:
lodash.clonedeep:
specifier: ^4.5.0
version: 4.5.0
lucide-react:
specifier: ^1.17.0
version: 1.17.0
mantine-datatable:
specifier: ^9.2.2
version: 9.2.2
mantine-react-table:
specifier: npm:mantine-react-table-open@9.0.3
version: 9.0.3
@@ -835,6 +841,12 @@ importers:
jotai:
specifier: 'catalog:'
version: 2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.16)(react@19.2.7)
lucide-react:
specifier: 'catalog:'
version: 1.17.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2484,6 +2496,12 @@ importers:
dayjs:
specifier: 'catalog:'
version: 1.11.21
lucide-react:
specifier: 'catalog:'
version: 1.17.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -7478,6 +7496,11 @@ packages:
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
lucide-react@1.17.0:
resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
m3u8-parser@7.2.0:
resolution: {integrity: sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==}
@@ -7491,6 +7514,15 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
mantine-datatable@9.2.2:
resolution: {integrity: sha512-pSWIAggUQC1I4lNUzwVoNm85CIkx8jst0syzRo8N9CrTHdc+Z2+8aFLfBniSHbF4nJyLE6nkeAcb8uvFdHbVfw==}
peerDependencies:
'@mantine/core': '>=9.0'
'@mantine/hooks': '>=9.0'
clsx: '>=2'
react: '>=19'
react-dom: '>=19'
mantine-react-table-open@9.0.3:
resolution: {integrity: sha512-HsrBU4KZluj96FPvvH0wcLzog0m5LIMdwB/59oj6BpftLGEibiLxX00G+tDM06PL2jXuiRxiGKPJ4oQOV+VK8w==}
engines: {node: '>=16'}
@@ -14851,6 +14883,10 @@ snapshots:
lru.min@1.1.4: {}
lucide-react@1.17.0(react@19.2.7):
dependencies:
react: 19.2.7
m3u8-parser@7.2.0:
dependencies:
'@babel/runtime': 7.29.7
+2
View File
@@ -134,6 +134,8 @@ catalog:
json5: ^2.2.3
ldapts: 8.1.8
lodash.clonedeep: ^4.5.0
lucide-react: ^1.17.0
mantine-datatable: ^9.2.2
mantine-react-table: npm:mantine-react-table-open@9.0.3
maria2: ^0.4.1
mysql2: 3.22.4