Compare commits

..
Author SHA1 Message Date
Thomas 99e013c1fb Merge branch 'dev' into feat/widget-overlay 2026-06-10 16:54:25 +02:00
Thomas Camlong 786ad347a0 style(board): round overlay on all sides and hide on mobile
Apply full border-radius to hover overlay panel instead of bottom-only,
and add media query to hide the overlay on screens <= 768px since hover
is not available on touch devices.
2026-06-08 22:23:41 +02:00
Thomas Camlong 93ce4e4a1b perf(board): lift integration query to SectionContent
Avoid N identical useQuery hooks by fetching integrations once
at the parent and passing them as a prop to each WidgetHoverOverlay.
2026-06-07 10:40:35 +02:00
ajnart 4cf41f2c4c style: auto-fix lint and format 2026-06-07 00:06:03 +00:00
Thomas Camlong ab3c8b4d1f feat(board): mount widget hover overlay on dashboard grid items 2026-06-07 02:05:01 +02:00
Thomas Camlong 78ddc81b15 feat(board): add widget hover overlay component 2026-06-07 02:04:57 +02:00
Thomas Camlong ad98f2d262 feat(widgets): export widget option types for hover metadata 2026-06-07 02:04:52 +02:00
71 changed files with 426 additions and 5536 deletions

No files matched your search

