fix: address v2 stack review findings
This commit is contained in:
1 parent
53b518960c
commit
263702210a
48 files changed
+1235
-530
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>⇧</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}>⇧</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,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 });
|
||||
|
||||
|
||||
@@ -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}:`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+65
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user