Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db64ded3f0 | ||
|
|
fe35a9cf68 | ||
|
|
ad76ce9cdc | ||
|
|
ced96a57b3 | ||
|
|
302a9f4e54 | ||
|
|
69ed608241 | ||
|
|
9da8ea1dec | ||
|
|
2b4a18385d | ||
|
|
c9f003dadd |
No files matched your search
@@ -5,21 +5,9 @@ import "@homarr/common/env";
|
|||||||
import "@homarr/core/infrastructure/logs/env";
|
import "@homarr/core/infrastructure/logs/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
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
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -29,9 +17,6 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
env: {
|
|
||||||
HOMARR_VERSION: homarrVersion,
|
|
||||||
},
|
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
@@ -42,7 +27,7 @@ const nextConfig: NextConfig = {
|
|||||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
* 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
|
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
|||||||
@@ -72,13 +72,9 @@
|
|||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
|
||||||
"flag-icons": "catalog:",
|
"flag-icons": "catalog:",
|
||||||
"glob": "catalog:",
|
"glob": "catalog:",
|
||||||
"adm-zip": "catalog:",
|
|
||||||
"better-sqlite3": "catalog:",
|
|
||||||
"isomorphic-dompurify": "catalog:",
|
"isomorphic-dompurify": "catalog:",
|
||||||
"jszip": "catalog:",
|
|
||||||
"jotai": "catalog:",
|
"jotai": "catalog:",
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
@@ -89,7 +85,6 @@
|
|||||||
"react-error-boundary": "catalog:",
|
"react-error-boundary": "catalog:",
|
||||||
"react-simple-code-editor": "catalog:",
|
"react-simple-code-editor": "catalog:",
|
||||||
"sass": "catalog:",
|
"sass": "catalog:",
|
||||||
"sql.js": "catalog:",
|
|
||||||
"superjson": "catalog:",
|
"superjson": "catalog:",
|
||||||
"swagger-ui-react": "catalog:",
|
"swagger-ui-react": "catalog:",
|
||||||
"use-deep-compare-effect": "catalog:",
|
"use-deep-compare-effect": "catalog:",
|
||||||
@@ -98,8 +93,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/adm-zip": "catalog:",
|
|
||||||
"@types/better-sqlite3": "catalog:",
|
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/prismjs": "catalog:",
|
"@types/prismjs": "catalog:",
|
||||||
|
|||||||
Binary file not shown.
@@ -2,16 +2,13 @@ import { Stack, Text } from "@mantine/core";
|
|||||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
import { InitSqliteRestore } from "./sqlite-restore";
|
|
||||||
import { InitStartButton } from "./next-button";
|
import { InitStartButton } from "./next-button";
|
||||||
|
|
||||||
export const InitStart = async () => {
|
export const InitStart = async () => {
|
||||||
const tStart = await getScopedI18n("init.step.start");
|
const tStart = await getScopedI18n("init.step.start");
|
||||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<InitStepCard>
|
||||||
@@ -30,7 +27,6 @@ export const InitStart = async () => {
|
|||||||
>
|
>
|
||||||
{tStart("action.importOldmarr")}
|
{tStart("action.importOldmarr")}
|
||||||
</InitStartButton>
|
</InitStartButton>
|
||||||
{isSqlite && <InitSqliteRestore />}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, Collapse, Stack, Text } from "@mantine/core";
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
|
||||||
|
|
||||||
export const InitSqliteRestore = () => {
|
|
||||||
const t = useScopedI18n("init.step.start.action");
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
>
|
|
||||||
{t("backToOptions")}
|
|
||||||
</Button>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("restoreSqliteDescription")}
|
|
||||||
</Text>
|
|
||||||
<DatabaseRestoreFlow variant="standalone" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
|
||||||
>
|
|
||||||
{t("restoreSqlite")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+2
@@ -8,6 +8,7 @@ import {
|
|||||||
IconPasswordUser,
|
IconPasswordUser,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconServer,
|
IconServer,
|
||||||
|
IconTag,
|
||||||
IconUser,
|
IconUser,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -28,4 +29,5 @@ export const integrationSecretIcons = {
|
|||||||
githubAppId: IconCode,
|
githubAppId: IconCode,
|
||||||
githubInstallationId: IconPlug,
|
githubInstallationId: IconPlug,
|
||||||
privateKey: IconKey,
|
privateKey: IconKey,
|
||||||
|
slug: IconTag,
|
||||||
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
} satisfies Record<IntegrationSecretKind, TablerIcon>;
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
IconBrandTablerFilled,
|
IconBrandTablerFilled,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconClipboardListFilled,
|
IconClipboardListFilled,
|
||||||
IconDatabaseExport,
|
|
||||||
IconDirectionsFilled,
|
IconDirectionsFilled,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHelpSquareRoundedFilled,
|
IconHelpSquareRoundedFilled,
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -156,12 +154,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("items.tools.items.backup"),
|
|
||||||
icon: IconDatabaseExport,
|
|
||||||
href: "/manage/tools/backup",
|
|
||||||
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
|
|
||||||
|
|
||||||
export const BackupExportCard = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.export");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/backup/export");
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = await response.json().then(
|
|
||||||
(data) => data.error,
|
|
||||||
() => `Server returned ${response.status}`,
|
|
||||||
);
|
|
||||||
throw new Error(errorMessage ?? t("error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
|
||||||
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
setShowConfirm(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("error"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconDatabaseExport size={24} />
|
|
||||||
<Title order={4}>{t("title")}</Title>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("description")}
|
|
||||||
</Text>
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showConfirm ? (
|
|
||||||
<DangerousActionConfirmation
|
|
||||||
title={t("confirm.title")}
|
|
||||||
warningTitle={t("confirm.warningTitle")}
|
|
||||||
warningBody={t("confirm.warningBody")}
|
|
||||||
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
|
||||||
submitLabel={t("confirm.submit")}
|
|
||||||
submitIcon={<IconDownload size={16} />}
|
|
||||||
cancelLabel={t("confirm.cancel")}
|
|
||||||
onConfirm={handleExport}
|
|
||||||
onCancel={() => setShowConfirm(false)}
|
|
||||||
disabled={loading}
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Group>
|
|
||||||
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
|
||||||
{t("button")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
|
||||||
|
|
||||||
export const BackupImportCard = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.import");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconDatabaseImport size={24} />
|
|
||||||
<Title order={4}>{t("title")}</Title>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("description")}
|
|
||||||
</Text>
|
|
||||||
<DatabaseRestoreFlow />
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Stack, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { BackupExportCard } from "./_components/backup-export-card";
|
|
||||||
import { BackupImportCard } from "./_components/backup-import-card";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.tool.backup");
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: createMetaTitle(t("metaTitle")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BackupPage() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.tool.backup");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("title")}</Title>
|
|
||||||
<BackupExportCard />
|
|
||||||
<BackupImportCard />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import Database from "better-sqlite3";
|
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { env } from "@homarr/common/env";
|
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbPath = dbEnv.URL as string | undefined;
|
|
||||||
if (!dbPath || !fs.existsSync(dbPath)) {
|
|
||||||
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
|
|
||||||
let sourceDb: InstanceType<typeof Database> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
sourceDb = new Database(dbPath, { readonly: true });
|
|
||||||
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
|
|
||||||
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
|
|
||||||
sourceDb.close();
|
|
||||||
sourceDb = null;
|
|
||||||
|
|
||||||
const dbBuffer = fs.readFileSync(tempPath);
|
|
||||||
|
|
||||||
const metadata = {
|
|
||||||
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
dbDialect: "sqlite",
|
|
||||||
encryptionKey: env.SECRET_ENCRYPTION_KEY,
|
|
||||||
};
|
|
||||||
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", dbBuffer);
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
|
|
||||||
|
|
||||||
const zipBuffer = zip.toBuffer();
|
|
||||||
|
|
||||||
const date = new Date().toISOString().split("T")[0];
|
|
||||||
const filename = `homarr-backup-${date}.zip`;
|
|
||||||
|
|
||||||
return new NextResponse(zipBuffer, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/zip",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Content-Length": zipBuffer.length.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[backup/export] Export failed:", error);
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
|
|
||||||
} finally {
|
|
||||||
sourceDb?.close();
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import Database from "better-sqlite3";
|
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { env } from "@homarr/common/env";
|
|
||||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { db } from "@homarr/db";
|
|
||||||
|
|
||||||
import { findMigrationsFolder } from "../shared";
|
|
||||||
|
|
||||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
|
||||||
const ALGORITHM = "aes-256-cbc";
|
|
||||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
|
||||||
|
|
||||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
|
||||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
|
||||||
if (importedKeyHex === currentKeyHex) return;
|
|
||||||
|
|
||||||
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
|
|
||||||
throw new Error("Imported encryption key is not a valid 64-character hex string");
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldKey = Buffer.from(importedKeyHex, "hex");
|
|
||||||
const newKey = Buffer.from(currentKeyHex, "hex");
|
|
||||||
|
|
||||||
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
|
|
||||||
integration_id: string;
|
|
||||||
kind: string;
|
|
||||||
value: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
|
|
||||||
const transaction = tempDb.transaction(() => {
|
|
||||||
for (const row of rows) {
|
|
||||||
const parts = row.value.split(".");
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
throw new Error(
|
|
||||||
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const [data, dataIv] = parts as [string, string];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const iv = Buffer.from(dataIv, "hex");
|
|
||||||
const encrypted = Buffer.from(data, "hex");
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
|
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
|
||||||
|
|
||||||
const newIv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
|
|
||||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
|
||||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
|
||||||
|
|
||||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
transaction();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOnboardingActiveAsync = async (): Promise<boolean> => {
|
|
||||||
const onboardingRow = await db.query.onboarding.findFirst();
|
|
||||||
if (!onboardingRow) return false;
|
|
||||||
return onboardingRow.step === "start";
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
|
||||||
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
|
|
||||||
|
|
||||||
if (!isAdmin && !isOnboarding) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbPath = dbEnv.URL as string | undefined;
|
|
||||||
if (!dbPath) {
|
|
||||||
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let formData: FormData;
|
|
||||||
try {
|
|
||||||
formData = await req.formData();
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = formData.get("file");
|
|
||||||
if (!(file instanceof File)) {
|
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
let zip: AdmZip;
|
|
||||||
try {
|
|
||||||
zip = new AdmZip(Buffer.from(arrayBuffer));
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = zip.getEntries().map((e) => e.entryName);
|
|
||||||
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
|
||||||
if (missingEntries.length > 0) {
|
|
||||||
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrationsFolder = findMigrationsFolder();
|
|
||||||
if (!migrationsFolder) {
|
|
||||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
|
|
||||||
let tempDb: InstanceType<typeof Database> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dbEntry = zip.getEntry("db.sqlite")!;
|
|
||||||
fs.writeFileSync(tempPath, dbEntry.getData());
|
|
||||||
|
|
||||||
tempDb = new Database(tempPath);
|
|
||||||
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
|
|
||||||
migrate(drizzleDb, { migrationsFolder });
|
|
||||||
|
|
||||||
const metadataEntry = zip.getEntry("metadata.json")!;
|
|
||||||
const metadata = JSON.parse(metadataEntry.getData().toString());
|
|
||||||
|
|
||||||
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
|
|
||||||
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
|
|
||||||
|
|
||||||
const secretCount = (
|
|
||||||
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
|
|
||||||
).count;
|
|
||||||
|
|
||||||
if (secretCount > 0 && !importedKey) {
|
|
||||||
throw new Error(
|
|
||||||
"Backup contains integration secrets but no encryption key. " +
|
|
||||||
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (importedKey) {
|
|
||||||
reEncryptSecrets(tempDb, importedKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
tempDb.close();
|
|
||||||
tempDb = null;
|
|
||||||
|
|
||||||
fs.renameSync(tempPath, dbPath);
|
|
||||||
|
|
||||||
for (const suffix of ["-wal", "-shm"] as const) {
|
|
||||||
try {
|
|
||||||
const sidecar = `${dbPath}${suffix}`;
|
|
||||||
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log("Database restored, restarting server...");
|
|
||||||
process.exit(0);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[backup/import] Restore failed:", error);
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
|
||||||
} finally {
|
|
||||||
tempDb?.close();
|
|
||||||
if (fs.existsSync(tempPath)) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(tempPath);
|
|
||||||
} catch {
|
|
||||||
// temp file may have been renamed already
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { findMigrationsFolder } from "../shared";
|
|
||||||
|
|
||||||
interface JournalEntry {
|
|
||||||
idx: number;
|
|
||||||
version: string;
|
|
||||||
when: number;
|
|
||||||
tag: string;
|
|
||||||
breakpoints: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MigrationFile {
|
|
||||||
idx: number;
|
|
||||||
tag: string;
|
|
||||||
sql: string;
|
|
||||||
when: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const migrationsFolder = findMigrationsFolder();
|
|
||||||
if (!migrationsFolder) {
|
|
||||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
|
|
||||||
if (!fs.existsSync(journalPath)) {
|
|
||||||
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
|
|
||||||
|
|
||||||
const migrations: MigrationFile[] = [];
|
|
||||||
for (const entry of journal.entries) {
|
|
||||||
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
|
|
||||||
if (!fs.existsSync(sqlPath)) continue;
|
|
||||||
|
|
||||||
migrations.push({
|
|
||||||
idx: entry.idx,
|
|
||||||
tag: entry.tag,
|
|
||||||
sql: fs.readFileSync(sqlPath, "utf-8"),
|
|
||||||
when: entry.when,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ total: migrations.length, migrations },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, max-age=3600",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[backup/migrations] Failed to read migrations:", error);
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const migrationsFolderCandidates = [
|
|
||||||
path.join(process.cwd(), "db/migrations/sqlite"),
|
|
||||||
path.join(process.cwd(), "packages/db/migrations/sqlite"),
|
|
||||||
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
|
|
||||||
"/app/db/migrations/sqlite",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const findMigrationsFolder = () =>
|
|
||||||
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const wasmCandidates = [
|
|
||||||
path.join(process.cwd(), "public/sql-wasm.wasm"),
|
|
||||||
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
|
|
||||||
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
|
|
||||||
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let cachedWasm: Buffer | null = null;
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
if (!cachedWasm) {
|
|
||||||
const found = wasmCandidates.find((p) => fs.existsSync(p));
|
|
||||||
if (!found) {
|
|
||||||
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
|
|
||||||
}
|
|
||||||
cachedWasm = fs.readFileSync(found);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(cachedWasm, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/wasm",
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import fs from "fs";
|
|
||||||
import os from "os";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import Database from "better-sqlite3";
|
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
|
||||||
|
|
||||||
const ALGORITHM = "aes-256-cbc";
|
|
||||||
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
|
||||||
|
|
||||||
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
|
|
||||||
|
|
||||||
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
|
|
||||||
const key = Buffer.from(keyHex, "hex");
|
|
||||||
const iv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
||||||
let encrypted = cipher.update(text);
|
|
||||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
||||||
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
|
|
||||||
const key = Buffer.from(keyHex, "hex");
|
|
||||||
const [data, dataIv] = value.split(".") as [string, string];
|
|
||||||
const iv = Buffer.from(dataIv, "hex");
|
|
||||||
const encrypted = Buffer.from(data, "hex");
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
||||||
let decrypted = decipher.update(encrypted);
|
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
||||||
return decrypted.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTestDb = (dbPath: string) => {
|
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
const db = drizzle(sqlite, { casing: DB_CASING });
|
|
||||||
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
||||||
return { db, sqlite };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("SQLite backup", () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("export ZIP structure", () => {
|
|
||||||
it("should create a ZIP containing all required entries", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
|
||||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
|
||||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
||||||
sqlite.close();
|
|
||||||
|
|
||||||
const dbBuffer = fs.readFileSync(backupPath);
|
|
||||||
const metadata = {
|
|
||||||
homarrVersion: "test",
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
dbDialect: "sqlite",
|
|
||||||
encryptionKey: generateHexKey(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", dbBuffer);
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
|
|
||||||
|
|
||||||
const zipPath = path.join(tmpDir, "backup.zip");
|
|
||||||
zip.writeZip(zipPath);
|
|
||||||
|
|
||||||
const readBack = new AdmZip(zipPath);
|
|
||||||
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
|
||||||
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
|
||||||
|
|
||||||
const dbEntry = readBack.getEntry("db.sqlite");
|
|
||||||
expect(dbEntry).toBeDefined();
|
|
||||||
expect(dbEntry!.header.size).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should produce a valid SQLite database in the ZIP", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
|
||||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
||||||
sqlite.close();
|
|
||||||
|
|
||||||
const backupDb = new Database(backupPath, { readonly: true });
|
|
||||||
const tables = backupDb
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
||||||
.all() as { name: string }[];
|
|
||||||
|
|
||||||
expect(tables.map((t) => t.name)).toContain("board");
|
|
||||||
expect(tables.map((t) => t.name)).toContain("user");
|
|
||||||
expect(tables.map((t) => t.name)).toContain("integrationSecret");
|
|
||||||
|
|
||||||
backupDb.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("import ZIP validation", () => {
|
|
||||||
it("should detect missing required entries", () => {
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
||||||
|
|
||||||
const entries = zip.getEntries().map((e) => e.entryName);
|
|
||||||
const required = ["db.sqlite", "metadata.json"];
|
|
||||||
const missing = required.filter((name) => !entries.includes(name));
|
|
||||||
|
|
||||||
expect(missing).toEqual(["metadata.json"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept valid backup ZIP with all entries", () => {
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
|
|
||||||
|
|
||||||
const entries = zip.getEntries().map((e) => e.entryName);
|
|
||||||
const required = ["db.sqlite", "metadata.json"];
|
|
||||||
const missing = required.filter((name) => !entries.includes(name));
|
|
||||||
|
|
||||||
expect(missing).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept legacy backup ZIP with encryption-key.txt", () => {
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
||||||
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
|
|
||||||
|
|
||||||
const entries = zip.getEntries().map((e) => e.entryName);
|
|
||||||
const required = ["db.sqlite", "metadata.json"];
|
|
||||||
const missing = required.filter((name) => !entries.includes(name));
|
|
||||||
|
|
||||||
expect(missing).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("secret re-encryption", () => {
|
|
||||||
it("should re-encrypt secrets from old key to new key", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const oldKey = generateHexKey();
|
|
||||||
const newKey = generateHexKey();
|
|
||||||
const plaintext = "my-secret-password";
|
|
||||||
|
|
||||||
const encryptedValue = encryptWithKey(plaintext, oldKey);
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
|
||||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
|
||||||
`);
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
|
||||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
|
||||||
`);
|
|
||||||
|
|
||||||
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
|
|
||||||
expect(decryptedBefore).toBe(plaintext);
|
|
||||||
|
|
||||||
const oldKeyBuf = Buffer.from(oldKey, "hex");
|
|
||||||
const newKeyBuf = Buffer.from(newKey, "hex");
|
|
||||||
|
|
||||||
const rows = sqlite
|
|
||||||
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
|
|
||||||
.all() as { integration_id: string; kind: string; value: string }[];
|
|
||||||
|
|
||||||
const updateStmt = sqlite.prepare(
|
|
||||||
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const [data, dataIv] = row.value.split(".") as [string, string];
|
|
||||||
const iv = Buffer.from(dataIv, "hex");
|
|
||||||
const encrypted = Buffer.from(data, "hex");
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
|
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
|
||||||
|
|
||||||
const newIv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
|
|
||||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
|
||||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
|
||||||
|
|
||||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedRow = sqlite
|
|
||||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
|
||||||
.get("int-1") as { value: string };
|
|
||||||
|
|
||||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
|
||||||
expect(decryptedAfter).toBe(plaintext);
|
|
||||||
|
|
||||||
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
|
||||||
|
|
||||||
sqlite.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip re-encryption when keys match", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const sameKey = generateHexKey();
|
|
||||||
const plaintext = "my-secret";
|
|
||||||
const encryptedValue = encryptWithKey(plaintext, sameKey);
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
|
||||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
|
||||||
`);
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
|
||||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
|
||||||
`);
|
|
||||||
|
|
||||||
const rowBefore = sqlite
|
|
||||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
|
||||||
.get("int-1") as { value: string };
|
|
||||||
|
|
||||||
expect(rowBefore.value).toBe(encryptedValue);
|
|
||||||
|
|
||||||
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
|
|
||||||
expect(decrypted).toBe(plaintext);
|
|
||||||
|
|
||||||
sqlite.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("VACUUM INTO consistency", () => {
|
|
||||||
it("should produce a consistent copy via VACUUM INTO", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "app" ("id", "name", "icon_url")
|
|
||||||
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
|
|
||||||
`);
|
|
||||||
|
|
||||||
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
|
|
||||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
|
||||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
||||||
|
|
||||||
const backupDb = new Database(backupPath, { readonly: true });
|
|
||||||
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
|
|
||||||
|
|
||||||
expect(app).toBeDefined();
|
|
||||||
expect(app!.name).toBe("TestApp");
|
|
||||||
|
|
||||||
backupDb.close();
|
|
||||||
sqlite.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("migration on imported DB", () => {
|
|
||||||
it("should successfully migrate an imported database", () => {
|
|
||||||
const sourcePath = path.join(tmpDir, "source.sqlite");
|
|
||||||
const { sqlite: sourceDb } = createTestDb(sourcePath);
|
|
||||||
|
|
||||||
sourceDb.exec(`
|
|
||||||
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
|
|
||||||
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
|
|
||||||
`);
|
|
||||||
sourceDb.close();
|
|
||||||
|
|
||||||
const importPath = path.join(tmpDir, "import.sqlite");
|
|
||||||
fs.copyFileSync(sourcePath, importPath);
|
|
||||||
|
|
||||||
const importDb = new Database(importPath);
|
|
||||||
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
|
|
||||||
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
||||||
|
|
||||||
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
|
|
||||||
expect(board).toBeDefined();
|
|
||||||
expect(board!.name).toBe("TestBoard");
|
|
||||||
|
|
||||||
importDb.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("encryption key validation", () => {
|
|
||||||
it("should reject invalid hex keys", () => {
|
|
||||||
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
|
|
||||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
|
||||||
|
|
||||||
for (const key of invalidKeys) {
|
|
||||||
expect(hexKeyRegex.test(key)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept valid 64-char hex keys", () => {
|
|
||||||
const validKey = generateHexKey();
|
|
||||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
|
||||||
expect(hexKeyRegex.test(validKey)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
|
|
||||||
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { BackupAnalysis } from "./types";
|
|
||||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
|
||||||
|
|
||||||
interface BackupPreviewPanelProps {
|
|
||||||
analysis: BackupAnalysis;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.preview");
|
|
||||||
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
|
|
||||||
const pendingCount = analysis.migrations.pending.length;
|
|
||||||
const hasPendingMigrations = pendingCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Paper p="md" radius="md" withBorder>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
|
|
||||||
<IconDatabase size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Title order={5}>{t("title")}</Title>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("exported", { date: exportDate })}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Badge variant="light" color="gray" ml="auto">
|
|
||||||
Homarr {analysis.metadata.homarrVersion}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Table striped withTableBorder>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("entity")}</Table.Th>
|
|
||||||
<Table.Th ta="right">{t("count")}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{PREVIEW_TABLE_KEYS.map((key) => (
|
|
||||||
<Table.Tr key={key}>
|
|
||||||
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
<Group gap={4} justify="flex-end">
|
|
||||||
{(analysis.counts[key] ?? 0) > 0 && (
|
|
||||||
<IconCheck size={14} color="var(--mantine-color-green-6)" />
|
|
||||||
)}
|
|
||||||
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
{analysis.boardNames.length > 0 && (
|
|
||||||
<Group gap="xs" wrap="wrap">
|
|
||||||
<Text size="sm" fw={500}>{t("boards")}:</Text>
|
|
||||||
{analysis.boardNames.map((name) => (
|
|
||||||
<Badge key={name} variant="light" size="sm">{name}</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{hasPendingMigrations && (
|
|
||||||
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
|
|
||||||
<IconGitBranch size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{pendingCount === 1
|
|
||||||
? t("migrationsApplied", { count: String(pendingCount) })
|
|
||||||
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{pendingCount === 1
|
|
||||||
? t("migrationsOlderVersion", { count: String(pendingCount) })
|
|
||||||
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Badge variant="light" color="orange" ml="auto">
|
|
||||||
{t("migrationsPending", { count: String(pendingCount) })}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasPendingMigrations && (
|
|
||||||
<Paper p="md" radius="md" withBorder>
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
|
|
||||||
<IconGitBranch size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" fw={500} c="dimmed">
|
|
||||||
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
|
|
||||||
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
const CONFIRMATION_PHRASE = "I understand";
|
|
||||||
|
|
||||||
interface DangerousActionConfirmationProps {
|
|
||||||
title: string;
|
|
||||||
warningTitle: string;
|
|
||||||
warningBody: string;
|
|
||||||
typePrompt: string;
|
|
||||||
submitLabel: string;
|
|
||||||
submitIcon: ReactNode;
|
|
||||||
cancelLabel: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DangerousActionConfirmation = ({
|
|
||||||
title,
|
|
||||||
warningTitle,
|
|
||||||
warningBody,
|
|
||||||
typePrompt,
|
|
||||||
submitLabel,
|
|
||||||
submitIcon,
|
|
||||||
cancelLabel,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
disabled,
|
|
||||||
color = "red",
|
|
||||||
}: DangerousActionConfirmationProps) => {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
|
|
||||||
<Text size="lg" fw={700} c={color}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text size="sm" fw={600}>{warningTitle}</Text>
|
|
||||||
<Text size="sm">{warningBody}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text size="sm" fw={500} mb={4}>
|
|
||||||
{typePrompt}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.currentTarget.value)}
|
|
||||||
placeholder={CONFIRMATION_PHRASE}
|
|
||||||
disabled={disabled}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontFamily: "var(--mantine-font-family-monospace)",
|
|
||||||
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color={color}
|
|
||||||
leftSection={submitIcon}
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={!isMatch || disabled}
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
|
|
||||||
import type { FileWithPath } from "@mantine/dropzone";
|
|
||||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
|
||||||
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import "@mantine/dropzone/styles.css";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { RestoreStep } from "./types";
|
|
||||||
import { BackupPreviewPanel } from "./backup-preview-panel";
|
|
||||||
import { MigrationProgressPanel } from "./migration-progress-panel";
|
|
||||||
import { RestoreConfirmation } from "./restore-confirmation";
|
|
||||||
import { RestoreProgressPanel } from "./restore-progress-panel";
|
|
||||||
import { useBackupAnalysis } from "./use-backup-analysis";
|
|
||||||
|
|
||||||
interface DatabaseRestoreFlowProps {
|
|
||||||
variant?: "card" | "standalone";
|
|
||||||
onRestoreComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
|
||||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
|
||||||
const [step, setStep] = useState<RestoreStep>("upload");
|
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
|
||||||
const apiDoneRef = useRef(false);
|
|
||||||
const animDoneRef = useRef(false);
|
|
||||||
const apiErrorRef = useRef<string | null>(null);
|
|
||||||
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
|
||||||
|
|
||||||
const handleFileDrop = useCallback(
|
|
||||||
(files: FileWithPath[]) => {
|
|
||||||
const droppedFile = files[0];
|
|
||||||
if (!droppedFile) return;
|
|
||||||
setFile(droppedFile);
|
|
||||||
setStep("preview");
|
|
||||||
setImportError(null);
|
|
||||||
void analyzeFile(droppedFile);
|
|
||||||
},
|
|
||||||
[analyzeFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setFile(null);
|
|
||||||
setStep("upload");
|
|
||||||
setImportError(null);
|
|
||||||
apiDoneRef.current = false;
|
|
||||||
animDoneRef.current = false;
|
|
||||||
apiErrorRef.current = null;
|
|
||||||
reset();
|
|
||||||
}, [reset]);
|
|
||||||
|
|
||||||
const tryFinalize = useCallback(() => {
|
|
||||||
if (!apiDoneRef.current) return;
|
|
||||||
|
|
||||||
if (apiErrorRef.current) {
|
|
||||||
setImportError(apiErrorRef.current);
|
|
||||||
setStep("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!animDoneRef.current) return;
|
|
||||||
onRestoreComplete?.();
|
|
||||||
window.location.reload();
|
|
||||||
}, [onRestoreComplete]);
|
|
||||||
|
|
||||||
const handleConfirm = useCallback(async () => {
|
|
||||||
if (!file) return;
|
|
||||||
setStep("restoring");
|
|
||||||
setImportError(null);
|
|
||||||
apiDoneRef.current = false;
|
|
||||||
animDoneRef.current = false;
|
|
||||||
apiErrorRef.current = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
try {
|
|
||||||
const data = await response.json();
|
|
||||||
apiErrorRef.current = data.error ?? t("failed.title");
|
|
||||||
} catch {
|
|
||||||
apiErrorRef.current = `Server returned ${response.status}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
apiErrorRef.current = t("failed.title");
|
|
||||||
} finally {
|
|
||||||
apiDoneRef.current = true;
|
|
||||||
tryFinalize();
|
|
||||||
}
|
|
||||||
}, [file, t, tryFinalize]);
|
|
||||||
|
|
||||||
const handleAnimationComplete = useCallback(() => {
|
|
||||||
animDoneRef.current = true;
|
|
||||||
tryFinalize();
|
|
||||||
}, [tryFinalize]);
|
|
||||||
|
|
||||||
if (step === "upload") {
|
|
||||||
return (
|
|
||||||
<Dropzone
|
|
||||||
onDrop={handleFileDrop}
|
|
||||||
acceptColor="blue.6"
|
|
||||||
rejectColor="red.6"
|
|
||||||
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
|
||||||
multiple={false}
|
|
||||||
maxSize={1024 * 1024 * 256}
|
|
||||||
radius="md"
|
|
||||||
>
|
|
||||||
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
|
|
||||||
<Dropzone.Accept>
|
|
||||||
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
|
|
||||||
</Dropzone.Accept>
|
|
||||||
<Dropzone.Reject>
|
|
||||||
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
|
|
||||||
</Dropzone.Reject>
|
|
||||||
<Dropzone.Idle>
|
|
||||||
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
|
|
||||||
</Dropzone.Idle>
|
|
||||||
<div>
|
|
||||||
<Text size="xl" inline>
|
|
||||||
{t("dropzone.title")}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" inline mt={7}>
|
|
||||||
{t("dropzone.description")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Dropzone>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "preview") {
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconFileZip size={20} />
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>{file?.name}</Text>
|
|
||||||
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Button variant="subtle" size="xs" onClick={handleClear}>
|
|
||||||
{t("changeFile")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{loading && migrationProgress && (
|
|
||||||
<MigrationProgressPanel progress={migrationProgress} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && !migrationProgress && (
|
|
||||||
<Group justify="center" py="xl">
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{analysisError && (
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
|
|
||||||
{analysisError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{analysis && (
|
|
||||||
<>
|
|
||||||
<BackupPreviewPanel analysis={analysis} />
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button
|
|
||||||
rightSection={<IconArrowRight size={16} />}
|
|
||||||
onClick={() => setStep("confirm")}
|
|
||||||
>
|
|
||||||
{t("continueToRestore")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "confirm") {
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{analysis && <BackupPreviewPanel analysis={analysis} />}
|
|
||||||
<RestoreConfirmation
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setStep("preview")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "restoring") {
|
|
||||||
return (
|
|
||||||
<RestoreProgressPanel
|
|
||||||
active
|
|
||||||
migrations={analysis?.migrations.pending ?? []}
|
|
||||||
onComplete={handleAnimationComplete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "error") {
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
|
|
||||||
<Text size="sm">{importError}</Text>
|
|
||||||
</Alert>
|
|
||||||
<Group>
|
|
||||||
<Button variant="subtle" onClick={handleClear}>
|
|
||||||
{t("tryAgain")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { DatabaseRestoreFlow } from "./database-restore-flow";
|
|
||||||
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
|
||||||
export type { BackupAnalysis, RestoreStep } from "./types";
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
|
||||||
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { MigrationProgress } from "./use-backup-analysis";
|
|
||||||
|
|
||||||
interface MigrationProgressPanelProps {
|
|
||||||
progress: MigrationProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.migration");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
|
|
||||||
<IconDatabase size={12} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("title", { current: String(progress.current), total: String(progress.total) })}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Box pl={4}>
|
|
||||||
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
|
|
||||||
{Array.from({ length: progress.total }, (_, i) => {
|
|
||||||
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
|
|
||||||
const isActive = i === progress.current - 1 && progress.phase === "applying";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Timeline.Item
|
|
||||||
key={i}
|
|
||||||
bullet={
|
|
||||||
isDone ? (
|
|
||||||
<IconCheck size={12} />
|
|
||||||
) : isActive ? (
|
|
||||||
<Loader size={10} color="white" />
|
|
||||||
) : (
|
|
||||||
<IconLoader size={12} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
|
|
||||||
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
|
||||||
|
|
||||||
interface RestoreConfirmationProps {
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DangerousActionConfirmation
|
|
||||||
title={t("title")}
|
|
||||||
warningTitle={t("warningTitle")}
|
|
||||||
warningBody={t("warningBody")}
|
|
||||||
typePrompt={t("typePrompt", { phrase: "I understand" })}
|
|
||||||
submitLabel={t("submit")}
|
|
||||||
submitIcon={<IconDatabaseImport size={16} />}
|
|
||||||
cancelLabel={t("cancel")}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
onCancel={onCancel}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconCheck,
|
|
||||||
IconDatabaseImport,
|
|
||||||
IconFileZip,
|
|
||||||
IconKey,
|
|
||||||
IconLoader,
|
|
||||||
IconRefresh,
|
|
||||||
IconTransform,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import type { Icon } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { MigrationFile } from "./types";
|
|
||||||
|
|
||||||
const BASE_PHASE_DURATIONS: Record<string, number> = {
|
|
||||||
extracting: 1200,
|
|
||||||
encrypting: 1500,
|
|
||||||
swapping: 800,
|
|
||||||
restarting: 10000,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIGRATION_STEP_DURATION = 100;
|
|
||||||
|
|
||||||
interface TimelineStep {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
icon: Icon;
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RestoreProgressPanelProps {
|
|
||||||
active: boolean;
|
|
||||||
migrations: MigrationFile[];
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.progress");
|
|
||||||
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
|
|
||||||
|
|
||||||
const steps = useMemo((): TimelineStep[] => {
|
|
||||||
const result: TimelineStep[] = [
|
|
||||||
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (migrations.length > 0) {
|
|
||||||
for (const migration of migrations) {
|
|
||||||
result.push({
|
|
||||||
key: `migration-${migration.idx}`,
|
|
||||||
label: migration.tag,
|
|
||||||
icon: IconDatabaseImport,
|
|
||||||
duration: MIGRATION_STEP_DURATION,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.push({
|
|
||||||
key: "migrating",
|
|
||||||
label: t("migrating"),
|
|
||||||
icon: IconDatabaseImport,
|
|
||||||
duration: 300,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(
|
|
||||||
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
|
|
||||||
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
|
|
||||||
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [migrations, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const run = async () => {
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
|
||||||
if (cancelled) return;
|
|
||||||
setCurrentStepIdx(i);
|
|
||||||
await new Promise((r) => setTimeout(r, steps[i]!.duration));
|
|
||||||
}
|
|
||||||
if (!cancelled) {
|
|
||||||
onComplete?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void run();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [active, onComplete, steps]);
|
|
||||||
|
|
||||||
if (!active && currentStepIdx === -1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap="xs">
|
|
||||||
<Loader size="sm" color="blue" />
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("title")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Box pl={4}>
|
|
||||||
<Timeline
|
|
||||||
active={currentStepIdx}
|
|
||||||
bulletSize={24}
|
|
||||||
lineWidth={2}
|
|
||||||
color="blue"
|
|
||||||
>
|
|
||||||
{steps.map((step, i) => {
|
|
||||||
const isDone = i < currentStepIdx;
|
|
||||||
const isActive = i === currentStepIdx;
|
|
||||||
const isMigration = step.key.startsWith("migration-");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Timeline.Item
|
|
||||||
key={step.key}
|
|
||||||
bullet={
|
|
||||||
isDone ? (
|
|
||||||
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
|
|
||||||
<IconCheck size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
) : isActive ? (
|
|
||||||
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
|
|
||||||
<Loader size={12} color="white" />
|
|
||||||
</ThemeIcon>
|
|
||||||
) : (
|
|
||||||
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
|
|
||||||
<IconLoader size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Group gap="xs" mt={-4}>
|
|
||||||
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
|
|
||||||
<Text
|
|
||||||
size={isMigration ? "xs" : "sm"}
|
|
||||||
fw={isActive ? 600 : 400}
|
|
||||||
ff={isMigration ? "monospace" : undefined}
|
|
||||||
c={isDone ? "green" : isActive ? "white" : "dimmed"}
|
|
||||||
>
|
|
||||||
{step.label}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Timeline.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export interface BackupMetadata {
|
|
||||||
homarrVersion: string;
|
|
||||||
exportedAt: string;
|
|
||||||
dbDialect: string;
|
|
||||||
encryptionKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigrationFile {
|
|
||||||
idx: number;
|
|
||||||
tag: string;
|
|
||||||
sql: string;
|
|
||||||
when: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigrationStatus {
|
|
||||||
applied: number;
|
|
||||||
pending: MigrationFile[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupAnalysis {
|
|
||||||
metadata: BackupMetadata;
|
|
||||||
counts: Record<string, number>;
|
|
||||||
boardNames: string[];
|
|
||||||
migrations: MigrationStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
|
|
||||||
|
|
||||||
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
|
|
||||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
|
||||||
|
|
||||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
const getAppliedMigrationCount = (db: any): number => {
|
|
||||||
try {
|
|
||||||
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
|
|
||||||
return (result[0]?.values[0]?.[0] as number) ?? 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMigrations = async (): Promise<MigrationFile[]> => {
|
|
||||||
const res = await fetch("/api/backup/migrations");
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch migration files from server");
|
|
||||||
const data = await res.json();
|
|
||||||
return data.migrations ?? [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const countEntities = (db: any): Record<string, number> => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const key of PREVIEW_TABLE_KEYS) {
|
|
||||||
try {
|
|
||||||
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
|
|
||||||
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
|
|
||||||
} catch {
|
|
||||||
counts[key] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBoardNames = (db: any): string[] => {
|
|
||||||
try {
|
|
||||||
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
|
|
||||||
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MigrationProgress {
|
|
||||||
current: number;
|
|
||||||
total: number;
|
|
||||||
tag: string;
|
|
||||||
phase: "pending" | "applying" | "done" | "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBackupAnalysis = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
|
||||||
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
|
|
||||||
const dbRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const analyzeFile = useCallback(async (file: File) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setAnalysis(null);
|
|
||||||
setMigrationProgress(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
|
|
||||||
import("jszip").then((m) => m.default),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
import("sql.js").then((m: any) => m.default),
|
|
||||||
fetchMigrations(),
|
|
||||||
fetch("/api/backup/sql-wasm").then((r) => {
|
|
||||||
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
|
|
||||||
return r.arrayBuffer();
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
|
||||||
|
|
||||||
const metadataFile = zip.file("metadata.json");
|
|
||||||
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
|
|
||||||
const dbFile = zip.file("db.sqlite");
|
|
||||||
if (!dbFile) throw new Error(t("invalidMissingDb"));
|
|
||||||
|
|
||||||
const metadata = JSON.parse(await metadataFile.async("string"));
|
|
||||||
const dbBuffer = await dbFile.async("uint8array");
|
|
||||||
|
|
||||||
const SQL = await initSqlJs({ wasmBinary });
|
|
||||||
|
|
||||||
const db = new SQL.Database(dbBuffer);
|
|
||||||
dbRef.current = db;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const appliedCount = getAppliedMigrationCount(db);
|
|
||||||
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
|
||||||
|
|
||||||
const migrations: MigrationStatus = {
|
|
||||||
applied: appliedCount,
|
|
||||||
pending: pendingMigrations,
|
|
||||||
total: allMigrations.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pendingMigrations.length > 0) {
|
|
||||||
for (let i = 0; i < pendingMigrations.length; i++) {
|
|
||||||
const migration = pendingMigrations[i]!;
|
|
||||||
setMigrationProgress({
|
|
||||||
current: i + 1,
|
|
||||||
total: pendingMigrations.length,
|
|
||||||
tag: migration.tag,
|
|
||||||
phase: "applying",
|
|
||||||
});
|
|
||||||
|
|
||||||
await delay(100);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statements = migration.sql
|
|
||||||
.split("--> statement-breakpoint")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
for (const stmt of statements) {
|
|
||||||
db.run(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
|
|
||||||
[migration.tag, Date.now()],
|
|
||||||
);
|
|
||||||
} catch (migrationErr) {
|
|
||||||
setMigrationProgress({
|
|
||||||
current: i + 1,
|
|
||||||
total: pendingMigrations.length,
|
|
||||||
tag: migration.tag,
|
|
||||||
phase: "error",
|
|
||||||
});
|
|
||||||
throw new Error(
|
|
||||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMigrationProgress({
|
|
||||||
current: i + 1,
|
|
||||||
total: pendingMigrations.length,
|
|
||||||
tag: migration.tag,
|
|
||||||
phase: "done",
|
|
||||||
});
|
|
||||||
|
|
||||||
await delay(50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts = countEntities(db);
|
|
||||||
const boardNames = getBoardNames(db);
|
|
||||||
|
|
||||||
setMigrationProgress(null);
|
|
||||||
setAnalysis({ metadata, counts, boardNames, migrations });
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
dbRef.current = null;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
if (dbRef.current) {
|
|
||||||
try { dbRef.current.close(); } catch { /* already closed */ }
|
|
||||||
dbRef.current = null;
|
|
||||||
}
|
|
||||||
setAnalysis(null);
|
|
||||||
setError(null);
|
|
||||||
setMigrationProgress(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
|
|
||||||
};
|
|
||||||
@@ -2,6 +2,7 @@ import { getBoardLayouts } from "@homarr/boards/context";
|
|||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import type { Modify } from "@homarr/common/types";
|
import type { Modify } from "@homarr/common/types";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||||
|
|
||||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||||
import { getFirstEmptyPosition } from "./empty-position";
|
import { getFirstEmptyPosition } from "./empty-position";
|
||||||
@@ -19,7 +20,7 @@ export const createItemCallback =
|
|||||||
(previous: Board): Board => {
|
(previous: Board): Board => {
|
||||||
const firstSection = previous.sections
|
const firstSection = previous.sections
|
||||||
.filter((section): section is EmptySection => section.kind === "empty")
|
.filter((section): section is EmptySection => section.kind === "empty")
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||||
.at(0);
|
.at(0);
|
||||||
|
|
||||||
if (!firstSection) return previous;
|
if (!firstSection) return previous;
|
||||||
@@ -28,7 +29,7 @@ export const createItemCallback =
|
|||||||
id: id ?? createId(),
|
id: id ?? createId(),
|
||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
layouts: createItemLayouts(previous, firstSection),
|
layouts: createItemLayouts(previous, firstSection, kind),
|
||||||
integrationIds,
|
integrationIds,
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
title: null,
|
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 layouts = getBoardLayouts(board);
|
||||||
|
const itemSize = getWidgetItemSize(kind);
|
||||||
|
|
||||||
return layouts.map((layoutId) => {
|
return layouts.map((layoutId) => {
|
||||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||||
|
|
||||||
const emptyPosition = boardLayout
|
const emptyPosition = boardLayout
|
||||||
? getFirstEmptyPosition(elements, boardLayout.columnCount)
|
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
|
||||||
: { xOffset: 0, yOffset: 0 };
|
: { xOffset: 0, yOffset: 0 };
|
||||||
|
|
||||||
if (!emptyPosition) {
|
if (!emptyPosition) {
|
||||||
@@ -64,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: 1,
|
width: itemSize.width,
|
||||||
height: 1,
|
height: itemSize.height,
|
||||||
...emptyPosition,
|
...emptyPosition,
|
||||||
sectionId: currentSection.id,
|
sectionId: currentSection.id,
|
||||||
layoutId,
|
layoutId,
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export const categoryTranslationKeys: Record<string, string> = {
|
|||||||
mediaMonitoring: "integration.category.mediaMonitoring",
|
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||||
speedtest: "integration.category.speedtest",
|
speedtest: "integration.category.speedtest",
|
||||||
analytics: "integration.category.analytics",
|
analytics: "integration.category.analytics",
|
||||||
|
documents: "integration.category.documents",
|
||||||
|
mediaLibrary: "integration.category.mediaLibrary",
|
||||||
|
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IntegrationGridItem {
|
export interface IntegrationGridItem {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||||
import { NavLink } from "@mantine/core";
|
import { NavLink } from "@mantine/core";
|
||||||
@@ -15,8 +16,21 @@ const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
|||||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
|
||||||
|
const useClientPathname = () => {
|
||||||
const pathname = usePathname();
|
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 tourId = props["data-onboarding-tour-id"];
|
||||||
const link = props.external ? (
|
const link = props.external ? (
|
||||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||||
@@ -26,15 +40,15 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
|||||||
label={props.label}
|
label={props.label}
|
||||||
leftSection={props.icon}
|
leftSection={props.icon}
|
||||||
href={props.href}
|
href={props.href}
|
||||||
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
|
active={isClient && pathMatches(pathname, props.href)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||||
const pathname = usePathname();
|
const { pathname, isClient } = useClientPathname();
|
||||||
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
|
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
|
||||||
const nav = (
|
const nav = (
|
||||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||||
{props.items.map((item) => (
|
{props.items.map((item) => (
|
||||||
|
|||||||
Vendored
-1
@@ -2,4 +2,3 @@ declare module "*.png";
|
|||||||
declare module "*.svg";
|
declare module "*.svg";
|
||||||
declare module "*.jpeg";
|
declare module "*.jpeg";
|
||||||
declare module "*.jpg";
|
declare module "*.jpg";
|
||||||
declare module "sql.js";
|
|
||||||
+1
-1
@@ -23,7 +23,7 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
client_max_body_size 256M;
|
client_max_body_size 32M;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -89,7 +89,7 @@
|
|||||||
"prismjs@<1.30.0": ">=1.30.0",
|
"prismjs@<1.30.0": ">=1.30.0",
|
||||||
"proxmox-api>undici": "7.24.6",
|
"proxmox-api>undici": "7.24.6",
|
||||||
"react-is": "^19.2.7",
|
"react-is": "^19.2.7",
|
||||||
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
|
"rollup@>=4.0.0 <4.22.4": ">=4.61.0",
|
||||||
"sha.js@<=2.4.11": ">=2.4.12",
|
"sha.js@<=2.4.11": ">=2.4.12",
|
||||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||||
|
|||||||
@@ -1604,24 +1604,22 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
items: items
|
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
||||||
.map(({ integrations: itemIntegrations, ...item }) =>
|
parseItem({
|
||||||
parseItem({
|
...item,
|
||||||
...item,
|
layouts: item.layouts.map((layout) => ({
|
||||||
layouts: item.layouts.map((layout) => ({
|
xOffset: layout.xOffset,
|
||||||
xOffset: layout.xOffset,
|
yOffset: layout.yOffset,
|
||||||
yOffset: layout.yOffset,
|
width: layout.width,
|
||||||
width: layout.width,
|
height: layout.height,
|
||||||
height: layout.height,
|
layoutId: layout.layoutId,
|
||||||
layoutId: layout.layoutId,
|
sectionId: layout.sectionId,
|
||||||
sectionId: layout.sectionId,
|
})),
|
||||||
})),
|
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
}),
|
||||||
}),
|
),
|
||||||
)
|
|
||||||
.filter((item) => item !== null),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1637,7 +1635,7 @@ const parseItem = (item: unknown) => {
|
|||||||
const result = outputItemSchema.safeParse(item);
|
const result = outputItemSchema.safeParse(item);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return null;
|
throw new Error(result.error.message);
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]() };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||||
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
||||||
|
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
|
||||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||||
@@ -30,6 +31,8 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||||
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
||||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
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)),
|
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -27,6 +27,10 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
|
|||||||
overseerr: ["overseerr"],
|
overseerr: ["overseerr"],
|
||||||
dashDot: ["dashdot", "dash-dot", "dash."],
|
dashDot: ["dashdot", "dash-dot", "dash."],
|
||||||
speedtestTracker: ["speedtest-tracker"],
|
speedtestTracker: ["speedtest-tracker"],
|
||||||
|
uptimeKuma: ["uptime-kuma"],
|
||||||
|
audiobookshelf: ["audiobookshelf"],
|
||||||
|
navidrome: ["navidrome"],
|
||||||
|
paperlessNgx: ["paperless-ngx", "paperless"],
|
||||||
searchCh: ["search-ch"],
|
searchCh: ["search-ch"],
|
||||||
gitHubContainerRegistry: ["ghcr"],
|
gitHubContainerRegistry: ["ghcr"],
|
||||||
linuxServerIO: ["linuxserver"],
|
linuxServerIO: ["linuxserver"],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const integrationSecretKindObject = {
|
|||||||
privateKey: { isPublic: false, multiline: true },
|
privateKey: { isPublic: false, multiline: true },
|
||||||
githubAppId: { isPublic: true, multiline: false },
|
githubAppId: { isPublic: true, multiline: false },
|
||||||
githubInstallationId: { isPublic: true, multiline: false },
|
githubInstallationId: { isPublic: true, multiline: false },
|
||||||
|
slug: { isPublic: true, multiline: false },
|
||||||
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
|
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
|
||||||
|
|
||||||
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
||||||
@@ -364,7 +365,7 @@ export const integrationDefs = {
|
|||||||
},
|
},
|
||||||
truenas: {
|
truenas: {
|
||||||
name: "TrueNAS",
|
name: "TrueNAS",
|
||||||
secretKinds: [["username", "password"], ["apiKey"]],
|
secretKinds: [["username", "password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
||||||
category: ["healthMonitoring"],
|
category: ["healthMonitoring"],
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||||
@@ -401,6 +402,14 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
|
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
|
||||||
defaultPort: 2283,
|
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: {
|
tracearr: {
|
||||||
name: "Tracearr",
|
name: "Tracearr",
|
||||||
secretKinds: [["apiKey"]],
|
secretKinds: [["apiKey"]],
|
||||||
@@ -417,6 +426,31 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
|
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
|
||||||
defaultPort: 80,
|
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: {
|
umami: {
|
||||||
name: "Umami",
|
name: "Umami",
|
||||||
secretKinds: [["username", "password"], ["apiKey"]],
|
secretKinds: [["username", "password"], ["apiKey"]],
|
||||||
@@ -452,6 +486,7 @@ export const integrationDefs = {
|
|||||||
"notifications",
|
"notifications",
|
||||||
"smartHomeServer",
|
"smartHomeServer",
|
||||||
"ups",
|
"ups",
|
||||||
|
"uptimeMonitoring",
|
||||||
],
|
],
|
||||||
documentationUrl: null,
|
documentationUrl: null,
|
||||||
},
|
},
|
||||||
@@ -540,6 +575,9 @@ export const integrationCategories = [
|
|||||||
"speedtest",
|
"speedtest",
|
||||||
"analytics",
|
"analytics",
|
||||||
"ups",
|
"ups",
|
||||||
|
"documents",
|
||||||
|
"mediaLibrary",
|
||||||
|
"uptimeMonitoring",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||||
@@ -33,8 +33,11 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
|||||||
timetable: ["searchCh"],
|
timetable: ["searchCh"],
|
||||||
"immich-serverStats": ["immich"],
|
"immich-serverStats": ["immich"],
|
||||||
"immich-albumCarousel": ["immich"],
|
"immich-albumCarousel": ["immich"],
|
||||||
|
paperlessNgx: ["paperlessNgx"],
|
||||||
tracearr: ["tracearr"],
|
tracearr: ["tracearr"],
|
||||||
speedtestTracker: ["speedtestTracker"],
|
speedtestTracker: ["speedtestTracker"],
|
||||||
|
uptimeKuma: ["uptimeKuma"],
|
||||||
|
audioStats: ["navidrome", "audiobookshelf"],
|
||||||
anchorNote: ["anchor"],
|
anchorNote: ["anchor"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,18 @@ export const widgetKinds = [
|
|||||||
"timetable",
|
"timetable",
|
||||||
"immich-serverStats",
|
"immich-serverStats",
|
||||||
"immich-albumCarousel",
|
"immich-albumCarousel",
|
||||||
|
"paperlessNgx",
|
||||||
"tracearr",
|
"tracearr",
|
||||||
"speedtestTracker",
|
"speedtestTracker",
|
||||||
|
"uptimeKuma",
|
||||||
|
"audioStats",
|
||||||
"umami",
|
"umami",
|
||||||
"ups",
|
"ups",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|
||||||
|
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||||
|
uptimeKuma: { width: 2, height: 3 },
|
||||||
|
audioStats: { width: 2, height: 2 },
|
||||||
|
paperlessNgx: { width: 2, height: 2 },
|
||||||
|
};
|
||||||
@@ -47,14 +47,12 @@
|
|||||||
"timezones-ical-library": "catalog:",
|
"timezones-ical-library": "catalog:",
|
||||||
"tsdav": "catalog:",
|
"tsdav": "catalog:",
|
||||||
"undici": "catalog:",
|
"undici": "catalog:",
|
||||||
"ws": "catalog:",
|
|
||||||
"xml2js": "catalog:",
|
"xml2js": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node-unifi": "catalog:",
|
"@types/node-unifi": "catalog:",
|
||||||
"@types/ws": "catalog:",
|
|
||||||
"@types/xml2js": "catalog:",
|
"@types/xml2js": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<AudiobookshelfDashboardData, "totalAudiobooks" | "totalPodcasts">
|
||||||
|
>;
|
||||||
|
|
||||||
|
export class AudiobookshelfIntegration extends Integration {
|
||||||
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
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<AudiobookshelfDashboardData> {
|
||||||
|
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<AudiobookshelfLibrary[]> {
|
||||||
|
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<AudiobookshelfLibraryStats> {
|
||||||
|
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<number> {
|
||||||
|
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<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof audiobookshelfLibrarySchema>;
|
||||||
|
export type AudiobookshelfLibraryStats = z.infer<typeof audiobookshelfLibraryStatsSchema>;
|
||||||
@@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
|||||||
|
|
||||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||||
|
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
|
||||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||||
@@ -30,12 +31,14 @@ import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integrati
|
|||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||||
import { MockIntegration } from "../mock/mock-integration";
|
import { MockIntegration } from "../mock/mock-integration";
|
||||||
|
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
|
||||||
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||||
import { NPMIntegration } from "../npm/npm-integration";
|
import { NPMIntegration } from "../npm/npm-integration";
|
||||||
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
||||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
|
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
|
||||||
import { PeaNutIntegration } from "../peanut/peanut-integration";
|
import { PeaNutIntegration } from "../peanut/peanut-integration";
|
||||||
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
||||||
import { PlexIntegration } from "../plex/plex-integration";
|
import { PlexIntegration } from "../plex/plex-integration";
|
||||||
@@ -48,6 +51,7 @@ import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-trac
|
|||||||
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
||||||
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||||
import { UmamiIntegration } from "../umami/umami-integration";
|
import { UmamiIntegration } from "../umami/umami-integration";
|
||||||
|
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||||
import type { Integration, IntegrationInput } from "./integration";
|
import type { Integration, IntegrationInput } from "./integration";
|
||||||
@@ -122,8 +126,12 @@ export const integrationCreators = {
|
|||||||
glances: GlancesIntegration,
|
glances: GlancesIntegration,
|
||||||
searchCh: SearchChIntegration,
|
searchCh: SearchChIntegration,
|
||||||
immich: ImmichIntegration,
|
immich: ImmichIntegration,
|
||||||
|
paperlessNgx: PaperlessNgxIntegration,
|
||||||
speedtestTracker: SpeedtestTrackerIntegration,
|
speedtestTracker: SpeedtestTrackerIntegration,
|
||||||
|
audiobookshelf: AudiobookshelfIntegration,
|
||||||
|
navidrome: NavidromeIntegration,
|
||||||
umami: UmamiIntegration,
|
umami: UmamiIntegration,
|
||||||
|
uptimeKuma: UptimeKumaIntegration,
|
||||||
peaNut: PeaNutIntegration,
|
peaNut: PeaNutIntegration,
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
|
|
||||||
|
|||||||
@@ -85,16 +85,6 @@ export abstract class Integration {
|
|||||||
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
|
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected webSocketUrl(
|
|
||||||
path: `/${string}`,
|
|
||||||
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
|
|
||||||
) {
|
|
||||||
const url = this.url(path, queryParams);
|
|
||||||
// http -> ws, https -> wss
|
|
||||||
url.protocol = url.protocol.replace("http", "ws");
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async testConnectionAsync(): Promise<TestingResult> {
|
public async testConnectionAsync(): Promise<TestingResult> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(this.integration.url);
|
const url = new URL(this.integration.url);
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export { CoolifyIntegration } from "./coolify/coolify-integration";
|
|||||||
export { ImmichIntegration } from "./immich/immich-integration";
|
export { ImmichIntegration } from "./immich/immich-integration";
|
||||||
export { TracearrIntegration } from "./tracearr/tracearr-integration";
|
export { TracearrIntegration } from "./tracearr/tracearr-integration";
|
||||||
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-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 { UmamiIntegration } from "./umami/umami-integration";
|
||||||
export { PeaNutIntegration } from "./peanut/peanut-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 { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
|
||||||
export type { Notification } from "./interfaces/notifications/notification-types";
|
export type { Notification } from "./interfaces/notifications/notification-types";
|
||||||
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
|
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
|
||||||
|
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
|
||||||
export type {
|
export type {
|
||||||
AnchorNote,
|
AnchorNote,
|
||||||
AnchorNotePermission,
|
AnchorNotePermission,
|
||||||
@@ -70,6 +74,9 @@ export type {
|
|||||||
} from "./anchor/anchor-types";
|
} from "./anchor/anchor-types";
|
||||||
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
|
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
|
||||||
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-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";
|
export type { UmamiVisitorStats } from "./umami/umami-types";
|
||||||
|
|
||||||
// Schemas
|
// Schemas
|
||||||
|
|||||||
@@ -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 = <TValue>(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<TestingResult> {
|
||||||
|
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<NavidromeDashboardData> {
|
||||||
|
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<number> {
|
||||||
|
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<NavidromeNowPlayingEntry[]> {
|
||||||
|
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<string, string | number> = {},
|
||||||
|
options: { tolerateEmptyLibrary?: boolean } = {},
|
||||||
|
): Promise<SubsonicResponseBody> {
|
||||||
|
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<string, string> {
|
||||||
|
return {
|
||||||
|
u: this.getSecretValue("username"),
|
||||||
|
p: this.getSecretValue("password"),
|
||||||
|
v: subsonicVersion,
|
||||||
|
c: subsonicClient,
|
||||||
|
f: "json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof subsonicResponseSchema>["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[];
|
||||||
|
}
|
||||||
@@ -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 = <T extends Record<string, unknown>>(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<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset === 500) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }),
|
||||||
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlStr.includes("getAlbumList2")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
|
||||||
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlStr.includes("getNowPlaying")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(subsonicFailed("Library not found or empty"), { 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.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<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TestingResult> {
|
||||||
|
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<PaperlessNgxStats> {
|
||||||
|
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<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Token ${this.getSecretValue("apiKey")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { trueNasApis } from "../truenas-api";
|
|
||||||
|
|
||||||
describe("trueNasApis descriptors", () => {
|
|
||||||
test("expose the expected endpoints and handshake requirements", () => {
|
|
||||||
expect(trueNasApis.jsonRpc).toMatchObject({
|
|
||||||
kind: "jsonRpc",
|
|
||||||
path: "/api/current",
|
|
||||||
requiresSessionHandshake: false,
|
|
||||||
});
|
|
||||||
expect(trueNasApis.legacy).toMatchObject({
|
|
||||||
kind: "legacy",
|
|
||||||
path: "/websocket",
|
|
||||||
requiresSessionHandshake: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("jsonRpc api", () => {
|
|
||||||
const { buildRequest, matchResponse } = trueNasApis.jsonRpc;
|
|
||||||
|
|
||||||
test("builds a JSON-RPC 2.0 envelope", () => {
|
|
||||||
expect(buildRequest("id-1", "system.info", [])).toStrictEqual({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: "id-1",
|
|
||||||
method: "system.info",
|
|
||||||
params: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches a result for the requested id", () => {
|
|
||||||
const result = matchResponse({ jsonrpc: "2.0", id: "id-1", result: true }, "id-1");
|
|
||||||
expect(result).toStrictEqual({ result: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns the error when the response carries one", () => {
|
|
||||||
const error = { code: -32001, message: "Not authenticated" };
|
|
||||||
expect(matchResponse({ jsonrpc: "2.0", id: "id-1", error }, "id-1")).toStrictEqual({ error });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores responses for a different id", () => {
|
|
||||||
expect(matchResponse({ jsonrpc: "2.0", id: "other", result: true }, "id-1")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores non JSON-RPC messages", () => {
|
|
||||||
expect(matchResponse({ msg: "result", id: "id-1", result: true }, "id-1")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("legacy api", () => {
|
|
||||||
const { buildRequest, matchResponse } = trueNasApis.legacy;
|
|
||||||
|
|
||||||
test("builds a DDP method envelope", () => {
|
|
||||||
expect(buildRequest("id-1", "auth.login", ["user", "pass"])).toStrictEqual({
|
|
||||||
id: "id-1",
|
|
||||||
msg: "method",
|
|
||||||
method: "auth.login",
|
|
||||||
params: ["user", "pass"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches a result message for the requested id", () => {
|
|
||||||
expect(matchResponse({ msg: "result", id: "id-1", result: false }, "id-1")).toStrictEqual({ result: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns the error when the result message carries one", () => {
|
|
||||||
const error = { error: 13, reason: "boom" };
|
|
||||||
expect(matchResponse({ msg: "result", id: "id-1", error }, "id-1")).toStrictEqual({ error });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores responses for a different id", () => {
|
|
||||||
expect(matchResponse({ msg: "result", id: "other", result: true }, "id-1")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores non-result messages such as the session handshake", () => {
|
|
||||||
expect(matchResponse({ msg: "connected", session: "abc" }, "id-1")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
|
|
||||||
import type { IntegrationSecret } from "../../base/types";
|
|
||||||
|
|
||||||
const ws = vi.hoisted(() => {
|
|
||||||
const sentPayloads: Record<string, unknown>[] = [];
|
|
||||||
const constructedUrls: string[] = [];
|
|
||||||
// A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
|
|
||||||
const failures: { path: string; code?: string }[] = [];
|
|
||||||
let responder: (method: string, params: unknown[]) => unknown = () => undefined;
|
|
||||||
|
|
||||||
type Listener = (...args: unknown[]) => void;
|
|
||||||
|
|
||||||
class FakeWebSocket {
|
|
||||||
static readonly OPEN = 1;
|
|
||||||
public readyState = FakeWebSocket.OPEN;
|
|
||||||
private readonly listeners: Record<string, Listener[]> = {};
|
|
||||||
|
|
||||||
constructor(url: URL | string) {
|
|
||||||
constructedUrls.push(url.toString());
|
|
||||||
setTimeout(() => {
|
|
||||||
const failure = failures.find((candidate) => url.toString().includes(candidate.path));
|
|
||||||
if (failure) {
|
|
||||||
const error: Error & { code?: string } = new Error(failure.code ?? "Unexpected server response: 404");
|
|
||||||
if (failure.code) error.code = failure.code;
|
|
||||||
this.dispatch("error", error);
|
|
||||||
} else {
|
|
||||||
this.dispatch("open");
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
on(event: string, listener: Listener) {
|
|
||||||
(this.listeners[event] ??= []).push(listener);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
once(event: string, listener: Listener) {
|
|
||||||
const wrapper: Listener = (...args) => {
|
|
||||||
this.off(event, wrapper);
|
|
||||||
listener(...args);
|
|
||||||
};
|
|
||||||
return this.on(event, wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
off(event: string, listener: Listener) {
|
|
||||||
this.listeners[event] = (this.listeners[event] ?? []).filter((candidate) => candidate !== listener);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(raw: string) {
|
|
||||||
const payload = JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
sentPayloads.push(payload);
|
|
||||||
|
|
||||||
if (payload.msg === "connect") {
|
|
||||||
this.respond(JSON.stringify({ msg: "connected", session: "test-session" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
|
|
||||||
const envelope =
|
|
||||||
payload.jsonrpc === "2.0"
|
|
||||||
? { jsonrpc: "2.0", id: payload.id, result }
|
|
||||||
: { msg: "result", id: payload.id, result };
|
|
||||||
this.respond(JSON.stringify(envelope));
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.readyState = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
private respond(message: string) {
|
|
||||||
setTimeout(() => this.dispatch("message", message), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatch(event: string, ...args: unknown[]) {
|
|
||||||
// `off` replaces the array rather than mutating it, so iterating it directly is safe.
|
|
||||||
for (const listener of this.listeners[event] ?? []) listener(...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
FakeWebSocket,
|
|
||||||
sentPayloads,
|
|
||||||
constructedUrls,
|
|
||||||
failures,
|
|
||||||
setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
|
|
||||||
responder = fn;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
|
|
||||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
|
||||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
|
||||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
|
||||||
}));
|
|
||||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
|
||||||
createCustomCheckServerIdentity: () => () => undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { TrueNasIntegration } from "../truenas-integration";
|
|
||||||
|
|
||||||
const happyResponder = (method: string) => {
|
|
||||||
switch (method) {
|
|
||||||
case "auth.login":
|
|
||||||
case "auth.login_with_api_key":
|
|
||||||
return true;
|
|
||||||
case "system.info":
|
|
||||||
return {
|
|
||||||
version: "TrueNAS-SCALE-24.10",
|
|
||||||
hostname: "nas",
|
|
||||||
physmem: 16_000_000_000,
|
|
||||||
model: "Intel Xeon",
|
|
||||||
uptime_seconds: 3600,
|
|
||||||
};
|
|
||||||
case "reporting.get_data":
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "cpu",
|
|
||||||
identifier: "cpu",
|
|
||||||
aggregations: emptyAggregations(),
|
|
||||||
start: 0,
|
|
||||||
end: 1,
|
|
||||||
legend: [],
|
|
||||||
data: [[1000, 12]],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "memory",
|
|
||||||
identifier: "memory",
|
|
||||||
aggregations: emptyAggregations(),
|
|
||||||
start: 0,
|
|
||||||
end: 1,
|
|
||||||
legend: [],
|
|
||||||
data: [[1000, 8_000_000_000]],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cputemp",
|
|
||||||
identifier: "cputemp",
|
|
||||||
aggregations: emptyAggregations(),
|
|
||||||
start: 0,
|
|
||||||
end: 1,
|
|
||||||
legend: [],
|
|
||||||
data: [[1000, 45]],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
case "pool.query":
|
|
||||||
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
|
||||||
case "interface.query":
|
|
||||||
return [{ id: "eth0", name: "eth0" }];
|
|
||||||
case "reporting.netdata_get_data":
|
|
||||||
return [{ name: "interface", identifier: "eth0", data: [[1000, 100, 200]] }];
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
|
|
||||||
|
|
||||||
let integrationCounter = 0;
|
|
||||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
|
||||||
new TrueNasIntegration({
|
|
||||||
id: `truenas-${(integrationCounter += 1)}`,
|
|
||||||
name: "TrueNAS Test",
|
|
||||||
url: "https://truenas.local",
|
|
||||||
externalUrl: null,
|
|
||||||
decryptedSecrets,
|
|
||||||
});
|
|
||||||
|
|
||||||
const credentials: IntegrationSecret[] = [
|
|
||||||
{ kind: "username", value: "root" },
|
|
||||||
{ kind: "password", value: "secret" },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("TrueNasIntegration", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
ws.sentPayloads.length = 0;
|
|
||||||
ws.constructedUrls.length = 0;
|
|
||||||
ws.failures.length = 0;
|
|
||||||
ws.setResponder(happyResponder);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetches and maps system info over the JSON-RPC API", async () => {
|
|
||||||
const integration = createIntegration(credentials);
|
|
||||||
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
|
|
||||||
// JSON-RPC does not use the legacy "connect" session handshake.
|
|
||||||
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
version: "TrueNAS-SCALE-24.10",
|
|
||||||
cpuModelName: "Intel Xeon",
|
|
||||||
cpuTemp: 45,
|
|
||||||
memAvailableInBytes: 16_000_000_000,
|
|
||||||
memUsedInBytes: 8_000_000_000,
|
|
||||||
uptime: 3600,
|
|
||||||
rebootRequired: false,
|
|
||||||
availablePkgUpdates: 0,
|
|
||||||
loadAverage: null,
|
|
||||||
gpu: [],
|
|
||||||
network: { up: 20_000, down: 10_000 },
|
|
||||||
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
|
|
||||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("authenticates with an API key when one is configured", async () => {
|
|
||||||
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
|
|
||||||
|
|
||||||
await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
|
|
||||||
expect(authPayload).toMatchObject({ method: "auth.login_with_api_key", params: ["api-key-123"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("authenticates with username and password when no API key is configured", async () => {
|
|
||||||
const integration = createIntegration(credentials);
|
|
||||||
|
|
||||||
await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
|
|
||||||
expect(authPayload).toMatchObject({ method: "auth.login", params: ["root", "secret"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to the legacy websocket API when the JSON-RPC endpoint is unavailable", async () => {
|
|
||||||
ws.failures.push({ path: "/api/current" });
|
|
||||||
const integration = createIntegration(credentials);
|
|
||||||
|
|
||||||
const result = await integration.getSystemInfoAsync();
|
|
||||||
|
|
||||||
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
|
|
||||||
expect(ws.constructedUrls).toContain("wss://truenas.local/websocket");
|
|
||||||
// The legacy API requires the "connect" session handshake before authenticating.
|
|
||||||
expect(ws.sentPayloads[0]).toMatchObject({ msg: "connect" });
|
|
||||||
expect(ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."))).toMatchObject({
|
|
||||||
msg: "method",
|
|
||||||
method: "auth.login",
|
|
||||||
});
|
|
||||||
expect(result.version).toBe("TrueNAS-SCALE-24.10");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects when authentication is not successful", async () => {
|
|
||||||
ws.setResponder((method) => (method.startsWith("auth.") ? false : happyResponder(method)));
|
|
||||||
const integration = createIntegration(credentials);
|
|
||||||
|
|
||||||
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("surfaces a self-signed certificate as a certificate request error without falling back", async () => {
|
|
||||||
ws.failures.push({ path: "/api/current", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
|
|
||||||
ws.failures.push({ path: "/websocket", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
|
|
||||||
const integration = createIntegration(credentials);
|
|
||||||
|
|
||||||
const error = await integration.getSystemInfoAsync().catch((caught: unknown) => caught);
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(IntegrationRequestError);
|
|
||||||
expect((error as IntegrationRequestError).cause).toMatchObject({ type: "certificate", reason: "untrusted" });
|
|
||||||
// A certificate failure is endpoint-independent, so the legacy API must not be tried.
|
|
||||||
expect(ws.constructedUrls).not.toContain("wss://truenas.local/websocket");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
export type TrueNasApiKind = "jsonRpc" | "legacy";
|
|
||||||
|
|
||||||
export type TrueNasResponse = { result: unknown } | { error: unknown };
|
|
||||||
|
|
||||||
export interface TrueNasApi {
|
|
||||||
kind: TrueNasApiKind;
|
|
||||||
/** Websocket path appended to the integration URL. */
|
|
||||||
path: `/${string}`;
|
|
||||||
/** The legacy DDP API requires a "connect" handshake before authentication, the JSON-RPC API does not. */
|
|
||||||
requiresSessionHandshake: boolean;
|
|
||||||
/** Builds the request envelope for a method call. */
|
|
||||||
buildRequest: (id: string, method: string, params: unknown[]) => Record<string, unknown>;
|
|
||||||
/**
|
|
||||||
* Returns the response payload when `message` is the reply to `id`,
|
|
||||||
* otherwise `undefined` so the caller keeps waiting for a later message.
|
|
||||||
*/
|
|
||||||
matchResponse: (message: Record<string, unknown>, id: string) => TrueNasResponse | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-RPC 2.0 API introduced in TrueNAS SCALE 24.10 (Electric Eel) and the
|
|
||||||
* legacy DDP-style websocket API used before it. Both expose the same
|
|
||||||
* middleware methods, so only the transport envelope differs.
|
|
||||||
* @see https://www.truenas.com/docs/api/scale_rest_api.html
|
|
||||||
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
|
|
||||||
*/
|
|
||||||
export const trueNasApis = {
|
|
||||||
jsonRpc: {
|
|
||||||
kind: "jsonRpc",
|
|
||||||
path: "/api/current",
|
|
||||||
requiresSessionHandshake: false,
|
|
||||||
buildRequest: (id: string, method: string, params: unknown[]) => ({ jsonrpc: "2.0", id, method, params }),
|
|
||||||
matchResponse: (message: Record<string, unknown>, id: string) => {
|
|
||||||
if (message.id !== id || message.jsonrpc !== "2.0") return undefined;
|
|
||||||
if (message.error != null) return { error: message.error };
|
|
||||||
return { result: message.result };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
legacy: {
|
|
||||||
kind: "legacy",
|
|
||||||
path: "/websocket",
|
|
||||||
requiresSessionHandshake: true,
|
|
||||||
buildRequest: (id: string, method: string, params: unknown[]) => ({ id, msg: "method", method, params }),
|
|
||||||
matchResponse: (message: Record<string, unknown>, id: string) => {
|
|
||||||
if (message.id !== id || message.msg !== "result") return undefined;
|
|
||||||
if (message.error != null) return { error: message.error };
|
|
||||||
return { result: message.result };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Record<TrueNasApiKind, TrueNasApi>;
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import type { RawData } from "ws";
|
|
||||||
import { WebSocket } from "ws";
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { createId } from "@homarr/common";
|
|
||||||
import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server";
|
|
||||||
import {
|
|
||||||
getAllTrustedCertificatesAsync,
|
|
||||||
getTrustedCertificateHostnamesAsync,
|
|
||||||
} from "@homarr/core/infrastructure/certificates";
|
|
||||||
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
|
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
|
||||||
import type { TrueNasApi } from "./truenas-api";
|
|
||||||
import { trueNasApis } from "./truenas-api";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "trueNasClient" });
|
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
type CertificateOptions = IntegrationTestingInput["options"];
|
|
||||||
|
|
||||||
export type TrueNasCredentials = { apiKey: string } | { username: string; password: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles talking to a TrueNAS server: opening a websocket with Homarr's
|
|
||||||
* trusted certificates, negotiating the API variant, authenticating, and
|
|
||||||
* sending method requests. Connections are reused across the per-request
|
|
||||||
* integration instances and reconnected transparently when the server drops
|
|
||||||
* an idle socket.
|
|
||||||
*/
|
|
||||||
export class TrueNasClient {
|
|
||||||
// Keyed by integration id. The value is the in-flight connection promise so concurrent
|
|
||||||
// callers reuse a single socket instead of racing to open duplicates.
|
|
||||||
private static readonly connectionMap = new Map<string, Promise<TrueNasSocket>>();
|
|
||||||
private static readonly apiMap = new Map<string, TrueNasApi>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly id: string,
|
|
||||||
private readonly webSocketUrl: (path: `/${string}`) => URL,
|
|
||||||
private readonly credentials: TrueNasCredentials,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Sends an authenticated request, reusing the cached connection while it is open. */
|
|
||||||
public async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
|
|
||||||
const socket = await this.getSocketAsync();
|
|
||||||
return socket.requestAsync(method, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a connection with the provided certificate options, authenticates,
|
|
||||||
* and closes it. Used by the connection test, which may run with updated
|
|
||||||
* credentials, so any cached connection is dropped and the API re-detected.
|
|
||||||
*/
|
|
||||||
public async testAsync(certificate: CertificateOptions): Promise<void> {
|
|
||||||
this.evictConnection();
|
|
||||||
TrueNasClient.apiMap.delete(this.id);
|
|
||||||
|
|
||||||
const socket = await this.openSocketAsync(certificate);
|
|
||||||
try {
|
|
||||||
await this.authenticateAsync(socket);
|
|
||||||
} finally {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSocketAsync(): Promise<TrueNasSocket> {
|
|
||||||
let connection = TrueNasClient.connectionMap.get(this.id);
|
|
||||||
if (!connection) {
|
|
||||||
connection = this.connectAndAuthenticateAsync();
|
|
||||||
TrueNasClient.connectionMap.set(this.id, connection);
|
|
||||||
|
|
||||||
const evict = () => {
|
|
||||||
if (TrueNasClient.connectionMap.get(this.id) === connection) {
|
|
||||||
TrueNasClient.connectionMap.delete(this.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Reconnect on the next request once this attempt fails or the server drops the socket.
|
|
||||||
connection.then((socket) => socket.onClose(evict), evict);
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async connectAndAuthenticateAsync(): Promise<TrueNasSocket> {
|
|
||||||
const socket = await this.openSocketAsync(await resolveCertificateOptionsAsync());
|
|
||||||
try {
|
|
||||||
await this.authenticateAsync(socket);
|
|
||||||
} catch (error) {
|
|
||||||
// Never keep an unauthenticated socket, it would be reused without re-authenticating.
|
|
||||||
socket.close();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return socket;
|
|
||||||
}
|
|
||||||
|
|
||||||
private evictConnection() {
|
|
||||||
const connection = TrueNasClient.connectionMap.get(this.id);
|
|
||||||
if (!connection) return;
|
|
||||||
|
|
||||||
TrueNasClient.connectionMap.delete(this.id);
|
|
||||||
connection.then((socket) => socket.close()).catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects using the JSON-RPC API (TrueNAS 24.10+) when available and falls
|
|
||||||
* back to the legacy websocket API otherwise. A missing JSON-RPC endpoint
|
|
||||||
* surfaces as a plain protocol error; certificate, connection, DNS and
|
|
||||||
* timeout errors are endpoint-independent and are never retried.
|
|
||||||
*/
|
|
||||||
private async openSocketAsync(certificate: CertificateOptions): Promise<TrueNasSocket> {
|
|
||||||
const cachedApi = TrueNasClient.apiMap.get(this.id);
|
|
||||||
if (cachedApi) {
|
|
||||||
return openTrueNasSocketAsync(this.webSocketUrl(cachedApi.path), cachedApi, certificate);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const socket = await openTrueNasSocketAsync(
|
|
||||||
this.webSocketUrl(trueNasApis.jsonRpc.path),
|
|
||||||
trueNasApis.jsonRpc,
|
|
||||||
certificate,
|
|
||||||
);
|
|
||||||
TrueNasClient.apiMap.set(this.id, trueNasApis.jsonRpc);
|
|
||||||
return socket;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof RequestError) throw error;
|
|
||||||
|
|
||||||
logger.debug("JSON-RPC API unavailable, falling back to the legacy websocket API", {
|
|
||||||
error: error instanceof Error ? error.message : undefined,
|
|
||||||
});
|
|
||||||
const socket = await openTrueNasSocketAsync(
|
|
||||||
this.webSocketUrl(trueNasApis.legacy.path),
|
|
||||||
trueNasApis.legacy,
|
|
||||||
certificate,
|
|
||||||
);
|
|
||||||
TrueNasClient.apiMap.set(this.id, trueNasApis.legacy);
|
|
||||||
return socket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticates with an API key when one is configured, otherwise with the
|
|
||||||
* username and password. Both methods work on either API.
|
|
||||||
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
|
|
||||||
*/
|
|
||||||
private async authenticateAsync(socket: TrueNasSocket): Promise<void> {
|
|
||||||
const response =
|
|
||||||
"apiKey" in this.credentials
|
|
||||||
? await socket.requestAsync("auth.login_with_api_key", [this.credentials.apiKey])
|
|
||||||
: await socket.requestAsync("auth.login", [this.credentials.username, this.credentials.password]);
|
|
||||||
|
|
||||||
const success = await z.boolean().parseAsync(response);
|
|
||||||
if (!success) throw new ResponseError({ status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a single websocket connection and speaks whichever TrueNAS API
|
|
||||||
* ({@link trueNasApis}) was negotiated when it was opened.
|
|
||||||
*/
|
|
||||||
class TrueNasSocket {
|
|
||||||
constructor(
|
|
||||||
private readonly webSocket: WebSocket,
|
|
||||||
private readonly api: TrueNasApi,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public close() {
|
|
||||||
this.webSocket.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
public onClose(listener: () => void) {
|
|
||||||
this.webSocket.once("close", listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The legacy DDP API requires a "connect" handshake to obtain a session
|
|
||||||
* before any method can be called.
|
|
||||||
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
|
|
||||||
*/
|
|
||||||
public registerSessionAsync(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const handler = (raw: RawData) => {
|
|
||||||
const message = parseMessage(raw);
|
|
||||||
if (message?.msg === "connected") {
|
|
||||||
this.webSocket.off("message", handler);
|
|
||||||
resolve();
|
|
||||||
} else if (message?.msg === "failed") {
|
|
||||||
this.webSocket.off("message", handler);
|
|
||||||
reject(new Error("Unable to establish TrueNAS session"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.webSocket.on("message", handler);
|
|
||||||
this.webSocket.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a method request and resolves with its result. Rejects after
|
|
||||||
* {@link REQUEST_TIMEOUT_MS} when no matching response was received.
|
|
||||||
*/
|
|
||||||
public requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const id = createId();
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
this.webSocket.off("message", handler);
|
|
||||||
this.webSocket.off("close", onClose);
|
|
||||||
this.webSocket.off("error", onError);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handler = (raw: RawData) => {
|
|
||||||
const message = parseMessage(raw);
|
|
||||||
if (!message) return;
|
|
||||||
|
|
||||||
const response = this.api.matchResponse(message, id);
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
if ("error" in response) {
|
|
||||||
reject(new Error(`TrueNAS method "${method}" failed: ${JSON.stringify(response.error)}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.debug("Received method response", { id, method, api: this.api.kind });
|
|
||||||
resolve(response.result);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A dropped connection would otherwise leave the request hanging until the timeout below.
|
|
||||||
const onClose = () => {
|
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
new RequestError(
|
|
||||||
{ type: "connection", reason: "reset", code: "ECONNRESET" },
|
|
||||||
{ cause: new Error("Connection closed before a response was received") },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const onError = (error: Error) => {
|
|
||||||
cleanup();
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
new RequestError(
|
|
||||||
{
|
|
||||||
type: "timeout",
|
|
||||||
reason: "aborted",
|
|
||||||
code: "ECONNABORTED",
|
|
||||||
},
|
|
||||||
{ cause: new Error("Canceled request after 5 seconds") },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, REQUEST_TIMEOUT_MS);
|
|
||||||
|
|
||||||
this.webSocket.on("message", handler);
|
|
||||||
this.webSocket.once("close", onClose);
|
|
||||||
this.webSocket.once("error", onError);
|
|
||||||
logger.debug("Sending method request", { id, method, api: this.api.kind });
|
|
||||||
this.webSocket.send(JSON.stringify(this.api.buildRequest(id, method, params)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a websocket using Homarr's trusted certificates and performs the
|
|
||||||
* session handshake when the API requires it. TLS, connection, DNS and timeout
|
|
||||||
* failures are translated into {@link RequestError}s so the connection test can
|
|
||||||
* offer to trust a self-signed certificate, exactly like the HTTP integrations.
|
|
||||||
*/
|
|
||||||
const openTrueNasSocketAsync = async (
|
|
||||||
url: URL,
|
|
||||||
api: TrueNasApi,
|
|
||||||
certificate: CertificateOptions,
|
|
||||||
): Promise<TrueNasSocket> => {
|
|
||||||
logger.debug("Connecting to TrueNAS websocket", { url: url.toString(), api: api.kind });
|
|
||||||
|
|
||||||
const webSocket = await new Promise<WebSocket>((resolve, reject) => {
|
|
||||||
const socket = new WebSocket(url, {
|
|
||||||
ca: certificate.ca,
|
|
||||||
// The @types/ws contract types this as returning a boolean, but ws forwards it to
|
|
||||||
// tls.connect which uses Node's `Error | undefined` contract that our helper follows.
|
|
||||||
checkServerIdentity: certificate.checkServerIdentity as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.once("open", () => resolve(socket));
|
|
||||||
socket.once("error", (error: Error & { code?: string }) => {
|
|
||||||
const matched = error.code ? matchErrorCode(error.code) : undefined;
|
|
||||||
reject(matched ? new RequestError(matched, { cause: error }) : error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const socket = new TrueNasSocket(webSocket, api);
|
|
||||||
if (api.requiresSessionHandshake) await socket.registerSessionAsync();
|
|
||||||
return socket;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveCertificateOptionsAsync = async (): Promise<CertificateOptions> => ({
|
|
||||||
ca: await getAllTrustedCertificatesAsync(),
|
|
||||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const parseMessage = (raw: RawData): Record<string, unknown> | undefined => {
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(raw.toString());
|
|
||||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
||||||
? (parsed as Record<string, unknown>)
|
|
||||||
: undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
|
import { createId } from "@homarr/common";
|
||||||
|
import { RequestError, ResponseError } from "@homarr/common/server";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
@@ -8,21 +10,252 @@ import { Integration } from "../base/integration";
|
|||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
||||||
import type { TrueNasCredentials } from "./truenas-client";
|
|
||||||
import { TrueNasClient } from "./truenas-client";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "trueNasIntegration" });
|
const logger = createLogger({ module: "trueNasIntegration" });
|
||||||
|
|
||||||
const NETWORK_MULTIPLIER = 100;
|
const NETWORK_MULTIPLIER = 100;
|
||||||
|
|
||||||
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
private client?: TrueNasClient;
|
private static webSocketMap = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
private wsUrl() {
|
||||||
|
const url = super.url("/websocket");
|
||||||
|
url.protocol = url.protocol.replace("http", "ws");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get webSocket() {
|
||||||
|
return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
const webSocket = await this.connectWebSocketAsync();
|
||||||
|
await this.registerSessionAsync(webSocket);
|
||||||
|
await this.authenticateWebSocketAsync(webSocket);
|
||||||
|
|
||||||
|
// Remove current socket connection so we can authenticate with updated credentials
|
||||||
|
TrueNasIntegration.webSocketMap.delete(this.integration.id);
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
|
||||||
await this.getClient().testAsync(input.options);
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrueNAS API uses WebSocket. This function connects to the socket
|
||||||
|
* and resolves the promise if the connection was successful.
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html
|
||||||
|
*/
|
||||||
|
private async connectWebSocketAsync(): Promise<WebSocket> {
|
||||||
|
logger.debug("Connecting to websocket server", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
const webSocket = new WebSocket(this.wsUrl());
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
webSocket.onopen = () => {
|
||||||
|
logger.debug("Connected to websocket server", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
resolve(webSocket);
|
||||||
|
};
|
||||||
|
|
||||||
|
webSocket.onerror = () => {
|
||||||
|
reject(new Error("Failed to connect"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before authentication, a session must be obtained from the server using the "connect" event.
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
|
||||||
|
*/
|
||||||
|
private async registerSessionAsync(webSocket: WebSocket): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const subscribe = (event: MessageEvent<string>) => {
|
||||||
|
const data = JSON.parse(event.data) as { msg: string };
|
||||||
|
if (data.msg === "connected") {
|
||||||
|
webSocket.removeEventListener("message", subscribe);
|
||||||
|
resolve();
|
||||||
|
} else if (data.msg === "failed") {
|
||||||
|
webSocket.removeEventListener("message", subscribe);
|
||||||
|
reject(new Error("Unable to establish connection"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
webSocket.addEventListener("message", subscribe);
|
||||||
|
webSocket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
msg: "connect",
|
||||||
|
version: "1", // this must be number, not string
|
||||||
|
support: ["1"], // this must be number, not string
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a session was obtained, the session can be authenticated.
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
|
||||||
|
*/
|
||||||
|
private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise<void> {
|
||||||
|
logger.debug("Authenticating with username and password", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
const response = await this.requestAsync(
|
||||||
|
"auth.login",
|
||||||
|
[this.getSecretValue("username"), this.getSecretValue("password")],
|
||||||
|
webSocket,
|
||||||
|
);
|
||||||
|
const result = await z.boolean().parseAsync(response);
|
||||||
|
if (!result) throw new ResponseError({ status: 401 });
|
||||||
|
logger.debug("Authenticated successfully with username and password", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPoolsAsync() {
|
||||||
|
logger.debug("Retrieving pools", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("pool.query", [
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
extra: {
|
||||||
|
is_upgraded: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await poolSchema.parseAsync(response);
|
||||||
|
// Offline / exported pools have null values for allocated, size and free, so we filter them out
|
||||||
|
// See https://github.com/homarr-labs/homarr/issues/5194
|
||||||
|
const activePools = result
|
||||||
|
.map((pool) => {
|
||||||
|
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
||||||
|
return {
|
||||||
|
...pool,
|
||||||
|
allocated: pool.allocated,
|
||||||
|
size: pool.size,
|
||||||
|
free: pool.free,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((pool) => pool !== null);
|
||||||
|
logger.debug("Retrieved pools", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
totalCount: result.length,
|
||||||
|
activeCount: activePools.length,
|
||||||
|
});
|
||||||
|
return activePools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves data using the reporting method
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
|
||||||
|
*/
|
||||||
|
private async getReportingAsync(): Promise<ReportingItem[]> {
|
||||||
|
logger.debug("Retrieving reporting data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("reporting.get_data", [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "cpu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cputemp",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
aggregate: true,
|
||||||
|
start: dayjs().add(-5, "minutes").unix(),
|
||||||
|
end: dayjs().unix(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await z.array(reportingItemSchema).parseAsync(response);
|
||||||
|
|
||||||
|
logger.debug("Retrieved reporting data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
count: result.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of all available network interfaces
|
||||||
|
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
|
||||||
|
*/
|
||||||
|
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
|
||||||
|
logger.debug("Retrieving available network-interfaces", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("interface.query", [
|
||||||
|
[], // no filters
|
||||||
|
{},
|
||||||
|
]);
|
||||||
|
const result = await networkInterfaceSchema.parseAsync(response);
|
||||||
|
|
||||||
|
logger.debug("Retrieved available network-interfaces", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
count: result.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves reporting network data of the last 5 minutes
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
|
||||||
|
*/
|
||||||
|
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
|
||||||
|
const networkInterfaces = await this.getNetworkInterfacesAsync();
|
||||||
|
|
||||||
|
logger.debug("Retrieving reporting network data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("reporting.netdata_get_data", [
|
||||||
|
networkInterfaces.map((networkInterface) => ({
|
||||||
|
name: "interface",
|
||||||
|
identifier: networkInterface.id,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
start: dayjs().add(-5, "minutes").unix(),
|
||||||
|
end: dayjs().unix(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await reportingNetDataSchema.parseAsync(response);
|
||||||
|
|
||||||
|
logger.debug("Retrieved reporting-network-data", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
count: result.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves information about the system
|
||||||
|
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
|
||||||
|
*/
|
||||||
|
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
|
||||||
|
logger.debug("Retrieving system-information", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.requestAsync("system.info");
|
||||||
|
const result = await systemInfoSchema.parseAsync(response);
|
||||||
|
|
||||||
|
logger.debug("Retrieved system-information", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||||
const systemInformation = await this.getSystemInformationAsync();
|
const systemInformation = await this.getSystemInformationAsync();
|
||||||
const reporting = await this.getReportingAsync();
|
const reporting = await this.getReportingAsync();
|
||||||
@@ -39,7 +272,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
|
||||||
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
|
||||||
memAvailableInBytes: systemInformation.physmem,
|
memAvailableInBytes: systemInformation.physmem,
|
||||||
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||||
fileSystem: datasets.map((dataset) => ({
|
fileSystem: datasets.map((dataset) => ({
|
||||||
@@ -68,164 +301,87 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClient(): TrueNasClient {
|
|
||||||
return (this.client ??= this.createClient());
|
|
||||||
}
|
|
||||||
|
|
||||||
private createClient(): TrueNasClient {
|
|
||||||
const credentials: TrueNasCredentials = this.hasSecretValue("apiKey")
|
|
||||||
? { apiKey: this.getSecretValue("apiKey") }
|
|
||||||
: { username: this.getSecretValue("username"), password: this.getSecretValue("password") };
|
|
||||||
|
|
||||||
return new TrueNasClient(this.integration.id, (path) => this.webSocketUrl(path), credentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
|
|
||||||
return this.getClient().requestAsync(method, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getPoolsAsync() {
|
|
||||||
logger.debug("Retrieving pools", { url: this.integration.url });
|
|
||||||
|
|
||||||
const response = await this.requestAsync("pool.query", [
|
|
||||||
[],
|
|
||||||
{
|
|
||||||
extra: {
|
|
||||||
is_upgraded: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await poolSchema.parseAsync(response);
|
|
||||||
// Offline / exported pools have null values for allocated, size and free, so we filter them out
|
|
||||||
// See https://github.com/homarr-labs/homarr/issues/5194
|
|
||||||
const activePools = result
|
|
||||||
.map((pool) => {
|
|
||||||
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
|
||||||
return {
|
|
||||||
...pool,
|
|
||||||
allocated: pool.allocated,
|
|
||||||
size: pool.size,
|
|
||||||
free: pool.free,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((pool) => pool !== null);
|
|
||||||
logger.debug("Retrieved pools", {
|
|
||||||
url: this.integration.url,
|
|
||||||
totalCount: result.length,
|
|
||||||
activeCount: activePools.length,
|
|
||||||
});
|
|
||||||
return activePools;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves data using the reporting method
|
* Send a request through websocket and return response
|
||||||
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
|
* Times out after 5 seconds when no response was received.
|
||||||
|
* @param method json-rpc method to call
|
||||||
|
* @param params array of parameters
|
||||||
|
* @param webSocketOverride override of webSocket, helpful for not storing the connection
|
||||||
|
* @returns result of json-rpc call
|
||||||
*/
|
*/
|
||||||
private async getReportingAsync(): Promise<ReportingItem[]> {
|
private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
|
||||||
logger.debug("Retrieving reporting data", { url: this.integration.url });
|
let webSocket = webSocketOverride ?? this.webSocket;
|
||||||
|
if (webSocket?.readyState !== WebSocket.OPEN) {
|
||||||
|
logger.debug("Connecting to websocket", {
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
// We can only land here with static webSocket
|
||||||
|
webSocket = await this.connectWebSocketAsync();
|
||||||
|
await this.registerSessionAsync(webSocket);
|
||||||
|
|
||||||
const response = await this.requestAsync("reporting.get_data", [
|
TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
|
||||||
[
|
await this.authenticateWebSocketAsync();
|
||||||
{
|
}
|
||||||
name: "cpu",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "memory",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cputemp",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
aggregate: true,
|
|
||||||
start: dayjs().add(-5, "minutes").unix(),
|
|
||||||
end: dayjs().unix(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await z.array(reportingItemSchema).parseAsync(response);
|
|
||||||
|
|
||||||
logger.debug("Retrieved reporting data", {
|
return await new Promise((resolve, reject) => {
|
||||||
url: this.integration.url,
|
const id = createId();
|
||||||
count: result.length,
|
const handler = (event: MessageEvent<string>) => {
|
||||||
|
const data = JSON.parse(event.data) as Record<string, unknown>;
|
||||||
|
if (data.msg !== "result") return;
|
||||||
|
if (data.id !== id) return;
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
webSocket.removeEventListener("message", handler);
|
||||||
|
logger.debug("Received method response", {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
resolve(data.result);
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
webSocket.removeEventListener("message", handler);
|
||||||
|
reject(
|
||||||
|
new RequestError(
|
||||||
|
{
|
||||||
|
type: "timeout",
|
||||||
|
reason: "aborted",
|
||||||
|
code: "ECONNABORTED",
|
||||||
|
},
|
||||||
|
{ cause: new Error("Canceled request after 5 seconds") },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
webSocket.addEventListener("message", handler);
|
||||||
|
|
||||||
|
logger.debug("Sending method request", {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
url: this.wsUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
webSocket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id,
|
||||||
|
msg: "method",
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index = 1 | 2) => {
|
||||||
* Retrieves a list of all available network interfaces
|
|
||||||
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
|
|
||||||
*/
|
|
||||||
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
|
|
||||||
logger.debug("Retrieving available network-interfaces", { url: this.integration.url });
|
|
||||||
|
|
||||||
const response = await this.requestAsync("interface.query", [
|
|
||||||
[], // no filters
|
|
||||||
{},
|
|
||||||
]);
|
|
||||||
const result = await networkInterfaceSchema.parseAsync(response);
|
|
||||||
|
|
||||||
logger.debug("Retrieved available network-interfaces", {
|
|
||||||
url: this.integration.url,
|
|
||||||
count: result.length,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves reporting network data of the last 5 minutes
|
|
||||||
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
|
|
||||||
*/
|
|
||||||
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
|
|
||||||
const networkInterfaces = await this.getNetworkInterfacesAsync();
|
|
||||||
|
|
||||||
logger.debug("Retrieving reporting network data", { url: this.integration.url });
|
|
||||||
|
|
||||||
const response = await this.requestAsync("reporting.netdata_get_data", [
|
|
||||||
networkInterfaces.map((networkInterface) => ({
|
|
||||||
name: "interface",
|
|
||||||
identifier: networkInterface.id,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
start: dayjs().add(-5, "minutes").unix(),
|
|
||||||
end: dayjs().unix(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await reportingNetDataSchema.parseAsync(response);
|
|
||||||
|
|
||||||
logger.debug("Retrieved reporting-network-data", {
|
|
||||||
url: this.integration.url,
|
|
||||||
count: result.length,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves information about the system
|
|
||||||
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
|
|
||||||
*/
|
|
||||||
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
|
|
||||||
logger.debug("Retrieving system-information", { url: this.integration.url });
|
|
||||||
|
|
||||||
const response = await this.requestAsync("system.info");
|
|
||||||
const result = await systemInfoSchema.parseAsync(response);
|
|
||||||
|
|
||||||
logger.debug("Retrieved system-information", { url: this.integration.url });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index: 1 | 2) => {
|
|
||||||
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
|
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) {
|
private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
|
||||||
const item = data.find((reportingItem) => reportingItem.identifier === identifier);
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
// Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading.
|
const dataObject = data.find((item) => item.identifier === key)!;
|
||||||
const latest = item?.data.at(-1);
|
// TODO: check why the below sorting is done, because right now it compares number[] with number[]?
|
||||||
if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`);
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
return latest;
|
return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,8 @@ export * from "./anchor/anchor-types";
|
|||||||
export * from "./interfaces/timetable/timetable-types";
|
export * from "./interfaces/timetable/timetable-types";
|
||||||
export * from "./tracearr/tracearr-types";
|
export * from "./tracearr/tracearr-types";
|
||||||
export * from "./speedtest-tracker/speedtest-tracker-types";
|
export * from "./speedtest-tracker/speedtest-tracker-types";
|
||||||
|
export * from "./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 "./umami/umami-types";
|
||||||
@@ -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<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(statusPageResponse), { status: 200 }),
|
||||||
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
publicGroupList: [{ id: 1, name: "G", monitorList: [{ id: 10, name: "Svc" }] }],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
});
|
||||||
|
|
||||||
|
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<typeof fetchWithTrustedCertificatesAsync>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const integration = createIntegration([{ kind: "slug", value: "default" }]);
|
||||||
|
const result = await integration.getDashboardDataAsync();
|
||||||
|
|
||||||
|
expect(result.totalMonitors).toBe(0);
|
||||||
|
expect(result.averageUptimePercent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TestingResult> {
|
||||||
|
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<UptimeKumaDashboardData> {
|
||||||
|
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<string, { status: number }[]>; uptimeList: Record<string, number> },
|
||||||
|
): 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number, UptimeKumaMonitorCategory> = {
|
||||||
|
[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<typeof uptimeKumaStatusPageResponseSchema>;
|
||||||
|
export type UptimeKumaHeartbeatResponse = z.infer<typeof uptimeKumaHeartbeatResponseSchema>;
|
||||||
@@ -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<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(10, "minutes"),
|
||||||
|
queryKey: "audiobookshelfDashboard",
|
||||||
|
});
|
||||||
@@ -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<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(10, "minutes"),
|
||||||
|
queryKey: "navidromeDashboard",
|
||||||
|
});
|
||||||
@@ -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<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getStatsAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(15, "minute"),
|
||||||
|
queryKey: "paperless-ngx-stats",
|
||||||
|
});
|
||||||
@@ -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<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration, _input) {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getDashboardDataAsync();
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(1, "minute"),
|
||||||
|
queryKey: "uptimeKumaDashboard",
|
||||||
|
});
|
||||||
@@ -7,10 +7,7 @@
|
|||||||
"description": "To get started, please select how you want to set up your Homarr instance.",
|
"description": "To get started, please select how you want to set up your Homarr instance.",
|
||||||
"action": {
|
"action": {
|
||||||
"scratch": "Start from scratch",
|
"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": {
|
"import": {
|
||||||
@@ -811,10 +808,13 @@
|
|||||||
"firewall": "Firewall",
|
"firewall": "Firewall",
|
||||||
"timetable": "Timetable",
|
"timetable": "Timetable",
|
||||||
"photoService": "Photos",
|
"photoService": "Photos",
|
||||||
|
"documents": "Documents",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"mediaMonitoring": "Monitoring",
|
"mediaMonitoring": "Monitoring",
|
||||||
"speedtest": "Speed Test",
|
"speedtest": "Speed Test",
|
||||||
"analytics": "Analytics"
|
"analytics": "Analytics",
|
||||||
|
"mediaLibrary": "Media Library",
|
||||||
|
"uptimeMonitoring": "Uptime Monitoring"
|
||||||
},
|
},
|
||||||
"testConnection": {
|
"testConnection": {
|
||||||
"action": {
|
"action": {
|
||||||
@@ -1087,6 +1087,10 @@
|
|||||||
"privateKey": {
|
"privateKey": {
|
||||||
"label": "Private key",
|
"label": "Private key",
|
||||||
"newLabel": "New private key"
|
"newLabel": "New private key"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"label": "Status page slug",
|
||||||
|
"newLabel": "New status page slug"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1094,6 +1098,30 @@
|
|||||||
"use": "Select integrations in items",
|
"use": "Select integrations in items",
|
||||||
"interact": "Interact with integrations",
|
"interact": "Interact with integrations",
|
||||||
"full": "Full integration access"
|
"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": {
|
"media": {
|
||||||
@@ -2432,6 +2460,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": {
|
"immich-serverStats": {
|
||||||
"name": "Immich Server Stats",
|
"name": "Immich Server Stats",
|
||||||
"description": "Information about your Immich instance",
|
"description": "Information about your Immich instance",
|
||||||
@@ -3040,6 +3113,113 @@
|
|||||||
"internalServerError": "Failed to fetch speedtest data"
|
"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": {
|
"systemResources": {
|
||||||
"name": "System resources",
|
"name": "System resources",
|
||||||
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
||||||
@@ -3501,8 +3681,7 @@
|
|||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"certificates": "Certificates",
|
"certificates": "Certificates",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks"
|
||||||
"backup": "Backup"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -4042,125 +4221,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"title": "Full Database Backup",
|
|
||||||
"metaTitle": "Backup & Restore",
|
|
||||||
"export": {
|
|
||||||
"title": "Export Database",
|
|
||||||
"description": "Download a full backup of your SQLite database including all settings, users, boards, and encrypted integration secrets. The backup is bundled as a ZIP file with the encryption key and metadata.",
|
|
||||||
"button": "Download Backup",
|
|
||||||
"error": "Export failed",
|
|
||||||
"confirm": {
|
|
||||||
"title": "Sensitive data export",
|
|
||||||
"warningTitle": "This export contains your entire database.",
|
|
||||||
"warningBody": "The backup includes all users, integration secrets (encrypted), API keys, board configurations, and app data. Store the exported file securely and do not share it.",
|
|
||||||
"typePrompt": "Type \"{phrase}\" to continue",
|
|
||||||
"submit": "Export Database",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"import": {
|
|
||||||
"title": "Restore Database",
|
|
||||||
"description": "Upload a previously exported backup ZIP to restore your database. This will replace your entire database. If the backup was created with a different encryption key, secrets will be automatically re-encrypted.",
|
|
||||||
"warning": "Restoring a backup will completely replace your current database and restart the application. This action cannot be undone.",
|
|
||||||
"dropzone": {
|
|
||||||
"title": "Drop your backup ZIP here or click to browse",
|
|
||||||
"description": "Only .zip files from a Homarr SQLite backup are accepted"
|
|
||||||
},
|
|
||||||
"changeFile": "Change file",
|
|
||||||
"button": "Restore Database",
|
|
||||||
"success": {
|
|
||||||
"title": "Restore Complete",
|
|
||||||
"message": "Database has been restored successfully. The application is restarting..."
|
|
||||||
},
|
|
||||||
"error": "Import failed"
|
|
||||||
},
|
|
||||||
"preview": {
|
|
||||||
"title": "Backup Contents",
|
|
||||||
"exported": "Exported {date}",
|
|
||||||
"loading": "Analyzing backup file...",
|
|
||||||
"errorTitle": "Preview Error",
|
|
||||||
"entity": "Entity",
|
|
||||||
"count": "Count",
|
|
||||||
"boards": "Boards",
|
|
||||||
"entities": {
|
|
||||||
"board": "Boards",
|
|
||||||
"user": "Users",
|
|
||||||
"app": "Apps",
|
|
||||||
"integration": "Integrations",
|
|
||||||
"item": "Widgets",
|
|
||||||
"media": "Media",
|
|
||||||
"search_engine": "Search Engines"
|
|
||||||
},
|
|
||||||
"analyzeError": "Failed to analyze backup file",
|
|
||||||
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
|
|
||||||
"invalidMissingDb": "Invalid backup: missing db.sqlite"
|
|
||||||
},
|
|
||||||
"restore": {
|
|
||||||
"dropzone": {
|
|
||||||
"title": "Drop your backup ZIP here",
|
|
||||||
"description": "Accepts .zip files from a Homarr SQLite backup (max 256 MB)"
|
|
||||||
},
|
|
||||||
"analyzing": "Analyzing backup...",
|
|
||||||
"changeFile": "Change file",
|
|
||||||
"continueToRestore": "Continue to restore",
|
|
||||||
"tryAgain": "Try again",
|
|
||||||
"analysisError": "Analysis Error",
|
|
||||||
"preview": {
|
|
||||||
"title": "Backup Contents",
|
|
||||||
"exported": "Exported {date}",
|
|
||||||
"entity": "Entity",
|
|
||||||
"count": "Count",
|
|
||||||
"boards": "Boards",
|
|
||||||
"entities": {
|
|
||||||
"board": "Boards",
|
|
||||||
"user": "Users",
|
|
||||||
"app": "Apps",
|
|
||||||
"integration": "Integrations",
|
|
||||||
"item": "Widgets",
|
|
||||||
"media": "Media",
|
|
||||||
"search_engine": "Search Engines"
|
|
||||||
},
|
|
||||||
"migrationsApplied": "{count} migration applied",
|
|
||||||
"migrationsAppliedPlural": "{count} migrations applied",
|
|
||||||
"migrationsOlderVersion": "This backup was from an older Homarr version. {count} schema migration was applied to bring it up to date.",
|
|
||||||
"migrationsOlderVersionPlural": "This backup was from an older Homarr version. {count} schema migrations were applied to bring it up to date.",
|
|
||||||
"migrationsUpToDate": "Database schema is up to date ({count} migrations applied)",
|
|
||||||
"upToDate": "Up to date",
|
|
||||||
"migrationsPending": "{count} pending"
|
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"title": "Applying migrations ({current}/{total})",
|
|
||||||
"migrationLabel": "Migration {index}"
|
|
||||||
},
|
|
||||||
"confirm": {
|
|
||||||
"title": "Destructive action",
|
|
||||||
"warningTitle": "This will permanently replace your entire database.",
|
|
||||||
"warningBody": "All current boards, users, apps, integrations, settings, and sessions will be lost. This action cannot be undone. The application will restart after the restore.",
|
|
||||||
"typePrompt": "Type \"{phrase}\" to continue",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"submit": "Restore Database"
|
|
||||||
},
|
|
||||||
"progress": {
|
|
||||||
"title": "Restoring database...",
|
|
||||||
"extracting": "Extracting backup archive",
|
|
||||||
"migrating": "Applying database migrations",
|
|
||||||
"encrypting": "Re-encrypting integration secrets",
|
|
||||||
"swapping": "Replacing database file",
|
|
||||||
"restarting": "Restarting server"
|
|
||||||
},
|
|
||||||
"complete": {
|
|
||||||
"title": "Restore Complete",
|
|
||||||
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
|
|
||||||
},
|
|
||||||
"failed": {
|
|
||||||
"title": "Restore Failed"
|
|
||||||
},
|
|
||||||
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
|
|
||||||
"invalidMissingDb": "Invalid backup: missing db.sqlite",
|
|
||||||
"analyzeError": "Failed to analyze backup"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|||||||
@@ -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<AudioStatsBackend, NavidromeDashboardData | null> = {
|
||||||
|
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 (
|
||||||
|
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
|
||||||
|
{visibleStats.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
|
||||||
|
style={{ "--stat-cols": gridCols } as CSSProperties}
|
||||||
|
>
|
||||||
|
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
|
||||||
|
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
|
||||||
|
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||||
|
<span className={classes.statValue}>{value}</span>
|
||||||
|
<span className={classes.statLabel}>{t(statKey as never)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNowPlayingSection && navidromeStats && (
|
||||||
|
<div className={classes.nowPlaying}>
|
||||||
|
<div className={classes.nowPlayingHeader}>
|
||||||
|
<IconHeadphones size={iconSize} stroke={1.5} />
|
||||||
|
<span className={classes.nowPlayingTitle}>{t("nowPlaying" as never)}</span>
|
||||||
|
{!options.showNowPlayingList && (
|
||||||
|
<Badge className={classes.nowPlayingCount} variant="light" size="sm">
|
||||||
|
{(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", {
|
||||||
|
count: navidromeStats.nowPlaying.length,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{options.showNowPlayingList && (
|
||||||
|
<div className={classes.nowPlayingList}>
|
||||||
|
{nowPlayingTracks.map((track, index) => (
|
||||||
|
<div key={`${track.username}-${track.title}-${index}`} className={classes.nowPlayingItem}>
|
||||||
|
<div className={classes.trackInfo}>
|
||||||
|
<span className={classes.trackTitle}>{track.title}</span>
|
||||||
|
<span className={classes.trackArtist}>
|
||||||
|
{[track.artist, track.album].filter(Boolean).join(" · ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge className={classes.trackUser} variant="outline" size="xs">
|
||||||
|
{track.username || track.playerName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasContent && (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
@@ -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<NavidromeDisplayOptions & AudiobookshelfDisplayOptions>;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -42,6 +42,7 @@ export interface WidgetDefinition {
|
|||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
supportedIntegrations?: IntegrationKind[];
|
supportedIntegrations?: IntegrationKind[];
|
||||||
integrationsRequired?: boolean;
|
integrationsRequired?: boolean;
|
||||||
|
maxIntegrations?: number;
|
||||||
createOptions: (
|
createOptions: (
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||||
) => WidgetOptionsRecord;
|
) => WidgetOptionsRecord;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
|||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
|
|
||||||
import * as anchorNote from "./anchor-note";
|
import * as anchorNote from "./anchor-note";
|
||||||
|
import * as audioStats from "./audio-stats";
|
||||||
import * as app from "./app";
|
import * as app from "./app";
|
||||||
import * as bookmarks from "./bookmarks";
|
import * as bookmarks from "./bookmarks";
|
||||||
import * as calendar from "./calendar";
|
import * as calendar from "./calendar";
|
||||||
@@ -34,6 +35,7 @@ import * as minecraftServerStatus from "./minecraft/server-status";
|
|||||||
import * as networkControllerStatus from "./network-controller/network-status";
|
import * as networkControllerStatus from "./network-controller/network-status";
|
||||||
import * as networkControllerSummary from "./network-controller/summary";
|
import * as networkControllerSummary from "./network-controller/summary";
|
||||||
import * as notebook from "./notebook";
|
import * as notebook from "./notebook";
|
||||||
|
import * as paperlessNgx from "./paperless-ngx";
|
||||||
import * as notifications from "./notifications";
|
import * as notifications from "./notifications";
|
||||||
import type { WidgetOptionDefinition } from "./options";
|
import type { WidgetOptionDefinition } from "./options";
|
||||||
import * as releases from "./releases";
|
import * as releases from "./releases";
|
||||||
@@ -41,6 +43,7 @@ import * as rssFeed from "./rssFeed";
|
|||||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||||
import * as speedtestTracker from "./speedtest-tracker";
|
import * as speedtestTracker from "./speedtest-tracker";
|
||||||
|
import * as uptimeKuma from "./uptime-kuma";
|
||||||
import * as stockPrice from "./stocks";
|
import * as stockPrice from "./stocks";
|
||||||
import * as systemDisks from "./system-disks";
|
import * as systemDisks from "./system-disks";
|
||||||
import * as systemResources from "./system-resources";
|
import * as systemResources from "./system-resources";
|
||||||
@@ -91,8 +94,11 @@ export const widgetImports = {
|
|||||||
timetable,
|
timetable,
|
||||||
"immich-serverStats": immichServerStats,
|
"immich-serverStats": immichServerStats,
|
||||||
"immich-albumCarousel": immichAlbumCarousel,
|
"immich-albumCarousel": immichAlbumCarousel,
|
||||||
|
paperlessNgx,
|
||||||
tracearr,
|
tracearr,
|
||||||
speedtestTracker,
|
speedtestTracker,
|
||||||
|
uptimeKuma,
|
||||||
|
audioStats,
|
||||||
umami,
|
umami,
|
||||||
ups,
|
ups,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
|||||||
<WidgetIntegrationSelect
|
<WidgetIntegrationSelect
|
||||||
label={t("item.edit.field.integrations.label")}
|
label={t("item.edit.field.integrations.label")}
|
||||||
data={innerProps.integrationData}
|
data={innerProps.integrationData}
|
||||||
|
canSelectMultiple={
|
||||||
|
((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) >
|
||||||
|
1
|
||||||
|
}
|
||||||
{...form.getInputProps("integrationIds")}
|
{...form.getInputProps("integrationIds")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
.root {
|
||||||
|
container-type: size;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: clamp(6px, 3cqi, 14px);
|
||||||
|
gap: clamp(4px, 2cqi, 10px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: clamp(8px, 3cqi, 18px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||||
|
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroExpanded {
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: none;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTextOnly {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTextOnly .heroText {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTextOnly .heroInbox {
|
||||||
|
font-size: clamp(18px, 8cqi, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(2px, 1cqi, 4px);
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroLabel {
|
||||||
|
font-size: clamp(9px, 2.8cqi, 13px);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroCounts {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: clamp(4px, 1.5cqi, 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroInbox {
|
||||||
|
font-size: clamp(16px, 6cqi, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mantine-color-orange-6);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTotal {
|
||||||
|
font-size: clamp(11px, 3.5cqi, 15px);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: clamp(4px, 2cqi, 10px);
|
||||||
|
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
align-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statTile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
gap: clamp(2px, 1.5cqi, 5px);
|
||||||
|
padding: clamp(4px, 2.5cqi, 10px);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statIcon {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: clamp(8px, 2.5cqi, 12px);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: clamp(14px, 5.5cqi, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mantine-primary-color-filled);
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-size: clamp(11px, 3cqi, 14px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { RingProgress, Text } from "@mantine/core";
|
||||||
|
import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
|
const statVisibilityByOption = {
|
||||||
|
showDocumentsTotal: "documentsTotal",
|
||||||
|
showDocumentsInbox: "documentsInbox",
|
||||||
|
showCorrespondents: "correspondents",
|
||||||
|
showTags: "tags",
|
||||||
|
showDocumentTypes: "documentTypes",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const statIcons = {
|
||||||
|
documentsTotal: IconFileText,
|
||||||
|
documentsInbox: IconInbox,
|
||||||
|
correspondents: IconUsers,
|
||||||
|
tags: IconTag,
|
||||||
|
documentTypes: IconFileDescription,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]);
|
||||||
|
|
||||||
|
const gridColsByWidth = [
|
||||||
|
{ minWidth: 380, cols: 3 },
|
||||||
|
{ minWidth: 220, cols: 2 },
|
||||||
|
{ minWidth: 0, cols: 1 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ringSizeByWidth = [
|
||||||
|
{ minWidth: 400, size: 88 },
|
||||||
|
{ minWidth: 320, size: 72 },
|
||||||
|
{ minWidth: 220, size: 56 },
|
||||||
|
{ minWidth: 0, size: 44 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const iconSizeByWidth = [
|
||||||
|
{ minWidth: 320, size: 22 },
|
||||||
|
{ minWidth: 200, size: 18 },
|
||||||
|
{ minWidth: 0, size: 16 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ringLabelSizeByRingSize = [
|
||||||
|
{ minSize: 72, size: "sm" },
|
||||||
|
{ minSize: 56, size: "xs" },
|
||||||
|
{ minSize: 0, size: "xs" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const heroLayoutBySecondaryStats = {
|
||||||
|
false: classes.heroExpanded,
|
||||||
|
true: "",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const heroVariantByRing = {
|
||||||
|
true: "",
|
||||||
|
false: classes.heroTextOnly,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const heroPartVisibility = {
|
||||||
|
ratio: "showInboxRatio",
|
||||||
|
ring: "showInboxRing",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
|
||||||
|
const t = useScopedI18n("widget.paperlessNgx");
|
||||||
|
const [stats] = clientApi.widget.paperlessNgx.getStats.useSuspenseQuery({
|
||||||
|
integrationId: integrationIds[0] ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
|
||||||
|
const showHero = options.showInboxRatio && canShowInboxHero;
|
||||||
|
const inboxProgress = computeInboxProgress(stats.documentsTotal, stats.documentsInbox);
|
||||||
|
|
||||||
|
const statValues = {
|
||||||
|
documentsTotal: stats.documentsTotal,
|
||||||
|
documentsInbox: stats.documentsInbox,
|
||||||
|
correspondents: stats.correspondentsCount,
|
||||||
|
tags: stats.tagsCount,
|
||||||
|
documentTypes: stats.documentTypesCount,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||||
|
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||||
|
.map(([, statKey]) => statKey)
|
||||||
|
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
|
||||||
|
|
||||||
|
const gridCols = getGridCols(width);
|
||||||
|
const ringSize = getRingSize(width);
|
||||||
|
const iconSize = getIconSize(width);
|
||||||
|
const ringLabelSize = getRingLabelSize(ringSize);
|
||||||
|
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||||
|
|
||||||
|
const heroLayoutClass =
|
||||||
|
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
|
||||||
|
const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
|
||||||
|
|
||||||
|
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
|
||||||
|
([, optionKey]) => options[optionKey as keyof typeof options],
|
||||||
|
);
|
||||||
|
|
||||||
|
const heroPartRenderers = {
|
||||||
|
ratio: (
|
||||||
|
<div className={classes.heroText}>
|
||||||
|
<span className={classes.heroLabel}>{t("inboxRatio")}</span>
|
||||||
|
<div className={classes.heroCounts}>
|
||||||
|
<span className={classes.heroInbox}>
|
||||||
|
{stats.documentsInbox} {t("inbox")}
|
||||||
|
</span>
|
||||||
|
<span className={classes.heroTotal}>
|
||||||
|
/ {stats.documentsTotal} {t("total")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ring: (
|
||||||
|
<RingProgress
|
||||||
|
className={classes.ring}
|
||||||
|
size={ringSize}
|
||||||
|
thickness={Math.max(4, Math.round(ringSize / 10))}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: inboxProgress, color: "orange" }]}
|
||||||
|
label={
|
||||||
|
<Text ta="center" size={ringLabelSize} fw={700}>
|
||||||
|
{inboxProgress}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
{showHero && (
|
||||||
|
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||||
|
{visibleHeroParts.map(([partKey]) => (
|
||||||
|
<Fragment key={partKey}>{heroPartRenderers[partKey as keyof typeof heroPartRenderers]}</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasContent && (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleStatKeys.length > 0 && (
|
||||||
|
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||||
|
{visibleStatKeys.map((statKey) => {
|
||||||
|
const Icon = statIcons[statKey];
|
||||||
|
return (
|
||||||
|
<div key={statKey} className={classes.statTile}>
|
||||||
|
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||||
|
<span className={classes.statValue}>{statValues[statKey]}</span>
|
||||||
|
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeInboxProgress(total: number, inbox: number): number {
|
||||||
|
if (total <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round((inbox / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGridCols(width: number): number {
|
||||||
|
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||||
|
return match?.cols ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRingSize(width: number): number {
|
||||||
|
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||||
|
return match?.size ?? 44;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconSize(width: number): number {
|
||||||
|
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||||
|
return match?.size ?? 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRingLabelSize(ringSize: number): "xs" | "sm" {
|
||||||
|
const match = ringLabelSizeByRingSize.find(({ minSize }) => ringSize >= minSize);
|
||||||
|
return match?.size ?? "xs";
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { IconFileText, IconServerOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
|
||||||
|
icon: IconFileText,
|
||||||
|
supportedIntegrations: ["paperlessNgx"],
|
||||||
|
integrationsRequired: true,
|
||||||
|
createOptions() {
|
||||||
|
return optionsBuilder.from((factory) => ({
|
||||||
|
showInboxRatio: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showInboxRing: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showDocumentsTotal: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showDocumentsInbox: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showCorrespondents: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showTags: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showDocumentTypes: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
INTERNAL_SERVER_ERROR: {
|
||||||
|
icon: IconServerOff,
|
||||||
|
message: (t) => t("widget.paperlessNgx.error.internalServerError"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
.root {
|
||||||
|
container-type: size;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: clamp(6px, 3cqi, 14px);
|
||||||
|
gap: clamp(4px, 2cqi, 10px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: clamp(8px, 3cqi, 18px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: clamp(4px, 2cqi, 10px);
|
||||||
|
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroExpanded {
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: none;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTextOnly {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTextOnly .heroText {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTextOnly .heroValue {
|
||||||
|
font-size: clamp(22px, 10cqi, 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(2px, 1cqi, 4px);
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroLabel {
|
||||||
|
font-size: clamp(9px, 2.8cqi, 13px);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroValue {
|
||||||
|
font-size: clamp(18px, 8cqi, 32px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statGrid {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: clamp(4px, 2cqi, 10px);
|
||||||
|
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
align-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statTile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
gap: clamp(2px, 1.5cqi, 5px);
|
||||||
|
padding: clamp(4px, 2.5cqi, 10px);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: clamp(14px, 5.5cqi, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: clamp(8px, 2.5cqi, 12px);
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
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, 14px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { RingProgress, Text } from "@mantine/core";
|
||||||
|
import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { formatNumber } from "@homarr/common";
|
||||||
|
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
|
const emptyDashboard: UptimeKumaDashboardData = {
|
||||||
|
totalMonitors: 0,
|
||||||
|
upCount: 0,
|
||||||
|
downCount: 0,
|
||||||
|
pausedCount: 0,
|
||||||
|
averageUptimePercent: 0,
|
||||||
|
monitors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const statVisibilityByOption = {
|
||||||
|
showTotalMonitors: "totalMonitors",
|
||||||
|
showUpCount: "upCount",
|
||||||
|
showDownCount: "downCount",
|
||||||
|
showPausedCount: "pausedCount",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const statIcons = {
|
||||||
|
totalMonitors: IconServer,
|
||||||
|
upCount: IconArrowUp,
|
||||||
|
downCount: IconArrowDown,
|
||||||
|
pausedCount: IconClockPause,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const statColorByKey = {
|
||||||
|
totalMonitors: "gray",
|
||||||
|
upCount: "green",
|
||||||
|
downCount: "red",
|
||||||
|
pausedCount: "yellow",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const uptimeTierByThreshold = [
|
||||||
|
[99, "excellent"],
|
||||||
|
[95, "good"],
|
||||||
|
[0, "poor"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const uptimeColorByTier = {
|
||||||
|
excellent: "green",
|
||||||
|
good: "yellow",
|
||||||
|
poor: "red",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const gridColsByWidth = [
|
||||||
|
{ minWidth: 380, cols: 4 },
|
||||||
|
{ minWidth: 260, cols: 2 },
|
||||||
|
{ minWidth: 0, cols: 2 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ringSizeByWidth = [
|
||||||
|
{ minWidth: 400, size: 88 },
|
||||||
|
{ minWidth: 320, size: 72 },
|
||||||
|
{ minWidth: 220, size: 56 },
|
||||||
|
{ minWidth: 0, size: 44 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const iconSizeByWidth = [
|
||||||
|
{ minWidth: 320, size: 22 },
|
||||||
|
{ minWidth: 200, size: 18 },
|
||||||
|
{ minWidth: 0, size: 16 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const heroLayoutBySecondaryStats = {
|
||||||
|
false: classes.heroExpanded,
|
||||||
|
true: "",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const heroVariantByRing = {
|
||||||
|
true: "",
|
||||||
|
false: classes.heroTextOnly,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function UptimeKumaWidget({
|
||||||
|
integrationIds,
|
||||||
|
isEditMode,
|
||||||
|
options,
|
||||||
|
width,
|
||||||
|
}: WidgetComponentProps<"uptimeKuma">) {
|
||||||
|
if (integrationIds.length === 0) {
|
||||||
|
throw new NoIntegrationDataError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UptimeKumaContent integrationIds={integrationIds} isEditMode={isEditMode} options={options} width={width} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UptimeKumaContentProps {
|
||||||
|
integrationIds: string[];
|
||||||
|
isEditMode: boolean;
|
||||||
|
options: WidgetComponentProps<"uptimeKuma">["options"];
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UptimeKumaContent({ integrationIds, isEditMode, options, width }: UptimeKumaContentProps) {
|
||||||
|
const t = useScopedI18n("widget.uptimeKuma");
|
||||||
|
const [dashboardData] = clientApi.widget.uptimeKuma.getDashboard.useSuspenseQuery({ integrationIds });
|
||||||
|
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
clientApi.widget.uptimeKuma.subscribeToDashboard.useSubscription(
|
||||||
|
{ integrationIds },
|
||||||
|
{
|
||||||
|
enabled: !isEditMode,
|
||||||
|
onData(newData) {
|
||||||
|
utils.widget.uptimeKuma.getDashboard.setData({ integrationIds }, (prevData) => {
|
||||||
|
if (!prevData) return prevData;
|
||||||
|
return prevData.map((instance) => {
|
||||||
|
if (instance.integrationId === newData.integrationId) {
|
||||||
|
return { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp };
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const combined = dashboardData.reduce<UptimeKumaDashboardData>(
|
||||||
|
(acc, item) => ({
|
||||||
|
totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors,
|
||||||
|
upCount: acc.upCount + item.dashboard.upCount,
|
||||||
|
downCount: acc.downCount + item.dashboard.downCount,
|
||||||
|
pausedCount: acc.pausedCount + item.dashboard.pausedCount,
|
||||||
|
averageUptimePercent: acc.averageUptimePercent + item.dashboard.averageUptimePercent,
|
||||||
|
monitors: [...acc.monitors, ...item.dashboard.monitors],
|
||||||
|
}),
|
||||||
|
emptyDashboard,
|
||||||
|
);
|
||||||
|
|
||||||
|
const instanceCount = dashboardData.length;
|
||||||
|
const averageUptime = combined.averageUptimePercent / Math.max(instanceCount, 1);
|
||||||
|
const uptimeValue = clampPercent(averageUptime);
|
||||||
|
const uptimeColor = getUptimeColor(uptimeValue);
|
||||||
|
const ringSize = getRingSize(width);
|
||||||
|
const iconSize = getIconSize(width);
|
||||||
|
const gridCols = getGridCols(width);
|
||||||
|
|
||||||
|
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||||
|
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||||
|
.map(([, statKey]) => statKey);
|
||||||
|
|
||||||
|
const showHero = options.showAverageUptime;
|
||||||
|
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||||
|
|
||||||
|
const heroLayoutClass =
|
||||||
|
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
|
||||||
|
const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
{showHero && (
|
||||||
|
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||||
|
<div className={classes.heroText}>
|
||||||
|
<span className={classes.heroLabel}>{t("averageUptime")}</span>
|
||||||
|
<span className={classes.heroValue}>{formatNumber(uptimeValue, 1)}%</span>
|
||||||
|
</div>
|
||||||
|
{options.showUptimeRing && (
|
||||||
|
<RingProgress
|
||||||
|
className={classes.ring}
|
||||||
|
size={ringSize}
|
||||||
|
thickness={Math.max(4, Math.round(ringSize / 10))}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: uptimeValue, color: uptimeColor }]}
|
||||||
|
label={
|
||||||
|
<Text ta="center" size="xs" fw={700}>
|
||||||
|
{formatNumber(uptimeValue, 0)}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasContent && (
|
||||||
|
<div className={classes.emptyState}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleStatKeys.length > 0 && (
|
||||||
|
<div className={classes.statGrid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||||
|
{visibleStatKeys.map((statKey) => {
|
||||||
|
const Icon = statIcons[statKey];
|
||||||
|
const color = statColorByKey[statKey];
|
||||||
|
return (
|
||||||
|
<div key={statKey} className={classes.statTile}>
|
||||||
|
<Icon
|
||||||
|
className={classes.statIcon}
|
||||||
|
size={iconSize}
|
||||||
|
stroke={1.5}
|
||||||
|
color={`var(--mantine-color-${color}-6)`}
|
||||||
|
/>
|
||||||
|
<span className={classes.statValue}>{formatNumber(combined[statKey], 0)}</span>
|
||||||
|
<span className={classes.statLabel}>{t(`stats.${statKey}`)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercent(value: number): number {
|
||||||
|
return Math.min(100, Math.max(0, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUptimeTier(uptime: number): keyof typeof uptimeColorByTier {
|
||||||
|
const match = uptimeTierByThreshold.find(([min]) => uptime >= min);
|
||||||
|
const tier = match?.[1] ?? "poor";
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUptimeColor(uptime: number): string {
|
||||||
|
return uptimeColorByTier[getUptimeTier(uptime)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGridCols(width: number): number {
|
||||||
|
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||||
|
return match?.cols ?? 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRingSize(width: number): number {
|
||||||
|
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||||
|
return match?.size ?? 44;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconSize(width: number): number {
|
||||||
|
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||||
|
return match?.size ?? 16;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { IconHeartbeat, IconServerOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const { definition, componentLoader } = createWidgetDefinition("uptimeKuma", {
|
||||||
|
icon: IconHeartbeat,
|
||||||
|
createOptions() {
|
||||||
|
return optionsBuilder.from((factory) => ({
|
||||||
|
showAverageUptime: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showUptimeRing: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showTotalMonitors: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showUpCount: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showDownCount: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
showPausedCount: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
withDescription: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
supportedIntegrations: ["uptimeKuma"],
|
||||||
|
errors: {
|
||||||
|
INTERNAL_SERVER_ERROR: {
|
||||||
|
icon: IconServerOff,
|
||||||
|
message: (t) => t("widget.uptimeKuma.error.internalServerError"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
@@ -51,11 +51,14 @@ export const WidgetIntegrationSelect = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleValueSelect = (selectedValue: string) => {
|
const handleValueSelect = (selectedValue: string) => {
|
||||||
onChange(
|
if (multiSelectValues.includes(selectedValue)) {
|
||||||
multiSelectValues.includes(selectedValue)
|
onChange(multiSelectValues.filter((value) => value !== selectedValue));
|
||||||
? multiSelectValues.filter((value) => value !== selectedValue)
|
} else if (canSelectMultiple) {
|
||||||
: [...multiSelectValues, selectedValue],
|
onChange([...multiSelectValues, selectedValue]);
|
||||||
);
|
} else {
|
||||||
|
onChange([selectedValue]);
|
||||||
|
}
|
||||||
|
|
||||||
if (!canSelectMultiple) {
|
if (!canSelectMultiple) {
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+541
-626
File diff suppressed because it is too large.
Load diff
+32
-34
@@ -21,17 +21,17 @@ catalog:
|
|||||||
"@immich/sdk": ^2.7.5
|
"@immich/sdk": ^2.7.5
|
||||||
"@jellyfin/sdk": ^0.13.0
|
"@jellyfin/sdk": ^0.13.0
|
||||||
"@kubernetes/client-node": ^1.4.0
|
"@kubernetes/client-node": ^1.4.0
|
||||||
"@mantine/charts": ^9.3.0
|
"@mantine/charts": ^9.2.2
|
||||||
"@mantine/colors-generator": ^9.3.0
|
"@mantine/colors-generator": ^9.2.2
|
||||||
"@mantine/core": ^9.3.0
|
"@mantine/core": ^9.2.2
|
||||||
"@mantine/dates": ^9.3.0
|
"@mantine/dates": ^9.2.2
|
||||||
"@mantine/dropzone": ^9.3.0
|
"@mantine/dropzone": ^9.2.2
|
||||||
"@mantine/form": ^9.3.0
|
"@mantine/form": ^9.2.2
|
||||||
"@mantine/hooks": ^9.3.0
|
"@mantine/hooks": ^9.2.2
|
||||||
"@mantine/modals": ^9.3.0
|
"@mantine/modals": ^9.2.2
|
||||||
"@mantine/notifications": ^9.3.0
|
"@mantine/notifications": ^9.2.2
|
||||||
"@mantine/spotlight": ^9.3.0
|
"@mantine/spotlight": ^9.2.2
|
||||||
"@mantine/tiptap": ^9.3.0
|
"@mantine/tiptap": ^9.2.2
|
||||||
"@next/eslint-plugin-next": 16.2.7
|
"@next/eslint-plugin-next": 16.2.7
|
||||||
"@octokit/auth-app": ^8.2.0
|
"@octokit/auth-app": ^8.2.0
|
||||||
"@paralleldrive/cuid2": ^3.3.0
|
"@paralleldrive/cuid2": ^3.3.0
|
||||||
@@ -44,28 +44,28 @@ catalog:
|
|||||||
"@semantic-release/release-notes-generator": ^14.1.1
|
"@semantic-release/release-notes-generator": ^14.1.1
|
||||||
"@t3-oss/env-nextjs": ^0.13.11
|
"@t3-oss/env-nextjs": ^0.13.11
|
||||||
"@tabler/icons-react": ^3.44.0
|
"@tabler/icons-react": ^3.44.0
|
||||||
"@tanstack/react-query": ^5.101.0
|
"@tanstack/react-query": ^5.100.14
|
||||||
"@tanstack/react-query-devtools": ^5.101.0
|
"@tanstack/react-query-devtools": ^5.100.14
|
||||||
"@tanstack/react-query-next-experimental": ^5.101.0
|
"@tanstack/react-query-next-experimental": ^5.100.14
|
||||||
"@testcontainers/mysql": ^12.0.1
|
"@testcontainers/mysql": ^12.0.1
|
||||||
"@testcontainers/postgresql": ^12.0.1
|
"@testcontainers/postgresql": ^12.0.1
|
||||||
"@testcontainers/redis": ^12.0.1
|
"@testcontainers/redis": ^12.0.1
|
||||||
"@tiptap/extension-color": 3.25.0
|
"@tiptap/extension-color": 3.24.0
|
||||||
"@tiptap/extension-highlight": 3.25.0
|
"@tiptap/extension-highlight": 3.24.0
|
||||||
"@tiptap/extension-image": 3.25.0
|
"@tiptap/extension-image": 3.24.0
|
||||||
"@tiptap/extension-link": ^3.25.0
|
"@tiptap/extension-link": ^3.24.0
|
||||||
"@tiptap/extension-placeholder": ^3.25.0
|
"@tiptap/extension-placeholder": ^3.24.0
|
||||||
"@tiptap/extension-table": 3.25.0
|
"@tiptap/extension-table": 3.24.0
|
||||||
"@tiptap/extension-table-cell": 3.25.0
|
"@tiptap/extension-table-cell": 3.24.0
|
||||||
"@tiptap/extension-table-header": 3.25.0
|
"@tiptap/extension-table-header": 3.24.0
|
||||||
"@tiptap/extension-table-row": 3.25.0
|
"@tiptap/extension-table-row": 3.24.0
|
||||||
"@tiptap/extension-task-item": 3.25.0
|
"@tiptap/extension-task-item": 3.24.0
|
||||||
"@tiptap/extension-task-list": 3.25.0
|
"@tiptap/extension-task-list": 3.24.0
|
||||||
"@tiptap/extension-text-align": 3.25.0
|
"@tiptap/extension-text-align": 3.24.0
|
||||||
"@tiptap/extension-text-style": 3.25.0
|
"@tiptap/extension-text-style": 3.24.0
|
||||||
"@tiptap/extension-underline": 3.25.0
|
"@tiptap/extension-underline": 3.24.0
|
||||||
"@tiptap/react": ^3.25.0
|
"@tiptap/react": ^3.24.0
|
||||||
"@tiptap/starter-kit": ^3.25.0
|
"@tiptap/starter-kit": ^3.24.0
|
||||||
"@trpc/client": ^11.17.0
|
"@trpc/client": ^11.17.0
|
||||||
"@trpc/next": ^11.17.0
|
"@trpc/next": ^11.17.0
|
||||||
"@trpc/react-query": ^11.17.0
|
"@trpc/react-query": ^11.17.0
|
||||||
@@ -98,8 +98,6 @@ catalog:
|
|||||||
"@xterm/addon-fit": 0.11.0
|
"@xterm/addon-fit": 0.11.0
|
||||||
"@xterm/xterm": ^6.0.0
|
"@xterm/xterm": ^6.0.0
|
||||||
adm-zip: 0.5.17
|
adm-zip: 0.5.17
|
||||||
jszip: ^3.10.1
|
|
||||||
sql.js: ^1.12.0
|
|
||||||
babel-plugin-react-compiler: ^1.0.0
|
babel-plugin-react-compiler: ^1.0.0
|
||||||
bcrypt: ^6.0.0
|
bcrypt: ^6.0.0
|
||||||
better-sqlite3: ^12.10.0
|
better-sqlite3: ^12.10.0
|
||||||
@@ -128,7 +126,7 @@ catalog:
|
|||||||
glob: ^13.0.6
|
glob: ^13.0.6
|
||||||
ical.js: ^2.2.1
|
ical.js: ^2.2.1
|
||||||
ioredis: 5.11.0
|
ioredis: 5.11.0
|
||||||
isomorphic-dompurify: ^3.16.0
|
isomorphic-dompurify: ^3.15.0
|
||||||
jotai: ^2.20.0
|
jotai: ^2.20.0
|
||||||
jsdom: ^29.1.1
|
jsdom: ^29.1.1
|
||||||
json5: ^2.2.3
|
json5: ^2.2.3
|
||||||
@@ -157,7 +155,7 @@ catalog:
|
|||||||
recharts: ^3.8.1
|
recharts: ^3.8.1
|
||||||
sass: ^1.100.0
|
sass: ^1.100.0
|
||||||
semantic-release: ^25.0.3
|
semantic-release: ^25.0.3
|
||||||
semver-parser: ^4.2.1
|
semver-parser: ^4.2.0
|
||||||
superjson: 2.2.6
|
superjson: 2.2.6
|
||||||
svgson: ^5.3.1
|
svgson: ^5.3.1
|
||||||
swagger-ui-react: ^5.32.6
|
swagger-ui-react: ^5.32.6
|
||||||
|
|||||||
+13
-28
@@ -10,11 +10,8 @@ if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
|
|||||||
echo "DB migrations are disabled, skipping"
|
echo "DB migrations are disabled, skipping"
|
||||||
else
|
else
|
||||||
echo "Running DB migrations"
|
echo "Running DB migrations"
|
||||||
|
# We disable redis logs during migration as the redis client is not yet started
|
||||||
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "ERROR: DB migrations failed, aborting startup"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
|
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
|
||||||
@@ -39,38 +36,26 @@ else
|
|||||||
REDIS_PID=$!
|
REDIS_PID=$!
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SHUTTING_DOWN=false
|
node apps/nextjs/server.js &
|
||||||
|
NEXTJS_PID=$!
|
||||||
|
|
||||||
|
# Function to handle SIGTERM and shut down services
|
||||||
terminate() {
|
terminate() {
|
||||||
SHUTTING_DOWN=true
|
echo "Received SIGTERM. Shutting down..."
|
||||||
echo "Shutting down..."
|
kill -TERM $NGINX_PID $NEXTJS_PID 2>/dev/null
|
||||||
kill -TERM $NEXTJS_PID 2>/dev/null
|
wait
|
||||||
wait $NEXTJS_PID 2>/dev/null
|
# kill redis-server last because of logging of other services and only if $REDIS_PID is set
|
||||||
kill -TERM $NGINX_PID 2>/dev/null
|
|
||||||
wait $NGINX_PID 2>/dev/null
|
|
||||||
if [ -n "$REDIS_PID" ]; then
|
if [ -n "$REDIS_PID" ]; then
|
||||||
kill -TERM $REDIS_PID 2>/dev/null
|
kill -TERM $REDIS_PID 2>/dev/null
|
||||||
wait $REDIS_PID 2>/dev/null
|
wait
|
||||||
fi
|
fi
|
||||||
echo "Shutdown complete."
|
echo "Shutdown complete."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# When SIGTERM (docker stop <container>) / SIGINT (ctrl+c) is received, run the terminate function
|
||||||
trap terminate TERM INT
|
trap terminate TERM INT
|
||||||
|
|
||||||
node apps/nextjs/server.js &
|
# Wait for all processes
|
||||||
NEXTJS_PID=$!
|
wait $NEXTJS_PID
|
||||||
|
terminate
|
||||||
while true; do
|
|
||||||
wait $NEXTJS_PID
|
|
||||||
EXIT_CODE=$?
|
|
||||||
|
|
||||||
if [ "$SHUTTING_DOWN" = true ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Next.js exited with code $EXIT_CODE, restarting in 1s..."
|
|
||||||
sleep 1
|
|
||||||
node apps/nextjs/server.js &
|
|
||||||
NEXTJS_PID=$!
|
|
||||||
done
|
|
||||||
Reference in new issue
Block a user