Compare commits

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

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

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

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

No files matched your search

+1 -2
View File
@@ -66,8 +66,7 @@ e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
# next-intl
en.d.json.ts
# personal agent config
AGENTS.md
+16 -4
View File
@@ -5,18 +5,30 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const homarrVersion = (() => {
for (const candidate of ["../../package.json", "./package.json"]) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
if (pkg.name === "homarr") return pkg.version as string;
} catch {}
}
return "unknown";
})();
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({
experimental: {
createMessagesDeclaration: "../../packages/translation/src/lang/en.json",
},
requestConfig: "../../packages/translation/src/request.ts",
});
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
},
output: "standalone",
reactStrictMode: true,
// react compiler breaks mantine-react-table, so disabled for now
@@ -27,7 +39,7 @@ const nextConfig: NextConfig = {
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
+9
View File
@@ -72,10 +72,16 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"adm-zip": "catalog:",
"better-sqlite3": "catalog:",
"isomorphic-dompurify": "catalog:",
"jszip": "catalog:",
"jotai": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
@@ -85,6 +91,7 @@
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
@@ -93,6 +100,8 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
BIN
View File
Binary file not shown.
@@ -2,13 +2,16 @@ import { Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<InitStepCard>
@@ -27,6 +30,7 @@ export const InitStart = async () => {
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</InitStepCard>
);
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Button, Collapse, Stack, Text } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const InitSqliteRestore = () => {
const t = useScopedI18n("init.step.start.action");
const [expanded, setExpanded] = useState(false);
if (expanded) {
return (
<Stack gap="sm">
<Button
variant="subtle"
size="xs"
onClick={() => setExpanded(false)}
>
{t("backToOptions")}
</Button>
<Text size="sm" c="dimmed">
{t("restoreSqliteDescription")}
</Text>
<DatabaseRestoreFlow variant="standalone" />
</Stack>
);
}
return (
<Button
onClick={() => setExpanded(true)}
variant="default"
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
>
{t("restoreSqlite")}
</Button>
);
};
+1
View File
@@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "mantine-datatable/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
@@ -10,6 +10,7 @@ import {
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
@@ -28,6 +29,7 @@ import {
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
@@ -154,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.backup"),
icon: IconDatabaseExport,
href: "/manage/tools/backup",
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
},
],
},
{
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
export const BackupExportCard = () => {
const t = useScopedI18n("management.page.tool.backup.export");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const handleExport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/backup/export");
if (!response.ok) {
const errorMessage = await response.json().then(
(data) => data.error,
() => `Server returned ${response.status}`,
);
throw new Error(errorMessage ?? t("error"));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
setShowConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("error"));
} finally {
setLoading(false);
}
};
return (
<Card withBorder>
<Stack gap="sm">
<Group gap="sm">
<IconDatabaseExport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{showConfirm ? (
<DangerousActionConfirmation
title={t("confirm.title")}
warningTitle={t("confirm.warningTitle")}
warningBody={t("confirm.warningBody")}
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
submitLabel={t("confirm.submit")}
submitIcon={<IconDownload size={16} />}
cancelLabel={t("confirm.cancel")}
onConfirm={handleExport}
onCancel={() => setShowConfirm(false)}
disabled={loading}
color="orange"
/>
) : (
<Group>
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
{t("button")}
</Button>
</Group>
)}
</Stack>
</Card>
);
};
@@ -0,0 +1,27 @@
"use client";
import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const BackupImportCard = () => {
const t = useScopedI18n("management.page.tool.backup.import");
return (
<Card withBorder>
<Stack gap="md">
<Group gap="sm">
<IconDatabaseImport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<DatabaseRestoreFlow />
</Stack>
</Card>
);
};
@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
export async function generateMetadata() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
return {};
}
const t = await getScopedI18n("management.page.tool.backup");
return {
title: createMetaTitle(t("metaTitle")),
};
}
export default async function BackupPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
notFound();
}
const t = await getScopedI18n("management.page.tool.backup");
return (
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</Stack>
);
}
@@ -0,0 +1,75 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
export async function GET() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath || !fs.existsSync(dbPath)) {
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
let sourceDb: InstanceType<typeof Database> | null = null;
try {
sourceDb = new Database(dbPath, { readonly: true });
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
sourceDb.close();
sourceDb = null;
const dbBuffer = fs.readFileSync(tempPath);
const metadata = {
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: env.SECRET_ENCRYPTION_KEY,
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
const zipBuffer = zip.toBuffer();
const date = new Date().toISOString().split("T")[0];
const filename = `homarr-backup-${date}.zip`;
return new NextResponse(zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": zipBuffer.length.toString(),
},
});
} catch (error) {
console.error("[backup/export] Export failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
} finally {
sourceDb?.close();
try {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
} catch (cleanupErr) {
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
}
}
}
@@ -0,0 +1,194 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db } from "@homarr/db";
import { findMigrationsFolder } from "../shared";
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
const ALGORITHM = "aes-256-cbc";
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
if (importedKeyHex === currentKeyHex) return;
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
throw new Error("Imported encryption key is not a valid 64-character hex string");
}
const oldKey = Buffer.from(importedKeyHex, "hex");
const newKey = Buffer.from(currentKeyHex, "hex");
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
integration_id: string;
kind: string;
value: string;
}[];
if (rows.length === 0) return;
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
const transaction = tempDb.transaction(() => {
for (const row of rows) {
const parts = row.value.split(".");
if (parts.length !== 2) {
throw new Error(
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
);
}
const [data, dataIv] = parts as [string, string];
try {
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
} catch {
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
}
}
});
transaction();
};
const isOnboardingActiveAsync = async (): Promise<boolean> => {
const onboardingRow = await db.query.onboarding.findFirst();
if (!onboardingRow) return false;
return onboardingRow.step === "start";
};
export async function POST(req: Request) {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
if (!isAdmin && !isOnboarding) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath) {
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(arrayBuffer));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((e) => e.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
let tempDb: InstanceType<typeof Database> | null = null;
try {
const dbEntry = zip.getEntry("db.sqlite")!;
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new Database(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder });
const metadataEntry = zip.getEntry("metadata.json")!;
const metadata = JSON.parse(metadataEntry.getData().toString());
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
const secretCount = (
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
).count;
if (secretCount > 0 && !importedKey) {
throw new Error(
"Backup contains integration secrets but no encryption key. " +
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
);
}
if (importedKey) {
reEncryptSecrets(tempDb, importedKey);
}
tempDb.close();
tempDb = null;
fs.renameSync(tempPath, dbPath);
for (const suffix of ["-wal", "-shm"] as const) {
try {
const sidecar = `${dbPath}${suffix}`;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
} catch (cleanupErr) {
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
}
}
setTimeout(() => {
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
} catch (error) {
console.error("[backup/import] Restore failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
} finally {
tempDb?.close();
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// temp file may have been renamed already
}
}
}
}
@@ -0,0 +1,63 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { findMigrationsFolder } from "../shared";
interface JournalEntry {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
}
interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export async function GET() {
try {
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
if (!fs.existsSync(journalPath)) {
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
}
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
const migrations: MigrationFile[] = [];
for (const entry of journal.entries) {
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
if (!fs.existsSync(sqlPath)) continue;
migrations.push({
idx: entry.idx,
tag: entry.tag,
sql: fs.readFileSync(sqlPath, "utf-8"),
when: entry.when,
});
}
return NextResponse.json(
{ total: migrations.length, migrations },
{
headers: {
"Cache-Control": "public, max-age=3600",
},
},
);
} catch (error) {
console.error("[backup/migrations] Failed to read migrations:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
}
}
+12
View File
@@ -0,0 +1,12 @@
import fs from "fs";
import path from "path";
const migrationsFolderCandidates = [
path.join(process.cwd(), "db/migrations/sqlite"),
path.join(process.cwd(), "packages/db/migrations/sqlite"),
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
export const findMigrationsFolder = () =>
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
@@ -0,0 +1,30 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
const wasmCandidates = [
path.join(process.cwd(), "public/sql-wasm.wasm"),
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
];
let cachedWasm: Buffer | null = null;
export async function GET() {
if (!cachedWasm) {
const found = wasmCandidates.find((p) => fs.existsSync(p));
if (!found) {
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
}
cachedWasm = fs.readFileSync(found);
}
return new NextResponse(cachedWasm, {
headers: {
"Content-Type": "application/wasm",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
@@ -0,0 +1,308 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
const ALGORITHM = "aes-256-cbc";
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
const key = Buffer.from(keyHex, "hex");
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
};
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
const key = Buffer.from(keyHex, "hex");
const [data, dataIv] = value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
};
const createTestDb = (dbPath: string) => {
const sqlite = new Database(dbPath);
const db = drizzle(sqlite, { casing: DB_CASING });
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
return { db, sqlite };
};
describe("SQLite backup", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("export ZIP structure", () => {
it("should create a ZIP containing all required entries", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const dbBuffer = fs.readFileSync(backupPath);
const metadata = {
homarrVersion: "test",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: generateHexKey(),
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
const zipPath = path.join(tmpDir, "backup.zip");
zip.writeZip(zipPath);
const readBack = new AdmZip(zipPath);
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
const dbEntry = readBack.getEntry("db.sqlite");
expect(dbEntry).toBeDefined();
expect(dbEntry!.header.size).toBeGreaterThan(0);
});
it("should produce a valid SQLite database in the ZIP", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const backupDb = new Database(backupPath, { readonly: true });
const tables = backupDb
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as { name: string }[];
expect(tables.map((t) => t.name)).toContain("board");
expect(tables.map((t) => t.name)).toContain("user");
expect(tables.map((t) => t.name)).toContain("integrationSecret");
backupDb.close();
});
});
describe("import ZIP validation", () => {
it("should detect missing required entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toEqual(["metadata.json"]);
});
it("should accept valid backup ZIP with all entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
it("should accept legacy backup ZIP with encryption-key.txt", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
});
describe("secret re-encryption", () => {
it("should re-encrypt secrets from old key to new key", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const oldKey = generateHexKey();
const newKey = generateHexKey();
const plaintext = "my-secret-password";
const encryptedValue = encryptWithKey(plaintext, oldKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
expect(decryptedBefore).toBe(plaintext);
const oldKeyBuf = Buffer.from(oldKey, "hex");
const newKeyBuf = Buffer.from(newKey, "hex");
const rows = sqlite
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
.all() as { integration_id: string; kind: string; value: string }[];
const updateStmt = sqlite.prepare(
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
);
for (const row of rows) {
const [data, dataIv] = row.value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
}
const updatedRow = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
sqlite.close();
});
it("should skip re-encryption when keys match", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const sameKey = generateHexKey();
const plaintext = "my-secret";
const encryptedValue = encryptWithKey(plaintext, sameKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const rowBefore = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
expect(rowBefore.value).toBe(encryptedValue);
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
expect(decrypted).toBe(plaintext);
sqlite.close();
});
});
describe("VACUUM INTO consistency", () => {
it("should produce a consistent copy via VACUUM INTO", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
sqlite.exec(`
INSERT INTO "app" ("id", "name", "icon_url")
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
`);
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
const backupDb = new Database(backupPath, { readonly: true });
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
expect(app).toBeDefined();
expect(app!.name).toBe("TestApp");
backupDb.close();
sqlite.close();
});
});
describe("migration on imported DB", () => {
it("should successfully migrate an imported database", () => {
const sourcePath = path.join(tmpDir, "source.sqlite");
const { sqlite: sourceDb } = createTestDb(sourcePath);
sourceDb.exec(`
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
`);
sourceDb.close();
const importPath = path.join(tmpDir, "import.sqlite");
fs.copyFileSync(sourcePath, importPath);
const importDb = new Database(importPath);
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
expect(board).toBeDefined();
expect(board!.name).toBe("TestBoard");
importDb.close();
});
});
describe("encryption key validation", () => {
it("should reject invalid hex keys", () => {
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
for (const key of invalidKeys) {
expect(hexKeyRegex.test(key)).toBe(false);
}
});
it("should accept valid 64-char hex keys", () => {
const validKey = generateHexKey();
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
expect(hexKeyRegex.test(validKey)).toBe(true);
});
});
});
@@ -0,0 +1,114 @@
"use client";
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
interface BackupPreviewPanelProps {
analysis: BackupAnalysis;
}
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.preview");
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
const pendingCount = analysis.migrations.pending.length;
const hasPendingMigrations = pendingCount > 0;
return (
<Stack gap="md">
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Group gap="sm">
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
<IconDatabase size={18} />
</ThemeIcon>
<div>
<Title order={5}>{t("title")}</Title>
<Text size="xs" c="dimmed">
{t("exported", { date: exportDate })}
</Text>
</div>
<Badge variant="light" color="gray" ml="auto">
Homarr {analysis.metadata.homarrVersion}
</Badge>
</Group>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("entity")}</Table.Th>
<Table.Th ta="right">{t("count")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{PREVIEW_TABLE_KEYS.map((key) => (
<Table.Tr key={key}>
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
<Table.Td ta="right">
<Group gap={4} justify="flex-end">
{(analysis.counts[key] ?? 0) > 0 && (
<IconCheck size={14} color="var(--mantine-color-green-6)" />
)}
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{analysis.boardNames.length > 0 && (
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>{t("boards")}:</Text>
{analysis.boardNames.map((name) => (
<Badge key={name} variant="light" size="sm">{name}</Badge>
))}
</Group>
)}
</Stack>
</Paper>
{hasPendingMigrations && (
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
<Group gap="sm">
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>
{pendingCount === 1
? t("migrationsApplied", { count: String(pendingCount) })
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
</Text>
<Text size="xs" c="dimmed">
{pendingCount === 1
? t("migrationsOlderVersion", { count: String(pendingCount) })
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
</Text>
</div>
<Badge variant="light" color="orange" ml="auto">
{t("migrationsPending", { count: String(pendingCount) })}
</Badge>
</Group>
</Paper>
)}
{!hasPendingMigrations && (
<Paper p="md" radius="md" withBorder>
<Group gap="sm">
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<Text size="sm" fw={500} c="dimmed">
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
</Text>
</Group>
</Paper>
)}
</Stack>
);
};
@@ -0,0 +1,91 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
const CONFIRMATION_PHRASE = "I understand";
interface DangerousActionConfirmationProps {
title: string;
warningTitle: string;
warningBody: string;
typePrompt: string;
submitLabel: string;
submitIcon: ReactNode;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
color?: string;
}
export const DangerousActionConfirmation = ({
title,
warningTitle,
warningBody,
typePrompt,
submitLabel,
submitIcon,
cancelLabel,
onConfirm,
onCancel,
disabled,
color = "red",
}: DangerousActionConfirmationProps) => {
const [value, setValue] = useState("");
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
return (
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
<Stack gap="md">
<Group gap="sm">
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
<Text size="lg" fw={700} c={color}>
{title}
</Text>
</Group>
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
<Stack gap={4}>
<Text size="sm" fw={600}>{warningTitle}</Text>
<Text size="sm">{warningBody}</Text>
</Stack>
</Alert>
<Box>
<Text size="sm" fw={500} mb={4}>
{typePrompt}
</Text>
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder={CONFIRMATION_PHRASE}
disabled={disabled}
styles={{
input: {
fontFamily: "var(--mantine-font-family-monospace)",
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
},
}}
/>
</Box>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
{cancelLabel}
</Button>
<Button
color={color}
leftSection={submitIcon}
onClick={onConfirm}
disabled={!isMatch || disabled}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Paper>
);
};
@@ -0,0 +1,227 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
import type { RestoreStep } from "./types";
import { BackupPreviewPanel } from "./backup-preview-panel";
import { MigrationProgressPanel } from "./migration-progress-panel";
import { RestoreConfirmation } from "./restore-confirmation";
import { RestoreProgressPanel } from "./restore-progress-panel";
import { useBackupAnalysis } from "./use-backup-analysis";
interface DatabaseRestoreFlowProps {
variant?: "card" | "standalone";
onRestoreComplete?: () => void;
}
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
const handleFileDrop = useCallback(
(files: FileWithPath[]) => {
const droppedFile = files[0];
if (!droppedFile) return;
setFile(droppedFile);
setStep("preview");
setImportError(null);
void analyzeFile(droppedFile);
},
[analyzeFile],
);
const handleClear = useCallback(() => {
setFile(null);
setStep("upload");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
reset();
}, [reset]);
const tryFinalize = useCallback(() => {
if (!apiDoneRef.current) return;
if (apiErrorRef.current) {
setImportError(apiErrorRef.current);
setStep("error");
return;
}
if (!animDoneRef.current) return;
onRestoreComplete?.();
window.location.reload();
}, [onRestoreComplete]);
const handleConfirm = useCallback(async () => {
if (!file) return;
setStep("restoring");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
if (!response.ok) {
try {
const data = await response.json();
apiErrorRef.current = data.error ?? t("failed.title");
} catch {
apiErrorRef.current = `Server returned ${response.status}`;
}
}
} catch {
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
tryFinalize();
}
}, [file, t, tryFinalize]);
const handleAnimationComplete = useCallback(() => {
animDoneRef.current = true;
tryFinalize();
}, [tryFinalize]);
if (step === "upload") {
return (
<Dropzone
onDrop={handleFileDrop}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
multiple={false}
maxSize={1024 * 1024 * 256}
radius="md"
>
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{t("dropzone.title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{t("dropzone.description")}
</Text>
</div>
</Group>
</Dropzone>
);
}
if (step === "preview") {
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<IconFileZip size={20} />
<div>
<Text size="sm" fw={500}>{file?.name}</Text>
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
</div>
</Group>
<Button variant="subtle" size="xs" onClick={handleClear}>
{t("changeFile")}
</Button>
</Group>
{loading && migrationProgress && (
<MigrationProgressPanel progress={migrationProgress} />
)}
{loading && !migrationProgress && (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
</Group>
)}
{analysisError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
{analysisError}
</Alert>
)}
{analysis && (
<>
<BackupPreviewPanel analysis={analysis} />
<Group justify="flex-end">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => setStep("confirm")}
>
{t("continueToRestore")}
</Button>
</Group>
</>
)}
</Stack>
);
}
if (step === "confirm") {
return (
<Stack gap="md">
{analysis && <BackupPreviewPanel analysis={analysis} />}
<RestoreConfirmation
onConfirm={handleConfirm}
onCancel={() => setStep("preview")}
/>
</Stack>
);
}
if (step === "restoring") {
return (
<RestoreProgressPanel
active
migrations={analysis?.migrations.pending ?? []}
onComplete={handleAnimationComplete}
/>
);
}
if (step === "error") {
return (
<Stack gap="md">
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
<Text size="sm">{importError}</Text>
</Alert>
<Group>
<Button variant="subtle" onClick={handleClear}>
{t("tryAgain")}
</Button>
</Group>
</Stack>
);
}
return null;
};
@@ -0,0 +1,3 @@
export { DatabaseRestoreFlow } from "./database-restore-flow";
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export type { BackupAnalysis, RestoreStep } from "./types";
@@ -0,0 +1,60 @@
"use client";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationProgress } from "./use-backup-analysis";
interface MigrationProgressPanelProps {
progress: MigrationProgress;
}
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.migration");
return (
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconDatabase size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
{t("title", { current: String(progress.current), total: String(progress.total) })}
</Text>
</Group>
<Box pl={4}>
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
{Array.from({ length: progress.total }, (_, i) => {
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
const isActive = i === progress.current - 1 && progress.phase === "applying";
return (
<Timeline.Item
key={i}
bullet={
isDone ? (
<IconCheck size={12} />
) : isActive ? (
<Loader size={10} color="white" />
) : (
<IconLoader size={12} />
)
}
title={
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
</Text>
}
/>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -0,0 +1,32 @@
"use client";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
interface RestoreConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
}
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
return (
<DangerousActionConfirmation
title={t("title")}
warningTitle={t("warningTitle")}
warningBody={t("warningBody")}
typePrompt={t("typePrompt", { phrase: "I understand" })}
submitLabel={t("submit")}
submitIcon={<IconDatabaseImport size={16} />}
cancelLabel={t("cancel")}
onConfirm={onConfirm}
onCancel={onCancel}
disabled={disabled}
/>
);
};
@@ -0,0 +1,159 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import {
IconCheck,
IconDatabaseImport,
IconFileZip,
IconKey,
IconLoader,
IconRefresh,
IconTransform,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const BASE_PHASE_DURATIONS: Record<string, number> = {
extracting: 1200,
encrypting: 1500,
swapping: 800,
restarting: 10000,
};
const MIGRATION_STEP_DURATION = 100;
interface TimelineStep {
key: string;
label: string;
icon: Icon;
duration: number;
}
interface RestoreProgressPanelProps {
active: boolean;
migrations: MigrationFile[];
onComplete?: () => void;
}
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.progress");
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
const steps = useMemo((): TimelineStep[] => {
const result: TimelineStep[] = [
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
];
if (migrations.length > 0) {
for (const migration of migrations) {
result.push({
key: `migration-${migration.idx}`,
label: migration.tag,
icon: IconDatabaseImport,
duration: MIGRATION_STEP_DURATION,
});
}
} else {
result.push({
key: "migrating",
label: t("migrating"),
icon: IconDatabaseImport,
duration: 300,
});
}
result.push(
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
);
return result;
}, [migrations, t]);
useEffect(() => {
if (!active) return;
let cancelled = false;
const run = async () => {
for (let i = 0; i < steps.length; i++) {
if (cancelled) return;
setCurrentStepIdx(i);
await new Promise((r) => setTimeout(r, steps[i]!.duration));
}
if (!cancelled) {
onComplete?.();
}
};
void run();
return () => { cancelled = true; };
}, [active, onComplete, steps]);
if (!active && currentStepIdx === -1) return null;
return (
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="md">
<Group gap="xs">
<Loader size="sm" color="blue" />
<Text size="sm" fw={600}>
{t("title")}
</Text>
</Group>
<Box pl={4}>
<Timeline
active={currentStepIdx}
bulletSize={24}
lineWidth={2}
color="blue"
>
{steps.map((step, i) => {
const isDone = i < currentStepIdx;
const isActive = i === currentStepIdx;
const isMigration = step.key.startsWith("migration-");
return (
<Timeline.Item
key={step.key}
bullet={
isDone ? (
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
<IconCheck size={14} />
</ThemeIcon>
) : isActive ? (
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
<Loader size={12} color="white" />
</ThemeIcon>
) : (
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
<IconLoader size={14} />
</ThemeIcon>
)
}
>
<Group gap="xs" mt={-4}>
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
<Text
size={isMigration ? "xs" : "sm"}
fw={isActive ? 600 : 400}
ff={isMigration ? "monospace" : undefined}
c={isDone ? "green" : isActive ? "white" : "dimmed"}
>
{step.label}
</Text>
</Group>
</Timeline.Item>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -0,0 +1,30 @@
export interface BackupMetadata {
homarrVersion: string;
exportedAt: string;
dbDialect: string;
encryptionKey?: string;
}
export interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export interface MigrationStatus {
applied: number;
pending: MigrationFile[];
total: number;
}
export interface BackupAnalysis {
metadata: BackupMetadata;
counts: Record<string, number>;
boardNames: string[];
migrations: MigrationStatus;
}
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
@@ -0,0 +1,187 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getAppliedMigrationCount = (db: any): number => {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
return (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
return 0;
}
};
const fetchMigrations = async (): Promise<MigrationFile[]> => {
const res = await fetch("/api/backup/migrations");
if (!res.ok) throw new Error("Failed to fetch migration files from server");
const data = await res.json();
return data.migrations ?? [];
};
const countEntities = (db: any): Record<string, number> => {
const counts: Record<string, number> = {};
for (const key of PREVIEW_TABLE_KEYS) {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
counts[key] = 0;
}
}
return counts;
};
const getBoardNames = (db: any): string[] => {
try {
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
} catch {
return [];
}
};
export interface MigrationProgress {
current: number;
total: number;
tag: string;
phase: "pending" | "applying" | "done" | "error";
}
export const useBackupAnalysis = () => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
const dbRef = useRef<any>(null);
const analyzeFile = useCallback(async (file: File) => {
setLoading(true);
setError(null);
setAnalysis(null);
setMigrationProgress(null);
try {
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
import("jszip").then((m) => m.default),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import("sql.js").then((m: any) => m.default),
fetchMigrations(),
fetch("/api/backup/sql-wasm").then((r) => {
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
return r.arrayBuffer();
}),
]);
const arrayBuffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const metadataFile = zip.file("metadata.json");
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
const dbFile = zip.file("db.sqlite");
if (!dbFile) throw new Error(t("invalidMissingDb"));
const metadata = JSON.parse(await metadataFile.async("string"));
const dbBuffer = await dbFile.async("uint8array");
const SQL = await initSqlJs({ wasmBinary });
const db = new SQL.Database(dbBuffer);
dbRef.current = db;
try {
const appliedCount = getAppliedMigrationCount(db);
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
const migrations: MigrationStatus = {
applied: appliedCount,
pending: pendingMigrations,
total: allMigrations.length,
};
if (pendingMigrations.length > 0) {
for (let i = 0; i < pendingMigrations.length; i++) {
const migration = pendingMigrations[i]!;
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "applying",
});
await delay(100);
try {
const statements = migration.sql
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
db.run(stmt);
}
db.run(
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
[migration.tag, Date.now()],
);
} catch (migrationErr) {
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "error",
});
throw new Error(
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
);
}
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "done",
});
await delay(50);
}
}
const counts = countEntities(db);
const boardNames = getBoardNames(db);
setMigrationProgress(null);
setAnalysis({ metadata, counts, boardNames, migrations });
} finally {
db.close();
dbRef.current = null;
}
} catch (err) {
setError(err instanceof Error ? err.message : t("analyzeError"));
} finally {
setLoading(false);
}
}, [t]);
const reset = useCallback(() => {
if (dbRef.current) {
try { dbRef.current.close(); } catch { /* already closed */ }
dbRef.current = null;
}
setAnalysis(null);
setError(null);
setMigrationProgress(null);
}, []);
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
};
+1
View File
@@ -2,3 +2,4 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "sql.js";
+1 -1
View File
@@ -23,7 +23,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 32M;
client_max_body_size 256M;
}
}
}
+5 -5
View File
@@ -613,7 +613,7 @@ export const boardRouter = createTRPCRouter({
boardId: board.id,
});
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
const layoutToClone =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -1215,7 +1215,7 @@ export const boardRouter = createTRPCRouter({
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -1223,7 +1223,7 @@ export const boardRouter = createTRPCRouter({
user,
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupBoardPermission
@@ -1234,7 +1234,7 @@ export const boardRouter = createTRPCRouter({
},
permission,
}))
.sort((permissionA, permissionB) => {
.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
@@ -1586,7 +1586,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
...otherBoardProperties,
layouts: layouts
.map(({ boardId: _, ...layout }) => layout)
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
sections: sections.map(({ collapseStates, ...section }) =>
parseSection({
...section,
+135
View File
@@ -0,0 +1,135 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeSystems: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
return innerHandler.subscribe((systems) => {
emit.next({
integrationId: integration.id,
systems,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((fn) => fn());
};
});
}),
getAlerts: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().min(1).max(100).default(10),
}),
)
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
const [alertsResult, systemsResult] = await Promise.all([
alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
]);
const systemNameMap: Record<string, string> = {};
for (const system of systemsResult.data) {
systemNameMap[system.id] = system.name;
}
return {
integrationId: integration.id,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
updatedAt: alertsResult.timestamp,
};
}),
);
return results;
}),
subscribeAlerts: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
includeHistory: z.boolean().default(true),
maxHistoryItems: z.number().min(1).max(100).default(10),
}),
)
.subscription(({ ctx, input }) => {
return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
return innerHandler.subscribe((data) => {
emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
});
});
return () => {
unsubscribes.forEach((fn) => fn());
};
});
}),
getSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
includeDocker: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) => {
const integration = ctx.integrations[0];
if (!integration) return null;
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
});
+1
View File
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
@@ -10,6 +10,9 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind === "beszel") {
return null as never;
}
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
+8 -3
View File
@@ -41,8 +41,7 @@ import {
} from "../schema";
import type { Integration } from "../schema";
const isTruthyEnv = (value: string | undefined) =>
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
@@ -410,7 +409,13 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: notebook + dockerContainers + weather = 12
// Row 9: beszelSystemGrid + beszelAlerts = 12
{ kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
{ kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
// Row 10: beszelSystemTable + beszelSystemStats = 12
{ kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
{ kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
// Row 11: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
+8
View File
@@ -433,6 +433,14 @@ export const integrationDefs = {
category: ["ups"],
documentationUrl: null,
},
beszel: {
name: "Beszel",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
category: ["healthMonitoring"],
documentationUrl: null,
defaultPort: 8090,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
+4
View File
@@ -39,5 +39,9 @@ export const widgetKinds = [
"speedtestTracker",
"umami",
"ups",
"beszelSystemTable",
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -1,6 +1,7 @@
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
@@ -125,6 +126,7 @@ export const integrationCreators = {
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -0,0 +1,269 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelAuthResponse,
BeszelContainer,
BeszelContainerStatsRecord,
BeszelSmartDevice,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemdService,
BeszelSystemStatsRecord,
CreateAlertInput,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
interface BeszelSession {
token: string;
userId: string;
}
export class BeszelIntegration extends Integration {
private readonly sessionStore: SessionStore<BeszelSession>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync();
if (existingSession) {
return existingSession;
}
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) {
throw new ResponseError(response);
}
const data = (await response.json()) as BeszelAuthResponse;
const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session);
return session;
}
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
let session = await this.authenticateAsync();
const doFetch = (token: string) =>
fetchWithTrustedCertificatesAsync(url, {
method: options.method ?? "GET",
headers: {
Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}),
},
body: options.body,
});
let response = await doFetch(session.token);
if (response.status === 401) {
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await doFetch(session.token);
}
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
public async getSystemsAsync(): Promise<BeszelSystem[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/systems/records", { perPage: "500", sort: "-updated" }),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystem>;
return data.items;
}
public async getSystemDetailsAsync(systemId: string): Promise<BeszelSystemDetails> {
const response = await this.fetchWithAuthAsync(
this.url(`/api/collections/system_details/records/${systemId}` as `/${string}`),
);
return (await response.json()) as BeszelSystemDetails;
}
public async getSystemStatsAsync(systemId: string, type = "1m", perPage = 60): Promise<BeszelSystemStatsRecord[]> {
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/system_stats/records", {
filter,
sort: "-created",
perPage: String(perPage),
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemStatsRecord>;
return data.items;
}
public async getContainersAsync(systemId: string): Promise<BeszelContainer[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/containers/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
sort: "-updated",
perPage: "500",
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelContainer>;
return data.items;
}
public async getContainerStatsAsync(
systemId: string,
type = "1m",
perPage = 60,
): Promise<BeszelContainerStatsRecord[]> {
const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/container_stats/records", {
filter,
sort: "-created",
perPage: String(perPage),
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelContainerStatsRecord>;
return data.items;
}
public async getSmartDevicesAsync(systemId: string): Promise<BeszelSmartDevice[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/smart_devices/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSmartDevice>;
return data.items;
}
public async getSystemdServicesAsync(systemId: string): Promise<BeszelSystemdService[]> {
const response = await this.fetchWithAuthAsync(
this.url("/api/collections/systemd_services/records", {
filter: `system='${escapeFilterValue(systemId)}'`,
sort: "name",
}),
);
const data = (await response.json()) as PocketBaseListResponse<BeszelSystemdService>;
return data.items;
}
public async getAlertsAsync(systemId?: string): Promise<BeszelAlert[]> {
const params: Record<string, string> = { perPage: "500" };
if (systemId) {
params.filter = `system='${escapeFilterValue(systemId)}'`;
}
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records", params));
const data = (await response.json()) as PocketBaseListResponse<BeszelAlert>;
return data.items;
}
public async getAlertHistoryAsync(systemId?: string, perPage = 50): Promise<BeszelAlertHistory[]> {
const params: Record<string, string> = {
sort: "-created",
perPage: String(perPage),
};
if (systemId) {
params.filter = `system='${escapeFilterValue(systemId)}'`;
}
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts_history/records", params));
const data = (await response.json()) as PocketBaseListResponse<BeszelAlertHistory>;
return data.items;
}
public async createAlertAsync(systemId: string, input: CreateAlertInput): Promise<BeszelAlert> {
const session = await this.authenticateAsync();
const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records"), {
method: "POST",
body: JSON.stringify({
system: systemId,
user: session.userId,
name: input.name,
value: input.value,
min: input.min ?? 0,
triggered: false,
}),
});
return (await response.json()) as BeszelAlert;
}
public async updateAlertAsync(alertId: string, input: UpdateAlertInput): Promise<BeszelAlert> {
const response = await this.fetchWithAuthAsync(
this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`),
{
method: "PATCH",
body: JSON.stringify(input),
},
);
return (await response.json()) as BeszelAlert;
}
public async deleteAlertAsync(alertId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`), {
method: "DELETE",
});
}
public async pauseSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
method: "PATCH",
body: JSON.stringify({ status: "paused" }),
});
}
public async resumeSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
method: "PATCH",
body: JSON.stringify({ status: "pending" }),
});
}
public async deleteSystemAsync(systemId: string): Promise<void> {
await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
method: "DELETE",
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) {
return TestConnectionError.StatusResult({ status: response.status, url: response.url });
}
const data = (await response.json()) as BeszelAuthResponse;
if (!data.token) {
return TestConnectionError.UnauthorizedResult(401);
}
return { success: true };
}
}
@@ -0,0 +1,389 @@
export interface BeszelAuthResponse {
token: string;
record: {
id: string;
email: string;
username: string;
verified: boolean;
};
}
export type BeszelSystemStatus = "up" | "down" | "paused" | "pending";
/**
* Real-time system info snapshot from Beszel agent.
* Returned as `system.info` on each BeszelSystem record.
* All resource values are percentages or already-scaled summaries.
*/
export interface BeszelSystemInfo {
/** hostname */
h?: string;
/** CPU usage (%) */
cpu: number;
/** temperature (°C), overall */
t?: number;
/** CPU core count */
c?: number;
/** CPU thread count */
ct?: number;
/** CPU model string */
m?: string;
/** load average [1m, 5m, 15m] */
la?: [number, number, number];
/** OS identifier string */
o?: string;
/** uptime (seconds) */
u: number;
/** memory usage (%) */
mp: number;
/** disk usage (%) */
dp: number;
/** battery [level%, charging state] */
bat?: [number, number];
/** bandwidth — public interface (Mbps, legacy). Use bb when available */
b?: number;
/** bandwidth — public interface (bytes/s, newer format). Preferred over b */
bb?: number;
/** agent version */
v: string;
/** podman mode */
p?: boolean;
/** GPU usage (%) */
g?: number;
/** disk temperature (°C) */
dt?: number;
/** OS type identifier */
os?: number;
/** extra filesystem usage (%) keyed by mount path */
efs?: Record<string, number>;
/** systemd services [running, total] */
sv?: [number, number];
}
export interface BeszelSystem {
id: string;
name: string;
host: string;
port: string;
status: BeszelSystemStatus;
info: BeszelSystemInfo;
users?: string[];
created: string;
updated: string;
}
export interface BeszelSystemDetails {
id: string;
system: string;
hostname: string;
kernel: string;
cores: number;
threads: number;
cpu: string;
os: number;
os_name: string;
arch: string;
memory: number;
podman: boolean;
updated: string;
}
/**
* Historical system stats record from Beszel PocketBase.
* Stored per-interval (1m, 10m, 20m, 120m, 480m depending on time range).
*
* Units:
* - CPU/GPU/memory/disk percentages: 0–100
* - Memory/disk absolute values (mu, du, etc.): bytes
* - Disk I/O (dr, dw): bytes/s
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
*/
export interface BeszelSystemStats {
/** CPU usage (%) */
cpu: number;
/** CPU usage max (%) */
cpum?: number;
/** per-core busy (%) */
cpub?: number[];
/** per-core speed (MHz) */
cpus?: number[];
/** load average [1m, 5m, 15m] */
la?: [number, number, number];
/** total memory (bytes) */
m: number;
/** memory used (bytes) */
mu: number;
/** memory usage (%) */
mp: number;
/** memory buffer/cache (bytes) */
mb: number;
/** memory max used (bytes) */
mm?: number;
/** memory zfs arc (bytes) */
mz?: number;
/** total swap (bytes) */
s: number;
/** swap used (bytes) */
su: number;
/** total disk (bytes) */
d: number;
/** disk used (bytes) */
du: number;
/** disk usage (%) */
dp: number;
/** disk read (bytes/s) */
dr: number;
/** disk write (bytes/s) */
dw: number;
/** disk read max (bytes/s) */
drm?: number;
/** disk write max (bytes/s) */
dwm?: number;
/** disk IOPS [read, write] */
dio?: [number, number];
/** disk IOPS max [read, write] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
ns: number;
/** network received — all interfaces (bytes/s) */
nr: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
nsm?: number;
/** network received max (bytes/s) */
nrm?: number;
/** bandwidth max [sent, recv] (bytes/s) */
bm?: [number, number];
/** temperatures keyed by sensor name (°C) */
t?: Record<string, number>;
/** extra filesystem stats keyed by mount path */
efs?: Record<string, BeszelExtraFsStats>;
/** GPU stats keyed by device id */
g?: Record<string, BeszelGPUData>;
/** battery [level%, charging state] */
bat?: [number, number];
/** network interfaces [rx bytes/s, tx bytes/s, rx max, tx max] */
ni?: Record<string, [number, number, number, number]>;
}
export interface BeszelExtraFsStats {
/** total disk (bytes) */
d: number;
/** disk used (bytes) */
du: number;
/** read (bytes/s) */
r: number;
/** write (bytes/s) */
w: number;
/** read max (bytes/s) */
rm: number;
/** write max (bytes/s) */
wm: number;
/** read (IOPS) */
rb: number;
/** write (IOPS) */
wb: number;
/** read max (IOPS) */
rbm: number;
/** write max (IOPS) */
wbm: number;
}
export interface BeszelGPUData {
/** GPU name/model */
n: string;
/** memory used (bytes) */
mu?: number;
/** memory total (bytes) */
mt?: number;
/** GPU utilization (%) */
u: number;
/** power draw (W) */
p?: number;
/** power peak (W) */
pp?: number;
/** encoder utilization keyed by engine name (%) */
e?: Record<string, number>;
}
export interface BeszelSystemStatsRecord {
id: string;
system: string;
stats: BeszelSystemStats;
type: string;
created: string;
updated: string;
}
export interface BeszelContainer {
id: string;
system: string;
name: string;
image: string;
status: string;
health: number;
cpu: number;
memory: number;
net: number;
ports?: string;
updated: number;
}
/**
* Per-container resource stats from Beszel agent.
* One entry per container in a BeszelContainerStatsRecord.
*
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
*/
export interface BeszelContainerStats {
/** container name */
n: string;
/** CPU usage (%) */
c: number;
/** memory usage (bytes) */
m: number;
/** network sent (bytes/s) — legacy, use b when available */
ns?: number;
/** network received (bytes/s) — legacy, use b when available */
nr?: number;
/** network bandwidth [sent, recv] (bytes/s) — preferred over ns/nr */
b?: [number, number];
}
export interface BeszelContainerStatsRecord {
id: string;
system: string;
stats: BeszelContainerStats[];
type: string;
created: string;
updated: string;
}
export interface BeszelSmartDevice {
id: string;
system: string;
name: string;
model: string;
state: string;
capacity: number;
temp: number;
firmware: string;
serial: string;
type: string;
hours: number;
cycles: number;
attributes: BeszelSmartAttribute[];
updated: string;
}
export interface BeszelSmartAttribute {
/** attribute ID */
id?: number;
/** attribute name */
n: string;
/** current value */
v: number;
/** worst value */
w?: number;
/** threshold */
t?: number;
/** raw value (numeric) */
rv?: number;
/** raw value (string) */
rs?: string;
/** when failed */
wf?: string;
}
export interface BeszelSystemdService {
id: string;
system: string;
name: string;
state: number;
sub: number;
cpu: number;
cpuPeak: number;
memory: number;
memPeak: number;
updated: number;
}
export interface BeszelAlert {
id: string;
user: string;
system: string;
name: string;
triggered: boolean;
value: number;
min: number;
}
export interface BeszelAlertHistory {
id: string;
alert: string;
user: string;
system: string;
name: string;
val: number;
created: string;
resolved?: string | null;
}
export interface CreateAlertInput {
name: string;
value: number;
min?: number;
}
export interface UpdateAlertInput {
value?: number;
min?: number;
}
export interface PocketBaseListResponse<T> {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: T[];
}
/**
* Normalized view model for a Beszel-monitored system.
* Constructed by mapToSystemRow() in request-handler from BeszelSystem + BeszelSystemDetails.
* This is the shape consumed by grid/table widgets — all values are pre-scaled for display.
*/
export interface BeszelSystemRow {
id: string;
name: string;
status: BeszelSystemStatus;
/** CPU usage (%) */
cpu: number;
/** memory usage (%) */
memory: number;
/** disk usage (%) */
disk: number;
/** GPU usage (%) */
gpu: number;
/** load average [1m, 5m, 15m] */
loadAvg: [number, number, number] | null;
/** public interface bandwidth (bytes/s). From info.bb or info.b * 1M */
netBytes: number;
/** temperature (°C) */
temp: number | null;
/** battery [level%, charging state] */
battery: [number, number] | null;
/** running systemd services count */
services: number;
/** uptime (seconds) */
uptime: number;
agentVersion: string;
hostname: string;
cpuModel: string;
cores: number;
/** total memory (bytes) */
memoryTotal: number;
osName: string;
}
@@ -0,0 +1,226 @@
// @vitest-environment node
import { beforeAll, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
process.env.ENABLE_DNS_CACHING = "false";
});
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
vi.mock("@homarr/redis", () => ({
createGetSetChannel: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
removeAsync: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
ErrorWithMetadata: class extends Error {},
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
}));
import { BeszelIntegration } from "../beszel-integration";
function createBeszelIntegration() {
return new BeszelIntegration({
id: "test-beszel",
name: "Test Beszel",
url: BESZEL_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: BESZEL_EMAIL },
{ kind: "password", value: BESZEL_PASSWORD },
],
});
}
const shouldRun = BESZEL_EMAIL && BESZEL_PASSWORD;
describe.skipIf(!shouldRun)("BeszelIntegration (real instance)", () => {
let integration: BeszelIntegration;
let systemId: string;
beforeAll(() => {
integration = createBeszelIntegration();
});
describe("getSystemsAsync", () => {
test("returns an array of systems", async () => {
const systems = await integration.getSystemsAsync();
expect(systems).toBeInstanceOf(Array);
expect(systems.length).toBeGreaterThan(0);
});
test("each system has required fields", async () => {
const systems = await integration.getSystemsAsync();
const system = systems[0];
if (!system) {
throw new Error("Expected at least one system");
}
systemId = system.id;
expect(system.id).toBeDefined();
expect(system.name).toBeDefined();
expect(system.status).toMatch(/^(up|down|paused|pending)$/);
expect(system.info).toBeDefined();
expect(typeof system.info.cpu).toBe("number");
expect(typeof system.info.mp).toBe("number");
expect(typeof system.info.dp).toBe("number");
});
});
describe("getSystemDetailsAsync", () => {
test("returns system details with hardware info", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const details = await integration.getSystemDetailsAsync(systemId);
expect(details.hostname).toBeDefined();
expect(details.cpu).toBeDefined();
expect(typeof details.cores).toBe("number");
expect(details.cores).toBeGreaterThan(0);
expect(typeof details.memory).toBe("number");
expect(details.memory).toBeGreaterThan(0);
expect(details.os_name).toBeDefined();
});
});
describe("getSystemStatsAsync", () => {
test("returns time-series stats records", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const stats = await integration.getSystemStatsAsync(systemId, "1m", 5);
expect(stats).toBeInstanceOf(Array);
if (stats.length === 0) return;
const record = stats[0];
expect(record?.stats).toBeDefined();
expect(typeof record?.stats.cpu).toBe("number");
expect(typeof record?.stats.mp).toBe("number");
expect(typeof record?.stats.dp).toBe("number");
expect(record?.created).toBeDefined();
});
});
describe("getContainersAsync", () => {
test("returns container list", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const containers = await integration.getContainersAsync(systemId);
expect(containers).toBeInstanceOf(Array);
if (containers.length > 0) {
const container = containers[0];
expect(container?.name).toBeDefined();
expect(container?.image).toBeDefined();
expect(container?.status).toBeDefined();
expect(typeof container?.cpu).toBe("number");
expect(typeof container?.memory).toBe("number");
}
});
});
describe("alert CRUD", () => {
let alertId: string;
test("creates an alert", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
const alert = await integration.createAlertAsync(systemId, {
name: "CPU",
value: 90,
min: 1,
});
expect(alert.id).toBeDefined();
expect(alert.name).toBe("CPU");
expect(alert.value).toBe(90);
expect(alert.system).toBe(systemId);
alertId = alert.id;
});
test("lists alerts including the created one", async () => {
const alerts = await integration.getAlertsAsync(systemId);
const found = alerts.find((a) => a.id === alertId);
expect(found).toBeDefined();
expect(found?.name).toBe("CPU");
});
test("updates alert threshold", async () => {
const updated = await integration.updateAlertAsync(alertId, { value: 95 });
expect(updated.value).toBe(95);
});
test("deletes alert", async () => {
await integration.deleteAlertAsync(alertId);
const alerts = await integration.getAlertsAsync(systemId);
const found = alerts.find((a) => a.id === alertId);
expect(found).toBeUndefined();
});
});
describe("system actions", () => {
test("pauses and resumes a system", async () => {
const systems = await integration.getSystemsAsync();
systemId = systems[0]?.id ?? "";
await integration.pauseSystemAsync(systemId);
const afterPause = await integration.getSystemsAsync();
const paused = afterPause.find((s) => s.id === systemId);
expect(paused?.status).toBe("paused");
await integration.resumeSystemAsync(systemId);
const afterResume = await integration.getSystemsAsync();
const resumed = afterResume.find((s) => s.id === systemId);
expect(resumed?.status).toMatch(/^(up|pending)$/);
});
});
describe("authentication", () => {
test("authenticates with valid credentials", async () => {
const systems = await integration.getSystemsAsync();
expect(systems.length).toBeGreaterThan(0);
});
test("fails with invalid credentials", async () => {
const badIntegration = new BeszelIntegration({
id: "test-bad",
name: "Bad Beszel",
url: BESZEL_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "wrong@example.com" },
{ kind: "password", value: "wrongpassword" },
],
});
await expect(badIntegration.getSystemsAsync()).rejects.toThrow();
});
});
});
@@ -0,0 +1,343 @@
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelContainerStats,
BeszelContainerStatsRecord,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemStats,
BeszelSystemStatsRecord,
} from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
const KB = 1024;
interface MockSystemDef {
name: string;
host: string;
os: string;
cpu: string;
cores: number;
mem: number;
arch: string;
baseCpu: number;
baseMem: number;
baseDisk: number;
containers: number[];
}
const mockSystems: MockSystemDef[] = [
{
name: "web-server",
host: "192.168.1.10",
os: "Ubuntu 24.04",
cpu: "AMD Ryzen 9 7950X",
cores: 16,
mem: 64 * GB,
arch: "amd64",
baseCpu: 35,
baseMem: 55,
baseDisk: 42,
containers: [0, 1, 2, 3, 5],
},
{
name: "nas-01",
host: "192.168.1.20",
os: "Debian 12",
cpu: "Intel Xeon E-2388G",
cores: 8,
mem: 32 * GB,
arch: "amd64",
baseCpu: 12,
baseMem: 70,
baseDisk: 68,
containers: [1, 2, 6],
},
{
name: "pi-cluster-1",
host: "192.168.1.30",
os: "Raspberry Pi OS",
cpu: "ARM Cortex-A76",
cores: 4,
mem: 8 * GB,
arch: "aarch64",
baseCpu: 45,
baseMem: 82,
baseDisk: 35,
containers: [0, 2, 7],
},
{
name: "media-server",
host: "192.168.1.40",
os: "Proxmox VE 8.2",
cpu: "Intel i7-13700K",
cores: 16,
mem: 128 * GB,
arch: "amd64",
baseCpu: 22,
baseMem: 48,
baseDisk: 55,
containers: [0, 1, 2, 3, 4, 5, 6, 7],
},
{
name: "backup-node",
host: "192.168.1.50",
os: "TrueNAS SCALE",
cpu: "Intel Xeon E5-2680",
cores: 12,
mem: 48 * GB,
arch: "amd64",
baseCpu: 8,
baseMem: 30,
baseDisk: 78,
containers: [1, 6],
},
];
const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [
{ name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 },
{ name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 },
{ name: "redis", cpuBase: 3, memMB: 180, netKB: 500 },
{ name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 },
{ name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 },
{ name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 },
{ name: "minio", cpuBase: 3, memMB: 200, netKB: 600 },
{ name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 },
];
const rand = (min: number, max: number) => min + Math.random() * (max - min);
const randInt = (min: number, max: number) => Math.floor(rand(min, max));
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
function smoothWalk(base: number, amplitude: number, steps: number, lo: number, hi: number): number[] {
const values: number[] = [];
let current = base + rand(-amplitude * 0.3, amplitude * 0.3);
for (let i = 0; i < steps; i++) {
const spike = Math.random() < 0.08 ? rand(-amplitude * 2, amplitude * 3) : 0;
current += rand(-amplitude, amplitude) + spike;
current = clamp(current, lo, hi);
values.push(current);
}
return values;
}
function resolveSystem(systemId: string): MockSystemDef {
const idx = Number(systemId.replace("mock-sys-", "")) - 1;
return mockSystems[idx] ?? mockSystems[0] ?? (mockSystems.find(Boolean) as MockSystemDef);
}
const typeToMinutes: Record<string, number> = {
"1m": 1,
"10m": 10,
"20m": 20,
"120m": 120,
"480m": 480,
};
function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSystemStats[] {
const cpuWalk = smoothWalk(sys.baseCpu, 3, count, 1, 98);
const memPctWalk = smoothWalk(sys.baseMem, 1.5, count, 10, 95);
const diskPctWalk = smoothWalk(sys.baseDisk, 0.2, count, 5, 95);
const diskReadWalk = smoothWalk(5, 3, count, 0, 80);
const diskWriteWalk = smoothWalk(3, 2, count, 0, 50);
const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
const memTotal = sys.mem;
const diskTotal = rand(200, 2000) * GB;
return cpuWalk.map((cpu, i) => {
const memPct = memPctWalk[i] ?? sys.baseMem;
const diskPct = diskPctWalk[i] ?? sys.baseDisk;
return {
cpu,
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
m: memTotal,
mu: (memTotal * memPct) / 100,
mp: memPct,
mb: rand(0.5, 4) * GB,
s: 4 * GB,
su: rand(0, 2) * GB,
d: diskTotal,
du: (diskTotal * diskPct) / 100,
dp: diskPct,
dr: (diskReadWalk[i] ?? 5) * MB,
dw: (diskWriteWalk[i] ?? 3) * MB,
ns: (netSendWalk[i] ?? 500) * KB,
nr: (netRecvWalk[i] ?? 800) * KB,
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
};
});
}
function generateContainerStatsSeries(count: number, profiles: typeof containerProfiles): BeszelContainerStats[][] {
const walks = profiles.map((p) => ({
profile: p,
cpuWalk: smoothWalk(p.cpuBase, 1.5, count, 0, 50),
memWalk: smoothWalk(p.memMB, p.memMB * 0.1, count, p.memMB * 0.5, p.memMB * 2),
netWalk: smoothWalk(p.netKB, p.netKB * 0.3, count, 0, p.netKB * 4),
}));
return Array.from({ length: count }, (_, i) =>
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
n: profile.name,
c: cpuWalk[i] ?? profile.cpuBase,
m: (memWalk[i] ?? profile.memMB) * MB,
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
})),
);
}
function buildTimeSeries<T>(
systemId: string,
count: number,
intervalMinutes: number,
type: string,
dataPoints: T[],
): (T & { id: string; system: string; type: string; created: string; updated: string })[] {
const now = Date.now();
const items = dataPoints.slice(0, count).map((data, i) => {
const ts = new Date(now - (count - i) * intervalMinutes * 60_000).toISOString();
return { id: `mock-stat-${i}`, system: systemId, type, created: ts, updated: ts, ...data };
});
return items.toReversed();
}
const statusByIndex: Record<number, "up" | "paused" | "down"> = { 2: "paused", 4: "down" };
export class BeszelMockService {
public async getSystemsAsync(): Promise<BeszelSystem[]> {
return mockSystems.map((sys, i) => ({
id: `mock-sys-${i + 1}`,
name: sys.name,
host: sys.host,
port: "45876",
status: statusByIndex[i] ?? "up",
info: {
h: sys.name,
cpu: clamp(sys.baseCpu + rand(-5, 10), 0, 100),
c: sys.cores,
ct: sys.cores * 2,
m: sys.cpu,
la: [sys.baseCpu / 20, sys.baseCpu / 25, sys.baseCpu / 30] as [number, number, number],
o: sys.os,
u: randInt(86400, 2_000_000),
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
bb: rand(200, 3000) * KB,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
dt: rand(32, 58),
sv: [randInt(8, 25), randInt(25, 40)] as [number, number],
},
users: [],
created: new Date().toISOString(),
updated: new Date().toISOString(),
}));
}
public async getSystemDetailsAsync(systemId: string): Promise<BeszelSystemDetails> {
const sys = resolveSystem(systemId);
return {
id: systemId,
system: systemId,
hostname: sys.name,
kernel: "6.8.0-40-generic",
cores: sys.cores,
threads: sys.cores * 2,
cpu: sys.cpu,
os: 1,
os_name: sys.os,
arch: sys.arch,
memory: sys.mem,
podman: false,
updated: new Date().toISOString(),
};
}
public async getSystemStatsAsync(
systemId: string,
type: string,
perPage: number,
): Promise<BeszelSystemStatsRecord[]> {
const sys = resolveSystem(systemId);
const intervalMinutes = typeToMinutes[type] ?? 1;
const statsSeries = generateSystemStatsSeries(perPage, sys);
return buildTimeSeries(
systemId,
perPage,
intervalMinutes,
type,
statsSeries.map((stats) => ({ stats })),
);
}
public async getContainerStatsAsync(
systemId: string,
type: string,
perPage: number,
): Promise<BeszelContainerStatsRecord[]> {
const sys = resolveSystem(systemId);
const intervalMinutes = typeToMinutes[type] ?? 1;
const selectedProfiles = sys.containers
.map((i) => containerProfiles[i])
.filter(Boolean) as typeof containerProfiles;
const series = generateContainerStatsSeries(perPage, selectedProfiles);
return buildTimeSeries(
systemId,
perPage,
intervalMinutes,
type,
series.map((stats) => ({ stats })),
);
}
public async getAlertsAsync(): Promise<BeszelAlert[]> {
return [
{ id: "mock-alert-1", user: "mock-user", system: "mock-sys-1", name: "CPU", triggered: true, value: 90, min: 5 },
{
id: "mock-alert-2",
user: "mock-user",
system: "mock-sys-2",
name: "Memory",
triggered: false,
value: 80,
min: 10,
},
{
id: "mock-alert-3",
user: "mock-user",
system: "mock-sys-1",
name: "Disk",
triggered: false,
value: 85,
min: 15,
},
{
id: "mock-alert-4",
user: "mock-user",
system: "mock-sys-4",
name: "Temperature",
triggered: true,
value: 75,
min: 3,
},
];
}
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise<BeszelAlertHistory[]> {
const now = Date.now();
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
return Array.from({ length: Math.min(perPage, 5) }, (_, i) => ({
id: `mock-history-${i}`,
alert: `mock-alert-${(i % 4) + 1}`,
user: "mock-user",
system: `mock-sys-${(i % 5) + 1}`,
name: names[i % names.length] ?? "CPU",
val: rand(60, 95),
created: new Date(now - (i + 1) * 3_600_000).toISOString(),
resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null,
}));
}
}
@@ -29,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
import { NotificationsMockService } from "./data/notifications";
import { SmartHomeMockService } from "./data/smart-home";
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
import { BeszelMockService } from "./data/beszel";
import { UpsSummaryMockService } from "./data/ups-summary";
export class MockIntegration
@@ -61,6 +62,7 @@ export class MockIntegration
private static readonly networkController = new NetworkControllerSummaryMockService();
private static readonly notifications = new NotificationsMockService();
private static readonly smartHome = new SmartHomeMockService();
private static readonly beszel = new BeszelMockService();
private static readonly upsSummary = new UpsSummaryMockService();
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
@@ -130,4 +132,12 @@ export class MockIntegration
// UpsSummaryIntegration
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
// BeszelIntegration
getSystemsAsync = MockIntegration.beszel.getSystemsAsync.bind(MockIntegration.beszel);
getSystemDetailsAsync = MockIntegration.beszel.getSystemDetailsAsync.bind(MockIntegration.beszel);
getSystemStatsAsync = MockIntegration.beszel.getSystemStatsAsync.bind(MockIntegration.beszel);
getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
}
+1
View File
@@ -18,3 +18,4 @@ export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./umami/umami-types";
export * from "./beszel/beszel-types";
+122
View File
@@ -0,0 +1,122 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
BeszelAlert,
BeszelAlertHistory,
BeszelContainerStatsRecord,
BeszelSystem,
BeszelSystemDetails,
BeszelSystemRow,
BeszelSystemStatsRecord,
} from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export type { BeszelSystemRow } from "@homarr/integrations/types";
/**
* Maps raw Beszel API responses (BeszelSystem + BeszelSystemDetails) into a
* normalized BeszelSystemRow for widget consumption.
*
* BeszelSystemInfo uses short field names (see beszel-types.ts for full docs):
* cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version,
* bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery,
* sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads
*/
function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
const info = system.info;
return {
id: system.id,
name: system.name,
status: system.status,
cpu: info.cpu,
memory: info.mp,
disk: info.dp,
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
netBytes: info.bb ?? (info.b ?? 0) * 1_000_000,
temp: info.dt ?? null,
battery: info.bat ?? null,
services: info.sv?.[0] ?? 0,
uptime: info.u,
agentVersion: info.v,
hostname: details?.hostname ?? info.h ?? system.name,
cpuModel: details?.cpu ?? info.m ?? "Unknown",
cores: details?.cores ?? info.ct ?? info.c ?? 1,
memoryTotal: details?.memory ?? 0,
osName: details?.os_name ?? "",
};
}
export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
BeszelSystemRow[],
"beszel" | "mock",
Record<string, never>
>({
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
systems.map(async (system) => {
const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
return mapToSystemRow(system, details);
}),
);
return enriched;
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "beszelSystems",
});
export interface BeszelAlertsData {
alerts: BeszelAlert[];
history: BeszelAlertHistory[];
}
export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
BeszelAlertsData,
"beszel" | "mock",
{ includeHistory: boolean; maxHistoryItems: number }
>({
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
const alerts = await instance.getAlertsAsync();
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
return { alerts, history };
},
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "120m", perPage: 84 },
"30d": { type: "480m", perPage: 90 },
};
export interface BeszelStatsData {
systemStats: BeszelSystemStatsRecord[];
containerStats: BeszelContainerStatsRecord[];
}
export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
BeszelStatsData,
"beszel" | "mock",
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
: [];
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "beszelStats",
});
@@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
Record<string, never>
>({
async requestAsync(integration, _input) {
+338 -2
View File
@@ -7,7 +7,10 @@
"description": "To get started, please select how you want to set up your Homarr instance.",
"action": {
"scratch": "Start from scratch",
"importOldmarr": "Import from Homarr before 1.0"
"importOldmarr": "Import from Homarr before 1.0",
"restoreSqlite": "Restore from SQLite backup",
"restoreSqliteDescription": "Upload a full SQLite database backup from a previous Homarr instance. This will restore all boards, users, apps, integrations, and settings.",
"backToOptions": "← Back to setup options"
}
},
"import": {
@@ -3101,6 +3104,219 @@
"status": {
"unhealthy": "Unhealthy"
}
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
"option": {
"statusFilter": {
"label": "Status filter"
},
"sortBy": {
"label": "Sort by"
},
"sortDirection": {
"label": "Sort direction"
},
"showCpu": {
"label": "Show CPU"
},
"showMemory": {
"label": "Show memory"
},
"showDisk": {
"label": "Show disk"
},
"showGpu": {
"label": "Show GPU"
},
"showLoadAvg": {
"label": "Show load average"
},
"showNet": {
"label": "Show network"
},
"showTemp": {
"label": "Show temperature"
},
"showBattery": {
"label": "Show battery"
},
"showServices": {
"label": "Show services"
},
"showUptime": {
"label": "Show uptime"
},
"showAgent": {
"label": "Show agent version"
}
},
"noRecords": "No systems found",
"column": {
"system": "System",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"gpu": "GPU",
"loadAvg": "Load Avg",
"net": "Net",
"temp": "Temp",
"battery": "Bat",
"services": "Services",
"uptime": "Uptime",
"agent": "Agent"
}
},
"beszelSystemGrid": {
"name": "Beszel Systems (Grid)",
"description": "Card grid view of all Beszel-monitored systems with real-time metrics",
"option": {
"statusFilter": {
"label": "Status filter"
},
"showCpu": {
"label": "Show CPU"
},
"showMemory": {
"label": "Show memory"
},
"showDisk": {
"label": "Show disk"
},
"showGpu": {
"label": "Show GPU"
},
"showLoadAvg": {
"label": "Show load average"
},
"showNet": {
"label": "Show network"
},
"showTemp": {
"label": "Show temperature"
},
"showBattery": {
"label": "Show battery"
},
"showServices": {
"label": "Show services"
},
"showUptime": {
"label": "Show uptime"
},
"showAgent": {
"label": "Show agent version"
}
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"gpu": "GPU",
"loadAvg": "Load Avg",
"net": "Net",
"temp": "Temp",
"battery": "Bat",
"services": "Services",
"uptime": "Uptime",
"agent": "Agent"
}
},
"beszelAlerts": {
"name": "Beszel Alerts",
"description": "View Beszel alert configurations and history",
"option": {
"showHistory": {
"label": "Show alert history"
},
"maxHistoryItems": {
"label": "Max history items"
}
},
"empty": "No alerts configured",
"history": "History",
"resolved": "Resolved",
"status": {
"triggered": "Triggered",
"ok": "OK"
}
},
"beszelSystemStats": {
"name": "Beszel System Stats",
"description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics",
"option": {
"timePeriod": {
"label": "Time period"
},
"showCpu": {
"label": "Show CPU chart"
},
"showMemory": {
"label": "Show memory chart"
},
"showDisk": {
"label": "Show disk chart"
},
"showDiskIO": {
"label": "Show disk I/O chart"
},
"showNetwork": {
"label": "Show network chart"
},
"showDockerCpu": {
"label": "Show Docker CPU chart"
},
"showDockerMemory": {
"label": "Show Docker memory chart"
},
"showDockerNetwork": {
"label": "Show Docker network chart"
}
},
"selectSystem": "Select system",
"chart": {
"cpu": {
"title": "CPU Usage",
"subtitle": "Average system-wide CPU utilization",
"series": "CPU"
},
"memory": {
"title": "Memory Usage",
"subtitle": "Precise utilization at the recorded time",
"series": "Used",
"cache": "Cache/Buffers"
},
"disk": {
"title": "Disk Usage",
"subtitle": "Usage of root partition",
"series": "Used"
},
"diskIO": {
"title": "Disk I/O",
"subtitle": "Throughput of root filesystem",
"read": "Read",
"write": "Write"
},
"network": {
"title": "Bandwidth",
"subtitle": "Network traffic of public interfaces",
"sent": "Sent",
"recv": "Received"
},
"dockerCpu": {
"title": "Docker CPU Usage",
"subtitle": "Average CPU utilization of containers"
},
"dockerMemory": {
"title": "Docker Memory Usage",
"subtitle": "Memory usage of docker containers"
},
"dockerNetwork": {
"title": "Docker Network I/O",
"subtitle": "Network traffic of docker containers"
}
}
}
},
"widgetPreview": {
@@ -3498,7 +3714,8 @@
"logs": "Logs",
"api": "API",
"certificates": "Certificates",
"tasks": "Tasks"
"tasks": "Tasks",
"backup": "Backup"
}
},
"settings": "Settings",
@@ -4038,6 +4255,125 @@
}
}
}
},
"backup": {
"title": "Full Database Backup",
"metaTitle": "Backup & Restore",
"export": {
"title": "Export Database",
"description": "Download a full backup of your SQLite database including all settings, users, boards, and encrypted integration secrets. The backup is bundled as a ZIP file with the encryption key and metadata.",
"button": "Download Backup",
"error": "Export failed",
"confirm": {
"title": "Sensitive data export",
"warningTitle": "This export contains your entire database.",
"warningBody": "The backup includes all users, integration secrets (encrypted), API keys, board configurations, and app data. Store the exported file securely and do not share it.",
"typePrompt": "Type \"{phrase}\" to continue",
"submit": "Export Database",
"cancel": "Cancel"
}
},
"import": {
"title": "Restore Database",
"description": "Upload a previously exported backup ZIP to restore your database. This will replace your entire database. If the backup was created with a different encryption key, secrets will be automatically re-encrypted.",
"warning": "Restoring a backup will completely replace your current database and restart the application. This action cannot be undone.",
"dropzone": {
"title": "Drop your backup ZIP here or click to browse",
"description": "Only .zip files from a Homarr SQLite backup are accepted"
},
"changeFile": "Change file",
"button": "Restore Database",
"success": {
"title": "Restore Complete",
"message": "Database has been restored successfully. The application is restarting..."
},
"error": "Import failed"
},
"preview": {
"title": "Backup Contents",
"exported": "Exported {date}",
"loading": "Analyzing backup file...",
"errorTitle": "Preview Error",
"entity": "Entity",
"count": "Count",
"boards": "Boards",
"entities": {
"board": "Boards",
"user": "Users",
"app": "Apps",
"integration": "Integrations",
"item": "Widgets",
"media": "Media",
"search_engine": "Search Engines"
},
"analyzeError": "Failed to analyze backup file",
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
"invalidMissingDb": "Invalid backup: missing db.sqlite"
},
"restore": {
"dropzone": {
"title": "Drop your backup ZIP here",
"description": "Accepts .zip files from a Homarr SQLite backup (max 256 MB)"
},
"analyzing": "Analyzing backup...",
"changeFile": "Change file",
"continueToRestore": "Continue to restore",
"tryAgain": "Try again",
"analysisError": "Analysis Error",
"preview": {
"title": "Backup Contents",
"exported": "Exported {date}",
"entity": "Entity",
"count": "Count",
"boards": "Boards",
"entities": {
"board": "Boards",
"user": "Users",
"app": "Apps",
"integration": "Integrations",
"item": "Widgets",
"media": "Media",
"search_engine": "Search Engines"
},
"migrationsApplied": "{count} migration applied",
"migrationsAppliedPlural": "{count} migrations applied",
"migrationsOlderVersion": "This backup was from an older Homarr version. {count} schema migration was applied to bring it up to date.",
"migrationsOlderVersionPlural": "This backup was from an older Homarr version. {count} schema migrations were applied to bring it up to date.",
"migrationsUpToDate": "Database schema is up to date ({count} migrations applied)",
"upToDate": "Up to date",
"migrationsPending": "{count} pending"
},
"migration": {
"title": "Applying migrations ({current}/{total})",
"migrationLabel": "Migration {index}"
},
"confirm": {
"title": "Destructive action",
"warningTitle": "This will permanently replace your entire database.",
"warningBody": "All current boards, users, apps, integrations, settings, and sessions will be lost. This action cannot be undone. The application will restart after the restore.",
"typePrompt": "Type \"{phrase}\" to continue",
"cancel": "Cancel",
"submit": "Restore Database"
},
"progress": {
"title": "Restoring database...",
"extracting": "Extracting backup archive",
"migrating": "Applying database migrations",
"encrypting": "Re-encrypting integration secrets",
"swapping": "Replacing database file",
"restarting": "Restarting server"
},
"complete": {
"title": "Restore Complete",
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
},
"failed": {
"title": "Restore Failed"
},
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
"invalidMissingDb": "Invalid backup: missing db.sqlite",
"analyzeError": "Failed to analyze backup"
}
}
},
"about": {
+2
View File
@@ -70,6 +70,8 @@
"@tiptap/starter-kit": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -0,0 +1,264 @@
"use client";
import { useMemo } from "react";
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { LucideIcon } from "lucide-react";
import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
Memory: MemoryStick,
Disk: HardDrive,
Bandwidth: Network,
Temperature: Thermometer,
"Load Average": Activity,
LoadAvg1: Activity,
LoadAvg5: Activity,
LoadAvg15: Activity,
Status: Server,
GPU: Monitor,
Battery: Battery,
};
dayjs.extend(relativeTime);
export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const [results] = clientApi.widget.beszel.getAlerts.useSuspenseQuery({
integrationIds,
includeHistory: options.showHistory,
maxHistoryItems: options.maxHistoryItems,
});
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeAlerts.useSubscription(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
{
enabled: !isEditMode,
onData(data) {
utils.widget.beszel.getAlerts.setData(
{ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems },
(prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId
? { ...r, alerts: data.alerts.alerts, history: data.alerts.history, updatedAt: data.timestamp }
: r,
);
},
);
},
},
);
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
for (const r of results) {
if (r.systemNameMap) {
Object.assign(map, r.systemNameMap);
}
}
return map;
}, [results]);
const alerts = useMemo(
() => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))),
[results],
);
const history = useMemo(() => {
const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` })));
return all
.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
.slice(0, options.maxHistoryItems);
}, [results, options.maxHistoryItems]);
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
{alerts.length === 0 && (
<Stack align="center" justify="center" py="xl" gap="xs">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconBellOff size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{t("empty")}
</Text>
</Stack>
)}
{triggeredAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="red">
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
{triggeredAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
{okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
/>
))}
</Stack>
)}
{options.showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}>
<Group gap={6}>
<IconHistory size={14} opacity={0.5} />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("history")}
</Text>
</Group>
{history.map((entry) => {
const systemName = systemNameMap[entry.system] ?? entry.system;
const isResolved = !!entry.resolved;
const HistoryIcon = alertIconMap[entry.name] ?? Server;
return (
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
h={24}
style={{ borderRadius: 2, flexShrink: 0 }}
bg={isResolved ? "green.6" : "red.6"}
/>
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate>
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemName}
</Text>
</Stack>
</Group>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
{isResolved ? t("resolved") : t("status.triggered")}
</Badge>
<Text size="10px" c="dimmed">
{dayjs(entry.created).fromNow()}
</Text>
</Stack>
</Group>
);
})}
</Stack>
</>
)}
</Stack>
</ScrollArea>
);
}
interface AlertRowProps {
name: string;
value: number;
min: number;
systemName: string;
triggered: boolean;
}
const unitSuffixMap: Record<string, string> = {
CPU: "%",
Memory: "%",
Disk: "%",
GPU: "%",
Battery: "%",
Temperature: "°",
Bandwidth: " MB/s",
"Load Average": "",
LoadAvg1: "",
LoadAvg5: "",
LoadAvg15: "",
Status: "",
};
function formatAlertDescription(name: string, value: number, min: number): string {
const suffix = unitSuffixMap[name] ?? "";
if (name === "Status") return `down for ${min} min`;
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
const Icon = alertIconMap[name] ?? Server;
return (
<Group
wrap="nowrap"
gap="xs"
py={4}
pr={8}
pl={12}
style={{
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{formatAlertDescription(name, value, min)}
</Text>
</Stack>
</Group>
);
}
@@ -0,0 +1,17 @@
import { IconBell } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showHistory: factory.switch({ defaultValue: true }),
maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,378 @@
"use client";
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
import {
Activity,
Battery,
BatteryCharging,
Cpu,
HardDrive,
MemoryStick,
Monitor,
Network,
Server,
Thermometer,
Wifi,
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
interface SizeConfig {
iconSize: number;
fontSize: string;
progressSize: "xs" | "sm";
labelMiw: number;
valueMiw: number;
rowHeight: number;
cardPadding: number;
badgeHeight: number;
badgeSize: "xs" | "sm";
gap: number;
}
const defaultSizeConfig: SizeConfig = {
iconSize: 10,
fontSize: "10px",
progressSize: "xs",
labelMiw: 40,
valueMiw: 30,
rowHeight: 18,
cardPadding: 4,
badgeHeight: 20,
badgeSize: "xs",
gap: 2,
};
const sizeThresholds: [number, SizeConfig][] = [
[0, defaultSizeConfig],
[
160,
{
iconSize: 12,
fontSize: "xs",
progressSize: "xs",
labelMiw: 50,
valueMiw: 34,
rowHeight: 22,
cardPadding: 6,
badgeHeight: 22,
badgeSize: "xs",
gap: 6,
},
],
[
280,
{
iconSize: 14,
fontSize: "xs",
progressSize: "sm",
labelMiw: 55,
valueMiw: 38,
rowHeight: 24,
cardPadding: 8,
badgeHeight: 26,
badgeSize: "sm",
gap: 8,
},
],
];
const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => {
const basis = Math.min(cellWidth, cellHeight);
let config = defaultSizeConfig;
for (const [threshold, cfg] of sizeThresholds) {
if (basis >= threshold) config = cfg;
}
return config;
};
const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap;
return Math.max(1, Math.floor(available / size.rowHeight));
};
const MIN_CELL_WIDTH = 120;
const MIN_CELL_HEIGHT = 80;
const getColCount = (width: number, itemCount: number): number => {
if (itemCount <= 1) return 1;
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
let best = 1;
for (let c = 1; c <= maxCols; c++) {
const emptyCells = (c - (itemCount % c)) % c;
const bestEmpty = (best - (itemCount % best)) % best;
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
best = c;
}
}
return best;
};
interface MetricRowProps {
icon: React.ReactNode;
label: string;
value: string;
progress?: { value: number; color: string };
size: SizeConfig;
}
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
{value}
</Text>
{progress && (
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
)}
</Group>
);
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
{icon}
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
{label}
</Text>
<MetricValue value={value} progress={progress} size={size} />
</Group>
);
interface SystemCardProps {
system: BeszelSystemRow & { _key: string };
options: WidgetComponentProps<"beszelSystemGrid">["options"];
t: ReturnType<typeof useScopedI18n<"widget.beszelSystemGrid">>;
size: SizeConfig;
maxMetrics: number;
}
const metricRenderers = [
{
key: "showCpu",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="cpu"
icon={<Cpu size={sz.iconSize} />}
label={t("metric.cpu")}
value={formatPercent(s.cpu)}
progress={{ value: s.cpu, color: thresholdColor(s.cpu) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu,
},
{
key: "showMemory",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="mem"
icon={<MemoryStick size={sz.iconSize} />}
label={t("metric.memory")}
value={formatPercent(s.memory)}
progress={{ value: s.memory, color: thresholdColor(s.memory) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory,
},
{
key: "showDisk",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="disk"
icon={<HardDrive size={sz.iconSize} />}
label={t("metric.disk")}
value={formatPercent(s.disk)}
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
},
{
key: "showGpu",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="gpu"
icon={<Monitor size={sz.iconSize} />}
label={t("metric.gpu")}
value={formatPercent(s.gpu)}
progress={{ value: s.gpu, color: thresholdColor(s.gpu) }}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0,
},
{
key: "showLoadAvg",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="la"
icon={<Activity size={sz.iconSize} />}
label={t("metric.loadAvg")}
value={formatLoadAvg(s.loadAvg)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null,
},
{
key: "showNet",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="net"
icon={<Network size={sz.iconSize} />}
label={t("metric.net")}
value={formatByteRate(s.netBytes)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet,
},
{
key: "showTemp",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="temp"
icon={<Thermometer size={sz.iconSize} />}
label={t("metric.temp")}
value={formatTemp(s.temp, false)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null,
},
{
key: "showBattery",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => {
const batteryIcons = [Battery, BatteryCharging] as const;
const Icon = batteryIcons[Math.min(1, s.battery?.[1] ?? 0)] ?? Battery;
return (
<MetricRow
key="bat"
icon={<Icon size={sz.iconSize} />}
label={t("metric.battery")}
value={`${s.battery?.[0] ?? 0}%`}
size={sz}
/>
);
},
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null,
},
{
key: "showServices",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="svc"
icon={<Server size={sz.iconSize} />}
label={t("metric.services")}
value={String(s.services)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices,
},
{
key: "showUptime",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="up"
icon={<Activity size={sz.iconSize} />}
label={t("metric.uptime")}
value={formatUptime(s.uptime)}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime,
},
{
key: "showAgent",
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
<MetricRow
key="agent"
icon={<Wifi size={sz.iconSize} />}
label={t("metric.agent")}
value={s.agentVersion}
size={sz}
/>
),
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent,
},
] as const;
const SystemCard = ({ system, options, t, size, maxMetrics }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
return (
<Card
padding={size.cardPadding}
radius="sm"
withBorder
h="100%"
style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const }}
>
<Group gap="xs" mb={2}>
<Badge
size={size.badgeSize}
variant="dot"
color={statusColorMap[system.status]}
styles={{ root: { textTransform: "none" } }}
>
{system.name}
</Badge>
</Group>
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
</Card>
);
};
export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
width,
height,
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const cols = getColCount(width, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box
h="100%"
style={{
pointerEvents: isEditMode ? "none" : undefined,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
>
{filteredSystems.map((system) => (
<SystemCard key={system._key} system={system} options={options} t={t} size={size} maxMetrics={maxMetrics} />
))}
</Box>
);
}
@@ -0,0 +1,27 @@
import { IconLayoutGrid } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,248 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import {
BeszelAreaChart,
ChartPanel,
useContainerNames,
useDockerChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
[systemsResult],
);
const [selectedSystem, setSelectedSystem] = useState<string | null>(systems[0]?.value ?? null);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const [statsResult] = clientApi.widget.beszel.getSystemStats.useSuspenseQuery({
integrationIds,
systemId: selectedSystem ?? systems[0]?.value ?? "",
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
});
// BeszelSystemStats fields are documented in beszel-types.ts.
// mu/du = bytes, dr/dw = bytes/s, b = public bandwidth [sent,recv] bytes/s, ns/nr = all-interface bytes/s
const BYTES_TO_GB = 1024 * 1024 * 1024;
const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]);
const memoryMap = useCallback(
(s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu / BYTES_TO_GB,
[t("chart.memory.cache")]: (s.mb ?? 0) / BYTES_TO_GB,
}),
[t],
);
const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du / BYTES_TO_GB }), [t]);
const diskIOMap = useCallback(
(s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: s.dr ?? 0,
[t("chart.diskIO.write")]: s.dw ?? 0,
}),
[t],
);
// b = public interface bandwidth (preferred), ns/nr = all-interface traffic (fallback)
const networkMap = useCallback(
(s: { ns: number; nr: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
[t],
);
const cpuData = useSystemChartData(statsResult?.systemStats, cpuMap);
const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap);
const diskData = useSystemChartData(statsResult?.systemStats, diskMap);
const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap);
const networkData = useSystemChartData(statsResult?.systemStats, networkMap);
const containerNames = useContainerNames(statsResult?.containerStats);
const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu");
const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory");
const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network");
const containerSeries = containerNames.map((name, i) => ({
name,
color: containerColors[i % containerColors.length] as string,
}));
const cols = width > 600 ? 2 : 1;
return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="md" p="sm">
<Select
size="xs"
data={systems}
value={selectedSystem}
onChange={setSelectedSystem}
placeholder={t("selectSystem")}
/>
{statsResult && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={cpuData}
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.percent}
yAxisDomain={[0, "auto"]}
tooltipProps={tooltipPercent}
/>
</ChartPanel>
)}
{options.showMemory && memoryData.length > 0 && (
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={memoryData}
series={[
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
withLegend
/>
</ChartPanel>
)}
{options.showDisk && diskData.length > 0 && (
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskData}
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
</ChartPanel>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskIOData}
series={[
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
)}
{options.showNetwork && networkData.length > 0 && (
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={networkData}
series={[
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
]}
fillOpacity={0.3}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
withLegend
/>
</ChartPanel>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerCpuData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.percent}
tooltipProps={tooltipPercentTotal}
/>
</ChartPanel>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerMemData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.bytes}
tooltipProps={tooltipBytesTotal}
/>
</ChartPanel>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerNetData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
</ChartPanel>
)}
</>
)}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
);
}
@@ -0,0 +1,31 @@
import { IconChartAreaLine } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const timePeriodOptions = [
{ value: "1h", label: "1 Hour" },
{ value: "12h", label: "12 Hours" },
{ value: "24h", label: "24 Hours" },
{ value: "1w", label: "1 Week" },
{ value: "30d", label: "30 Days" },
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showDiskIO: factory.switch({ defaultValue: true }),
showNetwork: factory.switch({ defaultValue: true }),
showDockerCpu: factory.switch({ defaultValue: true }),
showDockerMemory: factory.switch({ defaultValue: true }),
showDockerNetwork: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,260 @@
"use client";
import "./styles.css";
import { useMemo, useState } from "react";
import { Group, Indicator, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
Activity,
Battery,
Cpu,
HardDrive,
MemoryStick,
Monitor,
Network,
Server,
Thermometer,
Wifi,
} from "lucide-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
interface SizeConfig {
iconSize: number;
fontSize: "xs" | "10px";
progressSize: "xs" | "sm";
cellPadding: number;
valueMiw: number;
}
const getSizeConfig = (width: number): SizeConfig => {
if (width < 400) {
return { iconSize: 10, fontSize: "10px", progressSize: "xs", cellPadding: 2, valueMiw: 30 };
}
return { iconSize: 14, fontSize: "xs", progressSize: "sm", cellPadding: 4, valueMiw: 38 };
};
export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const [results] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds });
const size = getSizeConfig(width);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
columnAccessor: options.sortBy,
direction: options.sortDirection as "asc" | "desc",
});
const sortedSystems = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
const dir = directionMultiplier[sortStatus.direction] ?? 1;
return [...filteredSystems].toSorted((a, b) => {
const aVal = a[accessor] ?? 0;
const bVal = b[accessor] ?? 0;
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
return 0;
});
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
</Group>
);
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
accessor: "name",
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.system")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={statusColorMap[record.status]} size={7} />
<Text size={size.fontSize} fw={500} truncate>
{record.name}
</Text>
</Group>
),
},
options.showCpu && {
accessor: "cpu",
title: (
<Group gap={4} wrap="nowrap">
<Cpu size={size.iconSize} />
<Text inherit>{t("column.cpu")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
},
options.showMemory && {
accessor: "memory",
title: (
<Group gap={4} wrap="nowrap">
<MemoryStick size={size.iconSize} />
<Text inherit>{t("column.memory")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
},
options.showDisk && {
accessor: "disk",
title: (
<Group gap={4} wrap="nowrap">
<HardDrive size={size.iconSize} />
<Text inherit>{t("column.disk")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.disk} />,
},
options.showGpu && {
accessor: "gpu",
title: (
<Group gap={4} wrap="nowrap">
<Monitor size={size.iconSize} />
<Text inherit>{t("column.gpu")}</Text>
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
},
options.showLoadAvg && {
accessor: "loadAvg",
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.loadAvg")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Group gap={8} wrap="nowrap">
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
</Group>
),
},
options.showNet && {
accessor: "netBytes",
title: (
<Group gap={4} wrap="nowrap">
<Network size={size.iconSize} />
<Text inherit>{t("column.net")}</Text>
</Group>
),
sortable: true,
render: (record) => (
<Text size={size.fontSize} style={{ whiteSpace: "nowrap" }}>
{formatByteRate(record.netBytes)}
</Text>
),
},
options.showTemp && {
accessor: "temp",
title: (
<Group gap={4} wrap="nowrap">
<Thermometer size={size.iconSize} />
<Text inherit>{t("column.temp")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
},
options.showBattery && {
accessor: "battery",
title: (
<Group gap={4} wrap="nowrap">
<Battery size={size.iconSize} />
<Text inherit>{t("column.battery")}</Text>
</Group>
),
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
},
options.showServices && {
accessor: "services",
title: (
<Group gap={4} wrap="nowrap">
<Server size={size.iconSize} />
<Text inherit>{t("column.services")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
},
options.showUptime && {
accessor: "uptime",
title: (
<Group gap={4} wrap="nowrap">
<Activity size={size.iconSize} />
<Text inherit>{t("column.uptime")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
},
options.showAgent && {
accessor: "agentVersion",
title: (
<Group gap={4} wrap="nowrap">
<Wifi size={size.iconSize} />
<Text inherit>{t("column.agent")}</Text>
</Group>
),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.agentVersion}</Text>,
},
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]);
return (
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
fz={size.fontSize}
records={sortedSystems}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="_key"
height="100%"
className="beszel-table"
/>
);
}
@@ -0,0 +1,48 @@
import { IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
const sortOptions = [
{ value: "name", label: "System" },
{ value: "cpu", label: "CPU" },
{ value: "memory", label: "Memory" },
{ value: "disk", label: "Disk" },
{ value: "gpu", label: "GPU" },
{ value: "loadAvg", label: "Load Avg" },
{ value: "netBytes", label: "Net" },
{ value: "temp", label: "Temp" },
{ value: "services", label: "Services" },
{ value: "uptime", label: "Uptime" },
{ value: "agentVersion", label: "Agent" },
];
const sortDirectionOptions = [
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
statusFilter: factory.select({ defaultValue: "all", options: statusOptions }),
sortBy: factory.select({ defaultValue: "name", options: sortOptions }),
sortDirection: factory.select({ defaultValue: "asc", options: sortDirectionOptions }),
showCpu: factory.switch({ defaultValue: true }),
showMemory: factory.switch({ defaultValue: true }),
showDisk: factory.switch({ defaultValue: true }),
showGpu: factory.switch({ defaultValue: true }),
showLoadAvg: factory.switch({ defaultValue: true }),
showNet: factory.switch({ defaultValue: true }),
showTemp: factory.switch({ defaultValue: true }),
showBattery: factory.switch({ defaultValue: true }),
showServices: factory.switch({ defaultValue: true }),
showUptime: factory.switch({ defaultValue: true }),
showAgent: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,24 @@
.beszel-table th {
white-space: nowrap;
}
.beszel-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.beszel-table td .mantine-Text-root,
.beszel-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.beszel-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
@@ -0,0 +1,109 @@
"use client";
import { useMemo } from "react";
import { Group, Stack, Text } from "@mantine/core";
import type { AreaChartProps } from "@mantine/charts";
import { AreaChart } from "@mantine/charts";
import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
interface ChartPanelProps {
title: string;
subtitle?: string;
children: React.ReactNode;
}
export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
<Stack gap={4} style={{ minWidth: 0, overflow: "hidden" }}>
<Group gap="xs">
<Text size="sm" fw={600}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</Group>
{children}
</Stack>
);
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
yAxisFormatter: (value: number) => string;
yAxisDomain?: [number, string];
};
export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => (
<AreaChart
dataKey="time"
curveType="monotone"
withDots={false}
withXAxis
withYAxis
style={{ minWidth: 0, ...style }}
yAxisProps={{
...yAxisBase,
...(yAxisDomain ? { domain: yAxisDomain } : {}),
tickFormatter: yAxisFormatter,
...yAxisProps,
}}
{...props}
/>
);
export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
) =>
useMemo(() => {
if (!systemStats) return [];
return [...systemStats].toReversed().map((r) => ({
time: formatTime(r.created),
...mapFn(r.stats),
}));
}, [systemStats, mapFn]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) =>
useMemo(() => {
if (!containerStats?.length) return [];
const names = new Set<string>();
for (const record of containerStats) {
for (const c of record.stats) {
names.add(c.n);
}
}
return [...names].slice(0, max);
}, [containerStats, max]);
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
// c = CPU (%), m = memory (bytes), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s)
const defaultContainerExtractors: Record<string, ContainerExtractor> = {
cpu: (c) => c?.c ?? 0,
memory: (c) => c?.m ?? 0,
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
containerNames: string[],
metric: "cpu" | "memory" | "network",
) =>
useMemo(() => {
if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric];
if (!extract) return [];
return [...containerStats].toReversed().map((record) => {
const point: Record<string, unknown> = { time: formatTime(record.created) };
for (const name of containerNames) {
point[name] = extract(record.stats.find((c) => c.n === name));
}
return point;
});
}, [containerStats, containerNames, metric]);
@@ -0,0 +1,39 @@
import type { BeszelSystemStatus } from "./types";
export const thresholdColor = (value: number): string => {
if (value >= 80) return "red";
if (value >= 50) return "yellow";
return "green";
};
export const loadAvgColor = (la1: number, cores: number): string => {
const ratio = la1 / cores;
if (ratio >= 1.5) return "red";
if (ratio >= 0.8) return "yellow";
return "green";
};
export const statusColorMap: Record<BeszelSystemStatus, string> = {
up: "green",
down: "red",
paused: "gray",
pending: "yellow",
};
export const containerColors = [
"blue.6",
"teal.6",
"orange.6",
"grape.6",
"cyan.6",
"pink.6",
"lime.6",
"violet.6",
"indigo.6",
"yellow.6",
"red.6",
"green.6",
"blue.4",
"teal.4",
"orange.4",
];
@@ -0,0 +1,56 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(duration);
dayjs.extend(relativeTime);
const byteUnits = ["B", "KB", "MB", "GB", "TB"] as const;
const rateUnits = ["B/s", "KB/s", "MB/s", "GB/s"] as const;
const formatScaled = (value: number, units: readonly string[], zeroLabel: string): string => {
if (!value || !Number.isFinite(value)) return zeroLabel;
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
const scaled = value / 1024 ** i;
const decimals = scaled < 10 ? 2 : 1;
return `${scaled.toFixed(decimals)} ${units[i] ?? ""}`;
};
const formatScaledCompact = (value: number, units: readonly string[], zeroLabel: string): string => {
if (!value || !Number.isFinite(value)) return zeroLabel;
const i = Math.max(0, Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(1024)), units.length - 1));
const scaled = value / 1024 ** i;
return `${scaled < 10 ? scaled.toFixed(1) : Math.round(scaled)}${units[i] ?? ""}`;
};
export const formatByteRate = (value: number): string => formatScaled(value, rateUnits, "0 B/s");
export const formatStorageBytes = (bytes: number): string => formatScaled(bytes, byteUnits, "0 B");
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
} as const;
export const formatUptime = (seconds: number): string => dayjs.duration(seconds, "seconds").humanize();
export const formatTemp = (celsius: number | null, fahrenheit: boolean): string => {
if (celsius === null) return "—";
if (fahrenheit) return `${((celsius * 9) / 5 + 32).toFixed(1)} °F`;
return `${celsius.toFixed(2)} °C`;
};
export const formatLoadAvg = (la: [number, number, number] | null): string => {
if (!la) return "—";
return la.map((v) => v.toFixed(2)).join(" ");
};
@@ -0,0 +1,42 @@
"use client";
import { useMemo } from "react";
import { clientApi } from "@homarr/api/client";
import type { BeszelSystemRow } from "./types";
type SystemRowWithKey = BeszelSystemRow & { _key: string };
export const useBeszelSystemsSubscription = (integrationIds: string[], enabled = true) => {
const utils = clientApi.useUtils();
clientApi.widget.beszel.subscribeSystems.useSubscription(
{ integrationIds },
{
enabled,
onData(data) {
utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => {
if (!prev) return prev;
return prev.map((r) =>
r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r,
);
});
},
},
);
};
export const useBeszelFilteredSystems = (
results: { integrationId: string; systems: BeszelSystemRow[] }[],
statusFilter: string,
): SystemRowWithKey[] => {
const allSystems = useMemo(
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
[results],
);
return useMemo(() => {
if (statusFilter === "all") return allSystems;
return allSystems.filter((s) => s.status === statusFilter);
}, [allSystems, statusFilter]);
};
@@ -0,0 +1,6 @@
export const statusOptions = [
{ value: "all", label: "All Systems" },
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
{ value: "paused", label: "Paused" },
];
@@ -0,0 +1,132 @@
"use client";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
const styles: Record<string, CSSProperties> = {
wrapper: {
background: "var(--mantine-color-dark-7)",
border: "1px solid var(--mantine-color-dark-4)",
borderRadius: 6,
padding: "6px 10px",
fontSize: 12,
lineHeight: 1.5,
pointerEvents: "none",
maxHeight: 320,
overflowY: "auto",
overflowX: "hidden",
},
header: { marginBottom: 4, fontWeight: 500, color: "var(--mantine-color-gray-2)" },
row: { display: "flex", alignItems: "center", gap: 6, padding: "1px 0" },
indicator: { width: 3, height: 14, borderRadius: 1, flexShrink: 0 },
name: {
flex: 1,
color: "var(--mantine-color-gray-3)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 140,
},
value: {
fontWeight: 700,
color: "var(--mantine-color-gray-1)",
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
},
separator: { borderTop: "1px solid var(--mantine-color-dark-4)", marginTop: 3, paddingTop: 3 },
};
export interface TooltipPayloadItem {
name: string;
value: number;
color: string;
dataKey: string;
}
interface PortalTooltipProps {
active?: boolean;
label?: string;
payload?: TooltipPayloadItem[];
formatter: (value: number) => string;
showTotal?: boolean;
}
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const mouseRef = useRef({ x: 0, y: 0 });
const [mouse, setMouse] = useState({ x: 0, y: 0 });
const rafRef = useRef(0);
const handleMouse = useCallback((e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setMouse(mouseRef.current);
});
}, []);
const isActive = active && (payload?.length ?? 0) > 0;
useEffect(() => {
if (!isActive) return;
document.addEventListener("mousemove", handleMouse);
return () => {
document.removeEventListener("mousemove", handleMouse);
cancelAnimationFrame(rafRef.current);
};
}, [isActive, handleMouse]);
if (!isActive) return null;
const sorted = [...(payload ?? [])].filter((p) => p.value > 0).toSorted((a, b) => b.value - a.value);
if (!sorted.length) return null;
const total = sorted.reduce((sum, p) => sum + p.value, 0);
const portalStyle: CSSProperties = {
position: "fixed",
left: mouse.x + 14,
top: mouse.y,
transform: "translateY(-50%)",
zIndex: 10000,
pointerEvents: "none",
};
return createPortal(
<div style={portalStyle}>
<div style={styles.wrapper}>
<div style={styles.header}>{label}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
</div>,
document.body,
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
content: (props: any) => (
<PortalTooltipContent
active={props.active}
label={props.label != null ? String(props.label) : undefined}
payload={props.payload}
formatter={formatter}
showTotal={showTotal}
/>
),
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
isAnimationActive: false,
});
@@ -0,0 +1 @@
export type { BeszelSystemRow, BeszelSystemStatus } from "@homarr/integrations/types";
+8
View File
@@ -48,6 +48,10 @@ import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as ups from "./ups";
import * as beszelSystemTable from "./beszel-system-table";
import * as beszelSystemGrid from "./beszel-system-grid";
import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
@@ -95,6 +99,10 @@ export const widgetImports = {
speedtestTracker,
umami,
ups,
beszelSystemTable,
beszelSystemGrid,
beszelAlerts,
beszelSystemStats,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
+101 -6
View File
@@ -384,12 +384,21 @@ catalogs:
json5:
specifier: ^2.2.3
version: 2.2.3
jszip:
specifier: ^3.10.1
version: 3.10.1
ldapts:
specifier: 8.1.8
version: 8.1.8
lodash.clonedeep:
specifier: ^4.5.0
version: 4.5.0
lucide-react:
specifier: ^1.17.0
version: 1.17.0
mantine-datatable:
specifier: ^9.2.2
version: 9.2.2
mantine-react-table:
specifier: npm:mantine-react-table-open@9.0.3
version: 9.0.3
@@ -796,9 +805,15 @@ importers:
'@xterm/xterm':
specifier: 'catalog:'
version: 6.0.0
adm-zip:
specifier: 'catalog:'
version: 0.5.17
babel-plugin-react-compiler:
specifier: 'catalog:'
version: 1.0.0
better-sqlite3:
specifier: 'catalog:'
version: 12.10.0
chroma-js:
specifier: 'catalog:'
version: 3.2.0
@@ -811,6 +826,9 @@ importers:
dotenv:
specifier: 'catalog:'
version: 17.4.2
drizzle-orm:
specifier: 'catalog:'
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
flag-icons:
specifier: 'catalog:'
version: 7.5.0
@@ -823,6 +841,12 @@ importers:
jotai:
specifier: 'catalog:'
version: 2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.16)(react@19.2.7)
lucide-react:
specifier: 'catalog:'
version: 1.17.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -850,6 +874,9 @@ importers:
sass:
specifier: 'catalog:'
version: 1.100.0
sql.js:
specifier: 'catalog:'
version: 1.14.1
superjson:
specifier: 'catalog:'
version: 2.2.6
@@ -869,6 +896,12 @@ importers:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
'@types/adm-zip':
specifier: 'catalog:'
version: 0.5.8
'@types/better-sqlite3':
specifier: 'catalog:'
version: 7.6.13
'@types/chroma-js':
specifier: 'catalog:'
version: 3.1.2
@@ -1338,7 +1371,7 @@ importers:
version: 0.2.9
drizzle-orm:
specifier: 'catalog:'
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
ioredis:
specifier: 'catalog:'
version: 5.11.0
@@ -1510,10 +1543,10 @@ importers:
version: 0.31.10
drizzle-orm:
specifier: 'catalog:'
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
drizzle-zod:
specifier: 'catalog:'
version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0))(zod@4.4.3)
version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1))(zod@4.4.3)
mysql2:
specifier: 'catalog:'
version: 3.22.4(@types/node@24.12.4)
@@ -2463,6 +2496,12 @@ importers:
dayjs:
specifier: 'catalog:'
version: 1.11.21
lucide-react:
specifier: 'catalog:'
version: 1.17.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -7039,6 +7078,9 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
@@ -7335,6 +7377,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keygrip@1.1.0:
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
engines: {node: '>= 0.6'}
@@ -7358,6 +7403,9 @@ packages:
resolution: {integrity: sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==}
engines: {node: '>=20'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
@@ -7448,6 +7496,11 @@ packages:
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
lucide-react@1.17.0:
resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
m3u8-parser@7.2.0:
resolution: {integrity: sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==}
@@ -7461,6 +7514,15 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
mantine-datatable@9.2.2:
resolution: {integrity: sha512-pSWIAggUQC1I4lNUzwVoNm85CIkx8jst0syzRo8N9CrTHdc+Z2+8aFLfBniSHbF4nJyLE6nkeAcb8uvFdHbVfw==}
peerDependencies:
'@mantine/core': '>=9.0'
'@mantine/hooks': '>=9.0'
clsx: '>=2'
react: '>=19'
react-dom: '>=19'
mantine-react-table-open@9.0.3:
resolution: {integrity: sha512-HsrBU4KZluj96FPvvH0wcLzog0m5LIMdwB/59oj6BpftLGEibiLxX00G+tDM06PL2jXuiRxiGKPJ4oQOV+VK8w==}
engines: {node: '>=16'}
@@ -8049,6 +8111,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parchment@3.0.0:
resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==}
@@ -8791,6 +8856,9 @@ packages:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -8927,6 +8995,9 @@ packages:
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
sql.js@1.14.1:
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
ssh-remote-port-forward@1.0.4:
resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==}
@@ -13743,7 +13814,7 @@ snapshots:
esbuild: 0.25.12
tsx: 4.21.0
drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0):
drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1):
optionalDependencies:
'@libsql/client-wasm': 0.14.0
'@types/better-sqlite3': 7.6.13
@@ -13752,10 +13823,11 @@ snapshots:
gel: 2.0.0
mysql2: 3.22.4(@types/node@24.12.4)
pg: 8.21.0
sql.js: 1.14.1
drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0))(zod@4.4.3):
drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1))(zod@4.4.3):
dependencies:
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
zod: 4.4.3
dunder-proto@1.0.1:
@@ -14429,6 +14501,8 @@ snapshots:
ieee754@1.2.1: {}
immediate@3.0.6: {}
immer@10.2.0: {}
immer@11.1.4: {}
@@ -14694,6 +14768,13 @@ snapshots:
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
jsep: 1.4.0
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keygrip@1.1.0:
dependencies:
tsscmp: 1.0.6
@@ -14714,6 +14795,10 @@ snapshots:
dependencies:
strict-event-emitter-types: 2.0.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
light-my-request@6.6.0:
dependencies:
cookie: 1.1.1
@@ -14798,6 +14883,10 @@ snapshots:
lru.min@1.1.4: {}
lucide-react@1.17.0(react@19.2.7):
dependencies:
react: 19.2.7
m3u8-parser@7.2.0:
dependencies:
'@babel/runtime': 7.29.7
@@ -15467,6 +15556,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
parchment@3.0.0: {}
parent-module@1.0.1:
@@ -16339,6 +16430,8 @@ snapshots:
gopd: 1.2.0
has-property-descriptors: 1.0.2
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {}
sha.js@2.4.12:
@@ -16507,6 +16600,8 @@ snapshots:
sql-escaper@1.3.3: {}
sql.js@1.14.1: {}
ssh-remote-port-forward@1.0.4:
dependencies:
'@types/ssh2': 0.5.52
+4
View File
@@ -98,6 +98,8 @@ catalog:
"@xterm/addon-fit": 0.11.0
"@xterm/xterm": ^6.0.0
adm-zip: 0.5.17
jszip: ^3.10.1
sql.js: ^1.12.0
babel-plugin-react-compiler: ^1.0.0
bcrypt: ^6.0.0
better-sqlite3: ^12.10.0
@@ -132,6 +134,8 @@ catalog:
json5: ^2.2.3
ldapts: 8.1.8
lodash.clonedeep: ^4.5.0
lucide-react: ^1.17.0
mantine-datatable: ^9.2.2
mantine-react-table: npm:mantine-react-table-open@9.0.3
maria2: ^0.4.1
mysql2: 3.22.4
+28 -13
View File
@@ -10,8 +10,11 @@ if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
echo "DB migrations are disabled, skipping"
else
echo "Running DB migrations"
# We disable redis logs during migration as the redis client is not yet started
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
if [ $? -ne 0 ]; then
echo "ERROR: DB migrations failed, aborting startup"
exit 1
fi
fi
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
@@ -36,26 +39,38 @@ else
REDIS_PID=$!
fi
node apps/nextjs/server.js &
NEXTJS_PID=$!
SHUTTING_DOWN=false
# Function to handle SIGTERM and shut down services
terminate() {
echo "Received SIGTERM. Shutting down..."
kill -TERM $NGINX_PID $NEXTJS_PID 2>/dev/null
wait
# kill redis-server last because of logging of other services and only if $REDIS_PID is set
SHUTTING_DOWN=true
echo "Shutting down..."
kill -TERM $NEXTJS_PID 2>/dev/null
wait $NEXTJS_PID 2>/dev/null
kill -TERM $NGINX_PID 2>/dev/null
wait $NGINX_PID 2>/dev/null
if [ -n "$REDIS_PID" ]; then
kill -TERM $REDIS_PID 2>/dev/null
wait
wait $REDIS_PID 2>/dev/null
fi
echo "Shutdown complete."
exit 0
}
# When SIGTERM (docker stop <container>) / SIGINT (ctrl+c) is received, run the terminate function
trap terminate TERM INT
# Wait for all processes
wait $NEXTJS_PID
terminate
node apps/nextjs/server.js &
NEXTJS_PID=$!
while true; do
wait $NEXTJS_PID
EXIT_CODE=$?
if [ "$SHUTTING_DOWN" = true ]; then
break
fi
echo "Next.js exited with code $EXIT_CODE, restarting in 1s..."
sleep 1
node apps/nextjs/server.js &
NEXTJS_PID=$!
done