Compare commits

..
Author SHA1 Message Date
ajnart af13531adc fix: keep destructive confirmations red 2026-08-27 11:33:34 +02:00
ajnart 7a5edb26c1 feat: reduce disruptive UI confirmations 2026-08-27 11:33:34 +02:00
41 changed files with 774 additions and 508 deletions

No files matched your search

+7 -13
View File
@@ -320,12 +320,8 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [container, preview-amd64, preview-arm64]
needs: [preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -379,6 +375,9 @@ jobs:
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: |
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
fi
docker buildx imagetools create "${tags[@]}" \
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
@@ -387,6 +386,9 @@ jobs:
if: "!env.ACT"
run: |
tags=("${PREVIEW_TAG}")
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
tags+=("pr-6545")
fi
for tag in "${tags[@]}"; do
image="${PREVIEW_IMAGE}:${tag}"
manifest="$(docker buildx imagetools inspect "$image" --raw)"
@@ -433,14 +435,6 @@ jobs:
core.info(`No package version found with tag "${tag}"`);
return;
}
const targetTags = target.metadata?.container?.tags ?? [];
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
if (extraTags.length > 0) {
core.setFailed(
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
-2
View File
@@ -115,8 +115,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Docker action payload migration
@@ -128,26 +128,6 @@ You can decide if app status is disabled on all boards.
You can set the default color scheme for users without their own color scheme.
## Branding
Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings.
### Identity
- **Application name** replaces the default Homarr name in page titles and branded surfaces.
- **Logo** is used in the header and other application branding.
- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo.
### Colors and corners
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
### Sign-in page
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page.
## Culture
### Default language
-2
View File
@@ -67,8 +67,6 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply.
### Configuration
<WidgetConfig configuration={iframeWidget.configuration} />
-1
View File
@@ -72,7 +72,6 @@
"@rexa-developer/tiks": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-sync-storage-persister": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
@@ -1,17 +1,20 @@
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersistence,
createSessionQueryPersister,
getQueryPersistenceStorageKey,
queryPersistenceBuster,
queryPersistenceMaxDataBytes,
shouldPersistDashboardQuery,
} from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data);
queryClient.setQueryData(queryKey, data, { updatedAt });
const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present");
return { query, queryClient };
@@ -34,98 +37,314 @@ const createMemoryStorage = (): Storage => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe("dashboard query persistence", () => {
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
describe("widget query persistence", () => {
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
apiKey: "secret",
});
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
});
const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]),
});
const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []);
failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" });
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false);
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
});
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
vi.useFakeTimers();
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
const userAKey = getQueryPersistenceStorageKey("user-a");
window.sessionStorage.setItem(userAKey, "a");
createSessionQueryPersister("user-a").removeClient();
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
});
test("evicts every other scope so signing out cannot leave data behind", () => {
const userAKey = getQueryPersistenceStorageKey("user-a");
const anonymousKey = getQueryPersistenceStorageKey(null);
window.sessionStorage.setItem(userAKey, "a");
window.sessionStorage.setItem(anonymousKey, "anonymous");
window.sessionStorage.setItem("unrelated", "kept");
createSessionQueryPersister("user-b");
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
});
test("never restores another session scope", async () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
userAPersister.flush();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
});
test("stops persisting once the provider tears the cache down", async () => {
const storage = createMemoryStorage();
const userA = createSessionQueryPersistence("user-a", storage);
const userB = createSessionQueryPersistence("user-b", storage);
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
const persister = createSessionQueryPersister("user-a", storage);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
persister.stop();
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("removes the cache instead of storing an empty one", () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
window.sessionStorage.setItem(key, "stale");
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers();
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const persister = createSessionQueryPersister(scope);
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
const cachedQuery = queryClient.getQueryCache().find({
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
});
if (!cachedQuery) throw new Error("Expected cached query to be present");
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
queryClient.getMutationCache().build(
queryClient,
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
{
context: undefined,
data: undefined,
error: null,
failureCount: 0,
failureReason: null,
isPaused: true,
status: "pending",
submittedAt: Date.now(),
variables: { apiKey: "secret" },
},
);
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
expect(shouldPersistDashboardQuery(query)).toBe(true);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, userA.dehydrateOptions),
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
};
expect(persistedClient.clientState.mutations).toHaveLength(0);
await userA.persister.persistClient(persistedClient);
await vi.advanceTimersByTimeAsync(1_000);
const restored = await userA.persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
persister.persistClient(persistedClient);
persister.flush();
const restored = await persister.restoreClient();
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(await userB.persister.restoreClient()).toBeUndefined();
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
await userA.persister.removeClient();
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
window.sessionStorage.setItem(key, "not-valid-superjson");
expect(persister.restoreClient()).toBeUndefined();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("treats unavailable session storage as an empty cache", async () => {
const blockedStorage = createMemoryStorage();
blockedStorage.getItem = () => {
throw new DOMException("blocked", "SecurityError");
};
blockedStorage.removeItem = () => {
throw new DOMException("blocked", "SecurityError");
};
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const { queryClient } = createSuccessfulQuery(
[["widget", "calendar", "findAllEvents"], { type: "query" }],
[{ title: "safe" }],
);
const validQuery = dehydrate(queryClient).queries[0];
if (!validQuery) throw new Error("Expected a dehydrated query");
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
expect(await persistence.persister.restoreClient()).toBeUndefined();
expect(() => persistence.persister.removeClient()).not.toThrow();
window.sessionStorage.setItem(
key,
stringify({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [{ state: { status: "pending" } }],
queries: [
{
...validQuery,
state: {
...validQuery.state,
error: new Error("private refresh error"),
fetchStatus: "fetching",
status: "error",
},
},
{
...validQuery,
queryHash: "unrelated",
queryKey: [["board", "getBoardByName"], { type: "query" }],
},
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
{
...validQuery,
queryHash: "missing-timestamp",
state: { ...validQuery.state, dataUpdatedAt: undefined },
},
],
},
}),
);
const restored = await createSessionQueryPersister(scope).restoreClient();
expect(restored?.clientState.mutations).toEqual([]);
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
});
test.each([
["expired", 1, queryPersistenceBuster],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
const scope = "user-a";
const persister = createSessionQueryPersister(scope);
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
persister.flush();
const restoredClient = new QueryClient();
await persistQueryClientRestore({
queryClient: restoredClient,
persister,
maxAge: 5 * 60 * 1_000,
buster: queryPersistenceBuster,
});
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry writes the cache after evicting the oldest query", async () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
const storage = createMemoryStorage();
const setItem = storage.setItem.bind(storage);
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
const client = parse<PersistedClient>(value);
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
setItem(key, value);
});
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient(persistedClient);
persister.flush();
expect(setItemSpy).toHaveBeenCalledTimes(2);
const restored = await persister.restoreClient();
expect(restored?.clientState.queries).toHaveLength(1);
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
});
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
const setItem = vi.spyOn(storage, "setItem");
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
const persister = createSessionQueryPersister("user-a", storage);
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(latest.queryClient),
});
expect(setItem).not.toHaveBeenCalled();
persister.flush();
expect(setItem).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(0);
expect(setItem).toHaveBeenCalledOnce();
const restored = await persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("treats blocked storage operations as an empty cache", async () => {
const blockedStorage = {
getItem: () => {
throw new DOMException("blocked", "SecurityError");
},
removeItem: () => {
throw new DOMException("blocked", "SecurityError");
},
setItem: () => {
throw new DOMException("blocked", "SecurityError");
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
expect(persister.restoreClient()).toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
});
test("treats an unavailable session storage getter as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
});
});
@@ -1,12 +1,13 @@
import type { QueryKey } from "@tanstack/react-query";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceBuster = "v6-dashboard-data";
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
export const queryPersistenceMaxDataBytes = 256 * 1024;
export const queryPersistenceBuster = "v2-session-superjson";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
@@ -15,33 +16,95 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
interface PersistableQuery {
queryKey: QueryKey;
state: { data: unknown; status: string };
state: {
data: unknown;
dataUpdatedAt: number;
};
}
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
const textEncoder = new TextEncoder();
const budgetVerdictCache = new WeakMap<object, boolean>();
const measureFitsBudget = (data: unknown) => {
try {
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
} catch {
return false;
}
};
const fitsStorageBudget = (data: unknown) => {
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
const cached = budgetVerdictCache.get(data);
if (cached !== undefined) return cached;
const verdict = measureFitsBudget(data);
budgetVerdictCache.set(data, verdict);
return verdict;
};
const holdsDashboardData = (query: PersistableQuery) =>
query.state.data !== undefined &&
Number.isFinite(query.state.dataUpdatedAt) &&
query.state.dataUpdatedAt > 0 &&
isWidgetDataQueryKey(query.queryKey);
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
const serializePersistedClient = (client: PersistedClient) =>
stringify({
...client,
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
isInvalidated: true,
status: "success",
},
});
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
return holdsDashboardData(value as unknown as PersistableQuery);
};
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
return {
timestamp: value.timestamp as number,
buster: value.buster,
clientState: {
...client.clientState,
queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
mutations: [],
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
},
});
};
};
const getSessionStorage = () => {
const getSessionStorage = (): Storage | undefined => {
if (typeof window === "undefined") return undefined;
try {
@@ -51,47 +114,113 @@ const getSessionStorage = () => {
}
};
const protectStorage = (storage: Storage | undefined) => {
if (!storage) return undefined;
const removeStorageItem = (storage: Storage | undefined, key: string) => {
try {
storage?.removeItem(key);
} catch {
return undefined;
}
};
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
if (!storage) return;
try {
const foreignKeys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const storedKey = storage.key(index);
if (storedKey === null || storedKey === activeKey) continue;
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
}
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
} catch {
return undefined;
}
};
export const createSessionQueryPersister = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersister => {
const key = getQueryPersistenceStorageKey(scope);
removeForeignScopes(storage, key);
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const persistNow = (client: PersistedClient) => {
if (!storage) return;
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
let errorCount = 0;
if (candidate.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return;
}
while (candidate) {
try {
storage.setItem(key, stringify(candidate));
return;
} catch (error) {
errorCount += 1;
candidate = removeOldestQuery({
persistedClient: candidate,
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
errorCount,
});
}
}
};
const clearPersistTimer = () => {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
};
const flush = () => {
clearPersistTimer();
const client = pendingClient;
pendingClient = undefined;
if (client) persistNow(client);
};
return {
getItem(key: string) {
try {
return storage.getItem(key);
} catch {
return null;
}
persistClient(client) {
if (!storage || stopped) return;
pendingClient = client;
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
setItem(key: string, value: string) {
storage.setItem(key, value);
},
removeItem(key: string) {
restoreClient() {
try {
storage.removeItem(key);
const storedValue = storage?.getItem(key);
if (!storedValue) return undefined;
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return undefined;
}
return client;
} catch {
removeStorageItem(storage, key);
return undefined;
}
},
removeClient() {
clearPersistTimer();
pendingClient = undefined;
removeStorageItem(storage, key);
},
flush,
stop() {
stopped = true;
clearPersistTimer();
pendingClient = undefined;
},
};
};
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
export const createSessionQueryPersistence = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersistence => ({
persister: createSyncStoragePersister({
storage: protectStorage(storage),
key: getQueryPersistenceStorageKey(scope),
serialize: serializePersistedClient,
deserialize: parse,
retry: removeOldestQuery,
}),
maxAge: queryCacheDefaultGcTimeMs,
buster: queryPersistenceBuster,
dehydrateOptions: {
shouldDehydrateMutation: () => false,
shouldDehydrateQuery: shouldPersistDashboardQuery,
},
});
@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
@@ -25,8 +25,6 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
dashboardSupportingQueryPolicies,
isTrpcForbiddenError,
isWidgetDataQueryKey,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
@@ -38,8 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import { createSessionQueryPersistence } from "./query-persistence";
import type { SessionQueryPersistence } from "./query-persistence";
import {
createSessionQueryPersister,
queryPersistenceBuster,
queryPersistenceMaxAgeMs,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
@@ -72,11 +75,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
const handleScopeChange = useCallback(() => {
void queryPersistence.persister.removeClient();
void queryPersister.removeClient();
reloadPage();
}, [queryPersistence]);
}, [queryPersister]);
return (
<SessionQueryScopeGuard
@@ -84,7 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
currentScope={sessionQueryScope}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
@@ -93,8 +96,8 @@ const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({
children,
queryPersistence,
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
queryPersister,
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -111,13 +114,6 @@ const ScopedTRPCReactProvider = ({
);
const [queryClient] = useState(() => {
const client = new QueryClient({
queryCache: new QueryCache({
onError(error, query) {
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
void queryPersistence.persister.removeClient();
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
},
}),
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
@@ -156,6 +152,15 @@ const ScopedTRPCReactProvider = ({
return client;
});
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
queryPersister.stop();
};
}, [queryPersister]);
useEffect(() => () => queryClient.clear(), [queryClient]);
const trpcClient = useMemo(() => {
@@ -206,7 +211,15 @@ const ScopedTRPCReactProvider = ({
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</PersistQueryClientProvider>
@@ -10,17 +10,24 @@ import { useI18n } from "@homarr/translation/client";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
interface CopyAiPromptButtonProps {
interface CopyAiPromptButtonBaseProps {
rawResponse?: string | null;
request?: string | null;
documentationUrl?: string | null;
getDraft: () => CustomWidgetAiDraft;
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
}
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
(
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
);
export const CopyAiPromptButton = ({
rawResponse,
draft,
getDraft,
diagnostics,
getDiagnostics,
request,
documentationUrl,
@@ -28,14 +35,23 @@ export const CopyAiPromptButton = ({
const t = useI18n("customWidget");
const assistant = useOptionalHomarrAssistant();
const readDraft = () => {
if (getDraft) return getDraft();
return draft;
};
const readDiagnostics = () => {
if (getDiagnostics) return getDiagnostics();
return diagnostics;
};
const handleAssistant = () => {
const prompt = buildCustomWidgetAssistantPrompt(
undefined,
rawResponse,
getDraft(),
readDraft(),
request,
documentationUrl,
getDiagnostics(),
readDiagnostics(),
);
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
@@ -46,10 +62,10 @@ export const CopyAiPromptButton = ({
const value = buildCustomWidgetAiPrompt(
undefined,
rawResponse,
getDraft(),
readDraft(),
request,
documentationUrl,
getDiagnostics(),
readDiagnostics(),
);
await navigator.clipboard.writeText(value);
showSuccessNotification({
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface ConfigMapsTableComponentProps {
@@ -49,7 +47,13 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialConfigMaps),
{
initialData: initialConfigMaps,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -17,7 +17,6 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
@@ -85,7 +84,13 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialIngresses),
{
initialData: initialIngresses,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -1,18 +0,0 @@
interface QueryWithStatus {
state: {
status: string;
};
}
const getErrorRefetchInterval = (query: QueryWithStatus) => {
if (query.state.status === "error") return 30_000;
return false;
};
export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({
initialData,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: getErrorRefetchInterval,
});
@@ -15,8 +15,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface NamespacesTableComponentProps {
@@ -68,7 +66,13 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialNamespaces),
{
initialData: initialNamespaces,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -17,7 +17,6 @@ import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import KubernetesErrorPage from "../cluster-dashboard/error";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
@@ -98,7 +97,13 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialNodes),
{
initialData: initialNodes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface PodsTableComponentProps {
@@ -59,7 +57,13 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialPods),
{
initialData: initialPods,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface SecretsTableComponentProps {
@@ -54,7 +52,13 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialSecrets),
{
initialData: initialSecrets,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -14,8 +14,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface ServicesTableComponentProps {
@@ -73,7 +71,13 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
const { data } = clientApi.kubernetes.services.getServices.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialServices),
{
initialData: initialServices,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -13,8 +13,6 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
dayjs.extend(relativeTime);
interface VolumesTableComponentProps {
@@ -78,7 +76,13 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
{ contextId },
createKubernetesResourceQueryOptions(initialVolumes),
{
initialData: initialVolumes,
refetchOnMount: "always",
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
},
);
const table = useTranslatedMantineReactTable({
@@ -11,17 +11,16 @@ import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
previewRowLimit?: number;
fitFullLayout?: boolean;
className?: string;
}
const compactBoardLayoutThumbnailRows = 12;
export const maxBoardLayoutThumbnailRows = 48;
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({
preview,
label,
previewRowLimit = compactBoardLayoutThumbnailRows,
fitFullLayout = false,
className,
}: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
@@ -35,7 +34,7 @@ export const BoardLayoutThumbnail = ({
}
const elements = projectBoardLayout(preview, layout, layout);
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
const roots = preview.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
@@ -95,21 +94,9 @@ export const BoardLayoutThumbnail = ({
}}
>
{laneElements.map((element) => {
const renderedWidth = Math.min(element.width, columnCount - element.xOffset);
const renderedHeight = Math.min(element.height, rowCount - element.yOffset);
const nestedItems =
element.type === "section"
? elements.filter(
(candidate) =>
candidate.type === "item" &&
candidate.sectionId === element.id &&
candidate.xOffset >= 0 &&
candidate.xOffset < renderedWidth &&
candidate.yOffset >= 0 &&
candidate.yOffset < renderedHeight &&
candidate.width > 0 &&
candidate.height > 0,
)
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
: [];
return (
<span
@@ -117,16 +104,16 @@ export const BoardLayoutThumbnail = ({
aria-hidden
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
}}
>
{element.type === "section" ? (
<span
className={classes.containerContents}
style={{
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
}}
>
{nestedItems.map((nestedItem) => (
@@ -136,11 +123,11 @@ export const BoardLayoutThumbnail = ({
style={{
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
nestedItem.width,
renderedWidth - nestedItem.xOffset,
element.width - nestedItem.xOffset,
)}`,
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
nestedItem.height,
renderedHeight - nestedItem.yOffset,
element.height - nestedItem.yOffset,
)}`,
}}
>
@@ -30,12 +30,11 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const;
export interface BoardSwitcherControls {
open: () => void;
@@ -62,12 +61,18 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
data: boards = [],
isPending,
isError,
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
enabled: isOpen,
});
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: isOpen,
},
);
const switcherBoards = useMemo(
() => boards.filter((board) => board.id !== currentBoard?.id),
() => [
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
[boards, currentBoard?.id],
);
const filteredBoards = useMemo(() => {
@@ -86,7 +91,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const spotlightAction = useMemo(
() => ({
@@ -336,7 +341,7 @@ const getBoardSwitcherResults = ({
<BoardLayoutThumbnail
preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
previewRowLimit={boardSwitcherPreview.previewRowLimit}
fitFullLayout
/>
</Card.Section>
<Card.Section withBorder px="sm" py={6}>
@@ -10,7 +10,6 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
@@ -22,7 +21,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
interface BoardItemMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: WidgetPreviewDimensions;
previewDimensions: { width: number; height: number; scale?: number };
resetErrorBoundary?: () => void;
}
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
@@ -31,7 +30,6 @@ interface InnerProps extends OpenItemMoveModalInput {
board: Board;
currentLayoutId: string;
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>;
}
interface MoveTarget {
@@ -59,7 +57,8 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const tLandmark = useI18n("board.landmark");
const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps;
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const registeredGridEditors = useRegisteredGridEditors();
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
@@ -236,7 +235,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
export const useOpenItemMoveModal = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const registeredGridEditors = useRegisteredGridEditors();
const { commitSectionGrids } = useGridLayoutActions();
const { openModal } = useModalAction(ItemMoveModal);
@@ -247,10 +245,9 @@ export const useOpenItemMoveModal = () => {
board,
currentLayoutId,
commitSectionGrids,
registeredGridEditors,
});
},
[board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors],
[board, commitSectionGrids, currentLayoutId, openModal],
);
};
@@ -25,7 +25,6 @@ import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definit
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
@@ -40,7 +39,7 @@ type ToggleOption = [key: string, option: { type: string; skipContextMenu?: bool
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: WidgetPreviewDimensions;
previewDimensions: { width: number; height: number; scale?: number };
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
@@ -54,13 +54,11 @@ export const LazySpotlight = () => {
};
}, []);
if (!isMounted && !isPreloaded) return null;
return (
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
{isMounted && <Spotlight />}
<Spotlight />
</>
);
) : null;
};
-20
View File
@@ -1,16 +1,5 @@
import type { QueryKey } from "@tanstack/react-query";
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
interface ForbiddenTrpcError {
data: { code: "FORBIDDEN" };
}
export const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => {
if (!isRecord(error) || !isRecord(error.data)) return false;
return error.data.code === "FORBIDDEN";
};
// RSC-prefetched data must survive the hydration pass without an immediate
// duplicate fetch. Widget-specific intervals still control live refreshes.
export const queryCacheDefaultStaleTimeMs = 30_000;
@@ -19,7 +8,6 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
const persistenceExcludedWidgetPaths = new Set(["widget.customApi.getData", "widget.customApi.queryRequest"]);
// Beszel system stats are large time-series payloads and were deliberately kept out of
// the persisted cache in #6289; live stats come from an SSE subscription instead.
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
@@ -78,11 +66,3 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
return isWidgetDataTrpcPath(path.join("."));
};
export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
const joinedPath = path.join(".");
return isWidgetDataTrpcPath(joinedPath) && !persistenceExcludedWidgetPaths.has(joinedPath);
};
+5 -12
View File
@@ -86,14 +86,9 @@ interface BoardItemPlacementRectangle {
}
const boardItemPlacementTails = new Map<string, Promise<void>>();
const defaultManageOverviewPreviewRows = 12;
const maxManageOverviewPreviewRows = 48;
const maxManageOverviewPreviewRows = 12;
const manageOverviewInputSchema = z
.object({
fullPreview: z.boolean().default(false),
previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(),
userId: z.string().optional(),
})
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
.optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
@@ -316,8 +311,6 @@ export const boardRouter = createTRPCRouter({
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
let previewRowLimit = input?.previewRowLimit ?? defaultManageOverviewPreviewRows;
if (input?.fullPreview) previewRowLimit = maxManageOverviewPreviewRows;
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
@@ -395,7 +388,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, previewRowLimit),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
@@ -420,7 +413,7 @@ export const boardRouter = createTRPCRouter({
inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0),
lt(sectionLayouts.yOffset, previewRowLimit),
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0),
),
@@ -455,7 +448,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, previewContainerSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, previewRowLimit),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
@@ -84,13 +84,15 @@ export const getDockerReconciliationAsync = async (db: Database) => {
const appAmbiguous =
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
const ambiguous = integrationAmbiguous || appAmbiguous;
const state = getReconciliationState({
integrationMoved,
linked,
represented: Boolean(integration || app),
recognized: Boolean(match),
});
const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous);
const state: DockerReconciliationState = integrationMoved
? "moved"
: linked
? "linked"
: integration || app
? "represented"
: match
? "newRecognized"
: "newApp";
return {
candidateKey: container.resourceId,
@@ -101,7 +103,11 @@ export const getDockerReconciliationAsync = async (db: Database) => {
match,
urlCandidates,
state,
nextAction,
nextAction: integrationAmbiguous
? ("reviewIntegration" as const)
: appAmbiguous
? ("viewRepresentation" as const)
: nextActionForState(state),
representation: {
integration: integration
? {
@@ -141,100 +147,87 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
status: endpoint.status,
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
})),
services: reconciliation.candidates.map((candidate) => {
const integration = candidate.representation.integration;
const app = candidate.representation.app;
const itemCount = integration?.itemCount;
return {
key: candidate.candidateKey,
identity: {
endpointId: candidate.endpointId,
nativeId: candidate.nativeId,
name: candidate.container.name,
integration,
app,
services: reconciliation.candidates.map((candidate) => ({
key: candidate.candidateKey,
identity: {
endpointId: candidate.endpointId,
nativeId: candidate.nativeId,
name: candidate.container.name,
integration: candidate.representation.integration,
app: candidate.representation.app,
},
layers: [
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
{
layer: "integrationConfiguration" as const,
status: integrationHealthStatus(
candidate.state,
candidate.match?.kind,
Boolean(candidate.representation.integration),
),
nextAction:
candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.state === "newRecognized"
? ("setupIntegration" as const)
: ("none" as const),
},
layers: [
{ layer: "docker" as const, status: "available" as const, nextAction: "none" as const },
{
layer: "integrationConfiguration" as const,
status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)),
nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction),
},
{
layer: "authentication" as const,
status: "notObserved" as const,
nextAction: getAuthenticationNextAction(
Boolean(integration),
Boolean(candidate.match),
candidate.nextAction,
),
},
{
layer: "apiRequest" as const,
status: "notObserved" as const,
nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const),
},
{
layer: "appRepresentation" as const,
status: app ? ("linked" as const) : ("missing" as const),
nextAction: getAppRepresentationNextAction(
Boolean(app),
candidate.representation.signals.ambiguous,
candidate.nextAction,
),
},
{
layer: "widgetConfiguration" as const,
status: getWidgetConfigurationStatus(itemCount),
nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const),
},
{
layer: "widgetQuery" as const,
status: "notObserved" as const,
nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const),
},
],
status: candidate.state,
nextAction: candidate.nextAction,
};
}),
{
layer: "authentication" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("testConnection" as const)
: candidate.nextAction === "reviewIntegration"
? ("reviewIntegration" as const)
: candidate.match
? ("setupIntegration" as const)
: ("none" as const),
},
{
layer: "apiRequest" as const,
status: "notObserved" as const,
nextAction: candidate.representation.integration
? ("openIntegrationDiagnostics" as const)
: ("none" as const),
},
{
layer: "appRepresentation" as const,
status: candidate.representation.app ? ("linked" as const) : ("missing" as const),
nextAction: candidate.representation.app
? ("none" as const)
: candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation"
? ("viewRepresentation" as const)
: ("createApp" as const),
},
{
layer: "widgetConfiguration" as const,
status: candidate.representation.integration
? candidate.representation.integration.itemCount > 0
? ("linked" as const)
: ("unused" as const)
: ("notApplicable" as const),
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount === 0
? ("addWidget" as const)
: ("none" as const),
},
{
layer: "widgetQuery" as const,
status: "notObserved" as const,
nextAction:
candidate.representation.integration && candidate.representation.integration.itemCount > 0
? ("openBoard" as const)
: ("none" as const),
},
],
status: candidate.state,
nextAction: candidate.nextAction,
})),
};
};
const normalizeName = (value: string) => value.trim().toLowerCase();
interface ReconciliationStateInput {
integrationMoved: boolean;
linked: boolean;
represented: boolean;
recognized: boolean;
}
const getReconciliationState = ({
integrationMoved,
linked,
represented,
recognized,
}: ReconciliationStateInput): DockerReconciliationState => {
if (integrationMoved) return "moved";
if (linked) return "linked";
if (represented) return "represented";
if (recognized) return "newRecognized";
return "newApp";
};
const getCandidateNextAction = (
state: DockerReconciliationState,
integrationAmbiguous: boolean,
appAmbiguous: boolean,
) => {
if (integrationAmbiguous) return "reviewIntegration" as const;
if (appAmbiguous) return "viewRepresentation" as const;
return nextActionForState(state);
};
const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => {
if (state === "newRecognized") return "setupIntegration";
if (state === "newApp") return "createApp";
@@ -242,42 +235,6 @@ const nextActionForState = (state: DockerReconciliationState): DockerReconciliat
return "viewRepresentation";
};
const getIntegrationConfigurationNextAction = (
state: DockerReconciliationState,
nextAction: DockerReconciliationNextAction,
) => {
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
if (state === "newRecognized") return "setupIntegration" as const;
return "none" as const;
};
const getAuthenticationNextAction = (
hasIntegration: boolean,
hasMatch: boolean,
nextAction: DockerReconciliationNextAction,
) => {
if (hasIntegration) return "testConnection" as const;
if (nextAction === "reviewIntegration") return "reviewIntegration" as const;
if (hasMatch) return "setupIntegration" as const;
return "none" as const;
};
const getAppRepresentationNextAction = (
hasApp: boolean,
ambiguous: boolean,
nextAction: DockerReconciliationNextAction,
) => {
if (hasApp) return "none" as const;
if (ambiguous && nextAction === "viewRepresentation") return "viewRepresentation" as const;
return "createApp" as const;
};
const getWidgetConfigurationStatus = (itemCount: number | undefined) => {
if (itemCount === undefined) return "notApplicable" as const;
if (itemCount > 0) return "linked" as const;
return "unused" as const;
};
const integrationHealthStatus = (
state: DockerReconciliationState,
kind: IntegrationKind | undefined,
@@ -285,6 +242,5 @@ const integrationHealthStatus = (
) => {
if (!kind) return "notApplicable" as const;
if (state === "moved") return "changed" as const;
if (hasIntegration) return "configured" as const;
return "missing" as const;
return hasIntegration ? ("configured" as const) : ("missing" as const);
};
@@ -14,7 +14,6 @@ import { dockerRouter } from "../../docker/docker-router";
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
const invalidateDockerCacheMock = vi.hoisted(() => vi.fn());
const resetDockerSingletonMock = vi.hoisted(() => vi.fn());
const getDockerDataAsyncMock = vi.hoisted(() =>
vi.fn<
() => Promise<{
@@ -63,7 +62,7 @@ vi.mock("@homarr/docker", () => ({
},
},
],
reset: resetDockerSingletonMock,
reset: () => undefined,
},
}));
vi.mock("@homarr/request-handler/docker", () => ({
@@ -502,7 +501,6 @@ test("invalidates the shared Docker inventory on explicit refresh", async () =>
await createAdminCaller().refreshInventory();
expect(invalidateDockerCacheMock).toHaveBeenCalledOnce();
expect(resetDockerSingletonMock).toHaveBeenCalledOnce();
});
test("projects persisted service layers without inventing runtime health", async () => {
+1 -4
View File
@@ -50,10 +50,7 @@ export const immichRouter = createTRPCRouter({
)
.concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
return mockWidgetData.immichAlbums.slice(0, input.limit);
}
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
const innerHandler = immichAlbumsRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
+1 -2
View File
@@ -6,8 +6,7 @@ import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness
const harnessCommandNames = new Set(["harness", "local-harness"]);
function isHarnessInvocation(argv: string[]): boolean {
const command = argv[2];
return command !== undefined && harnessCommandNames.has(command);
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
}
async function main(): Promise<void> {
@@ -12,11 +12,6 @@ describe("getUsableWidgetQueryData", () => {
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
});
test("throws access-denied refetches instead of rendering stale cached data", () => {
const error = { data: { code: "FORBIDDEN" } };
expect(() => getUsableWidgetQueryData({ data: [{ id: "revoked" }], error })).toThrow(error);
});
test("throws a terminal failure when no usable data exists", () => {
const error = new Error("offline");
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
@@ -1,5 +1,3 @@
import { isTrpcForbiddenError } from "@homarr/api/query-cache";
interface WidgetQueryState {
data: unknown;
error: unknown;
@@ -11,7 +9,6 @@ interface PendingWidgetQueryState extends WidgetQueryState {
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
if (isTrpcForbiddenError(query.error)) throw query.error;
if (query.error && query.data === undefined) throw query.error;
return query.data;
};
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component";
const permissions = {
allowFullScreen: true,
@@ -18,6 +18,12 @@ describe("iframe policy", () => {
expect(getSandboxFlags(permissions)).toContain("allow-modals");
});
it("accepts only HTTP protocols", () => {
expect(isSupportedProtocol("https://example.com")).toBe(true);
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
it("creates a display title without credentials, paths, or query parameters", () => {
expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com");
});
+29 -3
View File
@@ -1,21 +1,26 @@
"use client";
import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff } from "@tabler/icons-react";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n("widget.iframe");
const { embedUrl, allowScrolling, ...permissions } = options;
const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return <NoUrl />;
if (!safeEmbedUrl) {
return <UnsupportedProtocol />;
}
return (
<Stack h="100%" w="100%" gap={0}>
@@ -24,8 +29,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
loading="lazy"
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title={getFrameTitle(embedUrl)}
src={safeEmbedUrl}
title={getFrameTitle(safeEmbedUrl)}
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
@@ -37,6 +42,12 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
);
}
const supportedProtocols = ["http", "https"];
export const isSupportedProtocol = (url: string) => {
return getSafeApplicationUrl(url) !== undefined;
};
const NoUrl = () => {
const t = useI18n("widget.iframe");
@@ -48,6 +59,21 @@ const NoUrl = () => {
);
};
const UnsupportedProtocol = () => {
const t = useI18n("widget.iframe");
return (
<Stack align="center" justify="center" h="100%">
<IconProtocol />
<Title order={4} ta="center">
{t("error.unsupportedProtocol", {
supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
})}
</Title>
</Stack>
);
};
export const getAllowedPermissions = (
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
) => {
+12 -11
View File
@@ -1,6 +1,7 @@
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import { optionsBuilder } from "../options";
const createOptions = () =>
@@ -22,17 +23,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe",
supportsAdvancedFocus: false,
icon: IconBrowser,
contextActions: ({ options }) => {
const embedUrl = options.embedUrl;
if (typeof embedUrl !== "string" || embedUrl.trim() === "") return [];
return [
{
key: "open-iframe",
label: "widget.common.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
];
const embedUrl = getSafeApplicationUrl(options.embedUrl);
return embedUrl
? [
{
key: "open-iframe",
label: "widget.common.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
]
: [];
},
createOptions,
}).withDynamicImport(() => import("./component"));
@@ -68,14 +68,10 @@ export interface WidgetEditModalSize {
height: number;
}
export interface WidgetPreviewDimensions extends WidgetEditModalSize {
scale?: number;
}
interface WidgetEditPreviewResizeOptions {
initialSize: WidgetEditModalSize;
maximumSize: WidgetEditModalSize;
getDimensions?: (size: WidgetEditModalSize) => WidgetPreviewDimensions;
getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number };
}
export interface EmbeddedIntegrationEditFormHandle {
@@ -103,7 +99,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
previewDimensions?: WidgetPreviewDimensions;
previewDimensions?: { width: number; height: number; scale?: number };
previewResize?: WidgetEditPreviewResizeOptions;
previewWrapper?: ComponentType<PropsWithChildren>;
}
@@ -115,7 +111,7 @@ interface WidgetEditPreviewProps {
state: WidgetEditModalState;
itemId?: string;
boardId?: string;
dimensions?: WidgetPreviewDimensions;
dimensions?: { width: number; height: number; scale?: number };
integrationData: IntegrationSelectOption[];
onChangeOptions: (newOptions: Record<string, unknown>) => void;
resize?: {
@@ -183,9 +179,7 @@ const WidgetEditPreview = ({
},
}));
let componentItemId = itemId;
if (kind === "assistant" || kind === "timer") {
componentItemId = `widget-preview-${generatedPreviewId}`;
}
if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`;
const isPendingCustomWidget = kind === "customApi" && !itemId;
const previewOpacity = (board?.opacity ?? 100) / 100;
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {
-14
View File
@@ -189,9 +189,6 @@ catalogs:
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0
'@tanstack/query-sync-storage-persister':
specifier: 5.101.2
version: 5.101.2
'@tanstack/react-devtools':
specifier: ^0.10.8
version: 0.10.9
@@ -1136,9 +1133,6 @@ importers:
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
'@tanstack/query-sync-storage-persister':
specifier: 'catalog:'
version: 5.101.2
'@tanstack/react-devtools':
specifier: 'catalog:'
version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)
@@ -8852,9 +8846,6 @@ packages:
'@tanstack/query-persist-client-core@5.101.2':
resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==}
'@tanstack/query-sync-storage-persister@5.101.2':
resolution: {integrity: sha512-hRyLEJvkUWtfCUA3Lf5vD1CrXbHHK4pZNM/iNd7JM5GgC4CWzGIHkVkeg8zC2LV11KceKObKgjNuMSg3b6NOjA==}
'@tanstack/react-devtools@0.10.9':
resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
engines: {node: '>=18'}
@@ -23957,11 +23948,6 @@ snapshots:
dependencies:
'@tanstack/query-core': 5.101.2
'@tanstack/query-sync-storage-persister@5.101.2':
dependencies:
'@tanstack/query-core': 5.101.2
'@tanstack/query-persist-client-core': 5.101.2
'@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)':
dependencies:
'@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
-1
View File
@@ -59,7 +59,6 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0
"@tanstack/query-sync-storage-persister": 5.101.2
"@tanstack/react-devtools": ^0.10.8
"@tanstack/react-query": ^5.101.2
"@tanstack/react-query-devtools": ^5.101.2
+1 -1
View File
@@ -70,7 +70,7 @@ func BuildPlan(opts Options) (*Plan, error) {
}
p.Env = append(p.Env, opts.Env...)
if opts.Demo {
p.Env = append(p.Env, "DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
p.Env = append(p.Env, "DEMO_MODE=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
p.Label += " (demo)"
}
return p, nil
+1 -5
View File
@@ -76,7 +76,7 @@ func (m Model) instanceTarget(row instanceRow) target {
result := target{
label: row.container.Name,
container: row.container.Name,
volume: row.volume,
volume: row.container.Name + "_data",
running: row.container.Running(),
port: row.container.HostPort(),
}
@@ -315,10 +315,6 @@ func (m Model) deleteImage(reference string) (Model, tea.Cmd) {
// away either way, and failing halfway would leave the user to work out why.
func (m Model) deleteData(subject target) (Model, tea.Cmd) {
container, volume := subject.container, subject.volume
if volume == "" {
m.status, m.statusLevel = subject.label+" has no data volume to delete", levelWarn
return m, nil
}
m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error {
_ = docker.RemoveContext(ctx, container)
return docker.RemoveVolume(ctx, volume)
-4
View File
@@ -65,10 +65,6 @@ func (m Model) handleManageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m.arm(actionDeleteImage, subject)
case key.Matches(msg, m.keys.ManageData):
if subject.volume == "" {
m.status, m.statusLevel = subject.label+" has no data volume to delete", levelWarn
return m, nil
}
return m.arm(actionDeleteData, subject)
case key.Matches(msg, m.keys.ManageStop):