+2 -1
View File
@@ -66,7 +66,8 @@ e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
# next-intl
en.d.json.ts
# personal agent config
AGENTS.md
+4 -16
View File
@@ -5,30 +5,18 @@ 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
@@ -39,7 +27,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", "better-sqlite3"],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
-9
View File
@@ -72,16 +72,10 @@
"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:",
@@ -91,7 +85,6 @@
"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:",
@@ -100,8 +93,6 @@
},
"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:",
Binary file not shown.
@@ -2,16 +2,13 @@ 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>
@@ -30,7 +27,6 @@ export const InitStart = async () => {
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</InitStepCard>
);
@@ -1,43 +0,0 @@
"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,7 +5,6 @@ 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,7 +10,6 @@ import {
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
@@ -29,7 +28,6 @@ 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";
@@ -156,12 +154,6 @@ 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",
},
],
},
{
@@ -1,87 +0,0 @@
"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>
);
};
@@ -1,27 +0,0 @@
"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>
);
};
@@ -1,40 +0,0 @@
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>
);
}
@@ -1,75 +0,0 @@
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);
}
}
}
@@ -1,194 +0,0 @@
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
}
}
}
}
@@ -1,63 +0,0 @@
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
@@ -1,12 +0,0 @@
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));
@@ -1,30 +0,0 @@
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",
},
});
}
@@ -1,308 +0,0 @@
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);
});
});
});
@@ -1,114 +0,0 @@
"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>
);
};
@@ -1,91 +0,0 @@
"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>
);
};
@@ -1,227 +0,0 @@
"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;
};
@@ -1,3 +0,0 @@
export { DatabaseRestoreFlow } from "./database-restore-flow";
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export type { BackupAnalysis, RestoreStep } from "./types";
@@ -1,60 +0,0 @@
"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>
);
};
@@ -1,32 +0,0 @@
"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}
/>
);
};
@@ -1,159 +0,0 @@
"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>
);
};
@@ -1,30 +0,0 @@
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;
@@ -1,187 +0,0 @@
"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 };
};
@@ -0,0 +1,155 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -0,0 +1,116 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -0,0 +1,86 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -2,8 +2,12 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,7 +23,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -30,7 +35,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
))}
</>
);
@@ -39,12 +44,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef }: ItemProps) => {
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
-1
View File
@@ -2,4 +2,3 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "sql.js";
+4
View File
@@ -102,6 +102,10 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
+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 256M;
client_max_body_size 32M;
}
}
}
+22 -24
View File
@@ -613,7 +613,7 @@ export const boardRouter = createTRPCRouter({
boardId: board.id,
});
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
const sortedLayouts = board.layouts.sort((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.toSorted((permissionA, permissionB) => {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -1223,7 +1223,7 @@ export const boardRouter = createTRPCRouter({
user,
permission,
}))
.toSorted((permissionA, permissionB) => {
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupBoardPermission
@@ -1234,7 +1234,7 @@ export const boardRouter = createTRPCRouter({
},
permission,
}))
.toSorted((permissionA, permissionB) => {
.sort((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)
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
sections: sections.map(({ collapseStates, ...section }) =>
parseSection({
...section,
@@ -1604,24 +1604,22 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
items: items
.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
)
.filter((item) => item !== null),
items: items.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
),
};
};
@@ -1637,7 +1635,7 @@ const parseItem = (item: unknown) => {
const result = outputItemSchema.safeParse(item);
if (!result.success) {
return null;
throw new Error(result.error.message);
}
return result.data;
};
-135
View File
@@ -1,135 +0,0 @@
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,7 +3,6 @@ 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,9 +10,6 @@ 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);
}
+3 -8
View File
@@ -41,7 +41,8 @@ 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)) {
@@ -409,13 +410,7 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// 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
// Row 9: 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,14 +433,6 @@ 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,9 +39,5 @@ export const widgetKinds = [
"speedtestTracker",
"umami",
"ups",
"beszelSystemTable",
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -1,7 +1,6 @@
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";
@@ -126,7 +125,6 @@ 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> = {
@@ -1,269 +0,0 @@
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 };
}
}
@@ -1,389 +0,0 @@
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;
}
@@ -1,226 +0,0 @@
// @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();
});
});
});
@@ -1,343 +0,0 @@
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,7 +29,6 @@ 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
@@ -62,7 +61,6 @@ 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> {
@@ -132,12 +130,4 @@ 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,4 +18,3 @@ 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
@@ -1,122 +0,0 @@
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" | "beszel">,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify">,
Record<string, never>
>({
async requestAsync(integration, _input) {
+2 -338
View File
@@ -7,10 +7,7 @@
"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",
"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"
"importOldmarr": "Import from Homarr before 1.0"
}
},
"import": {
@@ -3104,219 +3101,6 @@
"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": {
@@ -3714,8 +3498,7 @@
"logs": "Logs",
"api": "API",
"certificates": "Certificates",
"tasks": "Tasks",
"backup": "Backup"
"tasks": "Tasks"
}
},
"settings": "Settings",
@@ -4255,125 +4038,6 @@
}
}
}
},
"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,8 +70,6 @@
"@tiptap/starter-kit": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -1,264 +0,0 @@
"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>
);
}
@@ -1,17 +0,0 @@
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"));
@@ -1,378 +0,0 @@
"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>
);
}
@@ -1,27 +0,0 @@
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"));
@@ -1,248 +0,0 @@
"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>
);
}
@@ -1,31 +0,0 @@
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"));
@@ -1,260 +0,0 @@
"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"
/>
);
}
@@ -1,48 +0,0 @@
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"));
@@ -1,24 +0,0 @@
.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;
}
@@ -1,109 +0,0 @@
"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]);
@@ -1,39 +0,0 @@
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",
];
@@ -1,56 +0,0 @@
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(" ");
};
@@ -1,42 +0,0 @@
"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]);
};
@@ -1,6 +0,0 @@
export const statusOptions = [
{ value: "all", label: "All Systems" },
{ value: "up", label: "Up" },
{ value: "down", label: "Down" },
{ value: "paused", label: "Paused" },
];
@@ -1,132 +0,0 @@
"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,
});
@@ -1 +0,0 @@
export type { BeszelSystemRow, BeszelSystemStatus } from "@homarr/integrations/types";
+1 -8
View File
@@ -48,15 +48,12 @@ 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";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
export const widgetImports = {
clock,
@@ -99,10 +96,6 @@ export const widgetImports = {
speedtestTracker,
umami,
ups,
beszelSystemTable,
beszelSystemGrid,
beszelAlerts,
beszelSystemStats,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
+6 -101
View File
@@ -384,21 +384,12 @@ 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
@@ -805,15 +796,9 @@ 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
@@ -826,9 +811,6 @@ 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
@@ -841,12 +823,6 @@ 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)
@@ -874,9 +850,6 @@ importers:
sass:
specifier: 'catalog:'
version: 1.100.0
sql.js:
specifier: 'catalog:'
version: 1.14.1
superjson:
specifier: 'catalog:'
version: 2.2.6
@@ -896,12 +869,6 @@ 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
@@ -1371,7 +1338,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)(sql.js@1.14.1)
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)
ioredis:
specifier: 'catalog:'
version: 5.11.0
@@ -1543,10 +1510,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)(sql.js@1.14.1)
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)
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)(sql.js@1.14.1))(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))(zod@4.4.3)
mysql2:
specifier: 'catalog:'
version: 3.22.4(@types/node@24.12.4)
@@ -2496,12 +2463,6 @@ 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)
@@ -7078,9 +7039,6 @@ 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==}
@@ -7377,9 +7335,6 @@ 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'}
@@ -7403,9 +7358,6 @@ 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==}
@@ -7496,11 +7448,6 @@ 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==}
@@ -7514,15 +7461,6 @@ 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'}
@@ -8111,9 +8049,6 @@ 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==}
@@ -8856,9 +8791,6 @@ 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==}
@@ -8995,9 +8927,6 @@ 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==}
@@ -13814,7 +13743,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)(sql.js@1.14.1):
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):
optionalDependencies:
'@libsql/client-wasm': 0.14.0
'@types/better-sqlite3': 7.6.13
@@ -13823,11 +13752,10 @@ 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)(sql.js@1.14.1))(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))(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)(sql.js@1.14.1)
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
dunder-proto@1.0.1:
@@ -14501,8 +14429,6 @@ snapshots:
ieee754@1.2.1: {}
immediate@3.0.6: {}
immer@10.2.0: {}
immer@11.1.4: {}
@@ -14768,13 +14694,6 @@ 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
@@ -14795,10 +14714,6 @@ 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
@@ -14883,10 +14798,6 @@ 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
@@ -15556,8 +15467,6 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
parchment@3.0.0: {}
parent-module@1.0.1:
@@ -16430,8 +16339,6 @@ snapshots:
gopd: 1.2.0
has-property-descriptors: 1.0.2
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {}
sha.js@2.4.12:
@@ -16600,8 +16507,6 @@ 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,8 +98,6 @@ 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
@@ -134,8 +132,6 @@ 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
+13 -28
View File
@@ -10,11 +10,8 @@ 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
@@ -39,38 +36,26 @@ else
REDIS_PID=$!
fi
SHUTTING_DOWN=false
node apps/nextjs/server.js &
NEXTJS_PID=$!
# Function to handle SIGTERM and shut down services
terminate() {
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
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
if [ -n "$REDIS_PID" ]; then
kill -TERM $REDIS_PID 2>/dev/null
wait $REDIS_PID 2>/dev/null
wait
fi
echo "Shutdown complete."
exit 0
}
# When SIGTERM (docker stop <container>) / SIGINT (ctrl+c) is received, run the terminate function
trap terminate TERM INT
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
# Wait for all processes
wait $NEXTJS_PID
terminate