Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6befc5ad41 | ||
|
|
2a76bb185b |
No files matched your search
@@ -30,7 +30,6 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `BASE_URL` | Public HTTP(S) origin used for OAuth metadata and redirects. Set this when Homarr is behind a reverse proxy that does not preserve the public host and protocol. Paths, credentials, query parameters, and fragments are not supported. | For example `https://homarr.example.com` | - |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
volumes:
|
||||
- ./homarr/appdata:/appdata
|
||||
environment:
|
||||
- BASE_URL=https://your.internal.dns.address.here.com
|
||||
- BASE_URL=your.internal.dns.address.here.com
|
||||
- SECRET_ENCRYPTION_KEY=your_64_character_hex_string # <--- can be generated with `openssl rand -hex 32`
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
@@ -64,16 +64,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{ source: "/.well-known/:path*", destination: "/well-known/:path*" },
|
||||
{
|
||||
source: "/api/mcp/:transport/.well-known/:path*",
|
||||
destination: "/well-known/:path*/api/mcp/:transport",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// skipcq: JS-0116
|
||||
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
||||
async headers() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
issuer: baseUrl,
|
||||
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
|
||||
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp:tools"],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
resource: baseUrl,
|
||||
authorization_servers: [baseUrl],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -12,10 +12,14 @@ 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 { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { findMigrationsFolder } from "../shared";
|
||||
|
||||
const logger = createLogger({ module: "backupImportRoute" });
|
||||
|
||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||
@@ -174,22 +178,26 @@ export async function POST(req: Request) {
|
||||
const sidecar = `${dbPath}${suffix}`;
|
||||
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
||||
} catch (cleanupErr) {
|
||||
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
|
||||
logger.warn("Failed to remove SQLite sidecar file after restore", { suffix, dbPath, cause: cleanupErr });
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
logger.info("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);
|
||||
logger.error(new ErrorWithMetadata("Backup restore failed", { dbPath }, { cause: error }));
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
||||
} finally {
|
||||
tempDb?.close();
|
||||
try {
|
||||
tempDb?.close();
|
||||
} catch (closeErr) {
|
||||
logger.warn("Failed to close temporary database after restore", { cause: closeErr });
|
||||
}
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
|
||||
@@ -3,8 +3,13 @@ import path from "path";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { findMigrationsFolder } from "../shared";
|
||||
|
||||
const logger = createLogger({ module: "backupMigrationsRoute" });
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
version: string;
|
||||
@@ -56,7 +61,7 @@ export async function GET() {
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[backup/migrations] Failed to read migrations:", error);
|
||||
logger.error(new ErrorWithMetadata("Failed to read migration files", {}, { cause: error }));
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||
import { removeTrailingSlash } from "@homarr/common";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { getMcpBaseUrl } from "../_base-url";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
@@ -265,17 +264,14 @@ const handler = async (req: NextRequest) => {
|
||||
|
||||
if (!apiKeyValue) {
|
||||
recordAuthFailure(ipAddress);
|
||||
const baseUrl = getMcpBaseUrl(req.headers);
|
||||
const resourcePath = removeTrailingSlash(new URL(req.url).pathname);
|
||||
const baseUrl = extractBaseUrlFromHeaders(req.headers);
|
||||
return jsonErrorResponse(
|
||||
401,
|
||||
{
|
||||
error: "unauthorized",
|
||||
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
|
||||
},
|
||||
{
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource${resourcePath}"`,
|
||||
},
|
||||
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export const getMcpBaseUrl = (headers: ReadonlyHeaders) =>
|
||||
removeTrailingSlash(env.BASE_URL ?? extractBaseUrlFromHeaders(headers));
|
||||
@@ -2,7 +2,6 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { getMcpBaseUrl } from "../../_base-url";
|
||||
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -138,8 +137,7 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getMcpBaseUrl(req.headers);
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
const loginUrl = new URL("/auth/login", url.origin);
|
||||
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
|
||||
return Response.redirect(loginUrl.toString());
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("~/env", () => ({ env: { BASE_URL: "https://homarr.example.com/" } }));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const request = new NextRequest("http://internal-container:3000/well-known/oauth-protected-resource", {
|
||||
headers: {
|
||||
host: "internal-container:3000",
|
||||
"x-forwarded-host": "untrusted.example.com",
|
||||
"x-forwarded-proto": "http",
|
||||
},
|
||||
});
|
||||
|
||||
describe("MCP OAuth metadata", () => {
|
||||
test("uses the configured public base URL for authorization server metadata", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-authorization-server"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
issuer: "https://homarr.example.com",
|
||||
authorization_endpoint: "https://homarr.example.com/api/mcp/oauth/authorize",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves a path-inserted authorization server issuer", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-authorization-server", "api", "mcp", "mcp"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
issuer: "https://homarr.example.com/api/mcp/mcp",
|
||||
authorization_endpoint: "https://homarr.example.com/api/mcp/oauth/authorize",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves the transport path in protected resource metadata", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-protected-resource", "api", "mcp", "mcp"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
resource: "https://homarr.example.com/api/mcp/mcp",
|
||||
authorization_servers: ["https://homarr.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the public origin for root protected resource metadata", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-protected-resource"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
resource: "https://homarr.example.com",
|
||||
authorization_servers: ["https://homarr.example.com"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getMcpBaseUrl } from "~/app/api/mcp/_base-url";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest, props: { params: Promise<{ path: string[] }> }) {
|
||||
const [kind, ...rest] = (await props.params).path;
|
||||
const baseUrl = getMcpBaseUrl(req.headers);
|
||||
const identifierPath = rest.join("/");
|
||||
const identifier = identifierPath ? `${baseUrl}/${identifierPath}` : baseUrl;
|
||||
|
||||
if (kind === "oauth-authorization-server") {
|
||||
return Response.json(
|
||||
{
|
||||
issuer: identifier,
|
||||
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
|
||||
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp:tools"],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "oauth-protected-resource") {
|
||||
return Response.json(
|
||||
{
|
||||
resource: identifier,
|
||||
authorization_servers: [baseUrl],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ error: "not_found" }, { status: 404, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, Group, Loader, rem, Stack, Text, ThemeIcon } 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 { IconAlertTriangle, IconArrowRight, IconCheck, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
|
||||
|
||||
import "@mantine/dropzone/styles.css";
|
||||
|
||||
@@ -30,8 +30,17 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
const apiDoneRef = useRef(false);
|
||||
const animDoneRef = useRef(false);
|
||||
const apiErrorRef = useRef<string | null>(null);
|
||||
const redirectTimerRef = useRef<number | null>(null);
|
||||
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (redirectTimerRef.current !== null) {
|
||||
window.clearTimeout(redirectTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
(files: FileWithPath[]) => {
|
||||
const droppedFile = files[0];
|
||||
@@ -45,6 +54,10 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (redirectTimerRef.current !== null) {
|
||||
window.clearTimeout(redirectTimerRef.current);
|
||||
redirectTimerRef.current = null;
|
||||
}
|
||||
setFile(null);
|
||||
setStep("upload");
|
||||
setImportError(null);
|
||||
@@ -65,9 +78,20 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
|
||||
if (!animDoneRef.current) return;
|
||||
onRestoreComplete?.();
|
||||
window.location.reload();
|
||||
setStep("complete");
|
||||
redirectTimerRef.current = window.setTimeout(() => {
|
||||
window.location.assign("/auth/login");
|
||||
}, 2000);
|
||||
}, [onRestoreComplete]);
|
||||
|
||||
const handleSignInAgain = useCallback(() => {
|
||||
if (redirectTimerRef.current !== null) {
|
||||
window.clearTimeout(redirectTimerRef.current);
|
||||
redirectTimerRef.current = null;
|
||||
}
|
||||
window.location.assign("/auth/login");
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setStep("restoring");
|
||||
@@ -89,7 +113,8 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
apiErrorRef.current = `Server returned ${response.status}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("Restore request failed", error);
|
||||
apiErrorRef.current = t("failed.title");
|
||||
} finally {
|
||||
apiDoneRef.current = true;
|
||||
@@ -212,6 +237,23 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "complete") {
|
||||
return (
|
||||
<Stack gap="md" align="center" py="lg">
|
||||
<ThemeIcon size={64} radius="xl" color="green" variant="light">
|
||||
<IconCheck size={32} style={{ width: rem(40), height: rem(40) }} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text size="lg" fw={600} ta="center">
|
||||
{t("complete.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={480}>
|
||||
{t("complete.message")}
|
||||
</Text>
|
||||
<Button onClick={handleSignInAgain}>{t("complete.button")}</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "error") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -25,6 +25,6 @@ export interface BackupAnalysis {
|
||||
migrations: MigrationStatus;
|
||||
}
|
||||
|
||||
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
|
||||
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "complete" | "error";
|
||||
|
||||
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
||||
@@ -168,6 +168,7 @@ export const useBackupAnalysis = () => {
|
||||
dbRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Backup analysis failed", err);
|
||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { baseUrlSchema } from "./env";
|
||||
|
||||
describe("BASE_URL", () => {
|
||||
test.each(["http://localhost:7575", "https://homarr.example.com", "https://homarr.example.com/"])(
|
||||
"accepts the HTTP(S) origin %s",
|
||||
(value) => {
|
||||
expect(baseUrlSchema.safeParse(value).success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
"ftp://homarr.example.com",
|
||||
"https://user:password@homarr.example.com",
|
||||
"https://homarr.example.com/prefix",
|
||||
"https://homarr.example.com/.",
|
||||
"https://homarr.example.com/a/..",
|
||||
"https://homarr.example.com?preview=1",
|
||||
"https://homarr.example.com#preview",
|
||||
])("rejects the non-origin URL %s", (value) => {
|
||||
expect(baseUrlSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,7 @@
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const httpOriginRegex = /^https?:\/\/[^/?#\\]+\/?$/i;
|
||||
|
||||
export const baseUrlSchema = z.url({ protocol: /^https?$/ }).refine((value) => {
|
||||
const url = new URL(value);
|
||||
return httpOriginRegex.test(value) && !url.username && !url.password;
|
||||
}, "BASE_URL must be an HTTP(S) origin without credentials, a path, query parameters, or a fragment");
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
BASE_URL: baseUrlSchema.optional(),
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
|
||||
@@ -5276,7 +5276,8 @@
|
||||
},
|
||||
"complete": {
|
||||
"title": "Restore Complete",
|
||||
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
|
||||
"message": "Database has been restored successfully and the server has restarted. Your previous session was invalidated, so you need to sign in again to see the restored board.",
|
||||
"button": "Sign in now"
|
||||
},
|
||||
"failed": {
|
||||
"title": "Restore Failed"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName, toScopedStorageVolumeValue } from "./filter-storage-volumes";
|
||||
import {
|
||||
filterStorageVolumes,
|
||||
normalizeStorageDeviceName,
|
||||
toScopedStorageVolumeValue,
|
||||
} from "./filter-storage-volumes";
|
||||
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
|
||||
|
||||
describe("normalizeStorageDeviceName", () => {
|
||||
@@ -9,12 +13,6 @@ describe("normalizeStorageDeviceName", () => {
|
||||
expect(normalizeStorageDeviceName("/dev/sda")).toBe("/dev/sda");
|
||||
});
|
||||
|
||||
test("strips partition suffixes from unprefixed device names (OpenMediaVault)", () => {
|
||||
expect(normalizeStorageDeviceName("sda1")).toBe("sda");
|
||||
expect(normalizeStorageDeviceName("sda")).toBe("sda");
|
||||
expect(normalizeStorageDeviceName("nvme0n1p1")).toBe("nvme0n1");
|
||||
});
|
||||
|
||||
test("preserves Synology-style volume names", () => {
|
||||
expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
|
||||
expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
|
||||
@@ -145,36 +143,4 @@ describe("matchFileSystemAndSmart", () => {
|
||||
overallStatus: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("joins unprefixed OMV partitions with their base device SMART data", () => {
|
||||
const result = matchFileSystemAndSmart(
|
||||
[
|
||||
{ deviceName: "sda1", used: "3.13 GiB", available: "26853056512", percentage: 12 },
|
||||
{ deviceName: "sdb1", used: "396.24 GiB", available: "1542365618176", percentage: 22 },
|
||||
],
|
||||
[
|
||||
{ deviceName: "sda", temperature: 0, overallStatus: "BAD_STATUS" },
|
||||
{ deviceName: "sdb", temperature: 33, overallStatus: "GOOD" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
deviceName: "sda",
|
||||
used: "3.13 GiB",
|
||||
available: "26853056512",
|
||||
percentage: 12,
|
||||
temperature: 0,
|
||||
overallStatus: "BAD_STATUS",
|
||||
},
|
||||
{
|
||||
deviceName: "sdb",
|
||||
used: "396.24 GiB",
|
||||
available: "1542365618176",
|
||||
percentage: 22,
|
||||
temperature: 33,
|
||||
overallStatus: "GOOD",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,16 +5,10 @@ interface StorageVolumeEntry {
|
||||
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
|
||||
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
|
||||
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
|
||||
// SCSI/SATA/VirtIO without device node prefix: sda1 -> sda (OMV reports unprefixed names)
|
||||
{ pattern: /^((?:sd|vd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
|
||||
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
|
||||
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
||||
// NVMe without device node prefix: nvme0n1p2 -> nvme0n1
|
||||
{ pattern: /^(nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
||||
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
|
||||
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
||||
// eMMC without device node prefix: mmcblk0p1 -> mmcblk0
|
||||
{ pattern: /^(mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
||||
];
|
||||
|
||||
export const normalizeStorageDeviceName = (deviceName: string): string => {
|
||||
@@ -34,14 +28,18 @@ export const toScopedStorageVolumeValue = (integrationId: string, value: string)
|
||||
return `${integrationId}:${volumeName}`;
|
||||
};
|
||||
|
||||
export const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
|
||||
const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
|
||||
return (
|
||||
leftDeviceName === rightDeviceName ||
|
||||
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
|
||||
);
|
||||
};
|
||||
|
||||
const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
|
||||
const matchesVisibleStorageVolume = (
|
||||
visibleVolume: string,
|
||||
integrationId: string,
|
||||
deviceName: string,
|
||||
): boolean => {
|
||||
const separatorIndex = visibleVolume.indexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return storageDeviceNamesMatch(visibleVolume, deviceName);
|
||||
|
||||
@@ -18,18 +18,16 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
|
||||
integration.kind === "proxmox" || integration.kind === "mock";
|
||||
|
||||
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
|
||||
const { data: integrations, isPending, isError } = clientApi.integration.byIds.useQuery(props.integrationIds);
|
||||
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
|
||||
const t = useI18n();
|
||||
|
||||
if (isPending || isError) return null;
|
||||
|
||||
const clusterIntegrationId = integrations?.find(isClusterIntegration)?.id;
|
||||
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
|
||||
|
||||
if (!clusterIntegrationId) {
|
||||
return <SystemHealthMonitoring {...props} />;
|
||||
}
|
||||
|
||||
const otherIntegrationIds = (integrations ?? [])
|
||||
const otherIntegrationIds = integrations
|
||||
// We want to have the mock integration also in the system tab, so we use it for both
|
||||
.filter((integration) => integration.kind !== "proxmox")
|
||||
.map((integration) => integration.id);
|
||||
|
||||
@@ -36,7 +36,7 @@ import { formatBytes } from "@homarr/common";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { filterStorageVolumes, storageDeviceNamesMatch } from "../filter-storage-volumes";
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { CpuRing } from "./rings/cpu-ring";
|
||||
@@ -296,7 +296,12 @@ interface SmartData {
|
||||
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
|
||||
return fileSystems
|
||||
.map((fileSystem) => {
|
||||
const smartDisk = smartData.find((smart) => storageDeviceNamesMatch(smart.deviceName, fileSystem.deviceName));
|
||||
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
|
||||
const smartDisk = smartData.find(
|
||||
(smart) =>
|
||||
smart.deviceName === fileSystem.deviceName ||
|
||||
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
|
||||
);
|
||||
|
||||
return {
|
||||
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { filterStorageVolumes, storageDeviceNamesMatch } from "../filter-storage-volumes";
|
||||
import { filterStorageVolumes } from "../filter-storage-volumes";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
|
||||
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
||||
@@ -140,7 +140,7 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
||||
return (
|
||||
<Stack gap="xs" p="xs" h="100%">
|
||||
{fileSystem.map((item) => {
|
||||
const smartItem = smart.find((smart) => storageDeviceNamesMatch(smart.deviceName, item.deviceName));
|
||||
const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
|
||||
|
||||
return (
|
||||
<SystemDiskCard
|
||||
|
||||
+8
-25
@@ -7,31 +7,14 @@ export PGID=${PGID:-0}
|
||||
echo "Starting with UID='$PUID', GID='$PGID'"
|
||||
|
||||
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
|
||||
# Only chown the paths the non-root user must actually write to at runtime.
|
||||
# Recursively chowning the whole /app tree is extremely slow on network- or
|
||||
# HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone
|
||||
# bundle and its node_modules contain tens of thousands of tiny files. This
|
||||
# turned a ~10s step into 15+ minutes for some users (#2190). Application
|
||||
# code, migrations and native modules are only read/executed, so they stay
|
||||
# owned by root and do not need chowning.
|
||||
echo "Changing owner to $PUID:$PGID..."
|
||||
# Application data volume (db, redis dumps, trusted certificates).
|
||||
# Ensure the known subdirectories exist and migrate ownership of any
|
||||
# existing content, so a persistent volume with stale or root-owned
|
||||
# entries stays writable after a PUID/PGID change. These trees are small
|
||||
# (a single sqlite db, a redis dump, a few certs), unlike the /app tree.
|
||||
mkdir -p /appdata/db /appdata/redis /appdata/trusted-certificates
|
||||
chown "${PUID}:${PGID}" /appdata
|
||||
chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates
|
||||
# Next.js runtime cache (image optimization, fetch cache, ...)
|
||||
mkdir -p /app/apps/nextjs/.next/cache
|
||||
chown -R "${PUID}:${PGID}" /app/apps/nextjs/.next/cache
|
||||
# nginx runtime directories and the rendered configuration
|
||||
chown -R "${PUID}:${PGID}" /var/cache/nginx
|
||||
chown -R "${PUID}:${PGID}" /var/log/nginx
|
||||
chown -R "${PUID}:${PGID}" /var/lib/nginx
|
||||
chown -R "${PUID}:${PGID}" /run/nginx/nginx.pid
|
||||
chown -R "${PUID}:${PGID}" /etc/nginx
|
||||
# The below command will change the owner of all files in the /app directory (except node_modules) to the new UID and GID
|
||||
echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
|
||||
find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
|
||||
chown -R $PUID:$PGID /var/cache/nginx
|
||||
chown -R $PUID:$PGID /var/log/nginx
|
||||
chown -R $PUID:$PGID /var/lib/nginx
|
||||
chown -R $PUID:$PGID /run/nginx/nginx.pid
|
||||
chown -R $PUID:$PGID /etc/nginx
|
||||
echo "Changing owner to $PUID:$PGID, done."
|
||||
fi
|
||||
|
||||
|
||||
Reference in new issue
Block a user