Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1b4c76a4 | ||
|
|
78f2a25f2b | ||
|
|
13c9f768fd | ||
|
|
8920be7fd3 | ||
|
|
4b0c69e6a3 | ||
|
|
79b3e5cc18 | ||
|
|
27521a6cfd | ||
|
|
5e4867edaf | ||
|
|
540ad5a2c1 | ||
|
|
5588761db7 | ||
|
|
4d886bf980 | ||
|
|
dfec430b86 | ||
|
|
466c439ba2 | ||
|
|
4c36c9cff1 | ||
|
|
cfa510dbf9 | ||
|
|
52d5c050b6 | ||
|
|
43f1613215 | ||
|
|
bb96ca354f | ||
|
|
c1bd6cccaa | ||
|
|
2bf014ba43 | ||
|
|
515b285ef7 | ||
|
|
5eebfb7719 | ||
|
|
7f1638fec6 | ||
|
|
6b1cc0fd6b | ||
|
|
3131687229 | ||
|
|
59d44933cf | ||
|
|
5cf9b1b752 | ||
|
|
48dcf65c4e | ||
|
|
6757fbe956 |
No files matched your search
+1
-2
@@ -66,8 +66,7 @@ e2e/shared/tmp
|
|||||||
#personal backgrounds
|
#personal backgrounds
|
||||||
apps/nextjs/public/images/background.png
|
apps/nextjs/public/images/background.png
|
||||||
|
|
||||||
# next-intl
|
|
||||||
en.d.json.ts
|
|
||||||
|
|
||||||
# personal agent config
|
# personal agent config
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ const homarrVersion = (() => {
|
|||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
experimental: {
|
|
||||||
createMessagesDeclaration: "../../packages/translation/src/lang/en.json",
|
|
||||||
},
|
|
||||||
requestConfig: "../../packages/translation/src/request.ts",
|
requestConfig: "../../packages/translation/src/request.ts",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@
|
|||||||
"isomorphic-dompurify": "catalog:",
|
"isomorphic-dompurify": "catalog:",
|
||||||
"jszip": "catalog:",
|
"jszip": "catalog:",
|
||||||
"jotai": "catalog:",
|
"jotai": "catalog:",
|
||||||
|
"lucide-react": "catalog:",
|
||||||
|
"mantine-datatable": "catalog:",
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"postcss-preset-mantine": "catalog:",
|
"postcss-preset-mantine": "catalog:",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css";
|
|||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
import "@homarr/ui/styles.css";
|
import "@homarr/ui/styles.css";
|
||||||
|
import "mantine-datatable/styles.css";
|
||||||
import "~/styles/color-scheme.scss";
|
import "~/styles/color-scheme.scss";
|
||||||
import "~/styles/scroll-area.scss";
|
import "~/styles/scroll-area.scss";
|
||||||
|
|
||||||
|
|||||||
@@ -613,7 +613,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
boardId: board.id,
|
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
|
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
|
||||||
const layoutToClone =
|
const layoutToClone =
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
@@ -1215,7 +1215,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
|
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
|
||||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||||
}),
|
}),
|
||||||
users: userPermissions
|
users: userPermissions
|
||||||
@@ -1223,7 +1223,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
user,
|
user,
|
||||||
permission,
|
permission,
|
||||||
}))
|
}))
|
||||||
.sort((permissionA, permissionB) => {
|
.toSorted((permissionA, permissionB) => {
|
||||||
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
|
||||||
}),
|
}),
|
||||||
groups: dbGroupBoardPermission
|
groups: dbGroupBoardPermission
|
||||||
@@ -1234,7 +1234,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
permission,
|
permission,
|
||||||
}))
|
}))
|
||||||
.sort((permissionA, permissionB) => {
|
.toSorted((permissionA, permissionB) => {
|
||||||
return permissionA.group.name.localeCompare(permissionB.group.name);
|
return permissionA.group.name.localeCompare(permissionB.group.name);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -1586,7 +1586,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
...otherBoardProperties,
|
...otherBoardProperties,
|
||||||
layouts: layouts
|
layouts: layouts
|
||||||
.map(({ boardId: _, ...layout }) => layout)
|
.map(({ boardId: _, ...layout }) => layout)
|
||||||
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||||
sections: sections.map(({ collapseStates, ...section }) =>
|
sections: sections.map(({ collapseStates, ...section }) =>
|
||||||
parseSection({
|
parseSection({
|
||||||
...section,
|
...section,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
|
|||||||
import { createTRPCRouter } from "../../trpc";
|
import { createTRPCRouter } from "../../trpc";
|
||||||
|
|
||||||
export const widgetRouter = createTRPCRouter({
|
export const widgetRouter = createTRPCRouter({
|
||||||
|
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||||
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
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>) => {
|
(integration, itemOptions: Record<string, never>) => {
|
||||||
const { kind } = integration;
|
const { kind } = integration;
|
||||||
|
|
||||||
|
if (kind === "beszel") {
|
||||||
|
return null as never;
|
||||||
|
}
|
||||||
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
|
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
|
||||||
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ import {
|
|||||||
} from "../schema";
|
} from "../schema";
|
||||||
import type { Integration } from "../schema";
|
import type { Integration } from "../schema";
|
||||||
|
|
||||||
const isTruthyEnv = (value: string | undefined) =>
|
const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
|
||||||
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
|
|
||||||
|
|
||||||
export const seedDataAsync = async (db: Database) => {
|
export const seedDataAsync = async (db: Database) => {
|
||||||
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
|
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 },
|
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: "notebook", width: 4, height: 4, needsIntegration: false },
|
||||||
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
|
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
|
||||||
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
|
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
|
||||||
|
|||||||
@@ -433,6 +433,14 @@ export const integrationDefs = {
|
|||||||
category: ["ups"],
|
category: ["ups"],
|
||||||
documentationUrl: null,
|
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)
|
// 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: {
|
mock: {
|
||||||
name: "Mock",
|
name: "Mock",
|
||||||
|
|||||||
@@ -39,5 +39,9 @@ export const widgetKinds = [
|
|||||||
"speedtestTracker",
|
"speedtestTracker",
|
||||||
"umami",
|
"umami",
|
||||||
"ups",
|
"ups",
|
||||||
|
"beszelSystemTable",
|
||||||
|
"beszelSystemGrid",
|
||||||
|
"beszelAlerts",
|
||||||
|
"beszelSystemStats",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
|
import { BeszelIntegration } from "../beszel/beszel-integration";
|
||||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||||
@@ -125,6 +126,7 @@ export const integrationCreators = {
|
|||||||
speedtestTracker: SpeedtestTrackerIntegration,
|
speedtestTracker: SpeedtestTrackerIntegration,
|
||||||
umami: UmamiIntegration,
|
umami: UmamiIntegration,
|
||||||
peaNut: PeaNutIntegration,
|
peaNut: PeaNutIntegration,
|
||||||
|
beszel: BeszelIntegration,
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
|
|
||||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
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 { NotificationsMockService } from "./data/notifications";
|
||||||
import { SmartHomeMockService } from "./data/smart-home";
|
import { SmartHomeMockService } from "./data/smart-home";
|
||||||
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
||||||
|
import { BeszelMockService } from "./data/beszel";
|
||||||
import { UpsSummaryMockService } from "./data/ups-summary";
|
import { UpsSummaryMockService } from "./data/ups-summary";
|
||||||
|
|
||||||
export class MockIntegration
|
export class MockIntegration
|
||||||
@@ -61,6 +62,7 @@ export class MockIntegration
|
|||||||
private static readonly networkController = new NetworkControllerSummaryMockService();
|
private static readonly networkController = new NetworkControllerSummaryMockService();
|
||||||
private static readonly notifications = new NotificationsMockService();
|
private static readonly notifications = new NotificationsMockService();
|
||||||
private static readonly smartHome = new SmartHomeMockService();
|
private static readonly smartHome = new SmartHomeMockService();
|
||||||
|
private static readonly beszel = new BeszelMockService();
|
||||||
private static readonly upsSummary = new UpsSummaryMockService();
|
private static readonly upsSummary = new UpsSummaryMockService();
|
||||||
|
|
||||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
@@ -130,4 +132,12 @@ export class MockIntegration
|
|||||||
|
|
||||||
// UpsSummaryIntegration
|
// UpsSummaryIntegration
|
||||||
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
|
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);
|
||||||
}
|
}
|
||||||
@@ -18,3 +18,4 @@ export * from "./interfaces/timetable/timetable-types";
|
|||||||
export * from "./tracearr/tracearr-types";
|
export * from "./tracearr/tracearr-types";
|
||||||
export * from "./speedtest-tracker/speedtest-tracker-types";
|
export * from "./speedtest-tracker/speedtest-tracker-types";
|
||||||
export * from "./umami/umami-types";
|
export * from "./umami/umami-types";
|
||||||
|
export * from "./beszel/beszel-types";
|
||||||
@@ -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<
|
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
|
||||||
SystemHealthMonitoring,
|
SystemHealthMonitoring,
|
||||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify">,
|
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
|
||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, _input) {
|
async requestAsync(integration, _input) {
|
||||||
|
|||||||
@@ -3104,6 +3104,219 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"unhealthy": "Unhealthy"
|
"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": {
|
"widgetPreview": {
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
"@tiptap/starter-kit": "catalog:",
|
"@tiptap/starter-kit": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
|
"lucide-react": "catalog:",
|
||||||
|
"mantine-datatable": "catalog:",
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"react": "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";
|
||||||
@@ -48,6 +48,10 @@ import * as timetable from "./timetable";
|
|||||||
import * as tracearr from "./tracearr";
|
import * as tracearr from "./tracearr";
|
||||||
import * as umami from "./umami";
|
import * as umami from "./umami";
|
||||||
import * as ups from "./ups";
|
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 video from "./video";
|
||||||
import * as weather from "./weather";
|
import * as weather from "./weather";
|
||||||
|
|
||||||
@@ -95,6 +99,10 @@ export const widgetImports = {
|
|||||||
speedtestTracker,
|
speedtestTracker,
|
||||||
umami,
|
umami,
|
||||||
ups,
|
ups,
|
||||||
|
beszelSystemTable,
|
||||||
|
beszelSystemGrid,
|
||||||
|
beszelAlerts,
|
||||||
|
beszelSystemStats,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
Generated
+36
@@ -393,6 +393,12 @@ catalogs:
|
|||||||
lodash.clonedeep:
|
lodash.clonedeep:
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 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:
|
mantine-react-table:
|
||||||
specifier: npm:mantine-react-table-open@9.0.3
|
specifier: npm:mantine-react-table-open@9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
@@ -835,6 +841,12 @@ importers:
|
|||||||
jotai:
|
jotai:
|
||||||
specifier: 'catalog:'
|
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)
|
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:
|
mantine-react-table:
|
||||||
specifier: 'catalog:'
|
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)
|
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:
|
dayjs:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.11.21
|
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:
|
mantine-react-table:
|
||||||
specifier: 'catalog:'
|
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)
|
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==}
|
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
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:
|
m3u8-parser@7.2.0:
|
||||||
resolution: {integrity: sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==}
|
resolution: {integrity: sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==}
|
||||||
|
|
||||||
@@ -7491,6 +7514,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
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:
|
mantine-react-table-open@9.0.3:
|
||||||
resolution: {integrity: sha512-HsrBU4KZluj96FPvvH0wcLzog0m5LIMdwB/59oj6BpftLGEibiLxX00G+tDM06PL2jXuiRxiGKPJ4oQOV+VK8w==}
|
resolution: {integrity: sha512-HsrBU4KZluj96FPvvH0wcLzog0m5LIMdwB/59oj6BpftLGEibiLxX00G+tDM06PL2jXuiRxiGKPJ4oQOV+VK8w==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -14851,6 +14883,10 @@ snapshots:
|
|||||||
|
|
||||||
lru.min@1.1.4: {}
|
lru.min@1.1.4: {}
|
||||||
|
|
||||||
|
lucide-react@1.17.0(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
m3u8-parser@7.2.0:
|
m3u8-parser@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ catalog:
|
|||||||
json5: ^2.2.3
|
json5: ^2.2.3
|
||||||
ldapts: 8.1.8
|
ldapts: 8.1.8
|
||||||
lodash.clonedeep: ^4.5.0
|
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
|
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||||
maria2: ^0.4.1
|
maria2: ^0.4.1
|
||||||
mysql2: 3.22.4
|
mysql2: 3.22.4
|
||||||
|
|||||||
Reference in new issue
Block a user