fix: address coderabbit review feedback
This commit is contained in:
1 parent
17aef0588f
commit
cf624e2f23
10 files changed
+256
-175
No files matched your search
@@ -40,6 +40,7 @@ const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEB
|
||||
const MAX_METADATA_BYTES = MEBIBYTE;
|
||||
const MAX_ENCRYPTION_KEY_BYTES = 1024;
|
||||
const MAX_ARCHIVE_ENTRIES = 1024;
|
||||
const UPLOAD_IDLE_TIMEOUT_MS = 60_000;
|
||||
const RESTART_DELAY_MS = 500;
|
||||
|
||||
let restoreInProgress = false;
|
||||
@@ -73,6 +74,26 @@ const createRequestSizeLimiter = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createUploadIdleTimeout = () => {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const stream = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
resetTimeout();
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
const resetTimeout = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => stream.destroy(new Error("Backup upload timed out")), UPLOAD_IDLE_TIMEOUT_MS);
|
||||
timeout.unref();
|
||||
};
|
||||
resetTimeout();
|
||||
stream.on("close", () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const streamUploadedBackupAsync = async (request: Request, uploadPath: string): Promise<number | null> => {
|
||||
assertDeclaredRequestSize(request);
|
||||
if (!request.body) return null;
|
||||
@@ -128,7 +149,9 @@ const streamUploadedBackupAsync = async (request: Request, uploadPath: string):
|
||||
|
||||
const requestStream = Readable.fromWeb(request.body as NodeReadableStream<Uint8Array>);
|
||||
try {
|
||||
await pipeline(requestStream, createRequestSizeLimiter(), parser);
|
||||
await pipeline(requestStream, createUploadIdleTimeout(), createRequestSizeLimiter(), parser, {
|
||||
signal: request.signal,
|
||||
});
|
||||
await uploadWrite;
|
||||
} catch (error) {
|
||||
await uploadWrite.catch(() => undefined);
|
||||
@@ -453,6 +476,11 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
} finally {
|
||||
zip.close();
|
||||
try {
|
||||
fs.rmSync(uploadPath, { force: true });
|
||||
} catch {
|
||||
// The outer restore-directory cleanup retries this path.
|
||||
}
|
||||
}
|
||||
|
||||
tempDb = new BetterSqlite3(tempPath);
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
.horizontalScroller {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
/*
|
||||
* Horizontal scrolling computes the other overflow axis to auto. Keep the
|
||||
* board chrome inside that scrollport without changing the sizer's height.
|
||||
*/
|
||||
padding-block: var(--board-canvas-overflow-top) var(--board-canvas-overflow-bottom);
|
||||
margin-block: calc(0px - var(--board-canvas-overflow-top)) calc(0px - var(--board-canvas-overflow-bottom));
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] .horizontalScroller {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
const MIN_READABLE_CANVAS_SCALE = 0.5;
|
||||
const CANVAS_OVERFLOW_TOP = 48;
|
||||
const CANVAS_OVERFLOW_BOTTOM = 8;
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
|
||||
@@ -76,18 +78,26 @@ export const ScaledBoardCanvas = ({
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
>
|
||||
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
style={{
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
width: logicalWidth,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
<Box
|
||||
className={classes.horizontalScroller}
|
||||
style={{
|
||||
"--board-canvas-overflow-top": `${CANVAS_OVERFLOW_TOP * Math.max(1, scale)}px`,
|
||||
"--board-canvas-overflow-bottom": `${CANVAS_OVERFLOW_BOTTOM * Math.max(1, scale)}px`,
|
||||
}}
|
||||
>
|
||||
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
style={{
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
width: logicalWidth,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
.board-grid-entry {
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-grid-entry:has([data-board-widget-header]):hover,
|
||||
|
||||
@@ -7,13 +7,24 @@ 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 GENERATION_PREFIX = "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";
|
||||
// Outlive the one-hour response TTL and 45-second request deadline so a
|
||||
// generation cannot reset while an entry from the previous one is reachable.
|
||||
const GENERATION_RETENTION_SECONDS = 2 * 60 * 60;
|
||||
const MAX_ENTRIES = 1_000;
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const INVALIDATE_GENERATIONS_SCRIPT = `
|
||||
for _, key in ipairs(KEYS) do
|
||||
redis.call('INCR', key)
|
||||
redis.call('EXPIRE', key, ARGV[1])
|
||||
end
|
||||
return #KEYS
|
||||
`;
|
||||
|
||||
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
|
||||
@@ -34,7 +45,7 @@ return totalBytes
|
||||
`;
|
||||
|
||||
const logger = createLogger({ module: "custom-widget:response-cache" });
|
||||
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
const inFlight = new Map<string, { cacheKey: string; request: Promise<CustomWidgetHttpResponse> }>();
|
||||
let redis: RedisClient | undefined;
|
||||
|
||||
const useSharedCache = () => process.env.NODE_ENV !== "test";
|
||||
@@ -44,9 +55,26 @@ const getRedis = () => {
|
||||
return redis;
|
||||
};
|
||||
|
||||
const getGeneration = async (client: RedisClient) => Number((await client.get(GENERATION_KEY)) ?? 0);
|
||||
const getGenerationKey = (prefix: string) => `${GENERATION_PREFIX}${createHash("sha256").update(prefix).digest("hex")}`;
|
||||
|
||||
const getEntryKey = (generation: number, cacheKey: string) => {
|
||||
const getCacheKeyPrefixes = (cacheKey: string) => {
|
||||
const prefixes: string[] = [];
|
||||
let separatorIndex = cacheKey.indexOf(":");
|
||||
while (separatorIndex >= 0) {
|
||||
prefixes.push(cacheKey.slice(0, separatorIndex + 1));
|
||||
separatorIndex = cacheKey.indexOf(":", separatorIndex + 1);
|
||||
}
|
||||
prefixes.push(cacheKey);
|
||||
return prefixes;
|
||||
};
|
||||
|
||||
const getGeneration = async (client: RedisClient, cacheKey: string) => {
|
||||
const keys = getCacheKeyPrefixes(cacheKey).map(getGenerationKey);
|
||||
const generations = await client.mget(...keys);
|
||||
return generations.map((generation) => generation ?? "0").join(":");
|
||||
};
|
||||
|
||||
const getEntryKey = (generation: string, cacheKey: string) => {
|
||||
const digest = createHash("sha256").update(cacheKey).digest("hex");
|
||||
return `${ENTRY_PREFIX}${generation}:${digest}`;
|
||||
};
|
||||
@@ -96,10 +124,10 @@ export async function executeWithCustomWidgetResponseCache(
|
||||
if (!useSharedCache()) return execute(input);
|
||||
|
||||
let client: RedisClient;
|
||||
let generation: number;
|
||||
let generation: string;
|
||||
try {
|
||||
client = getRedis();
|
||||
generation = await getGeneration(client);
|
||||
generation = await getGeneration(client, cacheKey);
|
||||
if ((input.cacheTtlSeconds ?? 0) > 0) {
|
||||
const cached = await readCachedResponse(client, getEntryKey(generation, cacheKey));
|
||||
if (cached) return cached;
|
||||
@@ -113,7 +141,7 @@ export async function executeWithCustomWidgetResponseCache(
|
||||
|
||||
const pendingKey = `${generation}:${cacheKey}`;
|
||||
const pending = inFlight.get(pendingKey);
|
||||
if (pending) return pending;
|
||||
if (pending) return pending.request;
|
||||
|
||||
let request: Promise<CustomWidgetHttpResponse>;
|
||||
request = execute({ ...input, cacheKey: undefined })
|
||||
@@ -121,7 +149,7 @@ export async function executeWithCustomWidgetResponseCache(
|
||||
const ttlSeconds = input.cacheTtlSeconds ?? 0;
|
||||
if (!response.ok || ttlSeconds <= 0) return response;
|
||||
try {
|
||||
const currentGeneration = await getGeneration(client);
|
||||
const currentGeneration = await getGeneration(client, cacheKey);
|
||||
if (currentGeneration === generation) {
|
||||
await storeCachedResponse(client, getEntryKey(generation, cacheKey), response, ttlSeconds);
|
||||
}
|
||||
@@ -133,19 +161,21 @@ export async function executeWithCustomWidgetResponseCache(
|
||||
return response;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.get(pendingKey) === request) inFlight.delete(pendingKey);
|
||||
if (inFlight.get(pendingKey)?.request === request) inFlight.delete(pendingKey);
|
||||
});
|
||||
inFlight.set(pendingKey, request);
|
||||
inFlight.set(pendingKey, { cacheKey, 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);
|
||||
const uniquePrefixes = [...new Set(prefixes)];
|
||||
for (const [key, pending] of inFlight) {
|
||||
if (uniquePrefixes.some((prefix) => pending.cacheKey.startsWith(prefix))) inFlight.delete(key);
|
||||
}
|
||||
try {
|
||||
await getRedis().incr(GENERATION_KEY);
|
||||
const keys = uniquePrefixes.map(getGenerationKey);
|
||||
await getRedis().eval(INVALIDATE_GENERATIONS_SCRIPT, keys.length, ...keys, GENERATION_RETENTION_SECONDS);
|
||||
} catch (error) {
|
||||
logger.error("Failed to invalidate the shared custom widget response cache", {
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { parse } from "superjson";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
@@ -34,6 +34,14 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({
|
||||
dbEnv: { DRIVER: "better-sqlite3" },
|
||||
}));
|
||||
const mocks = vi.hoisted(() => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: mocks.fetchWithTrustedCertificatesAsync,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
mocks.fetchWithTrustedCertificatesAsync.mockReset();
|
||||
});
|
||||
|
||||
const adminSession = {
|
||||
user: {
|
||||
@@ -169,30 +177,23 @@ describe("assistantRouter.updateConfiguration", () => {
|
||||
.set({ encryptedHeaders: null })
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
name: "DeepSeek: DeepSeek V4 Pro",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
name: "DeepSeek: DeepSeek V4 Pro",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateConfiguration({
|
||||
enabled: true,
|
||||
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
await caller.updateConfiguration({
|
||||
enabled: true,
|
||||
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
|
||||
});
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro");
|
||||
@@ -221,31 +222,24 @@ describe("assistantRouter.updateConfiguration", () => {
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "homarr/model",
|
||||
name: "Homarr model",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "homarr/model",
|
||||
name: "Homarr model",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateConfiguration({
|
||||
enabled: true,
|
||||
modelId: "homarr/model",
|
||||
webSearchEnabled: true,
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
await caller.updateConfiguration({
|
||||
enabled: true,
|
||||
modelId: "homarr/model",
|
||||
webSearchEnabled: true,
|
||||
});
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration?.webSearchEnabled).toBe(true);
|
||||
@@ -277,56 +271,49 @@ describe("assistant conversation features", () => {
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Example Model",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/no-tools",
|
||||
name: "No tools",
|
||||
supported_parameters: [],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/unnamed",
|
||||
name: "",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const options = await caller.getRuntimeOptions();
|
||||
|
||||
expect(options).toEqual({
|
||||
provider: "openrouter",
|
||||
defaultModelId: "example/model",
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Example Model",
|
||||
inputModalities: ["text", "image"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/no-tools",
|
||||
name: "No tools",
|
||||
supported_parameters: [],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/unnamed",
|
||||
name: "example/unnamed",
|
||||
}),
|
||||
name: "",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(options)).not.toContain("encrypted");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const options = await caller.getRuntimeOptions();
|
||||
|
||||
expect(options).toEqual({
|
||||
provider: "openrouter",
|
||||
defaultModelId: "example/model",
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: "example/model",
|
||||
name: "Example Model",
|
||||
inputModalities: ["text", "image"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "example/unnamed",
|
||||
name: "example/unnamed",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(options)).not.toContain("encrypted");
|
||||
});
|
||||
|
||||
test("returns permission-checked entities for composer mentions", async () => {
|
||||
@@ -383,33 +370,26 @@ describe("assistant conversation features", () => {
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
const thread = await caller.createThread();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Default",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/reasoning",
|
||||
name: "Reasoning",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Default",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/reasoning",
|
||||
name: "Reasoning",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
|
||||
|
||||
const storedThread = await db.query.assistantThreads.findFirst({
|
||||
where: eq(assistantThreads.id, thread.id),
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("UmamiIntegration", () => {
|
||||
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(20 * PAGE_SIZE);
|
||||
});
|
||||
|
||||
test("requests event series one at a time", async () => {
|
||||
test("requests event series with bounded concurrency", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
mockFetch.mockImplementation(async () => {
|
||||
@@ -186,14 +186,11 @@ describe("UmamiIntegration", () => {
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||
});
|
||||
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", [
|
||||
"signup",
|
||||
"purchase",
|
||||
"refund",
|
||||
]);
|
||||
const eventNames = ["signup", "purchase", "refund", "login", "logout", "download"];
|
||||
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", eventNames);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(maxInFlight).toBe(1);
|
||||
expect(result.map(({ eventName }) => eventName)).toEqual(eventNames);
|
||||
expect(maxInFlight).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,7 @@ import classes from "./onboarding-studio.module.css";
|
||||
|
||||
type StudioSection = "essentials" | "discover" | "connect" | "board" | "extend" | "review";
|
||||
type LayoutPreset = "balanced" | "wide" | "focused";
|
||||
type IncompleteIntegrationConfirmationIntent = "navigate" | "apply";
|
||||
|
||||
const getLayoutPresetForColumnCount = (columnCount: number): LayoutPreset =>
|
||||
columnCount <= 8 ? "focused" : columnCount <= 10 ? "balanced" : "wide";
|
||||
@@ -195,6 +196,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
||||
const focusSectionHeading = useRef(true);
|
||||
const seenIntegrationSourceIds = useRef(new Set<string>());
|
||||
const seenAppSourceIds = useRef(new Set<string>());
|
||||
const incompleteIntegrationConfirmationIntent = useRef<IncompleteIntegrationConfirmationIntent | null>(null);
|
||||
|
||||
const docker = clientApi.onboard.discoverDockerServices.useQuery(undefined, {
|
||||
enabled: environment.dockerConfigured,
|
||||
@@ -234,16 +236,19 @@ 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 incompleteDrafts = useMemo(() => drafts.filter((draft) => !isIntegrationDraftComplete(draft)), [drafts]);
|
||||
const incompleteIntegrationSignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
incompleteDrafts.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 })),
|
||||
})),
|
||||
),
|
||||
[incompleteDrafts],
|
||||
);
|
||||
const hasIncompleteIntegrations = incompleteIntegrationSignature !== "[]";
|
||||
const hasIncompleteIntegrations = incompleteDrafts.length > 0;
|
||||
const incompleteIntegrationsConfirmed =
|
||||
hasIncompleteIntegrations && confirmedIncompleteIntegrationSignature === incompleteIntegrationSignature;
|
||||
|
||||
@@ -350,16 +355,16 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
||||
hasIncompleteIntegrations &&
|
||||
!incompleteIntegrationsConfirmed
|
||||
) {
|
||||
incompleteIntegrationConfirmationIntent.current = "navigate";
|
||||
setIncompleteIntegrationConfirmationOpened(true);
|
||||
return;
|
||||
}
|
||||
changeSection(section);
|
||||
};
|
||||
|
||||
const confirmReviewNavigation = () => {
|
||||
setConfirmedIncompleteIntegrationSignature(incompleteIntegrationSignature);
|
||||
const closeIncompleteIntegrationConfirmation = () => {
|
||||
incompleteIntegrationConfirmationIntent.current = null;
|
||||
setIncompleteIntegrationConfirmationOpened(false);
|
||||
changeSection("review");
|
||||
};
|
||||
|
||||
const handleSectionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
@@ -397,9 +402,10 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
||||
});
|
||||
};
|
||||
|
||||
const applySetupAsync = async () => {
|
||||
const applySetupAsync = async (skipIncompleteIntegrationConfirmation = false) => {
|
||||
if (isApplying) return;
|
||||
if (hasIncompleteIntegrations && !incompleteIntegrationsConfirmed) {
|
||||
if (!skipIncompleteIntegrationConfirmation && hasIncompleteIntegrations && !incompleteIntegrationsConfirmed) {
|
||||
incompleteIntegrationConfirmationIntent.current = "apply";
|
||||
setIncompleteIntegrationConfirmationOpened(true);
|
||||
return;
|
||||
}
|
||||
@@ -532,6 +538,20 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
||||
}
|
||||
};
|
||||
|
||||
const confirmIncompleteIntegrations = () => {
|
||||
const intent = incompleteIntegrationConfirmationIntent.current;
|
||||
if (!intent) return;
|
||||
|
||||
incompleteIntegrationConfirmationIntent.current = null;
|
||||
setConfirmedIncompleteIntegrationSignature(incompleteIntegrationSignature);
|
||||
setIncompleteIntegrationConfirmationOpened(false);
|
||||
if (intent === "apply") {
|
||||
void applySetupAsync(true);
|
||||
return;
|
||||
}
|
||||
changeSection("review");
|
||||
};
|
||||
|
||||
const sectionProps: StudioSectionProps = {
|
||||
headingRef: sectionHeadingRef,
|
||||
environment,
|
||||
@@ -641,17 +661,17 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
||||
<>
|
||||
<Modal
|
||||
opened={incompleteIntegrationConfirmationOpened}
|
||||
onClose={() => setIncompleteIntegrationConfirmationOpened(false)}
|
||||
title={t("connect.incompleteConfirmation")}
|
||||
onClose={closeIncompleteIntegrationConfirmation}
|
||||
title={t("connect.incompleteConfirmationTitle")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{t("connect.incompleteConfirmation")}</Text>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" onClick={() => setIncompleteIntegrationConfirmationOpened(false)}>
|
||||
<Button variant="default" onClick={closeIncompleteIntegrationConfirmation}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={confirmReviewNavigation}>{tCommon("confirm")}</Button>
|
||||
<Button onClick={confirmIncompleteIntegrations}>{tCommon("confirm")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"needsConfiguration": "Needs configuration",
|
||||
"tested": "Connection tested",
|
||||
"testFailed": "Connection test failed",
|
||||
"incompleteConfirmationTitle": "Skip unfinished integrations?",
|
||||
"incompleteConfirmation": "Unfinished integrations will be skipped. Continue without them?"
|
||||
},
|
||||
"board": {
|
||||
|
||||
@@ -30,8 +30,11 @@ if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
|
||||
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
|
||||
mkdir -p /run/nginx
|
||||
touch /run/nginx/nginx.pid
|
||||
chown -R "${PUID}:${PGID}" /run/nginx
|
||||
touch /etc/nginx/nginx.conf
|
||||
chown "${PUID}:${PGID}" /etc/nginx/nginx.conf
|
||||
echo "Changing owner to $PUID:$PGID, done."
|
||||
fi
|
||||
|
||||
|
||||
Reference in new issue
Block a user