fix: address v2 stack review findings

This commit is contained in:
ajnart committed 2026-08-20 13:08:54 +02:00
1 parent 53b518960c
commit 263702210a
48 files changed
+1235 -530

No files matched your search

+4 -1
View File
@@ -49,7 +49,10 @@ summary of accessible Homarr resources. Live status is fetched only when needed.
## Permissions and privacy
- Read-only actions can run automatically. Changes require approval.
- Read-only actions can run automatically. Changes require approval by default.
- **Automatic approvals** can be enabled for one conversation. While enabled, protected changes, submitted form details,
and affirmative answers run without another approval click. It turns off when you switch conversations; Homarr
permissions still apply to every action.
- Homarr permissions apply to every tool call and mentioned item.
- API keys and custom headers are encrypted and never returned to the browser.
- Conversations are stored per user and can be exported or deleted.
@@ -61,8 +61,8 @@ than one variable quantifier are rejected.
- `ActionButton` and `ToggleSwitch` run actions only when the widget is mounted outside edit mode. A toggle needs both
enabled and disabled request parameters and rolls back its visual state if the action fails.
- `RefreshButton` renders a compact, accessible icon control that invalidates the client's load-query cache so active load
queries can refetch; it does not execute a request itself. It is disabled when the widget has no active item or preview
session, or while the board is in edit mode.
queries can refetch; it does not execute a request itself. It is disabled for anonymous viewers, when the widget has no
active item or preview session, or while the board is in edit mode.
- `Icon`/`TablerIcon` resolves installed safe icons.
Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile.
@@ -60,8 +60,8 @@ Queries default to view permission and actions to modify. DELETE always requires
Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and
configures the widget. After placement, query and action authorization follows the request's board permission. Public
boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache
invalidation: `RefreshButton` is disabled only without an active item or preview session, or in edit mode, while ordinary
scheduled query rendering remains available to anonymous viewers.
invalidation: `RefreshButton` is disabled for anonymous viewers, without an active item or preview session, and in edit
mode, while ordinary scheduled query rendering remains available to anonymous viewers.
Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every
Homarr replica stops serving the previous response without scanning or deleting a key prefix.
+1 -1
View File
@@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
preloadEntriesOnStart: true,
preloadEntriesOnStart: false,
turbopackFileSystemCacheForBuild: true,
useTypeScriptCli: true,
},
+4
View File
@@ -88,6 +88,7 @@
"assistant-stream": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"better-sqlite3": "catalog:",
"busboy": "catalog:",
"chroma-js": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
@@ -118,17 +119,20 @@
"superjson": "catalog:",
"use-deep-compare-effect": "catalog:",
"yaml": "catalog:",
"yauzl": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/busboy": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/yauzl": "catalog:",
"concurrently": "catalog:",
"node-loader": "catalog:",
"typescript": "catalog:"
@@ -91,8 +91,7 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
setResettingLayoutId(layout.id);
void (async () => {
try {
const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id);
if (!canonicalLayout) return;
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
@@ -1,4 +1,5 @@
import { fetchApi } from "@homarr/api/client";
import { mapWithConcurrency } from "@homarr/common";
import {
customWidgetDefinitionSchema,
getCustomWidgetDefaultOptions,
@@ -7,6 +8,7 @@ import {
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/server";
import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench";
import type { UseFormReturnType } from "@mantine/form";
@@ -102,25 +104,26 @@ export function isRuntimeParams(
}
export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) {
const data: Record<string, unknown> = {};
const status: Record<string, unknown> = {};
for (const [requestId] of Object.entries(definition.requests).filter(
const requests = Object.entries(definition.requests).filter(
([, entry]) => entry.kind === "query" && entry.trigger === "load",
)) {
);
const entries = await mapWithConcurrency(requests, CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, async ([requestId]) => {
try {
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} });
data[requestId] = result.data;
status[requestId] = { loading: false, ...result };
return [requestId, result.data, { loading: false, ...result }] as const;
} catch (error) {
data[requestId] = null;
status[requestId] = {
const failedStatus = {
loading: false,
ok: false,
error: error instanceof Error ? error.message : "Request failed",
};
return [requestId, null, failedStatus] as const;
}
}
return { data, status };
});
return {
data: Object.fromEntries(entries.map(([requestId, data]) => [requestId, data])),
status: Object.fromEntries(entries.map(([requestId, , status]) => [requestId, status])),
};
}
export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) {
@@ -15,6 +15,7 @@ import {
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { decryptSecret } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
@@ -615,6 +616,11 @@ export async function POST(request: Request) {
baseURL: configuration.baseUrl,
apiKey: providerApiKey,
headers: providerHeaders,
fetch: async (input, init) =>
(await fetchWithTrustedCertificatesAsync(
input as Parameters<typeof fetchWithTrustedCertificatesAsync>[0],
init as Parameters<typeof fetchWithTrustedCertificatesAsync>[1],
)) as unknown as Response,
includeUsage: true,
transformRequestBody:
configuration.provider === "openrouter"
+264 -103
View File
@@ -1,12 +1,17 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { Readable, Transform } from "stream";
import { pipeline } from "stream/promises";
import type { ReadableStream as NodeReadableStream } from "stream/web";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import BetterSqlite3 from "better-sqlite3";
import busboy from "busboy";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import yauzl from "yauzl";
import type { Entry as ZipEntry, ZipFile } from "yauzl";
import {
getOnboardingClaimTokenFromCookieHeader,
@@ -34,93 +39,249 @@ const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE;
const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE;
const MAX_METADATA_BYTES = MEBIBYTE;
const MAX_ENCRYPTION_KEY_BYTES = 1024;
const MAX_ARCHIVE_ENTRIES = 1024;
const RESTART_DELAY_MS = 500;
let restoreInProgress = false;
class BackupTooLargeError extends Error {}
const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array<ArrayBuffer>> => {
const assertDeclaredRequestSize = (request: Request) => {
const declaredLength = request.headers.get("content-length");
if (declaredLength !== null) {
const parsedLength = Number(declaredLength);
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
throw new Error("Invalid Content-Length header");
}
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
if (declaredLength === null) return;
const parsedLength = Number(declaredLength);
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
throw new Error("Invalid Content-Length header");
}
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
}
};
const createRequestSizeLimiter = () => {
let totalBytes = 0;
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
totalBytes += chunk.byteLength;
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
callback(new BackupTooLargeError("Backup upload exceeds the 256 MB limit"));
return;
}
callback(null, chunk);
},
});
};
const streamUploadedBackupAsync = async (request: Request, uploadPath: string): Promise<number | null> => {
assertDeclaredRequestSize(request);
if (!request.body) return null;
const parser = busboy({
headers: Object.fromEntries(request.headers.entries()),
preservePath: false,
limits: {
fileSize: MAX_COMPRESSED_BACKUP_BYTES,
files: 1,
fields: 0,
parts: 2,
headerPairs: 200,
},
});
let uploadSize = 0;
let uploadFound = false;
let uploadWrite = Promise.resolve();
let parseError: Error | null = null;
parser.on("file", (fieldName, file) => {
if (fieldName !== "file" || uploadFound) {
parseError = new Error("Unexpected file field");
file.resume();
return;
}
uploadFound = true;
file.on("data", (chunk: Buffer) => {
uploadSize += chunk.byteLength;
});
file.on("limit", () => {
parseError = new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
});
uploadWrite = pipeline(file, fs.createWriteStream(uploadPath, { flags: "wx" }));
void uploadWrite.catch((error: unknown) => {
parser.destroy(error instanceof Error ? error : new Error("Failed to store backup upload"));
});
});
parser.on("field", () => {
parseError = new Error("Unexpected form field");
});
parser.on("filesLimit", () => {
parseError = new Error("Too many files");
});
parser.on("fieldsLimit", () => {
parseError = new Error("Unexpected form field");
});
parser.on("partsLimit", () => {
parseError = new Error("Too many form parts");
});
const requestStream = Readable.fromWeb(request.body as NodeReadableStream<Uint8Array>);
try {
await pipeline(requestStream, createRequestSizeLimiter(), parser);
await uploadWrite;
} catch (error) {
await uploadWrite.catch(() => undefined);
throw error;
}
if (!request.body) return new Uint8Array();
if (parseError) throw parseError;
if (!uploadFound) return null;
return uploadSize;
};
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
interface BackupArchiveEntries {
database: ZipEntry;
metadata: ZipEntry;
encryptionKey: ZipEntry | null;
}
const openArchiveAsync = (archivePath: string) =>
new Promise<ZipFile>((resolve, reject) => {
yauzl.open(
archivePath,
{ lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true },
(error, zip) => {
if (error) {
reject(error);
return;
}
if (!zip) {
reject(new Error("ZIP archive could not be opened"));
return;
}
resolve(zip);
},
);
});
const inspectArchiveAsync = (zip: ZipFile) =>
new Promise<BackupArchiveEntries>((resolve, reject) => {
let database: ZipEntry | null = null;
let metadata: ZipEntry | null = null;
let encryptionKey: ZipEntry | null = null;
let totalSize = 0;
let entryCount = 0;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
zip.once("error", fail);
zip.on("entry", (entry: ZipEntry) => {
entryCount += 1;
if (entryCount > MAX_ARCHIVE_ENTRIES) {
fail(new BackupTooLargeError(`Backup archive contains more than ${MAX_ARCHIVE_ENTRIES} entries`));
return;
}
const entrySize = entry.uncompressedSize;
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
fail(new Error(`Invalid uncompressed size for ${entry.fileName}`));
return;
}
totalSize += entrySize;
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
fail(new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit"));
return;
}
if (entry.fileName === "db.sqlite") {
if (database) {
fail(new Error("Invalid backup: duplicate db.sqlite entry"));
return;
}
if (entrySize > MAX_UNCOMPRESSED_DATABASE_BYTES) {
fail(new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit"));
return;
}
database = entry;
} else if (entry.fileName === "metadata.json") {
if (metadata) {
fail(new Error("Invalid backup: duplicate metadata.json entry"));
return;
}
if (entrySize > MAX_METADATA_BYTES) {
fail(new BackupTooLargeError("Backup metadata exceeds the 1 MB limit"));
return;
}
metadata = entry;
} else if (entry.fileName === "encryption-key.txt") {
if (encryptionKey) {
fail(new Error("Invalid backup: duplicate encryption-key.txt entry"));
return;
}
if (entrySize > MAX_ENCRYPTION_KEY_BYTES) {
fail(new BackupTooLargeError("Backup encryption key entry is too large"));
return;
}
encryptionKey = entry;
}
zip.readEntry();
});
zip.once("end", () => {
if (settled) return;
settled = true;
if (!database || !metadata) {
const presentNames = [database ? "db.sqlite" : null, metadata ? "metadata.json" : null];
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !presentNames.includes(name));
reject(new Error(`Invalid backup: missing ${missingEntries.join(", ")}`));
return;
}
resolve({ database, metadata, encryptionKey });
});
zip.readEntry();
});
const openEntryStreamAsync = (zip: ZipFile, entry: ZipEntry) =>
new Promise<Readable>((resolve, reject) => {
zip.openReadStream(entry, (error, stream) => {
if (error) {
reject(error);
return;
}
if (!stream) {
reject(new Error(`Could not read ${entry.fileName}`));
return;
}
resolve(stream);
});
});
const extractEntryToFileAsync = async (zip: ZipFile, entry: ZipEntry, outputPath: string) => {
const source = await openEntryStreamAsync(zip, entry);
await pipeline(source, fs.createWriteStream(outputPath, { flags: "wx" }));
};
const readBoundedEntryAsync = async (zip: ZipFile, entry: ZipEntry, maximumBytes: number) => {
const source = await openEntryStreamAsync(zip, entry);
const chunks: Buffer[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
await reader.cancel();
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
}
chunks.push(value);
for await (const chunk of source) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += buffer.byteLength;
if (totalBytes > maximumBytes) {
source.destroy();
throw new BackupTooLargeError(`${entry.fileName} exceeds its size limit`);
}
} finally {
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return body;
};
const parseBoundedFormDataAsync = async (request: Request): Promise<FormData> => {
const contentType = request.headers.get("content-type");
if (!contentType) throw new Error("Missing Content-Type header");
const body = await readBoundedRequestBodyAsync(request);
return new Response(body, { headers: { "content-type": contentType } }).formData();
};
const assertArchiveSizeLimits = (zip: AdmZip) => {
const entries = zip.getEntries();
let totalSize = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
const entrySize = entry.header.size;
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
throw new Error(`Invalid uncompressed size for ${entry.entryName}`);
}
totalSize += entrySize;
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit");
}
}
const dbEntry = zip.getEntry("db.sqlite");
if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) {
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit");
}
const metadataEntry = zip.getEntry("metadata.json");
if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) {
throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit");
}
const encryptionKeyEntry = zip.getEntry("encryption-key.txt");
if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) {
throw new BackupTooLargeError("Backup encryption key entry is too large");
chunks.push(buffer);
}
return Buffer.concat(chunks, totalBytes);
};
const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKeyHex: string) => {
@@ -240,9 +401,13 @@ export async function POST(req: Request) {
let tempDb: InstanceType<typeof BetterSqlite3> | null = null;
try {
let formData: FormData;
tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-"));
const uploadPath = path.join(tempDirectory, "backup.zip");
const tempPath = path.join(tempDirectory, "db.sqlite");
let uploadSize: number | null;
try {
formData = await parseBoundedFormDataAsync(req);
uploadSize = await streamUploadedBackupAsync(req, uploadPath);
} catch (error) {
if (error instanceof BackupTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
@@ -250,56 +415,52 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
if (uploadSize === null) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (file.size > MAX_COMPRESSED_BACKUP_BYTES) {
if (uploadSize > MAX_COMPRESSED_BACKUP_BYTES) {
return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 });
}
let zip: AdmZip;
let zip: ZipFile;
try {
zip = new AdmZip(Buffer.from(await file.arrayBuffer()));
zip = await openArchiveAsync(uploadPath);
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((entry) => entry.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 });
}
let metadata: { encryptionKey?: unknown };
let archivedEncryptionKey: string | undefined;
try {
assertArchiveSizeLimits(zip);
const entries = await inspectArchiveAsync(zip);
await extractEntryToFileAsync(zip, entries.database, tempPath);
const metadataBuffer = await readBoundedEntryAsync(zip, entries.metadata, MAX_METADATA_BYTES);
const parsedMetadata: unknown = JSON.parse(metadataBuffer.toString());
if (typeof parsedMetadata !== "object" || parsedMetadata === null || Array.isArray(parsedMetadata)) {
throw new Error("Invalid backup: metadata.json must contain an object");
}
metadata = parsedMetadata as { encryptionKey?: unknown };
if (entries.encryptionKey) {
const keyBuffer = await readBoundedEntryAsync(zip, entries.encryptionKey, MAX_ENCRYPTION_KEY_BYTES);
archivedEncryptionKey = keyBuffer.toString().trim();
}
} catch (error) {
if (error instanceof BackupTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
}
throw error;
const message =
error instanceof Error && error.message.startsWith("Invalid backup:") ? error.message : "Invalid ZIP file";
return NextResponse.json({ error: message }, { status: 400 });
} finally {
zip.close();
}
tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-"));
const tempPath = path.join(tempDirectory, "db.sqlite");
const dbEntry = zip.getEntry("db.sqlite");
if (!dbEntry) {
return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 });
}
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new BetterSqlite3(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING, schema });
migrate(drizzleDb, { migrationsFolder });
await applyCustomMigrationsAsync(drizzleDb as unknown as Database);
const metadataEntry = zip.getEntry("metadata.json");
if (!metadataEntry) {
return NextResponse.json({ error: "Invalid backup: missing metadata.json" }, { status: 400 });
}
const metadata = JSON.parse(metadataEntry.getData().toString());
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
const rawKey = metadata.encryptionKey ?? archivedEncryptionKey;
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
const secretCount = (tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number })
@@ -234,7 +234,6 @@ describe("POST /api/backup/import", () => {
it("rejects a concurrent restore while the first request owns the restore lock", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore");
const originalArrayBuffer = File.prototype.arrayBuffer;
let signalStarted!: () => void;
let releaseFirstRequest!: () => void;
const started = new Promise<void>((resolve) => {
@@ -243,13 +242,25 @@ describe("POST /api/backup/import", () => {
const release = new Promise<void>((resolve) => {
releaseFirstRequest = resolve;
});
vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) {
signalStarted();
await release;
return originalArrayBuffer.call(this);
const originalRequest = createImportRequest(backup);
const requestBody = await originalRequest.arrayBuffer();
const delayedBody = new ReadableStream<Uint8Array>({
async pull(controller) {
signalStarted();
await release;
controller.enqueue(new Uint8Array(requestBody));
controller.close();
},
});
const delayedRequestInit: RequestInit & { duplex: "half" } = {
method: "POST",
headers: originalRequest.headers,
body: delayedBody,
duplex: "half",
};
const delayedRequest = new Request(originalRequest.url, delayedRequestInit);
const firstRestore = POST(createImportRequest(backup));
const firstRestore = POST(delayedRequest);
await started;
const concurrentResponse = await POST(createImportRequest(backup));
releaseFirstRequest();
@@ -7,10 +7,10 @@ import {
configureCustomWidgetSourceFromRequest,
configurePreviewSessionSource,
getCustomWidgetConfigurationRequest,
invalidateCustomWidgetResponseCache,
releaseCustomWidgetConfigurationRequest,
} from "@homarr/api/custom-widget-configuration";
import { customWidgetSourceSchema } from "@homarr/custom-widgets/core";
import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server";
import { db } from "@homarr/db";
import { adminRoute } from "../../admin";
@@ -90,7 +90,7 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route
sourceResult.data,
secrets,
);
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
await invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]);
} else {
const result = await configureCustomWidgetSourceFromRequest(db, {
definitionId: claimed.target.id,
@@ -3,11 +3,9 @@ import { describe, expect, test } from "vitest";
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
describe("getSafeAssistantMarkdownImageSource", () => {
test("allows Homarr-relative and ordinary remote images", () => {
test("allows Homarr-relative images without automatically loading remote images", () => {
expect(getSafeAssistantMarkdownImageSource("/logo/logo.png")).toBe("/logo/logo.png");
expect(getSafeAssistantMarkdownImageSource("https://cdn.example.com/icons/discord.svg")).toBe(
"https://cdn.example.com/icons/discord.svg",
);
expect(getSafeAssistantMarkdownImageSource("https://cdn.example.com/icons/discord.svg")).toBeNull();
});
test("rejects unsafe protocols, protocol-relative URLs, and embedded credentials", () => {
@@ -4,9 +4,12 @@ export const getSafeAssistantMarkdownImageSource = (source: string | Blob | unde
if (typeof source !== "string" || !source) return null;
if (source.startsWith("/") && !source.startsWith("//")) return source;
return getSafeAssistantHttpUrl(source);
return null;
};
export const getSafeAssistantExternalMarkdownImageSource = (source: string | Blob | undefined) =>
getSafeAssistantHttpUrl(source);
const safeAttachmentDataUrl =
/^data:image\/(?:gif|jpeg|png|webp);base64,(?:[a-z\d+/]{4})*(?:[a-z\d+/]{2}==|[a-z\d+/]{3}=)?$/iu;
@@ -141,7 +141,11 @@ import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from ".
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
import {
getSafeAssistantAttachmentImageSource,
getSafeAssistantExternalMarkdownImageSource,
getSafeAssistantMarkdownImageSource,
} from "./assistant-markdown-image";
import {
getAssistantContextBreakdown,
getAssistantConversationUsage,
@@ -212,7 +216,8 @@ const ContextDirectiveChip = ({
}: DirectiveChipProps & { iconUrl?: string }) => {
const t = useScopedI18n("common.assistant");
const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt;
const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl);
const safeIconUrl =
getSafeAssistantMarkdownImageSource(iconUrl) ?? getSafeAssistantExternalMarkdownImageSource(iconUrl);
const translationKey = getAssistantDirectiveTranslationKey(directiveType);
const typeLabel = t(`mentions.${translationKey}`);
@@ -284,13 +289,30 @@ const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
const t = useScopedI18n("common.assistant.image");
const [approvedExternalSource, setApprovedExternalSource] = useState<string | null>(null);
const safeSource = getSafeAssistantMarkdownImageSource(src);
if (!safeSource) return null;
const externalSource = getSafeAssistantExternalMarkdownImageSource(src);
const approvedSource = externalSource === approvedExternalSource ? externalSource : null;
const displaySource = safeSource ?? approvedSource;
if (!displaySource && externalSource) {
return (
<Button
variant="default"
size="compact-sm"
leftSection={<IconPhoto size={14} aria-hidden />}
onClick={() => setApprovedExternalSource(externalSource)}
>
{t("loadExternal", { host: new URL(externalSource).host })}
</Button>
);
}
if (!displaySource) return null;
return (
<AssistantImage
{...props}
source={safeSource}
source={displaySource}
alt={alt}
className={classes.markdownImage}
loadingLabel={t("loading")}
@@ -2099,6 +2121,8 @@ const AutoApprovalControl = () => {
width="min(19rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
trapFocus
returnFocus
>
<Popover.Target>
<ActionIcon
@@ -2106,6 +2130,11 @@ const AutoApprovalControl = () => {
variant={enabled ? "light" : "subtle"}
color={enabled ? "green" : "gray"}
onClick={() => setOpened((current) => !current)}
onKeyDown={(event) => {
if (event.key !== "Escape" || !opened) return;
event.stopPropagation();
setOpened(false);
}}
aria-label={t("label")}
aria-pressed={enabled}
title={enabled ? t("enabled") : t("label")}
@@ -2113,7 +2142,13 @@ const AutoApprovalControl = () => {
<IconShieldCheck size={17} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Popover.Dropdown
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.stopPropagation();
setOpened(false);
}}
>
<Stack gap="sm">
<Box>
<Text size="sm" fw={700}>
@@ -2124,6 +2159,7 @@ const AutoApprovalControl = () => {
</Text>
</Box>
<Button
data-autofocus
fullWidth
variant={enabled ? "default" : "light"}
color={enabled ? "gray" : "green"}
@@ -1,7 +1,16 @@
"use client";
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
@@ -28,9 +37,12 @@ interface OpenAdvancedFocusOptions {
restoreFocusTarget?: HTMLElement;
}
interface AdvancedFocusContextValue {
interface AdvancedFocusState {
active: ActiveFocus | null;
viewportSize: { width: number; height: number };
}
interface AdvancedFocusActions {
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
close: (restoreFocus?: boolean) => void;
dismiss: (itemId: string) => void;
@@ -38,7 +50,17 @@ interface AdvancedFocusContextValue {
leave: (itemId: string) => void;
}
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
interface AdvancedFocusStore {
getSnapshot: () => AdvancedFocusState;
subscribe: (listener: () => void) => () => void;
}
const AdvancedFocusActionsContext = createContext<AdvancedFocusActions | null>(null);
const AdvancedFocusStoreContext = createContext<AdvancedFocusStore | null>(null);
const INACTIVE_FOCUS_STATE: AdvancedFocusState = {
active: null,
viewportSize: { width: 0, height: 0 },
};
const HOVER_DELAY_MS = 500;
const POINTER_TRANSITION_GRACE_MS = 80;
const CLOSE_DURATION_MS = 180;
@@ -61,6 +83,15 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const holdIndicatorRef = useRef<HTMLDivElement | null>(null);
const pointerPositionRef = useRef({ x: -100, y: -100 });
const pointerFrameRef = useRef<number | null>(null);
const focusStateRef = useRef<AdvancedFocusState>({ active: null, viewportSize });
const focusListenersRef = useRef(new Set<() => void>());
const [focusStore] = useState<AdvancedFocusStore>(() => ({
getSnapshot: () => focusStateRef.current,
subscribe: (listener) => {
focusListenersRef.current.add(listener);
return () => focusListenersRef.current.delete(listener);
},
}));
const cancelHoverTimer = useCallback(() => {
if (hoverTimerRef.current === null) return;
@@ -315,55 +346,83 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
};
}, [active?.activation]);
const value = useMemo(
() => ({ active, viewportSize, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open, viewportSize],
);
useIsomorphicEffect(() => {
focusStateRef.current = { active, viewportSize };
for (const listener of focusListenersRef.current) listener();
}, [active, viewportSize]);
const actions = useMemo(() => ({ open, close, dismiss, hover, leave }), [close, dismiss, hover, leave, open]);
return (
<AdvancedFocusContext.Provider value={value}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusContext.Provider>
<AdvancedFocusActionsContext.Provider value={actions}>
<AdvancedFocusStoreContext.Provider value={focusStore}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusStoreContext.Provider>
</AdvancedFocusActionsContext.Provider>
);
};
const useAdvancedFocusStore = () => {
const store = useContext(AdvancedFocusStoreContext);
if (!store) throw new Error("Advanced focus state must be used within BoardAdvancedFocusProvider");
return store;
};
export const useAdvancedFocusActions = () => {
const actions = useContext(AdvancedFocusActionsContext);
if (!actions) throw new Error("Advanced focus actions must be used within BoardAdvancedFocusProvider");
return actions;
};
export const useAdvancedFocusItem = (itemId: string | null) => {
const store = useAdvancedFocusStore();
const getItemSnapshot = useCallback(() => {
const snapshot = store.getSnapshot();
if (itemId && snapshot.active?.itemId === itemId) return snapshot;
return INACTIVE_FOCUS_STATE;
}, [itemId, store]);
return useSyncExternalStore(store.subscribe, getItemSnapshot, () => INACTIVE_FOCUS_STATE);
};
export const useAdvancedFocus = () => {
const context = useContext(AdvancedFocusContext);
if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
return context;
const store = useAdvancedFocusStore();
const state = useSyncExternalStore(store.subscribe, store.getSnapshot, () => INACTIVE_FOCUS_STATE);
const actions = useAdvancedFocusActions();
return useMemo(() => ({ ...state, ...actions }), [actions, state]);
};
@@ -26,7 +26,7 @@ import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/
import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useAdvancedFocusActions, useAdvancedFocusItem } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
@@ -166,13 +166,14 @@ const LoadedBoardItemContent = ({
);
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const focusItemId = supportsAdvancedFocus ? item.id : null;
const { active: activeFocus, viewportSize } = useAdvancedFocusItem(focusItemId);
const { open, close, dismiss, hover, leave } = useAdvancedFocusActions();
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const widgetName = t(`widget.${item.kind}.name`);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
const isAdvanced = activeFocus !== null;
const advancedRect = activeFocus
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
@@ -339,13 +340,7 @@ const LoadedBoardItemContent = ({
return (
<>
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
<WidgetContextMenu
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
>
<WidgetContextMenu item={item} definition={definition} widgetRuntimeRef={widgetRuntimeRef} sourceRef={sourceRef}>
<Box
ref={sourceRef}
w="100%"
@@ -1,6 +1,6 @@
"use client";
import type { MutableRefObject, ReactNode, RefObject } from "react";
import type { ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
@@ -24,7 +24,7 @@ import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } fr
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useAdvancedFocusActions } from "../advanced-focus/context";
import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
@@ -33,7 +33,6 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
children: ReactNode;
@@ -46,6 +45,44 @@ export const WidgetContextMenu = ({
sourceRef,
children,
}: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings();
const [menuOpened, setMenuOpened] = useState(false);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
{menuOpened && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
/>
)}
</Menu>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children">;
const WidgetContextMenuDropdown = ({
item,
definition,
widgetRuntimeRef,
sourceRef,
}: WidgetContextMenuDropdownProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
@@ -64,9 +101,8 @@ export const WidgetContextMenu = ({
hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false));
const canOpenAdvancedFocus = supportsAdvancedFocus(definition);
const queryClient = useQueryClient();
const { open: openAdvancedFocus } = useAdvancedFocus();
const { open: openAdvancedFocus } = useAdvancedFocusActions();
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
@@ -209,104 +245,90 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
</Menu.CheckboxItem>
))}
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
</Menu.CheckboxItem>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
);
};
@@ -2,7 +2,8 @@
width: 100%;
max-width: 100%;
min-width: 0;
overflow: clip;
overflow-x: auto;
overflow-y: visible;
}
.viewport[data-board-hydrated="false"] {
@@ -8,6 +8,7 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
const MIN_READABLE_CANVAS_SCALE = 0.5;
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -15,7 +16,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return availableWidth / logicalWidth;
return Math.max(MIN_READABLE_CANVAS_SCALE, availableWidth / logicalWidth);
};
export const calculateBoardUiScale = (canvasScale: number) => {
@@ -33,8 +34,8 @@ interface ScaledBoardCanvasProps {
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
* fixed logical pixels. Narrow viewports stop scaling at a readable threshold
* and scroll the remaining logical surface horizontally.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
@@ -8,6 +8,7 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
touch-action: none;
}
.board-grid-entry:has([data-board-widget-header]):hover,
@@ -106,8 +106,11 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
export const BoardGridPortalRenderer = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const [isEditMode] = useEditMode();
const { announce, containers, integrations } = useBoardGridPortalHost();
if (!isEditMode) return null;
return (
<>
{Array.from(containers, ([id, container]) => {
@@ -5,3 +5,4 @@ export {
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
export { invalidateCustomWidgetResponseCache } from "./router/custom-widget/request-executor";
+5 -4
View File
@@ -5,6 +5,7 @@ import { z } from "zod/v4";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { and, asc, desc, eq, handleTransactionsAsync, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import {
@@ -33,7 +34,7 @@ import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation
import { env } from "../env";
import { orderMessagesByParent } from "../assistant-message-order";
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { createTRPCRouter, isDemoMode, isDemoReadOnly, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { boardRouter, getHomeIdBoardAsync } from "./board";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
@@ -206,9 +207,9 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
}
const endpoint = `${configuration.baseUrl}${configuration.modelDiscoveryPath}`;
let response: Response;
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
try {
response = await fetch(endpoint, {
response = await fetchWithTrustedCertificatesAsync(endpoint, {
headers: getProviderHeaders(configuration),
signal: AbortSignal.timeout(15_000),
});
@@ -454,7 +455,7 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
export const assistantRouter = createTRPCRouter({
getAvailability: protectedProcedure.query(async ({ ctx }) => {
if (isDemoMode) {
return { enabled: true };
return { enabled: !isDemoReadOnly };
}
const configuration = await getConfigurationAsync(ctx.db);
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
@@ -64,7 +64,7 @@ export const previewActionProcedures = {
kind: "action",
});
if (response.ok && request.invalidates?.length) {
invalidateCustomWidgetResponseCache(
await invalidateCustomWidgetResponseCache(
request.invalidates.map((requestId) => `custom-jsx:preview:${session.id}:${requestId}:`),
);
}
@@ -19,7 +19,7 @@ export const previewQueryProcedures = {
.input(z.object({ sessionId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${session.id}:`]);
await invalidateCustomWidgetResponseCache([`custom-jsx:preview:${session.id}:`]);
}),
previewQuery: permissionRequiredProcedure
@@ -16,6 +16,7 @@ import {
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server";
import { toTrpcError } from "./domain-error";
import { executeWithCustomWidgetResponseCache, invalidateSharedCustomWidgetResponseCache } from "./response-cache";
export type {
CustomWidgetAuthConfig,
@@ -74,14 +75,18 @@ export async function resolveAndValidateHost(hostname: string, scope: CustomWidg
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
try {
return await executeDomainRequest({
...input,
logError: (event) => logger.error("Custom widget request failed", event),
});
return await executeWithCustomWidgetResponseCache(input, (request) =>
executeDomainRequest({
...request,
logError: (event) => logger.error("Custom widget request failed", event),
}),
);
} catch (error) {
toTrpcError(error);
}
}
export const invalidateCustomWidgetResponseCache = (prefixes: readonly string[]) =>
export async function invalidateCustomWidgetResponseCache(prefixes: readonly string[]) {
invalidateDomainResponseCache(prefixes);
await invalidateSharedCustomWidgetResponseCache(prefixes);
}
@@ -0,0 +1,154 @@
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server";
const ENTRY_PREFIX = "custom-widget:response-cache:v2:entry:";
const GENERATION_KEY = "custom-widget:response-cache:v2:generation";
const LRU_KEY = "custom-widget:response-cache:v2:lru";
const SIZE_KEY = "custom-widget:response-cache:v2:sizes";
const TOTAL_BYTES_KEY = "custom-widget:response-cache:v2:bytes";
const MAX_ENTRIES = 1_000;
const MAX_BYTES = 64 * 1024 * 1024;
const STORE_SCRIPT = `
local previousSize = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if previousSize > 0 then redis.call('DECRBY', KEYS[3], previousSize) end
redis.call('SET', ARGV[1], ARGV[2], 'PX', ARGV[3])
redis.call('ZADD', KEYS[1], ARGV[4], ARGV[1])
redis.call('HSET', KEYS[2], ARGV[1], ARGV[5])
local totalBytes = redis.call('INCRBY', KEYS[3], ARGV[5])
while redis.call('ZCARD', KEYS[1]) > tonumber(ARGV[6]) or totalBytes > tonumber(ARGV[7]) do
local evicted = redis.call('ZPOPMIN', KEYS[1], 1)
if #evicted == 0 then break end
local evictedKey = evicted[1]
local evictedSize = tonumber(redis.call('HGET', KEYS[2], evictedKey) or '0')
redis.call('DEL', evictedKey)
redis.call('HDEL', KEYS[2], evictedKey)
if evictedSize > 0 then totalBytes = redis.call('DECRBY', KEYS[3], evictedSize) end
end
return totalBytes
`;
const logger = createLogger({ module: "custom-widget:response-cache" });
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let redis: RedisClient | undefined;
const useSharedCache = () => process.env.NODE_ENV !== "test";
const getRedis = () => {
redis ??= createRedisClient();
return redis;
};
const getGeneration = async (client: RedisClient) => Number((await client.get(GENERATION_KEY)) ?? 0);
const getEntryKey = (generation: number, cacheKey: string) => {
const digest = createHash("sha256").update(cacheKey).digest("hex");
return `${ENTRY_PREFIX}${generation}:${digest}`;
};
const readCachedResponse = async (client: RedisClient, entryKey: string) => {
const serialized = await client.get(entryKey);
if (serialized === null) return undefined;
await client.zadd(LRU_KEY, Date.now(), entryKey);
try {
return JSON.parse(serialized) as CustomWidgetHttpResponse;
} catch {
await client.del(entryKey);
return undefined;
}
};
const storeCachedResponse = async (
client: RedisClient,
entryKey: string,
response: CustomWidgetHttpResponse,
ttlSeconds: number,
) => {
const serialized = JSON.stringify(response);
const size = Buffer.byteLength(serialized, "utf8");
await client.eval(
STORE_SCRIPT,
3,
LRU_KEY,
SIZE_KEY,
TOTAL_BYTES_KEY,
entryKey,
serialized,
ttlSeconds * 1_000,
Date.now(),
size,
MAX_ENTRIES,
MAX_BYTES,
);
};
export async function executeWithCustomWidgetResponseCache(
input: CustomWidgetHttpRequest,
execute: (input: CustomWidgetHttpRequest) => Promise<CustomWidgetHttpResponse>,
): Promise<CustomWidgetHttpResponse> {
const cacheKey = input.kind === "query" ? input.cacheKey : undefined;
if (!cacheKey) return execute(input);
if (!useSharedCache()) return execute(input);
let client: RedisClient;
let generation: number;
try {
client = getRedis();
generation = await getGeneration(client);
if ((input.cacheTtlSeconds ?? 0) > 0) {
const cached = await readCachedResponse(client, getEntryKey(generation, cacheKey));
if (cached) return cached;
}
} catch (error) {
logger.error("Shared custom widget response cache is unavailable", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
return execute({ ...input, cacheKey: undefined });
}
const pendingKey = `${generation}:${cacheKey}`;
const pending = inFlight.get(pendingKey);
if (pending) return pending;
let request: Promise<CustomWidgetHttpResponse>;
request = execute({ ...input, cacheKey: undefined })
.then(async (response) => {
const ttlSeconds = input.cacheTtlSeconds ?? 0;
if (!response.ok || ttlSeconds <= 0) return response;
try {
const currentGeneration = await getGeneration(client);
if (currentGeneration === generation) {
await storeCachedResponse(client, getEntryKey(generation, cacheKey), response, ttlSeconds);
}
} catch (error) {
logger.error("Failed to store a custom widget response", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
return response;
})
.finally(() => {
if (inFlight.get(pendingKey) === request) inFlight.delete(pendingKey);
});
inFlight.set(pendingKey, request);
return request;
}
export async function invalidateSharedCustomWidgetResponseCache(prefixes: readonly string[]) {
if (prefixes.length === 0 || !useSharedCache()) return;
for (const key of inFlight.keys()) {
if (prefixes.some((prefix) => key.includes(prefix))) inFlight.delete(key);
}
try {
await getRedis().incr(GENERATION_KEY);
} catch (error) {
logger.error("Failed to invalidate the shared custom widget response cache", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
}
@@ -118,8 +118,8 @@ describe("Custom JSX v2 board router", () => {
});
test("refreshes the server response cache for a placed widget", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
const { db, itemId, ownerId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
await caller.refresh({ itemId });
+13 -7
View File
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { mapWithConcurrency } from "@homarr/common";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema";
@@ -13,6 +14,7 @@ import {
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/server";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
@@ -40,7 +42,7 @@ interface CustomWidgetItemOptions {
refreshInterval?: number;
}
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0] & { ipAddress?: string };
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
const parseItemOptions = (raw: string): CustomWidgetItemOptions => {
@@ -154,6 +156,7 @@ const withRequestLimit = async <T>(
const release = await acquireCustomWidgetRequestLimit({
category: request.kind === "query" ? "query" : request.method === "DELETE" ? "delete" : "action",
userId: ctx.session?.user.id,
anonymousId: ctx.ipAddress,
itemId: resolved.item.id,
definitionId: resolved.stored.id,
});
@@ -194,9 +197,9 @@ const executeRequest = async (
};
export const customApiRouter = createTRPCRouter({
refresh: publicProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => {
refresh: protectedProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
invalidateCustomWidgetResponseCache([
await invalidateCustomWidgetResponseCache([
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:`,
]);
}),
@@ -206,8 +209,10 @@ export const customApiRouter = createTRPCRouter({
const loadRequests = Object.entries(resolved.definition.requests).filter(
([, request]) => request.kind === "query" && request.trigger === "load",
);
const entries = await Promise.all(
loadRequests.map(async ([requestId, request]) => {
const entries = await mapWithConcurrency(
loadRequests,
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
async ([requestId, request]) => {
try {
const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {});
return [
@@ -237,12 +242,13 @@ export const customApiRouter = createTRPCRouter({
},
] as const;
}
}),
},
);
return {
type: "customJsx" as const,
template: resolved.definition.template,
canRefresh: ctx.session !== null,
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
options: resolved.configuration,
@@ -285,7 +291,7 @@ export const customApiRouter = createTRPCRouter({
}
const response = await executeRequest(ctx, resolved, request, input.params);
if (response.ok && request.invalidates?.length) {
invalidateCustomWidgetResponseCache(
await invalidateCustomWidgetResponseCache(
request.invalidates.flatMap((requestId) => [
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
`custom-widget:options:${resolved.stored.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`,
+3 -1
View File
@@ -13,7 +13,7 @@ import { ZodError } from "zod/v4";
import type { Session } from "@homarr/auth";
import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import { ipAddressFromHeaders, userAgent } from "@homarr/common/server";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
@@ -43,6 +43,7 @@ interface ApiContext {
deviceType: DeviceType;
baseUrl?: `${string}://${string}`;
onboardingClaimToken?: string;
ipAddress?: string;
db: typeof db;
}
@@ -60,6 +61,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
session,
deviceType: userAgent(opts.headers).device.type,
baseUrl: extractBaseUrlFromHeaders(opts.headers),
ipAddress: ipAddressFromHeaders(opts.headers) ?? undefined,
onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")),
db,
};
+23
View File
@@ -0,0 +1,23 @@
export async function mapWithConcurrency<T, U>(
values: readonly T[],
concurrency: number,
mapper: (value: T, index: number) => Promise<U>,
): Promise<U[]> {
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
throw new Error("Concurrency must be a positive integer");
}
const results: U[] = [];
let nextIndex = 0;
const workerCount = Math.min(concurrency, values.length);
const workers = Array.from({ length: workerCount }, async () => {
while (nextIndex < values.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(values[index] as T, index);
}
});
await Promise.all(workers);
return results;
}
+1
View File
@@ -13,3 +13,4 @@ export * from "./theme";
export * from "./function";
export * from "./grid-algorithm";
export * from "./id";
export * from "./async";
@@ -24,6 +24,10 @@ export interface RenderSafeJsxOptions {
budgets?: SafeJsxBudgets;
}
export interface RenderCompiledSafeJsxOptions extends Omit<RenderSafeJsxOptions, "template"> {
root: AstNode;
}
export interface SafeJsxRenderResult {
node: ReactNode;
warnings: string[];
@@ -351,11 +355,23 @@ class Interpreter {
}
}
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
export function compileSafeJsx(template: string): AstNode {
return parseCustomJsxTemplate(template);
}
export function renderCompiledSafeJsx({
root,
components,
bindings,
budgets,
}: RenderCompiledSafeJsxOptions): SafeJsxRenderResult {
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
}
const root = parseCustomJsxTemplate(template);
return new Interpreter(components, new Environment(bindings), limits).render(root);
}
export function renderSafeJsx({ template, ...options }: RenderSafeJsxOptions): SafeJsxRenderResult {
return renderCompiledSafeJsx({ ...options, root: compileSafeJsx(template) });
}
@@ -68,7 +68,7 @@ export function emitJsxElement(
child.type === "JSXExpressionContainer" ? asNode(child.expression, "JSX child expression") : null;
if (resolvedTag === "SubFetch" && expression?.type === "ArrowFunctionExpression") {
const callback = context.createCallback(expression, environment);
return (value: unknown) => context.renderCallback(callback, [value], depth + 1);
return (value: unknown, metadata: unknown) => context.renderCallback(callback, [value, metadata], depth + 1);
}
return context.evaluate(child, environment, depth + 1);
});
@@ -3,12 +3,14 @@ import { Component, useCallback, useEffect, useId, useMemo, useState } from "rea
import { Alert, Box, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { renderSafeJsx } from "../jsx/interpreter";
import { compileSafeJsx, renderCompiledSafeJsx } from "../jsx/interpreter";
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
import type { CustomJsxRequestCapability } from "./types";
const EMPTY_RECORD: Record<string, never> = {};
const referenceIds = new WeakMap<object, number>();
let nextReferenceId = 1;
const methodColors: Readonly<Record<string, string>> = {
GET: "blue",
@@ -162,12 +164,27 @@ function CustomJsxRendererSession({
},
[inputTypes],
);
const compiled = useMemo(() => {
try {
return { root: compileSafeJsx(template), error: null };
} catch (error) {
return { root: null, error: error instanceof Error ? error : new Error(String(error)) };
}
}, [template]);
const rendered = useMemo(() => {
if (compiled.error || !compiled.root) {
return {
node: null,
warnings: [],
boundaryKey: `${template.length}:compile-error`,
error: compiled.error ?? new Error("Template compilation failed"),
};
}
try {
const bindings = { ...createBindings(data), status, options, inputs };
return {
...renderSafeJsx({ template, components, bindings }),
boundaryKey: createBoundaryKey(template, bindings),
...renderCompiledSafeJsx({ root: compiled.root, components, bindings }),
boundaryKey: createBoundaryKey(template, [data, status, options, inputs]),
error: null,
};
} catch (error) {
@@ -178,7 +195,7 @@ function CustomJsxRendererSession({
error: error instanceof Error ? error : new Error(String(error)),
};
}
}, [components, createBindings, data, inputs, options, status, template]);
}, [compiled, components, createBindings, data, inputs, options, status, template]);
useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]);
const handleError = useCallback(
(error: Error) =>
@@ -228,9 +245,20 @@ function CustomJsxRendererSession({
);
}
function createBoundaryKey(template: string, bindings: Readonly<Record<string, unknown>>) {
let hash = 0;
const value = `${template}\0${JSON.stringify(bindings)}`;
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
return `${template.length}:${hash}`;
function createBoundaryKey(template: string, values: readonly unknown[]) {
return `${template.length}:${values.map(referenceKey).join(":")}`;
}
function referenceKey(value: unknown): string {
if ((typeof value === "object" && value !== null) || typeof value === "function") {
const reference = value as object;
let id = referenceIds.get(reference);
if (!id) {
id = nextReferenceId;
nextReferenceId += 1;
referenceIds.set(reference, id);
}
return `r${id}`;
}
return `${typeof value}:${String(value)}`;
}
@@ -37,7 +37,7 @@ export function RefreshButton({ label, color = "gray", variant = "subtle", size
size={size as never}
onClick={() => void refresh()}
loading={loading}
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
disabled={!runtime.canRefresh || (!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
>
<IconRefresh size={16} />
</ActionIcon>
@@ -82,6 +82,7 @@ export interface CustomWidgetRuntimeValue {
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
canRefresh?: boolean;
isEditMode: boolean;
requestCapabilities: readonly CustomJsxRequestCapability[];
port: CustomWidgetRuntimePort;
@@ -33,6 +33,7 @@ export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_QUERY_REDIRECTS = 3;
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
const MAX_RESPONSE_CACHE_BYTES = 64 * 1024 * 1024;
export const MAX_REQUEST_DURATION_MS = 45_000;
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE";
@@ -63,9 +64,10 @@ export interface CustomWidgetHttpResponse {
statusText: string;
data: unknown;
}
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse; size: number }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let cacheEpoch = 0;
let cacheBytes = 0;
type RequestHopResult =
| { kind: "response"; response: CustomWidgetHttpResponse }
@@ -256,8 +258,12 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest)
const key = input.kind === "query" ? input.cacheKey : undefined;
if (!key) return performRequest(input);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.response;
if (cached) cache.delete(key);
if (cached && cached.expiresAt > Date.now()) {
cache.delete(key);
cache.set(key, cached);
return cached.response;
}
if (cached) deleteCachedResponse(key);
const pending = inFlight.get(key);
if (pending) return pending;
const requestEpoch = cacheEpoch;
@@ -265,8 +271,12 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest)
request = performRequest(input)
.then((response) => {
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0 && requestEpoch === cacheEpoch) {
pruneCache();
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response });
const size = Buffer.byteLength(JSON.stringify(response), "utf8");
if (size <= MAX_RESPONSE_CACHE_BYTES) {
pruneCache(size);
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response, size });
cacheBytes += size;
}
}
return response;
})
@@ -280,15 +290,22 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest)
export function invalidateCustomWidgetResponseCache(prefixes: readonly string[]): void {
if (prefixes.length === 0) return;
cacheEpoch += 1;
for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) cache.delete(key);
for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) deleteCachedResponse(key);
for (const key of inFlight.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) inFlight.delete(key);
}
function pruneCache(): void {
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key);
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
function pruneCache(incomingSize: number): void {
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) deleteCachedResponse(key);
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES || cacheBytes + incomingSize > MAX_RESPONSE_CACHE_BYTES) {
const key = cache.keys().next().value as string | undefined;
if (!key) return;
cache.delete(key);
deleteCachedResponse(key);
}
}
function deleteCachedResponse(key: string): void {
const cached = cache.get(key);
if (!cached) return;
cache.delete(key);
cacheBytes = Math.max(0, cacheBytes - cached.size);
}
@@ -1,5 +1,6 @@
import dayjs from "dayjs";
import { mapWithConcurrency } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -32,6 +33,7 @@ const UMAMI_CUSTOM_EVENT_TYPE = 2;
const UMAMI_EVENT_NAME_LIMIT = 5_000;
const UMAMI_EVENT_PAGE_SIZE = 5_000;
const UMAMI_EVENT_PAGE_MAX = 20;
const UMAMI_EVENT_REQUEST_CONCURRENCY = 4;
const logger = createLogger({ module: "umami-integration" });
@@ -245,8 +247,7 @@ export class UmamiIntegration extends Integration {
): Promise<UmamiEventSeries[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const results: UmamiEventSeries[] = [];
for (const eventName of eventNames) {
return mapWithConcurrency(eventNames, UMAMI_EVENT_REQUEST_CONCURRENCY, async (eventName) => {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
@@ -255,9 +256,8 @@ export class UmamiIntegration extends Integration {
eventName,
authHeaders,
);
results.push({ eventName, dataPoints: dataPoints ?? [] });
}
return results;
return { eventName, dataPoints: dataPoints ?? [] };
});
}
private async getWebsiteEventMetricsAsync(
+174 -157
View File
@@ -16,9 +16,9 @@ import {
CopyButton,
Fieldset,
Group,
Modal,
Paper,
PasswordInput,
Popover,
Progress,
Select,
SimpleGrid,
@@ -160,6 +160,9 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const sounds = useOnboardingSounds();
const [activeSection, setActiveSection] = useState<StudioSection>("essentials");
const [incompleteIntegrationConfirmationOpened, setIncompleteIntegrationConfirmationOpened] = useState(false);
const [confirmedIncompleteIntegrationSignature, setConfirmedIncompleteIntegrationSignature] = useState<string | null>(
null,
);
const [selectedLocale, setSelectedLocale] = useState(currentLocale);
const [serverOrigin, setServerOrigin] = useState(environment.serverOrigin);
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("hostPort");
@@ -184,6 +187,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const [isApplying, setIsApplying] = useState(false);
const [applyProgress, setApplyProgress] = useState(0);
const [applyMessage, setApplyMessage] = useState("");
const [applyError, setApplyError] = useState<string | null>(null);
const [appError, setAppError] = useState<string | null>(null);
const sectionHeadingRef = useRef<HTMLHeadingElement>(null);
const sectionButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
@@ -201,11 +205,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
retry: false,
refetchOnWindowFocus: false,
});
const complete = clientApi.onboard.completeSetup.useMutation({
onError(error) {
showErrorNotification({ title: t("review.errorTitle"), message: error.message });
},
});
const complete = clientApi.onboard.completeSetup.useMutation();
const dockerData = docker.data;
const discoveredIntegrations = dockerData?.integrations ?? emptyDiscoveredIntegrations;
const discoveredApps = dockerData?.apps ?? emptyDiscoveredApps;
@@ -234,6 +234,18 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
() => new Set(discoveredIntegrations.map((integration) => integration.kind)),
[discoveredIntegrations],
);
const incompleteIntegrationSignature = JSON.stringify(
drafts
.filter((draft) => !isIntegrationDraftComplete(draft))
.map((draft) => ({
id: draft.id,
hasUrl: draft.url.trim().length > 0,
secrets: draft.secrets.map((secret) => ({ kind: secret.kind, hasValue: secret.value.trim().length > 0 })),
})),
);
const hasIncompleteIntegrations = incompleteIntegrationSignature !== "[]";
const incompleteIntegrationsConfirmed =
hasIncompleteIntegrations && confirmedIncompleteIntegrationSignature === incompleteIntegrationSignature;
useEffect(() => {
if (!docker.data) return;
@@ -326,11 +338,30 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
if (next) selectSection(next.id);
};
const selectSection = (section: StudioSection) => {
const changeSection = (section: StudioSection) => {
if (section !== activeSection) sounds.swoosh();
setActiveSection(section);
};
const selectSection = (section: StudioSection) => {
if (
section === "review" &&
activeSection !== "review" &&
hasIncompleteIntegrations &&
!incompleteIntegrationsConfirmed
) {
setIncompleteIntegrationConfirmationOpened(true);
return;
}
changeSection(section);
};
const confirmReviewNavigation = () => {
setConfirmedIncompleteIntegrationSignature(incompleteIntegrationSignature);
setIncompleteIntegrationConfirmationOpened(false);
changeSection("review");
};
const handleSectionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const nextIndex =
event.key === "Home"
@@ -368,7 +399,12 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const applySetupAsync = async () => {
if (isApplying) return;
if (hasIncompleteIntegrations && !incompleteIntegrationsConfirmed) {
setIncompleteIntegrationConfirmationOpened(true);
return;
}
setIsApplying(true);
setApplyError(null);
setAppError(null);
setAppErrorSourceId(null);
setApplyProgress(5);
@@ -483,8 +519,14 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
setApplyMessage(t("review.progress.done"));
setApplyProgress(100);
await revalidatePathActionAsync("/init");
} catch {
} catch (error) {
let message = t("common.unknownError");
if (error instanceof Error && error.message.trim().length > 0) {
message = error.message;
}
sounds.error();
setApplyError(message);
showErrorNotification({ title: t("review.errorTitle"), message });
setApplyProgress(0);
setIsApplying(false);
}
@@ -570,10 +612,10 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
},
applyProgress,
applyMessage,
applyError,
appError,
appErrorSourceId,
};
const hasIncompleteIntegrations = drafts.some((draft) => !isIntegrationDraftComplete(draft));
const selectedAppWithoutAddress = discoveredApps.find(
(app) => selectedAppIds.includes(app.sourceId) && (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0,
);
@@ -590,153 +632,122 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
}
setAppError(null);
setAppErrorSourceId(null);
if (hasIncompleteIntegrations) {
setIncompleteIntegrationConfirmationOpened(true);
} else {
move(1);
}
move(1);
} else {
move(1);
}
};
return (
<main
className={classes.page}
style={
{
"--studio-glow-color": primaryColor,
"--studio-secondary-glow-color": secondaryColor,
} as CSSProperties
}
>
<OnboardingBackdrop />
<div className={classes.shell}>
<Group className={classes.topbar} justify="center" mb="lg">
<OnboardingWordmark
primaryColor={primaryColor}
secondaryColor={secondaryColor.toLowerCase() === initialSecondaryColor ? undefined : secondaryColor}
/>
</Group>
<>
<Modal
opened={incompleteIntegrationConfirmationOpened}
onClose={() => setIncompleteIntegrationConfirmationOpened(false)}
title={t("connect.incompleteConfirmation")}
centered
>
<Stack gap="md">
<Text size="sm">{t("connect.incompleteConfirmation")}</Text>
<Group justify="flex-end" gap="xs">
<Button variant="default" onClick={() => setIncompleteIntegrationConfirmationOpened(false)}>
{tCommon("cancel")}
</Button>
<Button onClick={confirmReviewNavigation}>{tCommon("confirm")}</Button>
</Group>
</Stack>
</Modal>
<main
className={classes.page}
style={
{
"--studio-glow-color": primaryColor,
"--studio-secondary-glow-color": secondaryColor,
} as CSSProperties
}
>
<OnboardingBackdrop />
<div className={classes.shell}>
<Group className={classes.topbar} justify="center" mb="lg">
<OnboardingWordmark
primaryColor={primaryColor}
secondaryColor={secondaryColor.toLowerCase() === initialSecondaryColor ? undefined : secondaryColor}
/>
</Group>
<Paper className={classes.studio} radius="lg">
<div className={classes.studioGrid}>
<nav className={classes.rail} aria-label={t("navigationLabel")}>
<Timeline
active={activeIndex}
bulletSize={32}
lineWidth={2}
color={primaryColor}
className={classes.timeline}
classNames={{ itemBullet: classes.timelineBullet }}
>
{sectionDefinitions.map((section, index) => {
const Icon = section.icon;
const active = section.id === activeSection;
return (
<Timeline.Item
key={section.id}
bullet={<Icon size={16} aria-hidden />}
title={
<UnstyledButton
ref={(node) => {
sectionButtonRefs.current[index] = node;
}}
className={classes.timelineButton}
data-active={active}
onClick={() => selectSection(section.id)}
onKeyDown={(event) => handleSectionKeyDown(event, index)}
aria-label={`${t(`navigation.${section.id}`)} (${index + 1}/${sectionDefinitions.length})`}
aria-current={active ? "step" : undefined}
aria-controls="onboarding-studio-section"
tabIndex={active ? 0 : -1}
>
<Stack gap={0} align="flex-start">
<Text className={classes.timelineLabel} size="sm" fw={active ? 650 : 500}>
{t(`navigation.${section.id}`)}
</Text>
<Text className={classes.timelineLabel} size="xs" c="dimmed">
{index + 1} / {sectionDefinitions.length}
</Text>
</Stack>
</UnstyledButton>
}
/>
);
})}
</Timeline>
</nav>
<section id="onboarding-studio-section" className={classes.content} aria-labelledby="studio-section-title">
<div key={activeSection}>
<StudioSectionContent section={activeSection} {...sectionProps} />
</div>
<Group className={classes.stickyActions} justify="space-between" wrap="nowrap">
<Button
className={classes.backAction}
size="sm"
variant="default"
leftSection={<IconArrowLeft size={16} />}
aria-label={t("back")}
disabled={activeIndex === 0 || isApplying}
onClick={() => move(-1)}
<Paper className={classes.studio} radius="lg">
<div className={classes.studioGrid}>
<nav className={classes.rail} aria-label={t("navigationLabel")}>
<Timeline
active={activeIndex}
bulletSize={32}
lineWidth={2}
color={primaryColor}
className={classes.timeline}
classNames={{ itemBullet: classes.timelineBullet }}
>
{t("back")}
</Button>
{activeSection === "review" ? (
<Button size="sm" loading={isApplying} onClick={continueOnboarding}>
{t("buildBoard")}
</Button>
) : (
<Group className={classes.primaryActions} gap="xs" wrap="nowrap">
{activeSection === "connect" && hasIncompleteIntegrations ? (
<Popover
opened={incompleteIntegrationConfirmationOpened}
onDismiss={() => setIncompleteIntegrationConfirmationOpened(false)}
transitionProps={{ duration: 0 }}
position="top-end"
width={320}
shadow="md"
withArrow
trapFocus
>
<Popover.Target>
<Button
size="sm"
disabled={continueDisabled}
rightSection={<IconArrowRight size={16} />}
aria-haspopup="dialog"
aria-expanded={incompleteIntegrationConfirmationOpened}
onClick={() => setIncompleteIntegrationConfirmationOpened((opened) => !opened)}
{sectionDefinitions.map((section, index) => {
const Icon = section.icon;
const active = section.id === activeSection;
return (
<Timeline.Item
key={section.id}
bullet={<Icon size={16} aria-hidden />}
title={
<UnstyledButton
ref={(node) => {
sectionButtonRefs.current[index] = node;
}}
className={classes.timelineButton}
data-active={active}
onClick={() => selectSection(section.id)}
onKeyDown={(event) => handleSectionKeyDown(event, index)}
aria-label={`${t(`navigation.${section.id}`)} (${index + 1}/${sectionDefinitions.length})`}
aria-current={active ? "step" : undefined}
aria-controls="onboarding-studio-section"
tabIndex={active ? 0 : -1}
>
{t("continue")}
</Button>
</Popover.Target>
<Popover.Dropdown role="dialog" aria-label={t("connect.incompleteConfirmation")}>
<Stack gap="sm">
<Text size="sm">{t("connect.incompleteConfirmation")}</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => setIncompleteIntegrationConfirmationOpened(false)}
>
{tCommon("cancel")}
</Button>
<Button
size="xs"
onClick={() => {
setIncompleteIntegrationConfirmationOpened(false);
move(1);
}}
>
{tCommon("confirm")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
) : (
<Stack gap={0} align="flex-start">
<Text className={classes.timelineLabel} size="sm" fw={active ? 650 : 500}>
{t(`navigation.${section.id}`)}
</Text>
<Text className={classes.timelineLabel} size="xs" c="dimmed">
{index + 1} / {sectionDefinitions.length}
</Text>
</Stack>
</UnstyledButton>
}
/>
);
})}
</Timeline>
</nav>
<section
id="onboarding-studio-section"
className={classes.content}
aria-labelledby="studio-section-title"
>
<div key={activeSection}>
<StudioSectionContent section={activeSection} {...sectionProps} />
</div>
<Group className={classes.stickyActions} justify="space-between" wrap="nowrap">
<Button
className={classes.backAction}
size="sm"
variant="default"
leftSection={<IconArrowLeft size={16} />}
aria-label={t("back")}
disabled={activeIndex === 0 || isApplying}
onClick={() => move(-1)}
>
{t("back")}
</Button>
{activeSection === "review" ? (
<Button size="sm" loading={isApplying} onClick={continueOnboarding}>
{t("buildBoard")}
</Button>
) : (
<Group className={classes.primaryActions} gap="xs" wrap="nowrap">
<Button
size="sm"
disabled={continueDisabled}
@@ -745,15 +756,15 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
>
{t("continue")}
</Button>
)}
</Group>
)}
</Group>
</section>
</div>
</Paper>
</div>
</main>
</Group>
)}
</Group>
</section>
</div>
</Paper>
</div>
</main>
</>
);
};
@@ -807,6 +818,7 @@ interface StudioSectionProps {
setRightSidebar: (value: boolean) => void;
applyProgress: number;
applyMessage: string;
applyError: string | null;
appError: string | null;
appErrorSourceId: string | null;
}
@@ -1920,6 +1932,11 @@ const Review = (props: StudioSectionProps) => {
</Stack>
</Paper>
) : null}
{props.applyError ? (
<Alert color="red" icon={<IconAlertCircle size={18} />} title={t("errorTitle")} role="alert">
{props.applyError}
</Alert>
) : null}
</Stack>
);
};
+2 -1
View File
@@ -2416,7 +2416,8 @@
"image": {
"loading": "Loading image",
"failed": "This image could not be loaded.",
"retry": "Try image again"
"retry": "Try image again",
"loadExternal": "Load external image from {host}"
},
"quoteSelection": "Quote",
"dismissQuote": "Remove quote",
@@ -1,16 +1,24 @@
import { Image, Marquee, SimpleGrid } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import classes from "./integration-marquee.module.css";
const iconGroups = splitToNChunks(
Object.values(integrationDefs)
.filter((integration) => integration.name !== "Mock")
.map((integration) => integration.iconUrl),
3,
);
const localIconPaths = [
"/images/apps/sonarr.svg",
"/images/apps/radarr.svg",
"/images/apps/lidarr.svg",
"/images/apps/readarr.svg",
"/images/apps/nextcloud.svg",
"/images/apps/truenas.svg",
"/images/apps/unraid-alt.svg",
"/images/apps/imdb.svg",
"/images/apps/lastfm.svg",
"/images/apps/tmdb.svg",
"/images/apps/the-tvdb.svg",
"/images/apps/vgmdb.svg",
];
const iconGroups = splitToNChunks(localIconPaths, 3);
const animationDuration = iconGroups.flat().length * 2;
export const IntegrationMarquee = ({ className }: { className?: string }) => (
@@ -36,7 +44,16 @@ export const IntegrationMarquee = ({ className }: { className?: string }) => (
classNames={{ content: classes.content }}
>
{icons.map((icon) => (
<Image key={`${icon}-${columnIndex}`} src={icon} alt="" fit="contain" w={52} h={52} />
<Image
key={`${icon}-${columnIndex}`}
src={icon}
alt=""
fit="contain"
w={52}
h={52}
loading="lazy"
decoding="async"
/>
))}
</Marquee>
))}
@@ -69,6 +69,7 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
previewSessionId={previewSessionId}
previewLiveActions={data.previewLiveActions === true}
queriesDisabled={data.queriesDisabled === true}
canRefresh={data.canRefresh === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
setQueryState={publishQueryState}
@@ -21,6 +21,7 @@ interface WidgetDefinitionProviderProps {
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
canRefresh?: boolean;
isEditMode?: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
@@ -170,6 +171,7 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
previewSessionId={props.previewSessionId}
previewLiveActions={props.previewLiveActions}
queriesDisabled={props.queriesDisabled}
canRefresh={Boolean(props.previewSessionId) || props.canRefresh === true}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Badge,
Box,
@@ -58,6 +58,10 @@ export default function MediaMissingWidget({
});
const data = getUsableWidgetQueryData(mediaQuery);
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(showMissing ? "missing" : "queued");
const tabScrollPositionsRef = useRef<Record<MediaMissingTab, { x: number; y: number }>>({
missing: { x: 0, y: 0 },
queued: { x: 0, y: 0 },
});
const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued);
useEffect(() => {
@@ -92,10 +96,18 @@ export default function MediaMissingWidget({
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
const renderPanel = (
tab: MediaMissingTab,
entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
emptyLabel: string,
) => (
<ScrollArea h="100%" scrollbarSize={4}>
<ScrollArea
h="100%"
scrollbarSize={4}
startScrollPosition={tabScrollPositionsRef.current[tab]}
onScrollPositionChange={(position) => {
tabScrollPositionsRef.current[tab] = position;
}}
>
<Box p="xs">
{entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
@@ -146,7 +158,7 @@ export default function MediaMissingWidget({
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Text>
</Group>
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
<Box h="calc(100% - 40px)">{renderPanel("missing", missing, t("empty.missing"))}</Box>
</Paper>
)}
{showQueued && (
@@ -157,7 +169,7 @@ export default function MediaMissingWidget({
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Text>
</Group>
<Box h="calc(100% - 40px)">{renderPanel(queued, t("empty.queued"))}</Box>
<Box h="calc(100% - 40px)">{renderPanel("queued", queued, t("empty.queued"))}</Box>
</Paper>
)}
</SimpleGrid>
@@ -181,12 +193,20 @@ export default function MediaMissingWidget({
</Group>
<Tabs.List grow>
{showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size="var(--mantine-font-size-sm)" />}>
<Tabs.Tab
value="missing"
px={isThin ? 6 : undefined}
leftSection={<IconQuestionMark size="var(--mantine-font-size-sm)" />}
>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Tabs.Tab>
)}
{showQueued && (
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size="var(--mantine-font-size-sm)" />}>
<Tabs.Tab
value="queued"
px={isThin ? 6 : undefined}
leftSection={<IconDownload size="var(--mantine-font-size-sm)" />}
>
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Tabs.Tab>
)}
@@ -194,12 +214,12 @@ export default function MediaMissingWidget({
{showMissing && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
{activeTab === "missing" && renderPanel(missing, t("empty.missing"))}
{activeTab === "missing" && renderPanel("missing", missing, t("empty.missing"))}
</Tabs.Panel>
)}
{showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
{activeTab === "queued" && renderPanel(queued, t("empty.queued"))}
{activeTab === "queued" && renderPanel("queued", queued, t("empty.queued"))}
</Tabs.Panel>
)}
</Tabs>
+65
View File
@@ -285,6 +285,9 @@ catalogs:
'@types/better-sqlite3':
specifier: 7.6.13
version: 7.6.13
'@types/busboy':
specifier: 1.5.4
version: 1.5.4
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
@@ -330,6 +333,9 @@ catalogs:
'@types/xml2js':
specifier: ^0.4.14
version: 0.4.14
'@types/yauzl':
specifier: 3.4.0
version: 3.4.0
'@vitejs/plugin-react':
specifier: ^6.0.3
version: 6.0.3
@@ -372,6 +378,9 @@ catalogs:
better-sqlite3:
specifier: ^12.11.1
version: 12.11.1
busboy:
specifier: 1.6.0
version: 1.6.0
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
@@ -663,6 +672,9 @@ catalogs:
yaml:
specifier: 2.9.0
version: 2.9.0
yauzl:
specifier: 3.4.0
version: 3.4.0
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -1177,6 +1189,9 @@ importers:
better-sqlite3:
specifier: 'catalog:'
version: 12.11.1
busboy:
specifier: 'catalog:'
version: 1.6.0
chroma-js:
specifier: 'catalog:'
version: 3.2.0
@@ -1267,6 +1282,9 @@ importers:
yaml:
specifier: 'catalog:'
version: 2.9.0
yauzl:
specifier: 'catalog:'
version: 3.4.0
zod:
specifier: 'catalog:'
version: 4.4.3
@@ -1280,6 +1298,9 @@ importers:
'@types/better-sqlite3':
specifier: 'catalog:'
version: 7.6.13
'@types/busboy':
specifier: 'catalog:'
version: 1.5.4
'@types/chroma-js':
specifier: 'catalog:'
version: 3.1.2
@@ -1295,6 +1316,9 @@ importers:
'@types/react-dom':
specifier: 'catalog:'
version: 19.2.3(@types/react@19.2.17)
'@types/yauzl':
specifier: 'catalog:'
version: 3.4.0
concurrently:
specifier: 'catalog:'
version: 10.0.3
@@ -9205,6 +9229,9 @@ packages:
'@types/bonjour@3.5.13':
resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==}
'@types/busboy@1.5.4':
resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
@@ -9543,6 +9570,9 @@ packages:
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
'@types/yauzl@3.4.0':
resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
'@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'}
@@ -10356,6 +10386,10 @@ packages:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
byline@5.0.0:
resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==}
engines: {node: '>=0.10.0'}
@@ -14137,6 +14171,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
@@ -15743,6 +15780,10 @@ packages:
stream-combiner2@1.1.1:
resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
streamx@2.22.0:
resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==}
@@ -16929,6 +16970,10 @@ packages:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yauzl@3.4.0:
resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
engines: {node: '>=12'}
yjs@13.6.32:
resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@@ -24303,6 +24348,10 @@ snapshots:
dependencies:
'@types/node': 24.13.3
'@types/busboy@1.5.4':
dependencies:
'@types/node': 24.13.3
'@types/canvas-confetti@1.9.0': {}
'@types/chai@5.2.2':
@@ -24689,6 +24738,10 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@types/yauzl@3.4.0':
dependencies:
'@types/node': 24.13.3
'@typescript/typescript-aix-ppc64@7.0.2':
optional: true
@@ -25557,6 +25610,10 @@ snapshots:
dependencies:
run-applescript: 7.1.0
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
byline@5.0.0: {}
bytes@3.0.0: {}
@@ -29879,6 +29936,8 @@ snapshots:
pathe@2.0.3: {}
pend@1.2.0: {}
pg-cloudflare@1.4.0:
optional: true
@@ -32082,6 +32141,8 @@ snapshots:
duplexer2: 0.1.4
readable-stream: 2.3.8
streamsearch@1.1.0: {}
streamx@2.22.0:
dependencies:
fast-fifo: 1.3.2
@@ -33427,6 +33488,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 22.0.0
yauzl@3.4.0:
dependencies:
pend: 1.2.0
yjs@13.6.32:
dependencies:
lib0: 0.2.117
+4
View File
@@ -91,6 +91,7 @@ catalog:
"@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13
"@types/busboy": 1.5.4
"@types/chroma-js": 3.1.2
"@types/cookies": 0.9.2
"@types/crypto-js": ^4.2.2
@@ -108,6 +109,7 @@ catalog:
"@types/ws": ^8.18.1
"@uiw/react-codemirror": ^4.25.11
"@types/xml2js": ^0.4.14
"@types/yauzl": 3.4.0
posthog-js: ^1.399.1
posthog-node: ^5.40.0
"@vitejs/plugin-react": ^6.0.3
@@ -124,6 +126,7 @@ catalog:
babel-plugin-react-compiler: ^1.0.0
bcrypt: ^6.0.0
better-sqlite3: ^12.11.1
busboy: 1.6.0
chroma-js: ^3.2.0
clsx: ^2.1.1
codemirror: ^6.0.2
@@ -213,6 +216,7 @@ catalog:
ws: ^8.21.0
xml2js: ^0.6.2
yaml: 2.9.0
yauzl: 3.4.0
zod: ^4.4.3
zod-form-data: ^3.0.2
zod-validation-error: ^5.0.0
+25 -8
View File
@@ -7,14 +7,31 @@ export PGID=${PGID:-0}
echo "Starting with UID='$PUID', GID='$PGID'"
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
# 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
# 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
echo "Changing owner to $PUID:$PGID, done."
fi