diff --git a/.gitignore b/.gitignore index 0e991cc6c..768e58818 100644 --- a/.gitignore +++ b/.gitignore @@ -66,8 +66,7 @@ e2e/shared/tmp #personal backgrounds apps/nextjs/public/images/background.png -# next-intl -en.d.json.ts + # personal agent config AGENTS.md diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index bcce78519..636df3f52 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -5,18 +5,30 @@ import "@homarr/common/env"; import "@homarr/core/infrastructure/logs/env"; import "@homarr/docker/env"; +import { readFileSync } from "fs"; + import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +const homarrVersion = (() => { + for (const candidate of ["../../package.json", "./package.json"]) { + try { + const pkg = JSON.parse(readFileSync(candidate, "utf-8")); + if (pkg.name === "homarr") return pkg.version as string; + } catch {} + } + return "unknown"; +})(); + // Package path does not work... so we need to use relative path const withNextIntl = createNextIntlPlugin({ - experimental: { - createMessagesDeclaration: "../../packages/translation/src/lang/en.json", - }, requestConfig: "../../packages/translation/src/request.ts", }); const nextConfig: NextConfig = { + env: { + HOMARR_VERSION: homarrVersion, + }, output: "standalone", reactStrictMode: true, // react compiler breaks mantine-react-table, so disabled for now @@ -27,7 +39,7 @@ const nextConfig: NextConfig = { * dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612 * isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356 */ - serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"], + serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], turbopackFileSystemCacheForDev: true, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 61377ff7e..4a9ab466d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -74,10 +74,16 @@ "clsx": "catalog:", "dayjs": "catalog:", "dotenv": "catalog:", + "drizzle-orm": "catalog:", "flag-icons": "catalog:", "glob": "catalog:", + "adm-zip": "catalog:", + "better-sqlite3": "catalog:", "isomorphic-dompurify": "catalog:", + "jszip": "catalog:", "jotai": "catalog:", + "lucide-react": "catalog:", + "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", "mcp-handler": "catalog:", "next": "catalog:", @@ -88,6 +94,7 @@ "react-error-boundary": "catalog:", "react-simple-code-editor": "catalog:", "sass": "catalog:", + "sql.js": "catalog:", "superjson": "catalog:", "trpc-to-mcp": "catalog:", "use-deep-compare-effect": "catalog:", @@ -96,6 +103,8 @@ }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", + "@types/adm-zip": "catalog:", + "@types/better-sqlite3": "catalog:", "@types/chroma-js": "catalog:", "@types/node": "catalog:", "@types/prismjs": "catalog:", diff --git a/apps/nextjs/public/sql-wasm.wasm b/apps/nextjs/public/sql-wasm.wasm new file mode 100755 index 000000000..b32b66473 Binary files /dev/null and b/apps/nextjs/public/sql-wasm.wasm differ diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx index d63c95331..04a7fc159 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx @@ -2,13 +2,16 @@ import { Stack, Text } from "@mantine/core"; import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react"; import { getMantineColor } from "@homarr/common"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { getScopedI18n } from "@homarr/translation/server"; import { InitStepCard } from "../../_components/init-step-card"; +import { InitSqliteRestore } from "./sqlite-restore"; import { InitStartButton } from "./next-button"; export const InitStart = async () => { const tStart = await getScopedI18n("init.step.start"); + const isSqlite = dbEnv.DRIVER === "better-sqlite3"; return ( @@ -27,6 +30,7 @@ export const InitStart = async () => { > {tStart("action.importOldmarr")} + {isSqlite && } ); diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx new file mode 100644 index 000000000..75bc39f5f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { Button, Collapse, Stack, Text } from "@mantine/core"; +import { IconDatabaseImport } from "@tabler/icons-react"; + +import { getMantineColor } from "@homarr/common"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { DatabaseRestoreFlow } from "~/components/backup"; + +export const InitSqliteRestore = () => { + const t = useScopedI18n("init.step.start.action"); + const [expanded, setExpanded] = useState(false); + + if (expanded) { + return ( + + + + {t("restoreSqliteDescription")} + + + + ); + } + + return ( + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 479d9dcc3..a9c6b65cb 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -5,6 +5,7 @@ import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; import "@homarr/spotlight/styles.css"; import "@homarr/ui/styles.css"; +import "mantine-datatable/styles.css"; import "~/styles/color-scheme.scss"; import "~/styles/scroll-area.scss"; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index 652cad7aa..18b32206e 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -8,6 +8,7 @@ import { IconPasswordUser, IconPlug, IconServer, + IconTag, IconUser, } from "@tabler/icons-react"; @@ -28,4 +29,5 @@ export const integrationSecretIcons = { githubAppId: IconCode, githubInstallationId: IconPlug, privateKey: IconKey, + slug: IconTag, } satisfies Record; diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 368d0b2f7..3253670ff 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -10,6 +10,7 @@ import { IconBrandTablerFilled, IconCertificate, IconClipboardListFilled, + IconDatabaseExport, IconDirectionsFilled, IconGitFork, IconHelpSquareRoundedFilled, @@ -28,6 +29,7 @@ import { import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { createDocumentationLink } from "@homarr/definitions"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { env } from "@homarr/docker/env"; import { getScopedI18n } from "@homarr/translation/server"; @@ -154,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) { href: "/manage/tools/tasks", hidden: !session?.user.permissions.includes("admin"), }, + { + label: t("items.tools.items.backup"), + icon: IconDatabaseExport, + href: "/manage/tools/backup", + hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3", + }, ], }, { diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx new file mode 100644 index 000000000..cdbe2055e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core"; +import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation"; + +export const BackupExportCard = () => { + const t = useScopedI18n("management.page.tool.backup.export"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + + + {t("title")} + + + {t("description")} + + {error && ( + }> + {error} + + )} + + {showConfirm ? ( + } + cancelLabel={t("confirm.cancel")} + onConfirm={handleExport} + onCancel={() => setShowConfirm(false)} + disabled={loading} + color="orange" + /> + ) : ( + + + + )} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx new file mode 100644 index 000000000..794801976 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Card, Group, Stack, Text, Title } from "@mantine/core"; +import { IconDatabaseImport } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { DatabaseRestoreFlow } from "~/components/backup"; + +export const BackupImportCard = () => { + const t = useScopedI18n("management.page.tool.backup.import"); + + return ( + + + + + {t("title")} + + + {t("description")} + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx new file mode 100644 index 000000000..80a794b57 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { auth } from "@homarr/auth/next"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { createMetaTitle } from "~/metadata"; +import { BackupExportCard } from "./_components/backup-export-card"; +import { BackupImportCard } from "./_components/backup-import-card"; + +export async function generateMetadata() { + const session = await auth(); + if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") { + return {}; + } + + const t = await getScopedI18n("management.page.tool.backup"); + + return { + title: createMetaTitle(t("metaTitle")), + }; +} + +export default async function BackupPage() { + const session = await auth(); + if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") { + notFound(); + } + + const t = await getScopedI18n("management.page.tool.backup"); + + return ( + + {t("title")} + + + + ); +} diff --git a/apps/nextjs/src/app/api/backup/export/route.ts b/apps/nextjs/src/app/api/backup/export/route.ts new file mode 100644 index 000000000..cf612c337 --- /dev/null +++ b/apps/nextjs/src/app/api/backup/export/route.ts @@ -0,0 +1,75 @@ +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; + +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/common/env"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; + +export async function GET() { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + if (dbEnv.DRIVER !== "better-sqlite3") { + return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 }); + } + + const dbPath = dbEnv.URL as string | undefined; + if (!dbPath || !fs.existsSync(dbPath)) { + return NextResponse.json({ error: "Database file not found" }, { status: 500 }); + } + + const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite"); + let sourceDb: InstanceType | 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); + } + } +} diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts new file mode 100644 index 000000000..32002014a --- /dev/null +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -0,0 +1,194 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; + +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/common/env"; +import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; +import { db } from "@homarr/db"; + +import { findMigrationsFolder } from "../shared"; + +const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const; +const ALGORITHM = "aes-256-cbc"; +const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/; + +const reEncryptSecrets = (tempDb: InstanceType, 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 => { + 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 | 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 + } + } + } +} diff --git a/apps/nextjs/src/app/api/backup/migrations/route.ts b/apps/nextjs/src/app/api/backup/migrations/route.ts new file mode 100644 index 000000000..13724560d --- /dev/null +++ b/apps/nextjs/src/app/api/backup/migrations/route.ts @@ -0,0 +1,63 @@ +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; + +import { findMigrationsFolder } from "../shared"; + +interface JournalEntry { + idx: number; + version: string; + when: number; + tag: string; + breakpoints: boolean; +} + +interface MigrationFile { + idx: number; + tag: string; + sql: string; + when: number; +} + +export async function GET() { + try { + const migrationsFolder = findMigrationsFolder(); + if (!migrationsFolder) { + return NextResponse.json({ error: "Migration files not found" }, { status: 500 }); + } + + const journalPath = path.join(migrationsFolder, "meta", "_journal.json"); + if (!fs.existsSync(journalPath)) { + return NextResponse.json({ error: "Migration journal not found" }, { status: 500 }); + } + + const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] }; + + const migrations: MigrationFile[] = []; + for (const entry of journal.entries) { + const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`); + if (!fs.existsSync(sqlPath)) continue; + + migrations.push({ + idx: entry.idx, + tag: entry.tag, + sql: fs.readFileSync(sqlPath, "utf-8"), + when: entry.when, + }); + } + + return NextResponse.json( + { total: migrations.length, migrations }, + { + headers: { + "Cache-Control": "public, max-age=3600", + }, + }, + ); + } catch (error) { + console.error("[backup/migrations] Failed to read migrations:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 }); + } +} diff --git a/apps/nextjs/src/app/api/backup/shared.ts b/apps/nextjs/src/app/api/backup/shared.ts new file mode 100644 index 000000000..58978a596 --- /dev/null +++ b/apps/nextjs/src/app/api/backup/shared.ts @@ -0,0 +1,12 @@ +import fs from "fs"; +import path from "path"; + +const migrationsFolderCandidates = [ + path.join(process.cwd(), "db/migrations/sqlite"), + path.join(process.cwd(), "packages/db/migrations/sqlite"), + path.resolve(process.cwd(), "../../db/migrations/sqlite"), + "/app/db/migrations/sqlite", +]; + +export const findMigrationsFolder = () => + migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate)); diff --git a/apps/nextjs/src/app/api/backup/sql-wasm/route.ts b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts new file mode 100644 index 000000000..de5917ecb --- /dev/null +++ b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts @@ -0,0 +1,30 @@ +import fs from "fs"; +import path from "path"; + +import { NextResponse } from "next/server"; + +const wasmCandidates = [ + path.join(process.cwd(), "public/sql-wasm.wasm"), + path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"), + path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"), + path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"), +]; + +let cachedWasm: Buffer | null = null; + +export async function GET() { + if (!cachedWasm) { + const found = wasmCandidates.find((p) => fs.existsSync(p)); + if (!found) { + return NextResponse.json({ error: "WASM binary not found" }, { status: 500 }); + } + cachedWasm = fs.readFileSync(found); + } + + return new NextResponse(cachedWasm, { + headers: { + "Content-Type": "application/wasm", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/apps/nextjs/src/app/api/backup/test/backup.spec.ts b/apps/nextjs/src/app/api/backup/test/backup.spec.ts new file mode 100644 index 000000000..8e4e6039d --- /dev/null +++ b/apps/nextjs/src/app/api/backup/test/backup.spec.ts @@ -0,0 +1,308 @@ +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; + +const ALGORITHM = "aes-256-cbc"; +const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite"; + +const generateHexKey = () => crypto.randomBytes(32).toString("hex"); + +const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => { + const key = Buffer.from(keyHex, "hex"); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return `${encrypted.toString("hex")}.${iv.toString("hex")}`; +}; + +const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => { + const key = Buffer.from(keyHex, "hex"); + const [data, dataIv] = value.split(".") as [string, string]; + const iv = Buffer.from(dataIv, "hex"); + const encrypted = Buffer.from(data, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + let decrypted = decipher.update(encrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); +}; + +const createTestDb = (dbPath: string) => { + const sqlite = new Database(dbPath); + const db = drizzle(sqlite, { casing: DB_CASING }); + migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + return { db, sqlite }; +}; + +describe("SQLite backup", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("export ZIP structure", () => { + it("should create a ZIP containing all required entries", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const backupPath = path.join(tmpDir, "db.backup.sqlite"); + sqlite.pragma("wal_checkpoint(TRUNCATE)"); + sqlite.exec(`VACUUM INTO '${backupPath}'`); + sqlite.close(); + + const dbBuffer = fs.readFileSync(backupPath); + const metadata = { + homarrVersion: "test", + exportedAt: new Date().toISOString(), + dbDialect: "sqlite", + encryptionKey: generateHexKey(), + }; + + const zip = new AdmZip(); + zip.addFile("db.sqlite", dbBuffer); + zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata))); + + const zipPath = path.join(tmpDir, "backup.zip"); + zip.writeZip(zipPath); + + const readBack = new AdmZip(zipPath); + const entryNames = readBack.getEntries().map((e) => e.entryName).sort(); + expect(entryNames).toEqual(["db.sqlite", "metadata.json"]); + + const dbEntry = readBack.getEntry("db.sqlite"); + expect(dbEntry).toBeDefined(); + expect(dbEntry!.header.size).toBeGreaterThan(0); + }); + + it("should produce a valid SQLite database in the ZIP", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const backupPath = path.join(tmpDir, "db.backup.sqlite"); + sqlite.exec(`VACUUM INTO '${backupPath}'`); + sqlite.close(); + + const backupDb = new Database(backupPath, { readonly: true }); + const tables = backupDb + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as { name: string }[]; + + expect(tables.map((t) => t.name)).toContain("board"); + expect(tables.map((t) => t.name)).toContain("user"); + expect(tables.map((t) => t.name)).toContain("integrationSecret"); + + backupDb.close(); + }); + }); + + describe("import ZIP validation", () => { + it("should detect missing required entries", () => { + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("dummy")); + + const entries = zip.getEntries().map((e) => e.entryName); + const required = ["db.sqlite", "metadata.json"]; + const missing = required.filter((name) => !entries.includes(name)); + + expect(missing).toEqual(["metadata.json"]); + }); + + it("should accept valid backup ZIP with all entries", () => { + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("dummy")); + zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() }))); + + const entries = zip.getEntries().map((e) => e.entryName); + const required = ["db.sqlite", "metadata.json"]; + const missing = required.filter((name) => !entries.includes(name)); + + expect(missing).toHaveLength(0); + }); + + it("should accept legacy backup ZIP with encryption-key.txt", () => { + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("dummy")); + zip.addFile("encryption-key.txt", Buffer.from(generateHexKey())); + zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" }))); + + const entries = zip.getEntries().map((e) => e.entryName); + const required = ["db.sqlite", "metadata.json"]; + const missing = required.filter((name) => !entries.includes(name)); + + expect(missing).toHaveLength(0); + }); + }); + + describe("secret re-encryption", () => { + it("should re-encrypt secrets from old key to new key", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const oldKey = generateHexKey(); + const newKey = generateHexKey(); + const plaintext = "my-secret-password"; + + const encryptedValue = encryptWithKey(plaintext, oldKey); + + sqlite.exec(` + INSERT INTO "integration" ("id", "name", "url", "kind") + VALUES ('int-1', 'Test', 'http://test', 'adGuardHome') + `); + sqlite.exec(` + INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at") + VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)}) + `); + + const decryptedBefore = decryptWithKey(encryptedValue, oldKey); + expect(decryptedBefore).toBe(plaintext); + + const oldKeyBuf = Buffer.from(oldKey, "hex"); + const newKeyBuf = Buffer.from(newKey, "hex"); + + const rows = sqlite + .prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"') + .all() as { integration_id: string; kind: string; value: string }[]; + + const updateStmt = sqlite.prepare( + 'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?', + ); + + for (const row of rows) { + const [data, dataIv] = row.value.split(".") as [string, string]; + const iv = Buffer.from(dataIv, "hex"); + const encrypted = Buffer.from(data, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(); + + const newIv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv); + const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]); + const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`; + + updateStmt.run(newValue, row.integration_id, row.kind); + } + + const updatedRow = sqlite + .prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?') + .get("int-1") as { value: string }; + + const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey); + expect(decryptedAfter).toBe(plaintext); + + expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow(); + + sqlite.close(); + }); + + it("should skip re-encryption when keys match", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + const sameKey = generateHexKey(); + const plaintext = "my-secret"; + const encryptedValue = encryptWithKey(plaintext, sameKey); + + sqlite.exec(` + INSERT INTO "integration" ("id", "name", "url", "kind") + VALUES ('int-1', 'Test', 'http://test', 'adGuardHome') + `); + sqlite.exec(` + INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at") + VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)}) + `); + + const rowBefore = sqlite + .prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?') + .get("int-1") as { value: string }; + + expect(rowBefore.value).toBe(encryptedValue); + + const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey); + expect(decrypted).toBe(plaintext); + + sqlite.close(); + }); + }); + + describe("VACUUM INTO consistency", () => { + it("should produce a consistent copy via VACUUM INTO", () => { + const dbPath = path.join(tmpDir, "db.sqlite"); + const { sqlite } = createTestDb(dbPath); + + sqlite.exec(` + INSERT INTO "app" ("id", "name", "icon_url") + VALUES ('app-1', 'TestApp', 'https://example.com/icon.png') + `); + + const backupPath = path.join(tmpDir, "db.vacuum.sqlite"); + sqlite.pragma("wal_checkpoint(TRUNCATE)"); + sqlite.exec(`VACUUM INTO '${backupPath}'`); + + const backupDb = new Database(backupPath, { readonly: true }); + const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined; + + expect(app).toBeDefined(); + expect(app!.name).toBe("TestApp"); + + backupDb.close(); + sqlite.close(); + }); + }); + + describe("migration on imported DB", () => { + it("should successfully migrate an imported database", () => { + const sourcePath = path.join(tmpDir, "source.sqlite"); + const { sqlite: sourceDb } = createTestDb(sourcePath); + + sourceDb.exec(` + INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status") + VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0) + `); + sourceDb.close(); + + const importPath = path.join(tmpDir, "import.sqlite"); + fs.copyFileSync(sourcePath, importPath); + + const importDb = new Database(importPath); + const drizzleDb = drizzle(importDb, { casing: DB_CASING }); + migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + + const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined; + expect(board).toBeDefined(); + expect(board!.name).toBe("TestBoard"); + + importDb.close(); + }); + }); + + describe("encryption key validation", () => { + it("should reject invalid hex keys", () => { + const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""]; + const hexKeyRegex = /^[0-9a-fA-F]{64}$/; + + for (const key of invalidKeys) { + expect(hexKeyRegex.test(key)).toBe(false); + } + }); + + it("should accept valid 64-char hex keys", () => { + const validKey = generateHexKey(); + const hexKeyRegex = /^[0-9a-fA-F]{64}$/; + expect(hexKeyRegex.test(validKey)).toBe(true); + }); + }); +}); diff --git a/apps/nextjs/src/components/backup/backup-preview-panel.tsx b/apps/nextjs/src/components/backup/backup-preview-panel.tsx new file mode 100644 index 000000000..98a5effc5 --- /dev/null +++ b/apps/nextjs/src/components/backup/backup-preview-panel.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { BackupAnalysis } from "./types"; +import { PREVIEW_TABLE_KEYS } from "./types"; + +interface BackupPreviewPanelProps { + analysis: BackupAnalysis; +} + +export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => { + const t = useScopedI18n("management.page.tool.backup.restore.preview"); + const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString(); + const pendingCount = analysis.migrations.pending.length; + const hasPendingMigrations = pendingCount > 0; + + return ( + + + + + + + +
+ {t("title")} + + {t("exported", { date: exportDate })} + +
+ + Homarr {analysis.metadata.homarrVersion} + +
+ + + + + {t("entity")} + {t("count")} + + + + {PREVIEW_TABLE_KEYS.map((key) => ( + + {t(`entities.${key}` as never)} + + + {(analysis.counts[key] ?? 0) > 0 && ( + + )} + {analysis.counts[key] ?? 0} + + + + ))} + +
+ + {analysis.boardNames.length > 0 && ( + + {t("boards")}: + {analysis.boardNames.map((name) => ( + {name} + ))} + + )} +
+
+ + {hasPendingMigrations && ( + + + + + +
+ + {pendingCount === 1 + ? t("migrationsApplied", { count: String(pendingCount) }) + : t("migrationsAppliedPlural", { count: String(pendingCount) })} + + + {pendingCount === 1 + ? t("migrationsOlderVersion", { count: String(pendingCount) }) + : t("migrationsOlderVersionPlural", { count: String(pendingCount) })} + +
+ + {t("migrationsPending", { count: String(pendingCount) })} + +
+
+ )} + + {!hasPendingMigrations && ( + + + + + + + {t("migrationsUpToDate", { count: String(analysis.migrations.total) })} + + + + )} +
+ ); +}; diff --git a/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx b/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx new file mode 100644 index 000000000..3ebc71ce3 --- /dev/null +++ b/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx @@ -0,0 +1,91 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core"; +import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react"; + +const CONFIRMATION_PHRASE = "I understand"; + +interface DangerousActionConfirmationProps { + title: string; + warningTitle: string; + warningBody: string; + typePrompt: string; + submitLabel: string; + submitIcon: ReactNode; + cancelLabel: string; + onConfirm: () => void; + onCancel: () => void; + disabled?: boolean; + color?: string; +} + +export const DangerousActionConfirmation = ({ + title, + warningTitle, + warningBody, + typePrompt, + submitLabel, + submitIcon, + cancelLabel, + onConfirm, + onCancel, + disabled, + color = "red", +}: DangerousActionConfirmationProps) => { + const [value, setValue] = useState(""); + const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase(); + + return ( + + + + + + {title} + + + + }> + + {warningTitle} + {warningBody} + + + + + + {typePrompt} + + 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, + }, + }} + /> + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx new file mode 100644 index 000000000..502b5d9f1 --- /dev/null +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core"; +import type { FileWithPath } from "@mantine/dropzone"; +import { Dropzone, MIME_TYPES } from "@mantine/dropzone"; +import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react"; + +import "@mantine/dropzone/styles.css"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { RestoreStep } from "./types"; +import { BackupPreviewPanel } from "./backup-preview-panel"; +import { MigrationProgressPanel } from "./migration-progress-panel"; +import { RestoreConfirmation } from "./restore-confirmation"; +import { RestoreProgressPanel } from "./restore-progress-panel"; +import { useBackupAnalysis } from "./use-backup-analysis"; + +interface DatabaseRestoreFlowProps { + variant?: "card" | "standalone"; + onRestoreComplete?: () => void; +} + +export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => { + const t = useScopedI18n("management.page.tool.backup.restore"); + const [file, setFile] = useState(null); + const [step, setStep] = useState("upload"); + const [importError, setImportError] = useState(null); + const apiDoneRef = useRef(false); + const animDoneRef = useRef(false); + const apiErrorRef = useRef(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 ( + + + + + + + + + + + +
+ + {t("dropzone.title")} + + + {t("dropzone.description")} + +
+
+
+ ); + } + + if (step === "preview") { + return ( + + + + +
+ {file?.name} + {file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""} +
+
+ +
+ + {loading && migrationProgress && ( + + )} + + {loading && !migrationProgress && ( + + + {t("analyzing")} + + )} + + {analysisError && ( + } title={t("analysisError")}> + {analysisError} + + )} + + {analysis && ( + <> + + + + + + )} +
+ ); + } + + if (step === "confirm") { + return ( + + {analysis && } + setStep("preview")} + /> + + ); + } + + if (step === "restoring") { + return ( + + ); + } + + if (step === "error") { + return ( + + } title={t("failed.title")} radius="md"> + {importError} + + + + + + ); + } + + return null; +}; diff --git a/apps/nextjs/src/components/backup/index.ts b/apps/nextjs/src/components/backup/index.ts new file mode 100644 index 000000000..accd1a935 --- /dev/null +++ b/apps/nextjs/src/components/backup/index.ts @@ -0,0 +1,3 @@ +export { DatabaseRestoreFlow } from "./database-restore-flow"; +export { DangerousActionConfirmation } from "./dangerous-action-confirmation"; +export type { BackupAnalysis, RestoreStep } from "./types"; diff --git a/apps/nextjs/src/components/backup/migration-progress-panel.tsx b/apps/nextjs/src/components/backup/migration-progress-panel.tsx new file mode 100644 index 000000000..8b0cd7bfc --- /dev/null +++ b/apps/nextjs/src/components/backup/migration-progress-panel.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; +import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { MigrationProgress } from "./use-backup-analysis"; + +interface MigrationProgressPanelProps { + progress: MigrationProgress; +} + +export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => { + const t = useScopedI18n("management.page.tool.backup.restore.migration"); + + return ( + + + + + + + + {t("title", { current: String(progress.current), total: String(progress.total) })} + + + + + + {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 ( + + ) : isActive ? ( + + ) : ( + + ) + } + title={ + + {i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })} + + } + /> + ); + })} + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/restore-confirmation.tsx b/apps/nextjs/src/components/backup/restore-confirmation.tsx new file mode 100644 index 000000000..a5feff6c8 --- /dev/null +++ b/apps/nextjs/src/components/backup/restore-confirmation.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { IconDatabaseImport } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { DangerousActionConfirmation } from "./dangerous-action-confirmation"; + +interface RestoreConfirmationProps { + onConfirm: () => void; + onCancel: () => void; + disabled?: boolean; +} + +export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => { + const t = useScopedI18n("management.page.tool.backup.restore.confirm"); + + return ( + } + cancelLabel={t("cancel")} + onConfirm={onConfirm} + onCancel={onCancel} + disabled={disabled} + /> + ); +}; diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.tsx new file mode 100644 index 000000000..4faa397be --- /dev/null +++ b/apps/nextjs/src/components/backup/restore-progress-panel.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; +import { + IconCheck, + IconDatabaseImport, + IconFileZip, + IconKey, + IconLoader, + IconRefresh, + IconTransform, +} from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { MigrationFile } from "./types"; + +const BASE_PHASE_DURATIONS: Record = { + 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 ( + + + + + + {t("title")} + + + + + + {steps.map((step, i) => { + const isDone = i < currentStepIdx; + const isActive = i === currentStepIdx; + const isMigration = step.key.startsWith("migration-"); + + return ( + + + + ) : isActive ? ( + + + + ) : ( + + + + ) + } + > + + + + {step.label} + + + + ); + })} + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/types.ts b/apps/nextjs/src/components/backup/types.ts new file mode 100644 index 000000000..c197c6544 --- /dev/null +++ b/apps/nextjs/src/components/backup/types.ts @@ -0,0 +1,30 @@ +export interface BackupMetadata { + homarrVersion: string; + exportedAt: string; + dbDialect: string; + encryptionKey?: string; +} + +export interface MigrationFile { + idx: number; + tag: string; + sql: string; + when: number; +} + +export interface MigrationStatus { + applied: number; + pending: MigrationFile[]; + total: number; +} + +export interface BackupAnalysis { + metadata: BackupMetadata; + counts: Record; + 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; diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts new file mode 100644 index 000000000..a563e24cd --- /dev/null +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types"; +import { PREVIEW_TABLE_KEYS } from "./types"; + +const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const getAppliedMigrationCount = (db: any): number => { + try { + const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`); + return (result[0]?.values[0]?.[0] as number) ?? 0; + } catch { + return 0; + } +}; + +const fetchMigrations = async (): Promise => { + 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 => { + const counts: Record = {}; + 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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [migrationProgress, setMigrationProgress] = useState(null); + const dbRef = useRef(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 }; +}; diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index 42b619364..d4b2cb24d 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -2,6 +2,7 @@ import { getBoardLayouts } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import type { Modify } from "@homarr/common/types"; import type { WidgetKind } from "@homarr/definitions"; +import { widgetDefaultSizes } from "@homarr/definitions"; import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; import { getFirstEmptyPosition } from "./empty-position"; @@ -19,7 +20,7 @@ export const createItemCallback = (previous: Board): Board => { const firstSection = previous.sections .filter((section): section is EmptySection => section.kind === "empty") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); if (!firstSection) return previous; @@ -28,7 +29,7 @@ export const createItemCallback = id: id ?? createId(), kind, options, - layouts: createItemLayouts(previous, firstSection), + layouts: createItemLayouts(previous, firstSection, kind), integrationIds, advancedOptions: { title: null, @@ -48,15 +49,20 @@ export const createItemCallback = }; }; -const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => { +const defaultItemSize = { width: 1, height: 1 }; + +const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize; + +const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => { const layouts = getBoardLayouts(board); + const itemSize = getWidgetItemSize(kind); return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount) + ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -64,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayo } return { - width: 1, - height: 1, + width: itemSize.width, + height: itemSize.height, ...emptyPosition, sectionId: currentSection.id, layoutId, diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 704449b34..4b43a4299 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -57,6 +57,9 @@ export const categoryTranslationKeys: Record = { mediaMonitoring: "integration.category.mediaMonitoring", speedtest: "integration.category.speedtest", analytics: "integration.category.analytics", + documents: "integration.category.documents", + mediaLibrary: "integration.category.mediaLibrary", + uptimeMonitoring: "integration.category.uptimeMonitoring", }; export interface IntegrationGridItem { diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx index 80d58fa07..032dbc3bc 100644 --- a/apps/nextjs/src/components/layout/navigation-link.tsx +++ b/apps/nextjs/src/components/layout/navigation-link.tsx @@ -1,6 +1,7 @@ "use client"; import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { NavLink } from "@mantine/core"; @@ -15,8 +16,21 @@ const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => { return {children}; }; -const NavLinkHref = (props: NavigationLinkHref) => { +const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`); + +const useClientPathname = () => { const pathname = usePathname(); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return { pathname, isClient }; +}; + +const NavLinkHref = (props: NavigationLinkHref) => { + const { pathname, isClient } = useClientPathname(); const tourId = props["data-onboarding-tour-id"]; const link = props.external ? ( @@ -26,15 +40,15 @@ const NavLinkHref = (props: NavigationLinkHref) => { label={props.label} leftSection={props.icon} href={props.href} - active={pathname === props.href || pathname.startsWith(`${props.href}/`)} + active={isClient && pathMatches(pathname, props.href)} /> ); return {link}; }; const NavLinkWithItems = (props: NavigationLinkWithItems) => { - const pathname = usePathname(); - const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`)); + const { pathname, isClient } = useClientPathname(); + const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href)); const nav = ( {props.items.map((item) => ( diff --git a/apps/nextjs/src/react-app-env.d.ts b/apps/nextjs/src/react-app-env.d.ts index 0357e4f19..ca9b2ac8c 100644 --- a/apps/nextjs/src/react-app-env.d.ts +++ b/apps/nextjs/src/react-app-env.d.ts @@ -2,3 +2,4 @@ declare module "*.png"; declare module "*.svg"; declare module "*.jpeg"; declare module "*.jpg"; +declare module "sql.js"; diff --git a/nginx.conf b/nginx.conf index 4b82c8ca5..c1c082777 100644 --- a/nginx.conf +++ b/nginx.conf @@ -23,7 +23,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; - client_max_body_size 32M; + client_max_body_size 256M; } } } diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts new file mode 100644 index 000000000..d43876583 --- /dev/null +++ b/packages/api/src/router/widgets/audio-stats.ts @@ -0,0 +1,29 @@ +import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf"; +import { navidromeRequestHandler } from "@homarr/request-handler/navidrome"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const audioStatsRouter = createTRPCRouter({ + getStats: publicProcedure + .concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf")) + .query(async ({ ctx }) => { + const { kind } = ctx.integration; + + const fetchByKind = { + navidrome: async () => { + const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {}); + return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + }, + audiobookshelf: async () => { + const handler = audiobookshelfRequestHandler.handler( + { ...ctx.integration, kind: "audiobookshelf" as const }, + {}, + ); + return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data; + }, + } as const; + + return { kind, data: await fetchByKind[kind]() }; + }), +}); diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts new file mode 100644 index 000000000..d3b895d88 --- /dev/null +++ b/packages/api/src/router/widgets/beszel.ts @@ -0,0 +1,135 @@ +import { observable } from "@trpc/server/observable"; +import { z } from "zod/v4"; + +import { + beszelAlertsRequestHandler, + beszelStatsRequestHandler, + beszelSystemsRequestHandler, +} from "@homarr/request-handler/beszel"; +import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const beszelRouter = createTRPCRouter({ + getSystems: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + systems: data, + updatedAt: timestamp, + }; + }), + ); + return results; + }), + + subscribeSystems: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); + return innerHandler.subscribe((systems) => { + emit.next({ + integrationId: integration.id, + systems, + timestamp: new Date(), + }); + }); + }); + return () => { + unsubscribes.forEach((fn) => fn()); + }; + }); + }), + + getAlerts: publicProcedure + .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .input( + z.object({ + includeHistory: z.boolean().default(true), + maxHistoryItems: z.number().min(1).max(100).default(10), + }), + ) + .query(async ({ ctx, input }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const alertsHandler = beszelAlertsRequestHandler.handler(integration, { + includeHistory: input.includeHistory, + maxHistoryItems: input.maxHistoryItems, + }); + const systemsHandler = beszelSystemsRequestHandler.handler(integration, {}); + const [alertsResult, systemsResult] = await Promise.all([ + alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), + systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }), + ]); + const systemNameMap: Record = {}; + 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 }; + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 15f0963b4..9596cf44e 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -3,9 +3,11 @@ 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)), + paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), app: lazy(() => import("./app").then((mod) => mod.appRouter)), @@ -30,6 +32,8 @@ export const widgetRouter = createTRPCRouter({ timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)), tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), + uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)), + audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)), umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), }); diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts new file mode 100644 index 000000000..0fb81e72a --- /dev/null +++ b/packages/api/src/router/widgets/paperless-ngx.ts @@ -0,0 +1,12 @@ +import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const paperlessNgxRouter = createTRPCRouter({ + getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => { + const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {}); + const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.data; + }), +}); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts new file mode 100644 index 000000000..eb28f9e7e --- /dev/null +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -0,0 +1,55 @@ +import { observable } from "@trpc/server/observable"; + +import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; +import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const uptimeKumaRouter = createTRPCRouter({ + getDashboard: publicProcedure + .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + + subscribeToDashboard: publicProcedure + .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) + .subscription(({ ctx }) => { + return observable<{ + integrationId: string; + dashboard: UptimeKumaDashboardData; + timestamp: Date; + }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + return innerHandler.subscribe((dashboard) => { + emit.next({ + integrationId: integration.id, + dashboard, + timestamp: new Date(), + }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), +}); diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index 197a61c8e..4d09801d2 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -10,6 +10,9 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC (integration, itemOptions: Record) => { const { kind } = integration; + if (kind === "beszel") { + return null as never; + } if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") { return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); } diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index a2f19e59f..d491b6092 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -41,8 +41,7 @@ import { } from "../schema"; import type { Integration } from "../schema"; -const isTruthyEnv = (value: string | undefined) => - ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase()); +const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase()); export const seedDataAsync = async (db: Database) => { if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) { @@ -410,7 +409,13 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [ options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false }, }), ), - // Row 9: notebook + dockerContainers + weather = 12 + // Row 9: beszelSystemGrid + beszelAlerts = 12 + { kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true }, + { kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true }, + // Row 10: beszelSystemTable + beszelSystemStats = 12 + { kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true }, + { kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true }, + // Row 11: notebook + dockerContainers + weather = 12 { kind: "notebook", width: 4, height: 4, needsIntegration: false }, { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false }, { kind: "weather", width: 2, height: 1, needsIntegration: false }, diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index c85715808..9dcba12d4 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -27,6 +27,10 @@ const integrationAliases: Partial> = overseerr: ["overseerr"], dashDot: ["dashdot", "dash-dot", "dash."], speedtestTracker: ["speedtest-tracker"], + uptimeKuma: ["uptime-kuma"], + audiobookshelf: ["audiobookshelf"], + navidrome: ["navidrome"], + paperlessNgx: ["paperless-ngx", "paperless"], searchCh: ["search-ch"], gitHubContainerRegistry: ["ghcr"], linuxServerIO: ["linuxserver"], diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 2984a2519..eed534ea0 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -17,6 +17,7 @@ export const integrationSecretKindObject = { privateKey: { isPublic: false, multiline: true }, githubAppId: { isPublic: true, multiline: false }, githubInstallationId: { isPublic: true, multiline: false }, + slug: { isPublic: true, multiline: false }, } satisfies Record; export const integrationSecretKinds = objectKeys(integrationSecretKindObject); @@ -401,6 +402,14 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/immich"), defaultPort: 2283, }, + paperlessNgx: { + name: "Paperless-ngx", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg", + category: ["documents"], + documentationUrl: null, + defaultPort: 8000, + }, tracearr: { name: "Tracearr", secretKinds: [["apiKey"]], @@ -417,6 +426,31 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), defaultPort: 80, }, + uptimeKuma: { + name: "Uptime Kuma", + secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg", + category: ["uptimeMonitoring"], + documentationUrl: null, + defaultPort: 3001, + }, + audiobookshelf: { + name: "Audiobookshelf", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg", + category: ["mediaLibrary"], + documentationUrl: null, + defaultPort: 13378, + apiKeySettingsPath: "/account", + }, + navidrome: { + name: "Navidrome", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", + category: ["mediaLibrary"], + documentationUrl: null, + defaultPort: 4533, + }, umami: { name: "Umami", secretKinds: [["username", "password"], ["apiKey"]], @@ -433,6 +467,14 @@ export const integrationDefs = { category: ["ups"], documentationUrl: null, }, + beszel: { + name: "Beszel", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", + category: ["healthMonitoring"], + documentationUrl: null, + defaultPort: 8090, + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", @@ -452,6 +494,7 @@ export const integrationDefs = { "notifications", "smartHomeServer", "ups", + "uptimeMonitoring", ], documentationUrl: null, }, @@ -540,6 +583,9 @@ export const integrationCategories = [ "speedtest", "analytics", "ups", + "documents", + "mediaLibrary", + "uptimeMonitoring", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index bef83fea6..87898a49e 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -33,8 +33,11 @@ export const widgetIntegrationSupport: Partial> = { + uptimeKuma: { width: 2, height: 3 }, + audioStats: { width: 2, height: 2 }, + paperlessNgx: { width: 2, height: 2 }, +}; diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts new file mode 100644 index 000000000..441d7fe3a --- /dev/null +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts @@ -0,0 +1,133 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { + audiobookshelfLibrariesResponseSchema, + audiobookshelfLibraryStatsSchema, + audiobookshelfListeningStatsSchema, + audiobookshelfOnlineUsersResponseSchema, +} from "./audiobookshelf-types"; +import type { + AudiobookshelfDashboardData, + AudiobookshelfLibrary, + AudiobookshelfLibraryStats, +} from "./audiobookshelf-types"; + +const mediaTypeCountField = { + book: "totalAudiobooks", + podcast: "totalPodcasts", +} as const satisfies Record< + AudiobookshelfLibrary["mediaType"], + keyof Pick +>; + +export class AudiobookshelfIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const healthResponse = await input.fetchAsync(this.url("/healthcheck")); + + if (!healthResponse.ok) { + return TestConnectionError.StatusResult(healthResponse); + } + + const authorizeResponse = await input.fetchAsync(this.url("/api/authorize"), { + method: "POST", + headers: this.getAuthHeaders(), + }); + + if (!authorizeResponse.ok) { + return TestConnectionError.StatusResult(authorizeResponse); + } + + return { success: true }; + } + + public async getDashboardDataAsync(): Promise { + const [libraries, listeningStats, activeSessions] = await Promise.all([ + this.getLibrariesAsync(), + this.getListeningStatsAsync(), + this.getActiveSessionsCountAsync(), + ]); + + const statsByLibrary = await Promise.all( + libraries.map(async (library) => ({ + mediaType: library.mediaType, + stats: await this.getLibraryStatsAsync(library.id), + })), + ); + + const counts = { + totalAudiobooks: 0, + totalPodcasts: 0, + }; + + for (const item of statsByLibrary) { + const countField = mediaTypeCountField[item.mediaType]; + counts[countField] += item.stats.totalItems; + } + + return { + libraryCount: libraries.length, + totalAudiobooks: counts.totalAudiobooks, + totalPodcasts: counts.totalPodcasts, + totalListeningTimeSeconds: listeningStats.totalTime, + activeSessions, + }; + } + + private async getLibrariesAsync(): Promise { + const response = await this.getAsync("/api/libraries"); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfLibrariesResponseSchema.parse(await response.json()).libraries; + } + + private async getLibraryStatsAsync(libraryId: string): Promise { + const response = await this.getAsync(`/api/libraries/${libraryId}/stats`); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfLibraryStatsSchema.parse(await response.json()); + } + + private async getListeningStatsAsync() { + const response = await this.getAsync("/api/me/listening-stats"); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfListeningStatsSchema.parse(await response.json()); + } + + private async getActiveSessionsCountAsync(): Promise { + const response = await this.getAsync("/api/users/online"); + + if (!response.ok) { + throw new ResponseError(response); + } + + return audiobookshelfOnlineUsersResponseSchema.parse(await response.json()).openSessions.length; + } + + private async getAsync(path: `/api/${string}` | "/healthcheck") { + return await fetchWithTrustedCertificatesAsync(this.url(path), { + headers: this.getAuthHeaders(), + }); + } + + private getAuthHeaders(): Record { + return { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + Accept: "application/json", + }; + } +} diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts new file mode 100644 index 000000000..495a24c08 --- /dev/null +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts @@ -0,0 +1,36 @@ +import { z } from "zod/v4"; + +export const audiobookshelfLibrarySchema = z.object({ + id: z.string(), + name: z.string(), + mediaType: z.enum(["book", "podcast"]), +}); + +export const audiobookshelfLibrariesResponseSchema = z.object({ + libraries: z.array(audiobookshelfLibrarySchema), +}); + +export const audiobookshelfLibraryStatsSchema = z.object({ + totalItems: z.number(), + totalDuration: z.number().optional(), + totalSize: z.number().optional(), +}); + +export const audiobookshelfListeningStatsSchema = z.object({ + totalTime: z.number(), +}); + +export const audiobookshelfOnlineUsersResponseSchema = z.object({ + openSessions: z.array(z.unknown()), +}); + +export interface AudiobookshelfDashboardData { + libraryCount: number; + totalAudiobooks: number; + totalPodcasts: number; + totalListeningTimeSeconds: number; + activeSessions: number; +} + +export type AudiobookshelfLibrary = z.infer; +export type AudiobookshelfLibraryStats = z.infer; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 20688b708..51eb7fa64 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,7 +1,9 @@ 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 { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration"; import { CodebergIntegration } from "../codeberg/codeberg-integration"; import { CoolifyIntegration } from "../coolify/coolify-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; @@ -30,12 +32,14 @@ import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integrati import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; import { MockIntegration } from "../mock/mock-integration"; +import { NavidromeIntegration } from "../navidrome/navidrome-integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; import { NPMIntegration } from "../npm/npm-integration"; import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OPNsenseIntegration } from "../opnsense/opnsense-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; +import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration"; import { PeaNutIntegration } from "../peanut/peanut-integration"; import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory"; import { PlexIntegration } from "../plex/plex-integration"; @@ -48,6 +52,7 @@ import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-trac import { TracearrIntegration } from "../tracearr/tracearr-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration"; import { UmamiIntegration } from "../umami/umami-integration"; +import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnraidIntegration } from "../unraid/unraid-integration"; import type { Integration, IntegrationInput } from "./integration"; @@ -122,9 +127,14 @@ export const integrationCreators = { glances: GlancesIntegration, searchCh: SearchChIntegration, immich: ImmichIntegration, + paperlessNgx: PaperlessNgxIntegration, speedtestTracker: SpeedtestTrackerIntegration, + audiobookshelf: AudiobookshelfIntegration, + navidrome: NavidromeIntegration, umami: UmamiIntegration, + uptimeKuma: UptimeKumaIntegration, peaNut: PeaNutIntegration, + beszel: BeszelIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts new file mode 100644 index 000000000..eb646c5dc --- /dev/null +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -0,0 +1,269 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { SessionStore } from "../base/session-store"; +import { createSessionStore } from "../base/session-store"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { + BeszelAlert, + BeszelAlertHistory, + BeszelAuthResponse, + BeszelContainer, + BeszelContainerStatsRecord, + BeszelSmartDevice, + BeszelSystem, + BeszelSystemDetails, + BeszelSystemdService, + BeszelSystemStatsRecord, + CreateAlertInput, + PocketBaseListResponse, + UpdateAlertInput, +} from "./beszel-types"; + +const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + +interface BeszelSession { + token: string; + userId: string; +} + +export class BeszelIntegration extends Integration { + private readonly sessionStore: SessionStore; + + constructor(integration: IntegrationInput) { + super(integration); + this.sessionStore = createSessionStore(integration); + } + + private async authenticateAsync(): Promise { + 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 { + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/systems/records", { perPage: "500", sort: "-updated" }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getSystemDetailsAsync(systemId: string): Promise { + 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 { + 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; + return data.items; + } + + public async getContainersAsync(systemId: string): Promise { + 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; + return data.items; + } + + public async getContainerStatsAsync( + systemId: string, + type = "1m", + perPage = 60, + ): Promise { + 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; + return data.items; + } + + public async getSmartDevicesAsync(systemId: string): Promise { + const response = await this.fetchWithAuthAsync( + this.url("/api/collections/smart_devices/records", { + filter: `system='${escapeFilterValue(systemId)}'`, + }), + ); + const data = (await response.json()) as PocketBaseListResponse; + return data.items; + } + + public async getSystemdServicesAsync(systemId: string): Promise { + 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; + return data.items; + } + + public async getAlertsAsync(systemId?: string): Promise { + const params: Record = { 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; + return data.items; + } + + public async getAlertHistoryAsync(systemId?: string, perPage = 50): Promise { + const params: Record = { + 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; + return data.items; + } + + public async createAlertAsync(systemId: string, input: CreateAlertInput): Promise { + 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 { + 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 { + await this.fetchWithAuthAsync(this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`), { + method: "DELETE", + }); + } + + public async pauseSystemAsync(systemId: string): Promise { + 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 { + 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 { + await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), { + method: "DELETE", + }); + } + + protected async testingAsync(input: IntegrationTestingInput): Promise { + 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 }; + } +} diff --git a/packages/integrations/src/beszel/beszel-types.ts b/packages/integrations/src/beszel/beszel-types.ts new file mode 100644 index 000000000..52643c701 --- /dev/null +++ b/packages/integrations/src/beszel/beszel-types.ts @@ -0,0 +1,389 @@ +export interface BeszelAuthResponse { + token: string; + record: { + id: string; + email: string; + username: string; + verified: boolean; + }; +} + +export type BeszelSystemStatus = "up" | "down" | "paused" | "pending"; + +/** + * Real-time system info snapshot from Beszel agent. + * Returned as `system.info` on each BeszelSystem record. + * All resource values are percentages or already-scaled summaries. + */ +export interface BeszelSystemInfo { + /** hostname */ + h?: string; + /** CPU usage (%) */ + cpu: number; + /** temperature (°C), overall */ + t?: number; + /** CPU core count */ + c?: number; + /** CPU thread count */ + ct?: number; + /** CPU model string */ + m?: string; + /** load average [1m, 5m, 15m] */ + la?: [number, number, number]; + /** OS identifier string */ + o?: string; + /** uptime (seconds) */ + u: number; + /** memory usage (%) */ + mp: number; + /** disk usage (%) */ + dp: number; + /** battery [level%, charging state] */ + bat?: [number, number]; + /** bandwidth — public interface (Mbps, legacy). Use bb when available */ + b?: number; + /** bandwidth — public interface (bytes/s, newer format). Preferred over b */ + bb?: number; + /** agent version */ + v: string; + /** podman mode */ + p?: boolean; + /** GPU usage (%) */ + g?: number; + /** disk temperature (°C) */ + dt?: number; + /** OS type identifier */ + os?: number; + /** extra filesystem usage (%) keyed by mount path */ + efs?: Record; + /** 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; + /** extra filesystem stats keyed by mount path */ + efs?: Record; + /** GPU stats keyed by device id */ + g?: Record; + /** battery [level%, charging state] */ + bat?: [number, number]; + /** network interfaces [rx bytes/s, tx bytes/s, rx max, tx max] */ + ni?: Record; +} + +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; +} + +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 { + 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; +} diff --git a/packages/integrations/src/beszel/test/beszel-integration.spec.ts b/packages/integrations/src/beszel/test/beszel-integration.spec.ts new file mode 100644 index 000000000..a68e1dbc8 --- /dev/null +++ b/packages/integrations/src/beszel/test/beszel-integration.spec.ts @@ -0,0 +1,226 @@ +// @vitest-environment node +import { beforeAll, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; + process.env.ENABLE_DNS_CACHING = "false"; +}); + +const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090"; +const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? ""; +const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? ""; + +vi.mock("@homarr/redis", () => ({ + createGetSetChannel: () => ({ + getAsync: vi.fn().mockResolvedValue(null), + setAsync: vi.fn().mockResolvedValue(undefined), + removeAsync: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + ErrorWithMetadata: class extends Error {}, +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init), + createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}), + createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), + getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), +})); + +import { BeszelIntegration } from "../beszel-integration"; + +function createBeszelIntegration() { + return new BeszelIntegration({ + id: "test-beszel", + name: "Test Beszel", + url: BESZEL_URL, + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: BESZEL_EMAIL }, + { kind: "password", value: BESZEL_PASSWORD }, + ], + }); +} + +const shouldRun = BESZEL_EMAIL && BESZEL_PASSWORD; + +describe.skipIf(!shouldRun)("BeszelIntegration (real instance)", () => { + let integration: BeszelIntegration; + let systemId: string; + + beforeAll(() => { + integration = createBeszelIntegration(); + }); + + describe("getSystemsAsync", () => { + test("returns an array of systems", async () => { + const systems = await integration.getSystemsAsync(); + expect(systems).toBeInstanceOf(Array); + expect(systems.length).toBeGreaterThan(0); + }); + + test("each system has required fields", async () => { + const systems = await integration.getSystemsAsync(); + const system = systems[0]; + if (!system) { + throw new Error("Expected at least one system"); + } + systemId = system.id; + + expect(system.id).toBeDefined(); + expect(system.name).toBeDefined(); + expect(system.status).toMatch(/^(up|down|paused|pending)$/); + expect(system.info).toBeDefined(); + expect(typeof system.info.cpu).toBe("number"); + expect(typeof system.info.mp).toBe("number"); + expect(typeof system.info.dp).toBe("number"); + }); + }); + + describe("getSystemDetailsAsync", () => { + test("returns system details with hardware info", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const details = await integration.getSystemDetailsAsync(systemId); + + expect(details.hostname).toBeDefined(); + expect(details.cpu).toBeDefined(); + expect(typeof details.cores).toBe("number"); + expect(details.cores).toBeGreaterThan(0); + expect(typeof details.memory).toBe("number"); + expect(details.memory).toBeGreaterThan(0); + expect(details.os_name).toBeDefined(); + }); + }); + + describe("getSystemStatsAsync", () => { + test("returns time-series stats records", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const stats = await integration.getSystemStatsAsync(systemId, "1m", 5); + + expect(stats).toBeInstanceOf(Array); + if (stats.length === 0) return; + + const record = stats[0]; + expect(record?.stats).toBeDefined(); + expect(typeof record?.stats.cpu).toBe("number"); + expect(typeof record?.stats.mp).toBe("number"); + expect(typeof record?.stats.dp).toBe("number"); + expect(record?.created).toBeDefined(); + }); + }); + + describe("getContainersAsync", () => { + test("returns container list", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const containers = await integration.getContainersAsync(systemId); + expect(containers).toBeInstanceOf(Array); + + if (containers.length > 0) { + const container = containers[0]; + expect(container?.name).toBeDefined(); + expect(container?.image).toBeDefined(); + expect(container?.status).toBeDefined(); + expect(typeof container?.cpu).toBe("number"); + expect(typeof container?.memory).toBe("number"); + } + }); + }); + + describe("alert CRUD", () => { + let alertId: string; + + test("creates an alert", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + const alert = await integration.createAlertAsync(systemId, { + name: "CPU", + value: 90, + min: 1, + }); + + expect(alert.id).toBeDefined(); + expect(alert.name).toBe("CPU"); + expect(alert.value).toBe(90); + expect(alert.system).toBe(systemId); + alertId = alert.id; + }); + + test("lists alerts including the created one", async () => { + const alerts = await integration.getAlertsAsync(systemId); + const found = alerts.find((a) => a.id === alertId); + expect(found).toBeDefined(); + expect(found?.name).toBe("CPU"); + }); + + test("updates alert threshold", async () => { + const updated = await integration.updateAlertAsync(alertId, { value: 95 }); + expect(updated.value).toBe(95); + }); + + test("deletes alert", async () => { + await integration.deleteAlertAsync(alertId); + const alerts = await integration.getAlertsAsync(systemId); + const found = alerts.find((a) => a.id === alertId); + expect(found).toBeUndefined(); + }); + }); + + describe("system actions", () => { + test("pauses and resumes a system", async () => { + const systems = await integration.getSystemsAsync(); + systemId = systems[0]?.id ?? ""; + + await integration.pauseSystemAsync(systemId); + const afterPause = await integration.getSystemsAsync(); + const paused = afterPause.find((s) => s.id === systemId); + expect(paused?.status).toBe("paused"); + + await integration.resumeSystemAsync(systemId); + const afterResume = await integration.getSystemsAsync(); + const resumed = afterResume.find((s) => s.id === systemId); + expect(resumed?.status).toMatch(/^(up|pending)$/); + }); + }); + + describe("authentication", () => { + test("authenticates with valid credentials", async () => { + const systems = await integration.getSystemsAsync(); + expect(systems.length).toBeGreaterThan(0); + }); + + test("fails with invalid credentials", async () => { + const badIntegration = new BeszelIntegration({ + id: "test-bad", + name: "Bad Beszel", + url: BESZEL_URL, + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: "wrong@example.com" }, + { kind: "password", value: "wrongpassword" }, + ], + }); + + await expect(badIntegration.getSystemsAsync()).rejects.toThrow(); + }); + }); +}); diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 198a17671..2f1c7d2df 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -33,6 +33,9 @@ export { CoolifyIntegration } from "./coolify/coolify-integration"; export { ImmichIntegration } from "./immich/immich-integration"; export { TracearrIntegration } from "./tracearr/tracearr-integration"; export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration"; +export { AudiobookshelfIntegration } from "./audiobookshelf/audiobookshelf-integration"; +export { NavidromeIntegration } from "./navidrome/navidrome-integration"; +export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration"; export { UmamiIntegration } from "./umami/umami-integration"; export { PeaNutIntegration } from "./peanut/peanut-integration"; @@ -61,6 +64,7 @@ export type { export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration"; +export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types"; export type { AnchorNote, AnchorNotePermission, @@ -70,6 +74,9 @@ export type { } from "./anchor/anchor-types"; export type { TracearrDashboardData } from "./tracearr/tracearr-types"; export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types"; +export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types"; +export type { NavidromeDashboardData } from "./navidrome/navidrome-types"; +export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types"; export type { UmamiVisitorStats } from "./umami/umami-types"; // Schemas diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts new file mode 100644 index 000000000..8788cf136 --- /dev/null +++ b/packages/integrations/src/mock/data/beszel.ts @@ -0,0 +1,343 @@ +import type { + BeszelAlert, + BeszelAlertHistory, + BeszelContainerStats, + BeszelContainerStatsRecord, + BeszelSystem, + BeszelSystemDetails, + BeszelSystemStats, + BeszelSystemStatsRecord, +} from "../../beszel/beszel-types"; + +const GB = 1024 * 1024 * 1024; +const MB = 1024 * 1024; +const KB = 1024; + +interface MockSystemDef { + name: string; + host: string; + os: string; + cpu: string; + cores: number; + mem: number; + arch: string; + baseCpu: number; + baseMem: number; + baseDisk: number; + containers: number[]; +} + +const mockSystems: MockSystemDef[] = [ + { + name: "web-server", + host: "192.168.1.10", + os: "Ubuntu 24.04", + cpu: "AMD Ryzen 9 7950X", + cores: 16, + mem: 64 * GB, + arch: "amd64", + baseCpu: 35, + baseMem: 55, + baseDisk: 42, + containers: [0, 1, 2, 3, 5], + }, + { + name: "nas-01", + host: "192.168.1.20", + os: "Debian 12", + cpu: "Intel Xeon E-2388G", + cores: 8, + mem: 32 * GB, + arch: "amd64", + baseCpu: 12, + baseMem: 70, + baseDisk: 68, + containers: [1, 2, 6], + }, + { + name: "pi-cluster-1", + host: "192.168.1.30", + os: "Raspberry Pi OS", + cpu: "ARM Cortex-A76", + cores: 4, + mem: 8 * GB, + arch: "aarch64", + baseCpu: 45, + baseMem: 82, + baseDisk: 35, + containers: [0, 2, 7], + }, + { + name: "media-server", + host: "192.168.1.40", + os: "Proxmox VE 8.2", + cpu: "Intel i7-13700K", + cores: 16, + mem: 128 * GB, + arch: "amd64", + baseCpu: 22, + baseMem: 48, + baseDisk: 55, + containers: [0, 1, 2, 3, 4, 5, 6, 7], + }, + { + name: "backup-node", + host: "192.168.1.50", + os: "TrueNAS SCALE", + cpu: "Intel Xeon E5-2680", + cores: 12, + mem: 48 * GB, + arch: "amd64", + baseCpu: 8, + baseMem: 30, + baseDisk: 78, + containers: [1, 6], + }, +]; + +const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [ + { name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 }, + { name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 }, + { name: "redis", cpuBase: 3, memMB: 180, netKB: 500 }, + { name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 }, + { name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 }, + { name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 }, + { name: "minio", cpuBase: 3, memMB: 200, netKB: 600 }, + { name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 }, +]; + +const rand = (min: number, max: number) => min + Math.random() * (max - min); +const randInt = (min: number, max: number) => Math.floor(rand(min, max)); +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +function smoothWalk(base: number, amplitude: number, steps: number, lo: number, hi: number): number[] { + const values: number[] = []; + let current = base + rand(-amplitude * 0.3, amplitude * 0.3); + for (let i = 0; i < steps; i++) { + const spike = Math.random() < 0.08 ? rand(-amplitude * 2, amplitude * 3) : 0; + current += rand(-amplitude, amplitude) + spike; + current = clamp(current, lo, hi); + values.push(current); + } + return values; +} + +function resolveSystem(systemId: string): MockSystemDef { + const idx = Number(systemId.replace("mock-sys-", "")) - 1; + return mockSystems[idx] ?? mockSystems[0] ?? (mockSystems.find(Boolean) as MockSystemDef); +} + +const typeToMinutes: Record = { + "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( + 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 = { 2: "paused", 4: "down" }; + +export class BeszelMockService { + public async getSystemsAsync(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + })); + } +} diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index b435dbd32..0804267b1 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -29,6 +29,7 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s import { NotificationsMockService } from "./data/notifications"; import { SmartHomeMockService } from "./data/smart-home"; import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring"; +import { BeszelMockService } from "./data/beszel"; import { UpsSummaryMockService } from "./data/ups-summary"; export class MockIntegration @@ -61,6 +62,7 @@ export class MockIntegration private static readonly networkController = new NetworkControllerSummaryMockService(); private static readonly notifications = new NotificationsMockService(); private static readonly smartHome = new SmartHomeMockService(); + private static readonly beszel = new BeszelMockService(); private static readonly upsSummary = new UpsSummaryMockService(); protected async testingAsync(_: IntegrationTestingInput): Promise { @@ -130,4 +132,12 @@ export class MockIntegration // UpsSummaryIntegration getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary); + + // BeszelIntegration + getSystemsAsync = MockIntegration.beszel.getSystemsAsync.bind(MockIntegration.beszel); + getSystemDetailsAsync = MockIntegration.beszel.getSystemDetailsAsync.bind(MockIntegration.beszel); + getSystemStatsAsync = MockIntegration.beszel.getSystemStatsAsync.bind(MockIntegration.beszel); + getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel); + getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel); + getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel); } diff --git a/packages/integrations/src/navidrome/navidrome-integration.ts b/packages/integrations/src/navidrome/navidrome-integration.ts new file mode 100644 index 000000000..4d7609ea0 --- /dev/null +++ b/packages/integrations/src/navidrome/navidrome-integration.ts @@ -0,0 +1,171 @@ +import { ParseError, ResponseError } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types"; +import { subsonicResponseSchema } from "./navidrome-types"; + +const logger = createLogger({ module: "navidromeIntegration" }); + +const subsonicVersion = "1.16.1"; +const subsonicClient = "homarr"; +const albumListPageSize = 500; +const maxAlbumListPages = 20; +const emptyLibraryMessage = "Library not found or empty"; + +const subsonicAuthErrorCodes = new Set([40, 41]); + +const asArray = (value: TValue | TValue[] | undefined): TValue[] => + [value].flat().filter((item): item is TValue => item !== undefined); + +export class NavidromeIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const url = this.url("/rest/ping.view", this.getAuthParams()); + const response = await input.fetchAsync(url); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + const parsed = subsonicResponseSchema.safeParse(await response.json()); + + if (!parsed.success) { + return TestConnectionError.ParseResult(new ParseError("Invalid Subsonic response", { cause: parsed.error })); + } + + const subsonicResponse = parsed.data["subsonic-response"]; + + if (subsonicResponse.status === "failed") { + const errorCode = subsonicResponse.error?.code; + + if (errorCode !== undefined && subsonicAuthErrorCodes.has(errorCode)) { + return TestConnectionError.UnauthorizedResult(401); + } + + return TestConnectionError.UnknownResult(new Error(subsonicResponse.error?.message ?? "Subsonic request failed")); + } + + return { success: true }; + } + + public async getDashboardDataAsync(): Promise { + const [artistCount, libraryCounts, nowPlaying] = await Promise.all([ + this.getArtistCountAsync(), + this.getAlbumAndSongCountsAsync(), + this.getNowPlayingAsync(), + ]); + + return { + artistCount, + albumCount: libraryCounts.albumCount, + songCount: libraryCounts.songCount, + nowPlaying, + }; + } + + private async getArtistCountAsync(): Promise { + const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true }); + return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0); + } + + private async getAlbumAndSongCountsAsync(): Promise<{ albumCount: number; songCount: number }> { + let offset = 0; + let albumCount = 0; + let songCount = 0; + let page = 0; + + while (page < maxAlbumListPages) { + const response = await this.subsonicRequestAsync( + "/rest/getAlbumList2.view", + { + type: "alphabeticalByName", + size: albumListPageSize, + offset, + }, + { tolerateEmptyLibrary: true }, + ); + const albums = asArray(response.albumList2?.album); + + if (albums.length === 0) { + break; + } + + albumCount += albums.length; + + for (const album of albums) { + songCount += album.songCount ?? 0; + } + + if (albums.length < albumListPageSize) { + break; + } + + offset += albumListPageSize; + page += 1; + } + + if (page >= maxAlbumListPages) { + logger.warn("Reached Navidrome album list page limit", { + integrationId: this.integration.id, + maxPages: maxAlbumListPages, + albumCount, + }); + } + + return { albumCount, songCount }; + } + + private async getNowPlayingAsync(): Promise { + const response = await this.subsonicRequestAsync("/rest/getNowPlaying.view", {}, { tolerateEmptyLibrary: true }); + + return asArray(response.nowPlaying?.entry).map((entry) => ({ + title: entry.title ?? "", + artist: entry.artist ?? "", + album: entry.album ?? "", + username: entry.username ?? "", + playerName: entry.playerName ?? "", + })); + } + + private async subsonicRequestAsync( + path: `/rest/${string}`, + params: Record = {}, + options: { tolerateEmptyLibrary?: boolean } = {}, + ): Promise { + const url = this.url(path, { ...this.getAuthParams(), ...params }); + const response = await fetchWithTrustedCertificatesAsync(url); + + if (!response.ok) { + throw new ResponseError(response); + } + + const parsed = subsonicResponseSchema.parse(await response.json()); + const subsonicResponse = parsed["subsonic-response"]; + + if (subsonicResponse.status === "failed") { + const message = subsonicResponse.error?.message ?? "Subsonic request failed"; + + if (options.tolerateEmptyLibrary && message === emptyLibraryMessage) { + return subsonicResponse; + } + + throw new Error(message); + } + + return subsonicResponse; + } + + private getAuthParams(): Record { + return { + u: this.getSecretValue("username"), + p: this.getSecretValue("password"), + v: subsonicVersion, + c: subsonicClient, + f: "json", + }; + } +} diff --git a/packages/integrations/src/navidrome/navidrome-types.ts b/packages/integrations/src/navidrome/navidrome-types.ts new file mode 100644 index 000000000..a9e5067f2 --- /dev/null +++ b/packages/integrations/src/navidrome/navidrome-types.ts @@ -0,0 +1,71 @@ +import { z } from "zod/v4"; + +const subsonicArtistSchema = z.object({ + id: z.string(), + name: z.string(), + albumCount: z.number().optional(), +}); + +const subsonicIndexSchema = z.object({ + name: z.string(), + artist: z.union([subsonicArtistSchema, z.array(subsonicArtistSchema)]).optional(), +}); + +const subsonicAlbumSchema = z.object({ + id: z.string(), + name: z.string(), + songCount: z.number().optional(), +}); + +const subsonicNowPlayingEntrySchema = z.object({ + title: z.string().optional(), + artist: z.string().optional(), + album: z.string().optional(), + username: z.string().optional(), + playerName: z.string().optional(), +}); + +export const subsonicResponseSchema = z.object({ + "subsonic-response": z.object({ + status: z.enum(["ok", "failed"]), + version: z.string().optional(), + error: z + .object({ + code: z.number(), + message: z.string(), + }) + .optional(), + artists: z + .object({ + index: z.union([subsonicIndexSchema, z.array(subsonicIndexSchema)]).optional(), + }) + .optional(), + albumList2: z + .object({ + album: z.union([subsonicAlbumSchema, z.array(subsonicAlbumSchema)]).optional(), + }) + .optional(), + nowPlaying: z + .object({ + entry: z.union([subsonicNowPlayingEntrySchema, z.array(subsonicNowPlayingEntrySchema)]).optional(), + }) + .optional(), + }), +}); + +export type SubsonicResponseBody = z.infer["subsonic-response"]; + +export interface NavidromeNowPlayingEntry { + title: string; + artist: string; + album: string; + username: string; + playerName: string; +} + +export interface NavidromeDashboardData { + artistCount: number; + albumCount: number; + songCount: number; + nowPlaying: NavidromeNowPlayingEntry[]; +} diff --git a/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts new file mode 100644 index 000000000..921169b1a --- /dev/null +++ b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts @@ -0,0 +1,201 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { NavidromeIntegration } from "../navidrome-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://navidrome.example.com"; +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const secrets: IntegrationSecret[] = [ + { kind: "username", value: "admin" }, + { kind: "password", value: "secret" }, +]; + +const toUrlString = (url: unknown): string => String(url); + +const createIntegration = () => + new NavidromeIntegration({ + id: "test-navidrome", + name: "Test Navidrome", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: secrets, + }); + +const subsonicOk = >(data: T) => + JSON.stringify({ + "subsonic-response": { status: "ok", version: "1.16.1", ...data }, + }); + +const subsonicFailed = (message: string) => + JSON.stringify({ + "subsonic-response": { status: "failed", error: { code: 70, message } }, + }); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("NavidromeIntegration.getDashboardDataAsync", () => { + test("paginates album list and counts songs", async () => { + const makeAlbums = (count: number, songCount: number) => + Array.from({ length: count }, (_, i) => ({ + id: `album-${i}`, + name: `Album ${i}`, + songCount, + })); + + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getArtists")) { + return Promise.resolve( + new Response(subsonicOk({ artists: { index: [{ name: "A", artist: [{ id: "1", name: "Artist" }] }] } }), { + status: 200, + }), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getAlbumList2")) { + const offsetMatch = urlStr.match(/offset=(\d+)/); + const offset = Number(offsetMatch?.[1] ?? 0); + + if (offset === 0) { + return Promise.resolve( + new Response(subsonicOk({ albumList2: { album: makeAlbums(500, 10) } }), { status: 200 }), + ) as unknown as ReturnType; + } + + if (offset === 500) { + return Promise.resolve( + new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }), + ) as unknown as ReturnType; + } + + return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getDashboardDataAsync(); + + expect(result.albumCount).toBe(700); + expect(result.songCount).toBe(500 * 10 + 200 * 5); + expect(result.artistCount).toBe(1); + expect(result.nowPlaying).toEqual([]); + }); + + test("handles empty library gracefully", async () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getArtists")) { + return Promise.resolve( + new Response(subsonicFailed("Library not found or empty"), { status: 200 }), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getAlbumList2")) { + return Promise.resolve( + new Response(subsonicFailed("Library not found or empty"), { status: 200 }), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve( + new Response(subsonicFailed("Library not found or empty"), { status: 200 }), + ) as unknown as ReturnType; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getDashboardDataAsync(); + + expect(result.artistCount).toBe(0); + expect(result.albumCount).toBe(0); + expect(result.songCount).toBe(0); + expect(result.nowPlaying).toEqual([]); + }); + + test("throws on non-empty-library subsonic error", async () => { + mockFetch.mockImplementation( + () => + Promise.resolve(new Response(subsonicFailed("Permission denied"), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >, + ); + + const integration = createIntegration(); + await expect(integration.getDashboardDataAsync()).rejects.toThrow(); + }); + + test("now playing maps entries correctly", async () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve( + new Response( + subsonicOk({ + nowPlaying: { + entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" }, + }, + }), + { status: 200 }, + ), + ) as unknown as ReturnType; + } + + if (urlStr.includes("getArtists")) { + return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + if (urlStr.includes("getAlbumList2")) { + return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getDashboardDataAsync(); + + expect(result.nowPlaying).toHaveLength(1); + expect(result.nowPlaying[0]).toEqual({ + title: "Song", + artist: "Band", + album: "LP", + username: "user1", + playerName: "Chrome", + }); + }); +}); diff --git a/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts new file mode 100644 index 000000000..a6059c3e8 --- /dev/null +++ b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts @@ -0,0 +1,67 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { PaperlessNgxStats } from "./paperless-ngx-types"; +import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types"; + +export class PaperlessNgxIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const statisticsUrl = this.url("/api/statistics/"); + const statisticsResponse = await input.fetchAsync(statisticsUrl, { + headers: this.getAuthHeaders(), + }); + + if (!statisticsResponse.ok) { + return TestConnectionError.StatusResult(statisticsResponse); + } + + return { success: true }; + } + + public async getStatsAsync(): Promise { + const [statistics, tags, correspondents, documentTypes] = await Promise.all([ + this.fetchStatisticsAsync(), + this.fetchCountAsync("/api/tags/"), + this.fetchCountAsync("/api/correspondents/"), + this.fetchCountAsync("/api/document_types/"), + ]); + + return { + documentsTotal: statistics.documents_total, + documentsInbox: statistics.documents_inbox, + correspondentsCount: correspondents, + tagsCount: tags, + documentTypesCount: documentTypes, + }; + } + + private async fetchAuthenticatedAsync(path: `/${string}`) { + const response = await fetchWithTrustedCertificatesAsync(this.url(path), { + headers: this.getAuthHeaders(), + }); + if (!response.ok) { + throw new ResponseError(response); + } + return response; + } + + private async fetchStatisticsAsync() { + const response = await this.fetchAuthenticatedAsync("/api/statistics/"); + return paperlessNgxStatisticsSchema.parse(await response.json()); + } + + private async fetchCountAsync(path: `/${string}`) { + const response = await this.fetchAuthenticatedAsync(path); + return paperlessNgxPaginatedCountSchema.parse(await response.json()).count; + } + + private getAuthHeaders(): Record { + return { + Authorization: `Token ${this.getSecretValue("apiKey")}`, + }; + } +} diff --git a/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts b/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts new file mode 100644 index 000000000..8a47ffcdb --- /dev/null +++ b/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts @@ -0,0 +1,21 @@ +import { z } from "zod/v4"; + +export const paperlessNgxStatisticsSchema = z.object({ + documents_total: z.number(), + documents_inbox: z + .number() + .nullable() + .transform((value) => value ?? 0), +}); + +export const paperlessNgxPaginatedCountSchema = z.object({ + count: z.number(), +}); + +export interface PaperlessNgxStats { + documentsTotal: number; + documentsInbox: number; + correspondentsCount: number; + tagsCount: number; + documentTypesCount: number; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index fcb8a77ae..1ee395dfa 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -17,4 +17,9 @@ export * from "./anchor/anchor-types"; export * from "./interfaces/timetable/timetable-types"; export * from "./tracearr/tracearr-types"; export * from "./speedtest-tracker/speedtest-tracker-types"; +export * from "./uptime-kuma/uptime-kuma-types"; +export * from "./audiobookshelf/audiobookshelf-types"; +export * from "./navidrome/navidrome-types"; +export * from "./paperless-ngx/paperless-ngx-types"; export * from "./umami/umami-types"; +export * from "./beszel/beszel-types"; diff --git a/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts b/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts new file mode 100644 index 000000000..f21c522b6 --- /dev/null +++ b/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts @@ -0,0 +1,162 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { UptimeKumaIntegration } from "../uptime-kuma-integration"; +import { uptimeKumaHeartbeatCategoryMap } from "../uptime-kuma-types"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://uptime.example.com"; +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const toUrlString = (url: unknown): string => String(url); + +const createIntegration = (secrets: IntegrationSecret[] = []) => + new UptimeKumaIntegration({ + id: "test-uptime-kuma", + name: "Test Uptime Kuma", + url: TEST_URL, + externalUrl: null, + decryptedSecrets: secrets, + }); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("uptimeKumaHeartbeatCategoryMap", () => { + test("maps status 0 (down) to 'down'", () => { + expect(uptimeKumaHeartbeatCategoryMap[0]).toBe("down"); + }); + + test("maps status 1 (up) to 'up'", () => { + expect(uptimeKumaHeartbeatCategoryMap[1]).toBe("up"); + }); + + test("maps status 2 (pending) to 'paused'", () => { + expect(uptimeKumaHeartbeatCategoryMap[2]).toBe("paused"); + }); + + test("maps status 3 (maintenance) to 'paused'", () => { + expect(uptimeKumaHeartbeatCategoryMap[3]).toBe("paused"); + }); + + test("returns undefined for unknown status codes", () => { + expect(uptimeKumaHeartbeatCategoryMap[99]).toBeUndefined(); + }); +}); + +describe("UptimeKumaIntegration.getDashboardDataAsync", () => { + const statusPageResponse = { + publicGroupList: [ + { + id: 1, + name: "Services", + monitorList: [ + { id: 10, name: "Web" }, + { id: 20, name: "API" }, + { id: 30, name: "DB" }, + ], + }, + ], + }; + + const heartbeatResponse = { + heartbeatList: { + "10": [{ status: 1, time: "2025-01-01" }], + "20": [{ status: 0, time: "2025-01-01" }], + "30": [{ status: 2, time: "2025-01-01" }], + }, + uptimeList: { + "10_24": 0.995, + "20_24": 0.5, + "30_24": 0.8, + }, + }; + + const mockResponses = () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("/heartbeat/")) { + return Promise.resolve( + new Response(JSON.stringify(heartbeatResponse), { status: 200 }), + ) as unknown as ReturnType; + } + + return Promise.resolve( + new Response(JSON.stringify(statusPageResponse), { status: 200 }), + ) as unknown as ReturnType; + }); + }; + + test("aggregates monitor counts correctly", async () => { + mockResponses(); + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + expect(result.totalMonitors).toBe(3); + expect(result.upCount).toBe(1); + expect(result.downCount).toBe(1); + expect(result.pausedCount).toBe(1); + }); + + test("computes average uptime from uptimeList", async () => { + mockResponses(); + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + const expectedAverage = ((0.995 + 0.5 + 0.8) * 100) / 3; + expect(result.averageUptimePercent).toBeCloseTo(expectedAverage, 5); + }); + + test("defaults unknown heartbeat status to 'down'", async () => { + const unknownHeartbeat = { + heartbeatList: { "10": [{ status: 99, time: "2025-01-01" }] }, + uptimeList: { "10_24": 1 }, + }; + + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + if (urlStr.includes("/heartbeat/")) { + return Promise.resolve( + new Response(JSON.stringify(unknownHeartbeat), { status: 200 }), + ) as unknown as ReturnType; + } + return Promise.resolve( + new Response( + JSON.stringify({ + publicGroupList: [{ id: 1, name: "G", monitorList: [{ id: 10, name: "Svc" }] }], + }), + { status: 200 }, + ), + ) as unknown as ReturnType; + }); + + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + expect(result.monitors[0]?.status).toBe("down"); + expect(result.downCount).toBe(1); + }); + + test("handles empty monitor list gracefully", async () => { + mockFetch.mockImplementation( + () => + Promise.resolve( + new Response(JSON.stringify({ publicGroupList: [], heartbeatList: {}, uptimeList: {} }), { status: 200 }), + ) as unknown as ReturnType, + ); + + const integration = createIntegration([{ kind: "slug", value: "default" }]); + const result = await integration.getDashboardDataAsync(); + + expect(result.totalMonitors).toBe(0); + expect(result.averageUptimePercent).toBe(0); + }); +}); diff --git a/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts new file mode 100644 index 000000000..ce494e599 --- /dev/null +++ b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts @@ -0,0 +1,140 @@ +import { ParseError, ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { + uptimeKumaHeartbeatCategoryMap, + uptimeKumaHeartbeatResponseSchema, + uptimeKumaStatusPageResponseSchema, +} from "./uptime-kuma-types"; +import type { UptimeKumaDashboardData, UptimeKumaMonitor, UptimeKumaMonitorCategory } from "./uptime-kuma-types"; + +export class UptimeKumaIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const statusPageResponse = await input.fetchAsync(this.statusPageUrl()); + + if (!statusPageResponse.ok) { + return TestConnectionError.StatusResult(statusPageResponse); + } + + const statusPageParsed = uptimeKumaStatusPageResponseSchema.safeParse(await statusPageResponse.json()); + + if (!statusPageParsed.success) { + return TestConnectionError.ParseResult( + new ParseError("Invalid Uptime Kuma status page response", { cause: statusPageParsed.error }), + ); + } + + const heartbeatResponse = await input.fetchAsync(this.heartbeatUrl()); + + if (!heartbeatResponse.ok) { + return TestConnectionError.StatusResult(heartbeatResponse); + } + + const heartbeatParsed = uptimeKumaHeartbeatResponseSchema.safeParse(await heartbeatResponse.json()); + + if (!heartbeatParsed.success) { + return TestConnectionError.ParseResult( + new ParseError("Invalid Uptime Kuma heartbeat response", { cause: heartbeatParsed.error }), + ); + } + + return { success: true }; + } + + public async getDashboardDataAsync(): Promise { + const [statusPageResponse, heartbeatResponse] = await Promise.all([ + this.getStatusPageAsync(), + this.getHeartbeatAsync(), + ]); + + const statusPage = uptimeKumaStatusPageResponseSchema.parse(await statusPageResponse.json()); + const heartbeat = uptimeKumaHeartbeatResponseSchema.parse(await heartbeatResponse.json()); + + const monitors = statusPage.publicGroupList.flatMap((group) => + group.monitorList.map((monitor) => this.mapMonitor(monitor, heartbeat)), + ); + + return this.buildDashboardData(monitors); + } + + private mapMonitor( + monitor: { id: number; name: string }, + heartbeat: { heartbeatList: Record; uptimeList: Record }, + ): UptimeKumaMonitor { + const heartbeats = heartbeat.heartbeatList[String(monitor.id)] ?? []; + const latestHeartbeat = heartbeats.at(-1); + const uptimeKey = `${monitor.id}_24`; + const uptimeRaw = heartbeat.uptimeList[uptimeKey]; + const uptimePercent24h = typeof uptimeRaw === "number" ? uptimeRaw * 100 : null; + const status: UptimeKumaMonitorCategory = + (latestHeartbeat && (uptimeKumaHeartbeatCategoryMap[latestHeartbeat.status] ?? "down")) || "paused"; + + return { + id: monitor.id, + name: monitor.name, + status, + uptimePercent24h, + }; + } + + private buildDashboardData(monitors: UptimeKumaMonitor[]): UptimeKumaDashboardData { + const counts = monitors.reduce( + (acc, monitor) => { + acc[monitor.status] += 1; + return acc; + }, + { up: 0, down: 0, paused: 0 }, + ); + + const uptimeValues = monitors + .map((monitor) => monitor.uptimePercent24h) + .filter((value): value is number => value !== null); + + const averageUptimePercent = uptimeValues.reduce((sum, value) => sum + value, 0) / Math.max(uptimeValues.length, 1); + + return { + totalMonitors: monitors.length, + upCount: counts.up, + downCount: counts.down, + pausedCount: counts.paused, + averageUptimePercent, + monitors, + }; + } + + private async fetchOkAsync(url: string | URL) { + const response = await fetchWithTrustedCertificatesAsync(url); + if (!response.ok) { + throw new ResponseError(response); + } + return response; + } + + private getStatusPageAsync() { + return this.fetchOkAsync(this.statusPageUrl()); + } + + private getHeartbeatAsync() { + return this.fetchOkAsync(this.heartbeatUrl()); + } + + private statusPageUrl() { + return this.url(`/api/status-page/${this.getSlug()}`); + } + + private heartbeatUrl() { + return this.url(`/api/status-page/heartbeat/${this.getSlug()}`); + } + + private getSlug() { + if (this.hasSecretValue("slug")) { + return this.getSecretValue("slug").toLowerCase(); + } + + return "default"; + } +} diff --git a/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts b/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts new file mode 100644 index 000000000..f029754a8 --- /dev/null +++ b/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +export const uptimeKumaHeartbeatStatus = { + down: 0, + up: 1, + pending: 2, + maintenance: 3, +} as const; + +export type UptimeKumaMonitorCategory = "up" | "down" | "paused"; + +export const uptimeKumaHeartbeatCategoryMap: Record = { + [uptimeKumaHeartbeatStatus.down]: "down", + [uptimeKumaHeartbeatStatus.up]: "up", + [uptimeKumaHeartbeatStatus.pending]: "paused", + [uptimeKumaHeartbeatStatus.maintenance]: "paused", +}; + +export const uptimeKumaStatusPageMonitorSchema = z.object({ + id: z.number().int(), + name: z.string(), +}); + +export const uptimeKumaStatusPageGroupSchema = z.object({ + id: z.number().int(), + name: z.string(), + monitorList: z.array(uptimeKumaStatusPageMonitorSchema), +}); + +export const uptimeKumaStatusPageResponseSchema = z.object({ + publicGroupList: z.array(uptimeKumaStatusPageGroupSchema), +}); + +export const uptimeKumaHeartbeatEntrySchema = z.object({ + status: z.number().int(), + time: z.string(), + ping: z.number().nullable().optional(), +}); + +export const uptimeKumaHeartbeatResponseSchema = z.object({ + heartbeatList: z.record(z.string(), z.array(uptimeKumaHeartbeatEntrySchema)), + uptimeList: z.record(z.string(), z.number()), +}); + +export interface UptimeKumaMonitor { + id: number; + name: string; + status: UptimeKumaMonitorCategory; + uptimePercent24h: number | null; +} + +export interface UptimeKumaDashboardData { + totalMonitors: number; + upCount: number; + downCount: number; + pausedCount: number; + averageUptimePercent: number; + monitors: UptimeKumaMonitor[]; +} + +export type UptimeKumaStatusPageResponse = z.infer; +export type UptimeKumaHeartbeatResponse = z.infer; diff --git a/packages/request-handler/src/audiobookshelf.ts b/packages/request-handler/src/audiobookshelf.ts new file mode 100644 index 000000000..bb4b2d83c --- /dev/null +++ b/packages/request-handler/src/audiobookshelf.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler< + AudiobookshelfDashboardData, + "audiobookshelf", + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(10, "minutes"), + queryKey: "audiobookshelfDashboard", +}); diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts new file mode 100644 index 000000000..053edf52b --- /dev/null +++ b/packages/request-handler/src/beszel.ts @@ -0,0 +1,122 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { + BeszelAlert, + BeszelAlertHistory, + BeszelContainerStatsRecord, + BeszelSystem, + BeszelSystemDetails, + BeszelSystemRow, + BeszelSystemStatsRecord, +} from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export type { BeszelSystemRow } from "@homarr/integrations/types"; + +/** + * Maps raw Beszel API responses (BeszelSystem + BeszelSystemDetails) into a + * normalized BeszelSystemRow for widget consumption. + * + * BeszelSystemInfo uses short field names (see beszel-types.ts for full docs): + * cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version, + * bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery, + * sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads + */ +function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow { + const info = system.info; + return { + id: system.id, + name: system.name, + status: system.status, + cpu: info.cpu, + memory: info.mp, + disk: info.dp, + gpu: info.g ?? 0, + loadAvg: info.la ?? null, + // bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s) + netBytes: info.bb ?? (info.b ?? 0) * 1_000_000, + temp: info.dt ?? null, + battery: info.bat ?? null, + services: info.sv?.[0] ?? 0, + uptime: info.u, + agentVersion: info.v, + hostname: details?.hostname ?? info.h ?? system.name, + cpuModel: details?.cpu ?? info.m ?? "Unknown", + cores: details?.cores ?? info.ct ?? info.c ?? 1, + memoryTotal: details?.memory ?? 0, + osName: details?.os_name ?? "", + }; +} + +export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler< + BeszelSystemRow[], + "beszel" | "mock", + Record +>({ + 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 = { + "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", +}); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 98d0cce3d..640eb6b97 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration- export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< SystemHealthMonitoring, - Exclude, "proxmox" | "coolify">, + Exclude, "proxmox" | "coolify" | "beszel">, Record >({ async requestAsync(integration, _input) { diff --git a/packages/request-handler/src/navidrome.ts b/packages/request-handler/src/navidrome.ts new file mode 100644 index 000000000..b59f852a8 --- /dev/null +++ b/packages/request-handler/src/navidrome.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { NavidromeDashboardData } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const navidromeRequestHandler = createCachedIntegrationRequestHandler< + NavidromeDashboardData, + "navidrome", + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(10, "minutes"), + queryKey: "navidromeDashboard", +}); diff --git a/packages/request-handler/src/paperless-ngx.ts b/packages/request-handler/src/paperless-ngx.ts new file mode 100644 index 000000000..a3793658f --- /dev/null +++ b/packages/request-handler/src/paperless-ngx.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { PaperlessNgxStats } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHandler< + PaperlessNgxStats, + "paperlessNgx", + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getStatsAsync(); + }, + cacheDuration: dayjs.duration(15, "minute"), + queryKey: "paperless-ngx-stats", +}); diff --git a/packages/request-handler/src/uptime-kuma.ts b/packages/request-handler/src/uptime-kuma.ts new file mode 100644 index 000000000..39149c137 --- /dev/null +++ b/packages/request-handler/src/uptime-kuma.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler< + UptimeKumaDashboardData, + "uptimeKuma", + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(1, "minute"), + queryKey: "uptimeKumaDashboard", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a9be8146b..2d2f2f9f5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -7,7 +7,10 @@ "description": "To get started, please select how you want to set up your Homarr instance.", "action": { "scratch": "Start from scratch", - "importOldmarr": "Import from Homarr before 1.0" + "importOldmarr": "Import from Homarr before 1.0", + "restoreSqlite": "Restore from SQLite backup", + "restoreSqliteDescription": "Upload a full SQLite database backup from a previous Homarr instance. This will restore all boards, users, apps, integrations, and settings.", + "backToOptions": "← Back to setup options" } }, "import": { @@ -808,10 +811,13 @@ "firewall": "Firewall", "timetable": "Timetable", "photoService": "Photos", + "documents": "Documents", "notes": "Notes", "mediaMonitoring": "Monitoring", "speedtest": "Speed Test", - "analytics": "Analytics" + "analytics": "Analytics", + "mediaLibrary": "Media Library", + "uptimeMonitoring": "Uptime Monitoring" }, "testConnection": { "action": { @@ -1084,6 +1090,10 @@ "privateKey": { "label": "Private key", "newLabel": "New private key" + }, + "slug": { + "label": "Status page slug", + "newLabel": "New status page slug" } } }, @@ -1091,6 +1101,30 @@ "use": "Select integrations in items", "interact": "Interact with integrations", "full": "Full integration access" + }, + "audiobookshelf": { + "name": "Audiobookshelf", + "description": "Self-hosted audiobook and podcast server" + }, + "navidrome": { + "name": "Navidrome", + "description": "Self-hosted music streaming server with Subsonic API compatibility" + }, + "uptimeKuma": { + "name": "Uptime Kuma", + "description": "Self-hosted uptime monitoring with public status pages", + "secret": { + "slug": { + "description": "The slug of your public status page. Defaults to \"default\" if not set." + }, + "apiKey": { + "description": "Optional API key for authenticated /metrics access" + } + } + }, + "paperlessNgx": { + "name": "Paperless-ngx", + "description": "Document management system for archiving and organizing scanned documents" } }, "media": { @@ -2429,6 +2463,51 @@ } } }, + "paperlessNgx": { + "name": "Paperless-ngx", + "description": "Overview of your Paperless-ngx document archive", + "documentsTotal": "Total Documents", + "documentsInbox": "Inbox", + "correspondents": "Correspondents", + "tags": "Tags", + "documentTypes": "Document Types", + "inboxRatio": "Inbox Ratio", + "inbox": "inbox", + "total": "total", + "option": { + "showInboxRatio": { + "label": "Show inbox ratio", + "description": "Display the inbox-to-total document ratio in the hero section" + }, + "showInboxRing": { + "label": "Show inbox ring", + "description": "Display a progress ring alongside the inbox ratio" + }, + "showDocumentsTotal": { + "label": "Show total documents", + "description": "Show the total number of documents in your archive" + }, + "showDocumentsInbox": { + "label": "Show inbox count", + "description": "Show the number of unprocessed documents in the inbox" + }, + "showCorrespondents": { + "label": "Show correspondents", + "description": "Show the total number of correspondents" + }, + "showTags": { + "label": "Show tags", + "description": "Show the total number of tags" + }, + "showDocumentTypes": { + "label": "Show document types", + "description": "Show the total number of document types" + } + }, + "error": { + "internalServerError": "Failed to fetch Paperless-ngx data" + } + }, "immich-serverStats": { "name": "Immich Server Stats", "description": "Information about your Immich instance", @@ -3037,6 +3116,113 @@ "internalServerError": "Failed to fetch speedtest data" } }, + "uptimeKuma": { + "name": "Uptime Kuma", + "description": "Monitor uptime statistics from your Uptime Kuma status page", + "option": { + "showAverageUptime": { + "label": "Show average uptime", + "description": "Display the combined 24-hour average uptime percentage" + }, + "showUptimeRing": { + "label": "Show uptime ring", + "description": "Display a progress ring alongside the average uptime value" + }, + "showTotalMonitors": { + "label": "Show total monitors", + "description": "Display the total number of configured monitors" + }, + "showUpCount": { + "label": "Show up count", + "description": "Display how many monitors are currently up" + }, + "showDownCount": { + "label": "Show down count", + "description": "Display how many monitors are currently down" + }, + "showPausedCount": { + "label": "Show paused count", + "description": "Display how many monitors are currently paused" + } + }, + "averageUptime": "Average uptime (24h)", + "stats": { + "totalMonitors": "Total monitors", + "upCount": "Up", + "downCount": "Down", + "pausedCount": "Paused" + }, + "error": { + "internalServerError": "Failed to fetch uptime data" + } + }, + "audioStats": { + "name": "Audio stats", + "description": "Library and playback statistics from Navidrome or Audiobookshelf", + "option": { + "showArtists": { + "label": "Show artists", + "description": "Show the total number of artists in your music library (Navidrome)" + }, + "showAlbums": { + "label": "Show albums", + "description": "Show the total number of albums in your music library (Navidrome)" + }, + "showSongs": { + "label": "Show songs", + "description": "Show the total number of songs in your music library (Navidrome)" + }, + "showNowPlaying": { + "label": "Show now playing", + "description": "Show tracks currently being played (Navidrome)" + }, + "showNowPlayingList": { + "label": "Show now playing list", + "description": "Show individual tracks instead of only the active stream count (Navidrome)" + }, + "maxNowPlayingItems": { + "label": "Max now playing items", + "description": "Maximum number of tracks to show in the now playing list (Navidrome)" + }, + "showLibraryCount": { + "label": "Show library count", + "description": "Show the number of libraries on your instance (Audiobookshelf)" + }, + "showAudiobooks": { + "label": "Show audiobooks", + "description": "Show the total number of audiobooks across all libraries (Audiobookshelf)" + }, + "showPodcasts": { + "label": "Show podcasts", + "description": "Show the total number of podcasts across all libraries (Audiobookshelf)" + }, + "showListeningTime": { + "label": "Show listening time", + "description": "Show your total listening time (Audiobookshelf)" + }, + "showActiveSessions": { + "label": "Show active sessions", + "description": "Show the number of currently active playback sessions (Audiobookshelf)" + }, + "compactMode": { + "label": "Compact mode", + "description": "Use a denser layout with smaller tiles, ideal for small widget sizes" + } + }, + "artists": "Artists", + "albums": "Albums", + "songs": "Songs", + "libraryCount": "Libraries", + "audiobooks": "Audiobooks", + "podcasts": "Podcasts", + "listeningTime": "Listening Time", + "activeSessions": "Active Sessions", + "nowPlaying": "Now Playing", + "nowPlayingCount": "{count, plural, one {# stream} other {# streams}}", + "error": { + "internalServerError": "Failed to fetch audio stats data" + } + }, "systemResources": { "name": "System resources", "description": "CPU, Memory, Disk and other hardware usage of your system", @@ -3101,6 +3287,219 @@ "status": { "unhealthy": "Unhealthy" } + }, + "beszelSystemTable": { + "name": "Beszel Systems (Table)", + "description": "Table view of all Beszel-monitored systems with sortable columns and status indicators", + "option": { + "statusFilter": { + "label": "Status filter" + }, + "sortBy": { + "label": "Sort by" + }, + "sortDirection": { + "label": "Sort direction" + }, + "showCpu": { + "label": "Show CPU" + }, + "showMemory": { + "label": "Show memory" + }, + "showDisk": { + "label": "Show disk" + }, + "showGpu": { + "label": "Show GPU" + }, + "showLoadAvg": { + "label": "Show load average" + }, + "showNet": { + "label": "Show network" + }, + "showTemp": { + "label": "Show temperature" + }, + "showBattery": { + "label": "Show battery" + }, + "showServices": { + "label": "Show services" + }, + "showUptime": { + "label": "Show uptime" + }, + "showAgent": { + "label": "Show agent version" + } + }, + "noRecords": "No systems found", + "column": { + "system": "System", + "cpu": "CPU", + "memory": "Memory", + "disk": "Disk", + "gpu": "GPU", + "loadAvg": "Load Avg", + "net": "Net", + "temp": "Temp", + "battery": "Bat", + "services": "Services", + "uptime": "Uptime", + "agent": "Agent" + } + }, + "beszelSystemGrid": { + "name": "Beszel Systems (Grid)", + "description": "Card grid view of all Beszel-monitored systems with real-time metrics", + "option": { + "statusFilter": { + "label": "Status filter" + }, + "showCpu": { + "label": "Show CPU" + }, + "showMemory": { + "label": "Show memory" + }, + "showDisk": { + "label": "Show disk" + }, + "showGpu": { + "label": "Show GPU" + }, + "showLoadAvg": { + "label": "Show load average" + }, + "showNet": { + "label": "Show network" + }, + "showTemp": { + "label": "Show temperature" + }, + "showBattery": { + "label": "Show battery" + }, + "showServices": { + "label": "Show services" + }, + "showUptime": { + "label": "Show uptime" + }, + "showAgent": { + "label": "Show agent version" + } + }, + "metric": { + "cpu": "CPU", + "memory": "Memory", + "disk": "Disk", + "gpu": "GPU", + "loadAvg": "Load Avg", + "net": "Net", + "temp": "Temp", + "battery": "Bat", + "services": "Services", + "uptime": "Uptime", + "agent": "Agent" + } + }, + "beszelAlerts": { + "name": "Beszel Alerts", + "description": "View Beszel alert configurations and history", + "option": { + "showHistory": { + "label": "Show alert history" + }, + "maxHistoryItems": { + "label": "Max history items" + } + }, + "empty": "No alerts configured", + "history": "History", + "resolved": "Resolved", + "status": { + "triggered": "Triggered", + "ok": "OK" + } + }, + "beszelSystemStats": { + "name": "Beszel System Stats", + "description": "Time-series charts for CPU, memory, disk, network, and Docker container metrics", + "option": { + "timePeriod": { + "label": "Time period" + }, + "showCpu": { + "label": "Show CPU chart" + }, + "showMemory": { + "label": "Show memory chart" + }, + "showDisk": { + "label": "Show disk chart" + }, + "showDiskIO": { + "label": "Show disk I/O chart" + }, + "showNetwork": { + "label": "Show network chart" + }, + "showDockerCpu": { + "label": "Show Docker CPU chart" + }, + "showDockerMemory": { + "label": "Show Docker memory chart" + }, + "showDockerNetwork": { + "label": "Show Docker network chart" + } + }, + "selectSystem": "Select system", + "chart": { + "cpu": { + "title": "CPU Usage", + "subtitle": "Average system-wide CPU utilization", + "series": "CPU" + }, + "memory": { + "title": "Memory Usage", + "subtitle": "Precise utilization at the recorded time", + "series": "Used", + "cache": "Cache/Buffers" + }, + "disk": { + "title": "Disk Usage", + "subtitle": "Usage of root partition", + "series": "Used" + }, + "diskIO": { + "title": "Disk I/O", + "subtitle": "Throughput of root filesystem", + "read": "Read", + "write": "Write" + }, + "network": { + "title": "Bandwidth", + "subtitle": "Network traffic of public interfaces", + "sent": "Sent", + "recv": "Received" + }, + "dockerCpu": { + "title": "Docker CPU Usage", + "subtitle": "Average CPU utilization of containers" + }, + "dockerMemory": { + "title": "Docker Memory Usage", + "subtitle": "Memory usage of docker containers" + }, + "dockerNetwork": { + "title": "Docker Network I/O", + "subtitle": "Network traffic of docker containers" + } + } } }, "widgetPreview": { @@ -3498,7 +3897,8 @@ "logs": "Logs", "api": "API & MCP", "certificates": "Certificates", - "tasks": "Tasks" + "tasks": "Tasks", + "backup": "Backup" } }, "settings": "Settings", @@ -4127,6 +4527,125 @@ } } } + }, + "backup": { + "title": "Full Database Backup", + "metaTitle": "Backup & Restore", + "export": { + "title": "Export Database", + "description": "Download a full backup of your SQLite database including all settings, users, boards, and encrypted integration secrets. The backup is bundled as a ZIP file with the encryption key and metadata.", + "button": "Download Backup", + "error": "Export failed", + "confirm": { + "title": "Sensitive data export", + "warningTitle": "This export contains your entire database.", + "warningBody": "The backup includes all users, integration secrets (encrypted), API keys, board configurations, and app data. Store the exported file securely and do not share it.", + "typePrompt": "Type \"{phrase}\" to continue", + "submit": "Export Database", + "cancel": "Cancel" + } + }, + "import": { + "title": "Restore Database", + "description": "Upload a previously exported backup ZIP to restore your database. This will replace your entire database. If the backup was created with a different encryption key, secrets will be automatically re-encrypted.", + "warning": "Restoring a backup will completely replace your current database and restart the application. This action cannot be undone.", + "dropzone": { + "title": "Drop your backup ZIP here or click to browse", + "description": "Only .zip files from a Homarr SQLite backup are accepted" + }, + "changeFile": "Change file", + "button": "Restore Database", + "success": { + "title": "Restore Complete", + "message": "Database has been restored successfully. The application is restarting..." + }, + "error": "Import failed" + }, + "preview": { + "title": "Backup Contents", + "exported": "Exported {date}", + "loading": "Analyzing backup file...", + "errorTitle": "Preview Error", + "entity": "Entity", + "count": "Count", + "boards": "Boards", + "entities": { + "board": "Boards", + "user": "Users", + "app": "Apps", + "integration": "Integrations", + "item": "Widgets", + "media": "Media", + "search_engine": "Search Engines" + }, + "analyzeError": "Failed to analyze backup file", + "invalidMissingMetadata": "Invalid backup: missing metadata.json", + "invalidMissingDb": "Invalid backup: missing db.sqlite" + }, + "restore": { + "dropzone": { + "title": "Drop your backup ZIP here", + "description": "Accepts .zip files from a Homarr SQLite backup (max 256 MB)" + }, + "analyzing": "Analyzing backup...", + "changeFile": "Change file", + "continueToRestore": "Continue to restore", + "tryAgain": "Try again", + "analysisError": "Analysis Error", + "preview": { + "title": "Backup Contents", + "exported": "Exported {date}", + "entity": "Entity", + "count": "Count", + "boards": "Boards", + "entities": { + "board": "Boards", + "user": "Users", + "app": "Apps", + "integration": "Integrations", + "item": "Widgets", + "media": "Media", + "search_engine": "Search Engines" + }, + "migrationsApplied": "{count} migration applied", + "migrationsAppliedPlural": "{count} migrations applied", + "migrationsOlderVersion": "This backup was from an older Homarr version. {count} schema migration was applied to bring it up to date.", + "migrationsOlderVersionPlural": "This backup was from an older Homarr version. {count} schema migrations were applied to bring it up to date.", + "migrationsUpToDate": "Database schema is up to date ({count} migrations applied)", + "upToDate": "Up to date", + "migrationsPending": "{count} pending" + }, + "migration": { + "title": "Applying migrations ({current}/{total})", + "migrationLabel": "Migration {index}" + }, + "confirm": { + "title": "Destructive action", + "warningTitle": "This will permanently replace your entire database.", + "warningBody": "All current boards, users, apps, integrations, settings, and sessions will be lost. This action cannot be undone. The application will restart after the restore.", + "typePrompt": "Type \"{phrase}\" to continue", + "cancel": "Cancel", + "submit": "Restore Database" + }, + "progress": { + "title": "Restoring database...", + "extracting": "Extracting backup archive", + "migrating": "Applying database migrations", + "encrypting": "Re-encrypting integration secrets", + "swapping": "Replacing database file", + "restarting": "Restarting server" + }, + "complete": { + "title": "Restore Complete", + "message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds." + }, + "failed": { + "title": "Restore Failed" + }, + "invalidMissingMetadata": "Invalid backup: missing metadata.json", + "invalidMissingDb": "Invalid backup: missing db.sqlite", + "analyzeError": "Failed to analyze backup" + } } }, "about": { diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 9c80d4bd8..37f36daf8 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -70,6 +70,8 @@ "@tiptap/starter-kit": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", + "lucide-react": "catalog:", + "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", "next": "catalog:", "react": "catalog:", diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx new file mode 100644 index 000000000..3b17223b8 --- /dev/null +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -0,0 +1,125 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { Badge, Text } from "@mantine/core"; +import { IconHeadphones } from "@tabler/icons-react"; + +import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import classes from "./component.module.css"; +import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared"; +import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared"; + +const rootClassByCompact = { + true: classes.rootCompact, + false: "", +} as const; + +const gridClassByCompact = { + true: classes.gridCompact, + false: "", +} as const; + +const statTileClassByCompact = { + true: classes.statTileCompact, + false: "", +} as const; + +interface AudioStatsContentProps { + backend: AudioStatsBackend; + stats: NavidromeDashboardData | AudiobookshelfDashboardData; + options: AudioStatsDisplayOptions; + width: number; +} + +export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) { + const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE); + const visibleStats = getVisibleStats(backend, options, stats); + const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact; + const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false); + const iconSize = getIconSize(width, options.compactMode ?? false); + + const navidromeStatsByBackend: Record = { + navidrome: stats as NavidromeDashboardData, + audiobookshelf: null, + }; + const navidromeStats = navidromeStatsByBackend[backend]; + const showNowPlayingSection = + navidromeStats !== null && Boolean(options.showNowPlaying) && navidromeStats.nowPlaying.length > 0; + + const nowPlayingLimitByListVisibility = { + true: options.maxNowPlayingItems ?? 3, + false: 0, + } as const; + + const nowPlayingTracks = + navidromeStats?.nowPlaying.slice( + 0, + nowPlayingLimitByListVisibility[ + String(options.showNowPlayingList) as keyof typeof nowPlayingLimitByListVisibility + ], + ) ?? []; + + const hasContent = visibleStats.length > 0 || showNowPlayingSection; + + return ( +
+ {visibleStats.length > 0 && ( +
+ {visibleStats.map(({ optionKey, statKey, value, Icon }) => ( +
+ + {value} + {t(statKey as never)} +
+ ))} +
+ )} + + {showNowPlayingSection && navidromeStats && ( +
+
+ + {t("nowPlaying" as never)} + {!options.showNowPlayingList && ( + + {(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", { + count: navidromeStats.nowPlaying.length, + })} + + )} +
+ {options.showNowPlayingList && ( +
+ {nowPlayingTracks.map((track, index) => ( +
+
+ {track.title} + + {[track.artist, track.album].filter(Boolean).join(" · ")} + +
+ + {track.username || track.playerName} + +
+ ))} +
+ )} +
+ )} + + {!hasContent && ( +
+ + — + +
+ )} +
+ ); +} diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts new file mode 100644 index 000000000..3aab75060 --- /dev/null +++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vitest"; + +import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; + +import { getGridCols, getIconSize, getVisibleStats } from "./shared"; + +describe("getGridCols", () => { + test("caps columns to visible stat count", () => { + expect(getGridCols(500, 2, false)).toBe(2); + }); + + test("returns max cols when many stats visible", () => { + expect(getGridCols(500, 10, false)).toBe(5); + }); + + test("compact mode uses smaller breakpoints", () => { + expect(getGridCols(290, 5, true)).toBe(5); + expect(getGridCols(290, 5, false)).toBe(2); + }); + + test("returns at least 1 col even with 0 visible stats", () => { + expect(getGridCols(500, 0, false)).toBe(1); + }); +}); + +describe("getIconSize", () => { + test("returns large size for wide container", () => { + expect(getIconSize(400, false)).toBe(22); + }); + + test("returns compact size for narrow container in compact mode", () => { + expect(getIconSize(100, true)).toBe(14); + }); + + test("returns fallback for very narrow non-compact container", () => { + expect(getIconSize(50, false)).toBe(16); + }); +}); + +describe("getVisibleStats", () => { + const navidromeStats: NavidromeDashboardData = { + artistCount: 100, + albumCount: 200, + songCount: 3000, + nowPlaying: [], + }; + + const audiobookshelfStats: AudiobookshelfDashboardData = { + libraryCount: 3, + totalAudiobooks: 150, + totalPodcasts: 20, + totalListeningTimeSeconds: 86400, + activeSessions: 2, + }; + + test("returns only enabled navidrome stats", () => { + const result = getVisibleStats( + "navidrome", + { showArtists: true, showAlbums: false, showSongs: true }, + navidromeStats, + ); + expect(result).toHaveLength(2); + expect(result.map((s) => s.statKey)).toEqual(["artists", "songs"]); + expect(result[0]?.value).toBe(100); + expect(result[1]?.value).toBe(3000); + }); + + test("returns only enabled audiobookshelf stats", () => { + const result = getVisibleStats( + "audiobookshelf", + { + showLibraryCount: true, + showAudiobooks: true, + showPodcasts: false, + showListeningTime: false, + showActiveSessions: true, + }, + audiobookshelfStats, + ); + expect(result).toHaveLength(3); + expect(result.map((s) => s.statKey)).toEqual(["libraryCount", "audiobooks", "activeSessions"]); + }); + + test("returns empty array when all options disabled", () => { + const result = getVisibleStats("navidrome", {}, navidromeStats); + expect(result).toEqual([]); + }); + + test("formats listening time as duration string", () => { + const result = getVisibleStats("audiobookshelf", { showListeningTime: true }, audiobookshelfStats); + expect(result).toHaveLength(1); + expect(typeof result[0]?.value).toBe("string"); + }); +}); diff --git a/packages/widgets/src/audio-stats/component.module.css b/packages/widgets/src/audio-stats/component.module.css new file mode 100644 index 000000000..f665c8028 --- /dev/null +++ b/packages/widgets/src/audio-stats/component.module.css @@ -0,0 +1,185 @@ +.root { + container-type: size; + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: clamp(6px, 3cqi, 12px); + gap: clamp(4px, 2cqi, 8px); + overflow: hidden; +} + +.rootCompact { + padding: clamp(4px, 2cqi, 8px); + gap: clamp(2px, 1cqi, 4px); +} + +.grid { + display: grid; + flex: 1; + width: 100%; + min-height: 0; + gap: clamp(4px, 2cqi, 8px); + grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-content: stretch; +} + +.gridCompact { + gap: clamp(2px, 1cqi, 4px); +} + +.statTile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; + min-width: 0; + height: 100%; + gap: clamp(2px, 1.5cqi, 4px); + padding: clamp(4px, 2cqi, 8px); + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + overflow: hidden; +} + +.statTileCompact { + gap: clamp(1px, 0.8cqi, 2px); + padding: clamp(2px, 1.2cqi, 5px); + border-radius: var(--mantine-radius-sm); +} + +.statIcon { + color: var(--mantine-color-dimmed); + flex-shrink: 0; + line-height: 0; +} + +.statLabel { + font-size: clamp(8px, 2.5cqi, 11px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statTileCompact .statLabel { + font-size: clamp(7px, 2cqi, 9px); +} + +.statValue { + font-size: clamp(13px, 5cqi, 20px); + font-weight: 700; + color: var(--mantine-primary-color-filled); + line-height: 1.1; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statTileCompact .statValue { + font-size: clamp(11px, 4cqi, 16px); +} + +.nowPlaying { + display: flex; + flex-direction: column; + gap: clamp(3px, 1.2cqi, 5px); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.nowPlayingHeader { + display: flex; + align-items: center; + gap: clamp(4px, 1.5cqi, 6px); + font-size: clamp(10px, 2.8cqi, 12px); + font-weight: 600; + flex-shrink: 0; + min-width: 0; +} + +.nowPlayingTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nowPlayingCount { + flex-shrink: 0; + margin-inline-start: auto; +} + +.nowPlayingList { + display: flex; + flex-direction: column; + gap: clamp(2px, 0.8cqi, 4px); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.nowPlayingItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(4px, 1.5cqi, 6px); + padding: clamp(3px, 1.2cqi, 6px) clamp(4px, 1.5cqi, 8px); + border-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + min-width: 0; + flex-shrink: 0; +} + +.trackInfo { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.trackTitle { + font-size: clamp(9px, 2.6cqi, 12px); + font-weight: 500; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trackArtist { + font-size: clamp(8px, 2.2cqi, 10px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trackUser { + flex-shrink: 0; + max-width: clamp(40px, 22cqi, 88px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emptyState { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + color: var(--mantine-color-dimmed); + font-size: clamp(11px, 3cqi, 13px); + text-align: center; +} diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx new file mode 100644 index 000000000..ff2b56863 --- /dev/null +++ b/packages/widgets/src/audio-stats/component.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { clientApi } from "@homarr/api/client"; + +import type { WidgetComponentProps } from "../definition"; +import { AudioStatsContent } from "./audio-stats-content"; + +export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) { + const [response] = clientApi.widget.audioStats.getStats.useSuspenseQuery({ + integrationId: integrationIds[0] ?? "", + }); + + return ; +} diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts new file mode 100644 index 000000000..25af0bdaf --- /dev/null +++ b/packages/widgets/src/audio-stats/index.ts @@ -0,0 +1,62 @@ +import { IconHeadphones, IconServerOff } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +const hideUnlessNavidrome = { + shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("navidrome"), +}; + +const hideUnlessAudiobookshelf = { + shouldHide: (_: unknown, integrationKinds: string[]) => !integrationKinds.includes("audiobookshelf"), +}; + +export const { definition, componentLoader } = createWidgetDefinition("audioStats", { + icon: IconHeadphones, + supportedIntegrations: ["navidrome", "audiobookshelf"], + integrationsRequired: true, + maxIntegrations: 1, + createOptions() { + return optionsBuilder.from( + (factory) => ({ + showArtists: factory.switch({ defaultValue: true, withDescription: true }), + showAlbums: factory.switch({ defaultValue: true, withDescription: true }), + showSongs: factory.switch({ defaultValue: true, withDescription: true }), + showNowPlaying: factory.switch({ defaultValue: true, withDescription: true }), + showNowPlayingList: factory.switch({ defaultValue: true, withDescription: true }), + maxNowPlayingItems: factory.number({ + defaultValue: 3, + validate: z.number().min(1).max(5), + step: 1, + withDescription: true, + }), + showLibraryCount: factory.switch({ defaultValue: true, withDescription: true }), + showAudiobooks: factory.switch({ defaultValue: true, withDescription: true }), + showPodcasts: factory.switch({ defaultValue: true, withDescription: true }), + showListeningTime: factory.switch({ defaultValue: true, withDescription: true }), + showActiveSessions: factory.switch({ defaultValue: true, withDescription: true }), + compactMode: factory.switch({ defaultValue: false, withDescription: true }), + }), + { + showArtists: hideUnlessNavidrome, + showAlbums: hideUnlessNavidrome, + showSongs: hideUnlessNavidrome, + showNowPlaying: hideUnlessNavidrome, + showNowPlayingList: hideUnlessNavidrome, + maxNowPlayingItems: hideUnlessNavidrome, + showLibraryCount: hideUnlessAudiobookshelf, + showAudiobooks: hideUnlessAudiobookshelf, + showPodcasts: hideUnlessAudiobookshelf, + showListeningTime: hideUnlessAudiobookshelf, + showActiveSessions: hideUnlessAudiobookshelf, + }, + ); + }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.audioStats.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts new file mode 100644 index 000000000..39fc9b0b6 --- /dev/null +++ b/packages/widgets/src/audio-stats/shared.ts @@ -0,0 +1,169 @@ +import type { TablerIcon } from "@tabler/icons-react"; +import { + IconAlbum, + IconBook, + IconBooks, + IconDisc, + IconHeadphones, + IconMicrophone, + IconMicrophone2, + IconPlayerPlay, +} from "@tabler/icons-react"; + +import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; +import { formatDuration } from "@homarr/common"; + +export type AudioStatsBackend = "navidrome" | "audiobookshelf"; + +export interface NavidromeDisplayOptions { + showArtists: boolean; + showAlbums: boolean; + showSongs: boolean; + showNowPlaying: boolean; + showNowPlayingList: boolean; + maxNowPlayingItems: number; + compactMode?: boolean; +} + +export interface AudiobookshelfDisplayOptions { + showLibraryCount: boolean; + showAudiobooks: boolean; + showPodcasts: boolean; + showListeningTime: boolean; + showActiveSessions: boolean; + compactMode: boolean; +} + +export type AudioStatsDisplayOptions = Partial; + +const navidromeStatVisibilityOptions = { + showArtists: "artists", + showAlbums: "albums", + showSongs: "songs", +} as const; + +const audiobookshelfStatVisibilityOptions = { + showLibraryCount: "libraryCount", + showAudiobooks: "audiobooks", + showPodcasts: "podcasts", + showListeningTime: "listeningTime", + showActiveSessions: "activeSessions", +} as const; + +const statVisibilityOptionsByBackend = { + navidrome: navidromeStatVisibilityOptions, + audiobookshelf: audiobookshelfStatVisibilityOptions, +} as const; + +const navidromeStatIcons = { + artists: IconMicrophone2, + albums: IconAlbum, + songs: IconDisc, +} as const; + +const audiobookshelfStatIcons = { + libraryCount: IconBooks, + audiobooks: IconBook, + podcasts: IconMicrophone, + listeningTime: IconHeadphones, + activeSessions: IconPlayerPlay, +} as const; + +const statIconsByBackend = { + navidrome: navidromeStatIcons, + audiobookshelf: audiobookshelfStatIcons, +} as const; + +const gridColsByCompact = { + true: [ + { minWidth: 280, cols: 5 }, + { minWidth: 180, cols: 3 }, + { minWidth: 110, cols: 2 }, + { minWidth: 0, cols: 1 }, + ], + false: [ + { minWidth: 420, cols: 5 }, + { minWidth: 300, cols: 3 }, + { minWidth: 180, cols: 2 }, + { minWidth: 0, cols: 1 }, + ], +} as const; + +const iconSizeByCompact = { + true: [ + { minWidth: 260, size: 18 }, + { minWidth: 160, size: 16 }, + { minWidth: 0, size: 14 }, + ], + false: [ + { minWidth: 320, size: 22 }, + { minWidth: 200, size: 18 }, + { minWidth: 0, size: 16 }, + ], +} as const; + +export interface VisibleStat { + optionKey: string; + statKey: string; + value: string | number; + Icon: TablerIcon; +} + +const getNavidromeStatValues = (stats: NavidromeDashboardData) => + ({ + artists: stats.artistCount, + albums: stats.albumCount, + songs: stats.songCount, + }) as const; + +const getAudiobookshelfStatValues = (stats: AudiobookshelfDashboardData) => + ({ + libraryCount: stats.libraryCount, + audiobooks: stats.totalAudiobooks, + podcasts: stats.totalPodcasts, + listeningTime: formatDuration(stats.totalListeningTimeSeconds * 1000), + activeSessions: stats.activeSessions, + }) as const; + +const statValueGetterByBackend = { + navidrome: (stats: NavidromeDashboardData | AudiobookshelfDashboardData) => + getNavidromeStatValues(stats as NavidromeDashboardData), + audiobookshelf: (stats: NavidromeDashboardData | AudiobookshelfDashboardData) => + getAudiobookshelfStatValues(stats as AudiobookshelfDashboardData), +} as const; + +export const getVisibleStats = ( + backend: AudioStatsBackend, + options: AudioStatsDisplayOptions, + stats: NavidromeDashboardData | AudiobookshelfDashboardData, +): VisibleStat[] => { + const visibilityOptions = statVisibilityOptionsByBackend[backend]; + const statIcons = statIconsByBackend[backend]; + const statValues = statValueGetterByBackend[backend](stats); + + return Object.entries(visibilityOptions) + .filter(([optionKey]) => Boolean(options[optionKey as keyof typeof visibilityOptions])) + .map(([optionKey, statKey]) => ({ + optionKey, + statKey, + value: statValues[statKey as keyof typeof statValues], + Icon: statIcons[statKey as keyof typeof statIcons], + })); +}; + +export const getGridCols = (width: number, visibleCount: number, compact: boolean): number => { + const colsTable = gridColsByCompact[String(compact) as keyof typeof gridColsByCompact]; + const match = colsTable.find(({ minWidth }) => width >= minWidth); + const maxCols = match?.cols ?? 1; + return Math.min(maxCols, Math.max(visibleCount, 1)); +}; + +const fallbackIconSize = { true: 14, false: 16 } as const; + +export const getIconSize = (width: number, compact: boolean): number => { + const sizeTable = iconSizeByCompact[String(compact) as keyof typeof iconSizeByCompact]; + const match = sizeTable.find(({ minWidth }) => width >= minWidth); + return match?.size ?? fallbackIconSize[String(compact) as keyof typeof fallbackIconSize]; +}; + +export const AUDIO_STATS_TRANSLATION_SCOPE = "widget.audioStats" as const; diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx new file mode 100644 index 000000000..167b2ce1d --- /dev/null +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useMemo } from "react"; +import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core"; +import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import type { LucideIcon } from "lucide-react"; +import { Activity, Battery, Cpu, HardDrive, MemoryStick, Monitor, Network, Server, Thermometer } from "lucide-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +const alertIconMap: Record = { + 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 = {}; + 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 ( + + + {alerts.length === 0 && ( + + + + + + {t("empty")} + + + )} + + {triggeredAlerts.length > 0 && ( + + + + + {t("status.triggered")} ({triggeredAlerts.length}) + + + {triggeredAlerts.map((alert) => ( + + ))} + + )} + + {triggeredAlerts.length > 0 && okAlerts.length > 0 && } + + {okAlerts.length > 0 && ( + + + + + {t("status.ok")} ({okAlerts.length}) + + + {okAlerts.map((alert) => ( + + ))} + + )} + + {options.showHistory && history.length > 0 && ( + <> + + + + + + {t("history")} + + + {history.map((entry) => { + const systemName = systemNameMap[entry.system] ?? entry.system; + const isResolved = !!entry.resolved; + const HistoryIcon = alertIconMap[entry.name] ?? Server; + return ( + + + + + + + {entry.name} + + + {systemName} + + + + + + {isResolved ? t("resolved") : t("status.triggered")} + + + {dayjs(entry.created).fromNow()} + + + + ); + })} + + + )} + + + ); +} + +interface AlertRowProps { + name: string; + value: number; + min: number; + systemName: string; + triggered: boolean; +} + +const unitSuffixMap: Record = { + 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 ( + + + + + + {systemName} + + + · + + + {name} + + + + {formatAlertDescription(name, value, min)} + + + + ); +} diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts new file mode 100644 index 000000000..828c88b27 --- /dev/null +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -0,0 +1,17 @@ +import { IconBell } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { + icon: IconBell, + supportedIntegrations: ["beszel", "mock"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + showHistory: factory.switch({ defaultValue: true }), + maxHistoryItems: factory.number({ defaultValue: 10, validate: z.number().min(1).max(100) }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx new file mode 100644 index 000000000..f0a7c1ee0 --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core"; +import { + Activity, + Battery, + BatteryCharging, + Cpu, + HardDrive, + MemoryStick, + Monitor, + Network, + Server, + Thermometer, + Wifi, +} from "lucide-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import type { BeszelSystemRow } from "../beszel/_shared/types"; +import { statusColorMap, thresholdColor } from "../beszel/_shared/colors"; +import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; +import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; + +interface SizeConfig { + iconSize: number; + fontSize: string; + progressSize: "xs" | "sm"; + labelMiw: number; + valueMiw: number; + rowHeight: number; + cardPadding: number; + badgeHeight: number; + badgeSize: "xs" | "sm"; + gap: number; +} + +const defaultSizeConfig: SizeConfig = { + iconSize: 10, + fontSize: "10px", + progressSize: "xs", + labelMiw: 40, + valueMiw: 30, + rowHeight: 18, + cardPadding: 4, + badgeHeight: 20, + badgeSize: "xs", + gap: 2, +}; + +const sizeThresholds: [number, SizeConfig][] = [ + [0, defaultSizeConfig], + [ + 160, + { + iconSize: 12, + fontSize: "xs", + progressSize: "xs", + labelMiw: 50, + valueMiw: 34, + rowHeight: 22, + cardPadding: 6, + badgeHeight: 22, + badgeSize: "xs", + gap: 6, + }, + ], + [ + 280, + { + iconSize: 14, + fontSize: "xs", + progressSize: "sm", + labelMiw: 55, + valueMiw: 38, + rowHeight: 24, + cardPadding: 8, + badgeHeight: 26, + badgeSize: "sm", + gap: 8, + }, + ], +]; + +const getSizeConfig = (cellWidth: number, cellHeight: number): SizeConfig => { + const basis = Math.min(cellWidth, cellHeight); + let config = defaultSizeConfig; + for (const [threshold, cfg] of sizeThresholds) { + if (basis >= threshold) config = cfg; + } + return config; +}; + +const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => { + const available = cellHeight - size.cardPadding * 2 - size.badgeHeight - size.gap; + return Math.max(1, Math.floor(available / size.rowHeight)); +}; + +const MIN_CELL_WIDTH = 120; +const MIN_CELL_HEIGHT = 80; + +const getColCount = (width: number, itemCount: number): number => { + if (itemCount <= 1) return 1; + const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); + + let best = 1; + for (let c = 1; c <= maxCols; c++) { + const emptyCells = (c - (itemCount % c)) % c; + const bestEmpty = (best - (itemCount % best)) % best; + if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) { + best = c; + } + } + return best; +}; + +interface MetricRowProps { + icon: React.ReactNode; + label: string; + value: string; + progress?: { value: number; color: string }; + size: SizeConfig; +} + +const MetricValue = ({ value, progress, size }: Pick) => ( + + + {value} + + {progress && ( + + )} + +); + +const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( + + {icon} + + {label} + + + +); + +interface SystemCardProps { + system: BeszelSystemRow & { _key: string }; + options: WidgetComponentProps<"beszelSystemGrid">["options"]; + t: ReturnType>; + size: SizeConfig; + maxMetrics: number; +} + +const metricRenderers = [ + { + key: "showCpu", + render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => ( + } + 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) => ( + } + 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) => ( + } + 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) => ( + } + 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) => ( + } + 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) => ( + } + 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) => ( + } + 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 ( + } + 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) => ( + } + 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) => ( + } + 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) => ( + } + 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 ( + + + + {system.name} + + + + + {visibleMetrics.map((m) => m.render(system, t, size))} + + + ); +}; + +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 ( + + {filteredSystems.map((system) => ( + + ))} + + ); +} diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts new file mode 100644 index 000000000..fba380645 --- /dev/null +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -0,0 +1,27 @@ +import { IconLayoutGrid } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { statusOptions } from "../beszel/_shared/options"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { + icon: IconLayoutGrid, + supportedIntegrations: ["beszel", "mock"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + statusFilter: factory.select({ defaultValue: "all", options: statusOptions }), + showCpu: factory.switch({ defaultValue: true }), + showMemory: factory.switch({ defaultValue: true }), + showDisk: factory.switch({ defaultValue: true }), + showGpu: factory.switch({ defaultValue: true }), + showLoadAvg: factory.switch({ defaultValue: true }), + showNet: factory.switch({ defaultValue: true }), + showTemp: factory.switch({ defaultValue: true }), + showBattery: factory.switch({ defaultValue: true }), + showServices: factory.switch({ defaultValue: true }), + showUptime: factory.switch({ defaultValue: true }), + showAgent: factory.switch({ defaultValue: true }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx new file mode 100644 index 000000000..99ee0f732 --- /dev/null +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { containerColors } from "../beszel/_shared/colors"; +import { + BeszelAreaChart, + ChartPanel, + useContainerNames, + useDockerChartData, + useSystemChartData, +} from "../beszel/_shared/chart"; +import { + chartAxisFormatters, + formatByteRate, + formatGB, + formatPercent, + formatStorageBytes, +} from "../beszel/_shared/format"; +import { makeTooltipProps } from "../beszel/_shared/tooltip"; + +const tooltipPercent = makeTooltipProps(formatPercent); +const tooltipGB = makeTooltipProps(formatGB, true); +const tooltipRate = makeTooltipProps(formatByteRate, true); +const tooltipPercentTotal = makeTooltipProps(formatPercent, true); +const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true); + +const CHART_HEIGHT = 180; + +export default function BeszelSystemStatsWidget({ + options, + integrationIds, + isEditMode, + width, +}: WidgetComponentProps<"beszelSystemStats">) { + const t = useScopedI18n("widget.beszelSystemStats"); + const [systemsResult] = clientApi.widget.beszel.getSystems.useSuspenseQuery({ integrationIds }); + + const systems = useMemo( + () => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))), + [systemsResult], + ); + + const [selectedSystem, setSelectedSystem] = useState(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 ( + + +