Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55998f0d6a | ||
|
|
e1ab780a0e | ||
|
|
5eed557dda | ||
|
|
3633b1f883 | ||
|
|
e8ca3d3ad4 | ||
|
|
a845d57d57 | ||
|
|
a935fd78fb | ||
|
|
809eed8ef9 | ||
|
|
51d538d161 | ||
|
|
55b62a6e2b | ||
|
|
c34629d39e | ||
|
|
2449eb0bc7 | ||
|
|
6f85e18065 | ||
|
|
25cc3b9402 | ||
|
|
333034b162 | ||
|
|
aded62977e | ||
|
|
254438247e | ||
|
|
16e437decc | ||
|
|
f0eddda573 | ||
|
|
b2c72d54c2 |
No files matched your search
@@ -320,8 +320,12 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [preview-amd64, preview-arm64]
|
||||
needs: [container, 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') ||
|
||||
@@ -375,9 +379,6 @@ 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}"
|
||||
@@ -386,9 +387,6 @@ 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)"
|
||||
@@ -435,6 +433,14 @@ 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,
|
||||
|
||||
@@ -112,6 +112,8 @@ 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,6 +128,26 @@ 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
|
||||
|
||||
@@ -67,6 +67,8 @@ 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} />
|
||||
|
||||
@@ -38,8 +38,10 @@ its server; changing the browser clock cannot bypass them.
|
||||
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
|
||||
|
||||
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
|
||||
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
|
||||
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
|
||||
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
|
||||
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
|
||||
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
|
||||
train models.
|
||||
|
||||
We would rather become millionaires in a more ethical manner.
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
|
||||
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
|
||||
limit as the final monetary backstop because request quotas are not dollar accounting.
|
||||
|
||||
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
|
||||
collection for a daily usage breakdown; regular users cannot list or view its records.
|
||||
|
||||
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
|
||||
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
|
||||
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"@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,20 +1,17 @@
|
||||
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 {
|
||||
createSessionQueryPersister,
|
||||
createSessionQueryPersistence,
|
||||
getQueryPersistenceStorageKey,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxDataBytes,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
||||
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKey, data, { updatedAt });
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
const query = queryClient.getQueryCache().find({ queryKey });
|
||||
if (!query) throw new Error("Expected query to be present");
|
||||
return { query, queryClient };
|
||||
@@ -37,314 +34,98 @@ const createMemoryStorage = (): Storage => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
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 });
|
||||
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 });
|
||||
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
||||
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 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 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(unrelated.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
|
||||
});
|
||||
|
||||
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 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 () => {
|
||||
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = "user-a";
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
const storage = createMemoryStorage();
|
||||
const userA = createSessionQueryPersistence("user-a", storage);
|
||||
const userB = createSessionQueryPersistence("user-b", storage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||
});
|
||||
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
||||
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||
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" },
|
||||
},
|
||||
);
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||
clientState: dehydrate(queryClient, userA.dehydrateOptions),
|
||||
};
|
||||
|
||||
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(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"),
|
||||
});
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||
expect(await userB.persister.restoreClient()).toBeUndefined();
|
||||
|
||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
await userA.persister.removeClient();
|
||||
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
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(() => {
|
||||
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");
|
||||
};
|
||||
|
||||
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
|
||||
expect(await persistence.persister.restoreClient()).toBeUndefined();
|
||||
expect(() => persistence.persister.removeClient()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||
|
||||
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||
export const queryPersistenceBuster = "v2-session-superjson";
|
||||
export const queryPersistenceBuster = "v6-dashboard-data";
|
||||
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||
|
||||
@@ -16,95 +15,33 @@ export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||
|
||||
interface PersistableQuery {
|
||||
queryKey: QueryKey;
|
||||
state: {
|
||||
data: unknown;
|
||||
dataUpdatedAt: number;
|
||||
};
|
||||
state: { data: unknown; status: string };
|
||||
}
|
||||
|
||||
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) =>
|
||||
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
|
||||
|
||||
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,
|
||||
const serializePersistedClient = (client: PersistedClient) =>
|
||||
stringify({
|
||||
...client,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
||||
...client.clientState,
|
||||
queries: client.clientState.queries.map((query) => ({
|
||||
...query,
|
||||
state: {
|
||||
...query.state,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
status: "success",
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
const getSessionStorage = (): Storage | undefined => {
|
||||
const getSessionStorage = () => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
try {
|
||||
@@ -114,113 +51,47 @@ const getSessionStorage = (): Storage | 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);
|
||||
};
|
||||
const protectStorage = (storage: Storage | undefined) => {
|
||||
if (!storage) return undefined;
|
||||
|
||||
return {
|
||||
persistClient(client) {
|
||||
if (!storage || stopped) return;
|
||||
pendingClient = client;
|
||||
if (persistTimer !== undefined) return;
|
||||
persistTimer = setTimeout(flush, 0);
|
||||
},
|
||||
restoreClient() {
|
||||
getItem(key: string) {
|
||||
try {
|
||||
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;
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
storage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} 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 { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
@@ -25,6 +25,8 @@ import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
dashboardSupportingQueryPolicies,
|
||||
isTrpcForbiddenError,
|
||||
isWidgetDataQueryKey,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
@@ -36,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxAgeMs,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
import type { SessionQueryPersister } from "./query-persistence";
|
||||
import { createSessionQueryPersistence } from "./query-persistence";
|
||||
import type { SessionQueryPersistence } from "./query-persistence";
|
||||
import { createQueryRetry } from "./query-retry";
|
||||
|
||||
const DevelopmentTools =
|
||||
@@ -75,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
const { data: session } = useSession();
|
||||
const sessionQueryScope = getSessionQueryScope(session);
|
||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
|
||||
const handleScopeChange = useCallback(() => {
|
||||
void queryPersister.removeClient();
|
||||
void queryPersistence.persister.removeClient();
|
||||
reloadPage();
|
||||
}, [queryPersister]);
|
||||
}, [queryPersistence]);
|
||||
|
||||
return (
|
||||
<SessionQueryScopeGuard
|
||||
@@ -87,7 +84,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
>
|
||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
@@ -96,8 +93,8 @@ const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
queryPersister,
|
||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||
queryPersistence,
|
||||
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -114,6 +111,13 @@ 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,
|
||||
@@ -152,15 +156,6 @@ 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(() => {
|
||||
@@ -211,15 +206,7 @@ const ScopedTRPCReactProvider = ({
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: queryPersistenceMaxAgeMs,
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||
}}
|
||||
>
|
||||
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
@@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
interface CopyAiPromptButtonBaseProps {
|
||||
interface CopyAiPromptButtonProps {
|
||||
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,
|
||||
@@ -35,23 +28,14 @@ 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,
|
||||
readDraft(),
|
||||
getDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
getDiagnostics(),
|
||||
);
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
@@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({
|
||||
const value = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
readDraft(),
|
||||
getDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
getDiagnostics(),
|
||||
);
|
||||
await navigator.clipboard.writeText(value);
|
||||
showSuccessNotification({
|
||||
|
||||
+3
-7
@@ -13,6 +13,8 @@ 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 {
|
||||
@@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialConfigMaps,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialConfigMaps),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,6 +17,7 @@ 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);
|
||||
|
||||
@@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialIngresses,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialIngresses),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
});
|
||||
+3
-7
@@ -15,6 +15,8 @@ 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 {
|
||||
@@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNamespaces,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialNamespaces),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,6 +17,7 @@ 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);
|
||||
|
||||
@@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNodes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialNodes),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,6 +13,8 @@ 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 {
|
||||
@@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
||||
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialPods,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialPods),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,6 +13,8 @@ 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 {
|
||||
@@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialSecrets,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialSecrets),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -14,6 +14,8 @@ 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 {
|
||||
@@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
|
||||
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialServices,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialServices),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,6 +13,8 @@ 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 {
|
||||
@@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialVolumes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialVolumes),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -89,8 +89,9 @@ const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T
|
||||
};
|
||||
};
|
||||
|
||||
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
|
||||
if (!toolCall.toolName.startsWith("customWidget_")) return null;
|
||||
const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
|
||||
const isCustomWidgetTool = toolCall.toolName.startsWith("customWidget_");
|
||||
if (!isCustomWidgetTool && toolCall.toolName !== "configure_board_settings") return null;
|
||||
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
|
||||
try {
|
||||
const input = JSON.parse(toolCall.input) as unknown;
|
||||
@@ -114,4 +115,4 @@ const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall:
|
||||
};
|
||||
|
||||
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
|
||||
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
|
||||
repairIconSearchInput(toolCall) ?? repairMultilineToolInput(toolCall);
|
||||
@@ -151,12 +151,12 @@ Homarr concepts:
|
||||
|
||||
Action rules:
|
||||
- Prefer read-only tools before actions.
|
||||
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
|
||||
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never use ask_user to confirm a board settings or custom CSS change before configure_board_settings, or to confirm another change whose inputs are already sufficient for its native review form or mutating tool. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
|
||||
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
|
||||
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
|
||||
- Do not retry a denied action.
|
||||
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, call configure_widget afterward with kind "app" and options { appId: the returned appId }.
|
||||
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
|
||||
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then immediately call configure_board_settings with only the requested proposed changes. For a custom CSS request, include the complete resulting stylesheet in changes.customCss so the native form is prefilled and previews the proposed CSS. The native form is the user's review and confirmation step, so never insert ask_user between these calls when the requested change is known. Use the form's returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
|
||||
- Before adding any widget to a board, call configure_widget with the target board, widget kind, proposed options, and any integration IDs selected from integration_all. Homarr's native widget editor applies defaults, validates the options, and restricts the picker to compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds exactly as the input to board_addItem. If configure_widget returns cancelled, do not add the widget.
|
||||
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
|
||||
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
|
||||
|
||||
@@ -54,7 +54,7 @@ export const assistantBoardSettingsChangesSchema = z
|
||||
export const browserToolContracts = {
|
||||
ask_user: {
|
||||
description:
|
||||
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
|
||||
"Pause and ask the user one concise structured question. Use this only for missing information or a meaningful choice, never to confirm details that are already sufficient for a native review form or mutating tool. In particular, do not use ask_user before configure_app, configure_board_settings, or configure_widget when their inputs are known. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
|
||||
parameters: z.object({
|
||||
question: z.string().trim().min(1).max(240),
|
||||
description: z.string().trim().max(400).optional(),
|
||||
@@ -82,7 +82,7 @@ export const browserToolContracts = {
|
||||
},
|
||||
configure_board_settings: {
|
||||
description:
|
||||
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
|
||||
"Open Homarr's native board settings and custom CSS review form. This form is the user's confirmation step. Always call board_getBoardSettings first, then call this tool directly with only the requested proposed changes, including the complete resulting stylesheet in changes.customCss for a CSS request. Do not call ask_user between those tools when the requested changes are known. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings.",
|
||||
parameters: z.object({
|
||||
boardId: z.string().trim().min(1).max(64),
|
||||
boardName: z.string().trim().min(1).max(255),
|
||||
|
||||
@@ -11,16 +11,17 @@ import classes from "./board-layout-thumbnail.module.css";
|
||||
interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
previewRowLimit?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
const compactBoardLayoutThumbnailRows = 12;
|
||||
export const maxBoardLayoutThumbnailRows = 48;
|
||||
|
||||
export const BoardLayoutThumbnail = ({
|
||||
preview,
|
||||
label,
|
||||
fitFullLayout = false,
|
||||
previewRowLimit = compactBoardLayoutThumbnailRows,
|
||||
className,
|
||||
}: BoardLayoutThumbnailProps) => {
|
||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||
@@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
|
||||
const roots = preview.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
@@ -94,9 +95,21 @@ 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)
|
||||
? 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,
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<span
|
||||
@@ -104,16 +117,16 @@ export const BoardLayoutThumbnail = ({
|
||||
aria-hidden
|
||||
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
|
||||
style={{
|
||||
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
|
||||
gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
|
||||
}}
|
||||
>
|
||||
{element.type === "section" ? (
|
||||
<span
|
||||
className={classes.containerContents}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{nestedItems.map((nestedItem) => (
|
||||
@@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({
|
||||
style={{
|
||||
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
||||
nestedItem.width,
|
||||
element.width - nestedItem.xOffset,
|
||||
renderedWidth - nestedItem.xOffset,
|
||||
)}`,
|
||||
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
||||
nestedItem.height,
|
||||
element.height - nestedItem.yOffset,
|
||||
renderedHeight - nestedItem.yOffset,
|
||||
)}`,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } 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;
|
||||
@@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
data: boards = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.board.getManageOverview.useQuery(
|
||||
{ fullPreview: true },
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
|
||||
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(() => {
|
||||
@@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
|
||||
|
||||
const spotlightAction = useMemo(
|
||||
() => ({
|
||||
@@ -335,7 +330,7 @@ const getBoardSwitcherResults = ({
|
||||
<BoardLayoutThumbnail
|
||||
preview={board.preview}
|
||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||
fitFullLayout
|
||||
previewRowLimit={boardSwitcherPreview.previewRowLimit}
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Section withBorder px="sm" py={6}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
|
||||
@@ -21,7 +22,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
|
||||
interface BoardItemMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
previewDimensions: WidgetPreviewDimensions;
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ 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";
|
||||
@@ -35,7 +36,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
previewDimensions: WidgetPreviewDimensions;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
|
||||
@@ -54,11 +54,13 @@ export const LazySpotlight = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMounted || isPreloaded ? (
|
||||
if (!isMounted && !isPreloaded) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
||||
<Spotlight />
|
||||
{isMounted && <Spotlight />}
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
@@ -605,7 +605,12 @@ func (provider *homarrProvider) loadQuota(
|
||||
user *core.Record,
|
||||
now time.Time,
|
||||
) (*core.Record, quotaSnapshot, bool, error) {
|
||||
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
|
||||
day := now.Format(time.DateOnly)
|
||||
quota, err := app.FindFirstRecordByFilter(
|
||||
"assistant_quotas",
|
||||
"user = {:user} && day = {:day}",
|
||||
dbx.Params{"user": user.Id, "day": day},
|
||||
)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, quotaSnapshot{}, false, err
|
||||
}
|
||||
@@ -617,10 +622,7 @@ func (provider *homarrProvider) loadQuota(
|
||||
}
|
||||
quota = core.NewRecord(collection)
|
||||
quota.Set("user", user.Id)
|
||||
changed = true
|
||||
}
|
||||
if quota.GetString("day") != now.Format(time.DateOnly) {
|
||||
quota.Set("day", now.Format(time.DateOnly))
|
||||
quota.Set("day", day)
|
||||
quota.Set("used", 0)
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const quotas = app.findCollectionByNameOrId("assistant_quotas");
|
||||
quotas.removeIndex("idx_assistant_quotas_user");
|
||||
quotas.addIndex("idx_assistant_quotas_user_day", true, "user, day", "");
|
||||
app.save(quotas);
|
||||
},
|
||||
(app) => {
|
||||
const quotas = app.findCollectionByNameOrId("assistant_quotas");
|
||||
quotas.removeIndex("idx_assistant_quotas_user_day");
|
||||
|
||||
// The previous schema can keep only one record per user. Retain the latest UTC day when rolling back.
|
||||
app
|
||||
.db()
|
||||
.newQuery(`
|
||||
DELETE FROM assistant_quotas
|
||||
WHERE id NOT IN (
|
||||
SELECT (
|
||||
SELECT candidate.id
|
||||
FROM assistant_quotas candidate
|
||||
WHERE candidate.user = quota_user.user
|
||||
ORDER BY candidate.day DESC, candidate.created DESC, candidate.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
FROM assistant_quotas quota_user
|
||||
GROUP BY quota_user.user
|
||||
)
|
||||
`)
|
||||
.execute();
|
||||
|
||||
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
|
||||
app.save(quotas);
|
||||
},
|
||||
);
|
||||
@@ -82,6 +82,14 @@ for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activit
|
||||
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
|
||||
}
|
||||
|
||||
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
|
||||
for (const required of [
|
||||
'removeIndex("idx_assistant_quotas_user")',
|
||||
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
|
||||
]) {
|
||||
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
|
||||
}
|
||||
|
||||
const providerBackend = await read("apps/workshop/homarr_provider.go");
|
||||
for (const required of [
|
||||
'"homarr/model"',
|
||||
|
||||
@@ -436,9 +436,21 @@ const resetUsage = await request("/api/ai/usage", { headers: authorSession.heade
|
||||
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
|
||||
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
|
||||
}
|
||||
const resetQuota = (
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const resetQuotas = (
|
||||
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
|
||||
).items.find((item) => item.user === author.id);
|
||||
).items.filter((item) => item.user === author.id);
|
||||
const historicalQuota = resetQuotas.find((item) => item.id === authorQuota.id);
|
||||
const resetQuota = resetQuotas.find((item) => item.day === today);
|
||||
if (
|
||||
resetQuotas.length !== 2 ||
|
||||
historicalQuota?.day !== "2000-01-01" ||
|
||||
historicalQuota.used !== 49 ||
|
||||
!resetQuota ||
|
||||
resetQuota.used !== 0
|
||||
) {
|
||||
throw new Error(`Daily quota history was not preserved: ${JSON.stringify(resetQuotas)}`);
|
||||
}
|
||||
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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;
|
||||
@@ -8,6 +19,7 @@ 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"]);
|
||||
@@ -66,3 +78,11 @@ 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);
|
||||
};
|
||||
@@ -86,9 +86,14 @@ interface BoardItemPlacementRectangle {
|
||||
}
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
const defaultManageOverviewPreviewRows = 12;
|
||||
const maxManageOverviewPreviewRows = 48;
|
||||
const manageOverviewInputSchema = z
|
||||
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
|
||||
.object({
|
||||
fullPreview: z.boolean().default(false),
|
||||
previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(),
|
||||
userId: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
@@ -311,6 +316,8 @@ 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: {
|
||||
@@ -388,7 +395,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(itemLayouts.yOffset, previewRowLimit),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
@@ -413,7 +420,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||
gte(sectionLayouts.xOffset, 0),
|
||||
gte(sectionLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(sectionLayouts.yOffset, previewRowLimit),
|
||||
gt(sectionLayouts.width, 0),
|
||||
gt(sectionLayouts.height, 0),
|
||||
),
|
||||
@@ -448,7 +455,7 @@ export const boardRouter = createTRPCRouter({
|
||||
inArray(itemLayouts.sectionId, previewContainerSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
lt(itemLayouts.yOffset, previewRowLimit),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
|
||||
@@ -84,15 +84,13 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
const appAmbiguous =
|
||||
!linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1));
|
||||
const ambiguous = integrationAmbiguous || appAmbiguous;
|
||||
const state: DockerReconciliationState = integrationMoved
|
||||
? "moved"
|
||||
: linked
|
||||
? "linked"
|
||||
: integration || app
|
||||
? "represented"
|
||||
: match
|
||||
? "newRecognized"
|
||||
: "newApp";
|
||||
const state = getReconciliationState({
|
||||
integrationMoved,
|
||||
linked,
|
||||
represented: Boolean(integration || app),
|
||||
recognized: Boolean(match),
|
||||
});
|
||||
const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous);
|
||||
|
||||
return {
|
||||
candidateKey: container.resourceId,
|
||||
@@ -103,11 +101,7 @@ export const getDockerReconciliationAsync = async (db: Database) => {
|
||||
match,
|
||||
urlCandidates,
|
||||
state,
|
||||
nextAction: integrationAmbiguous
|
||||
? ("reviewIntegration" as const)
|
||||
: appAmbiguous
|
||||
? ("viewRepresentation" as const)
|
||||
: nextActionForState(state),
|
||||
nextAction,
|
||||
representation: {
|
||||
integration: integration
|
||||
? {
|
||||
@@ -147,87 +141,100 @@ export const getDockerServiceHealthAsync = async (db: Database) => {
|
||||
status: endpoint.status,
|
||||
nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const),
|
||||
})),
|
||||
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),
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
})),
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
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";
|
||||
@@ -235,6 +242,42 @@ 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,
|
||||
@@ -242,5 +285,6 @@ const integrationHealthStatus = (
|
||||
) => {
|
||||
if (!kind) return "notApplicable" as const;
|
||||
if (state === "moved") return "changed" as const;
|
||||
return hasIntegration ? ("configured" as const) : ("missing" as const);
|
||||
if (hasIntegration) return "configured" as const;
|
||||
return "missing" as const;
|
||||
};
|
||||
@@ -14,6 +14,7 @@ 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<{
|
||||
@@ -62,7 +63,7 @@ vi.mock("@homarr/docker", () => ({
|
||||
},
|
||||
},
|
||||
],
|
||||
reset: () => undefined,
|
||||
reset: resetDockerSingletonMock,
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/request-handler/docker", () => ({
|
||||
@@ -501,6 +502,7 @@ 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 () => {
|
||||
|
||||
@@ -50,7 +50,10 @@ export const immichRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
|
||||
if (ctx.integration.kind === "mock") {
|
||||
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
|
||||
return mockWidgetData.immichAlbums.slice(0, input.limit);
|
||||
}
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "immich" },
|
||||
{
|
||||
|
||||
@@ -6,7 +6,8 @@ import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness
|
||||
const harnessCommandNames = new Set(["harness", "local-harness"]);
|
||||
|
||||
function isHarnessInvocation(argv: string[]): boolean {
|
||||
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
|
||||
const command = argv[2];
|
||||
return command !== undefined && harnessCommandNames.has(command);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
@@ -12,6 +12,11 @@ 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,3 +1,5 @@
|
||||
import { isTrpcForbiddenError } from "@homarr/api/query-cache";
|
||||
|
||||
interface WidgetQueryState {
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
@@ -9,6 +11,7 @@ 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, isSupportedProtocol } from "./component";
|
||||
import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component";
|
||||
|
||||
const permissions = {
|
||||
allowFullScreen: true,
|
||||
@@ -18,12 +18,6 @@ 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");
|
||||
});
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
|
||||
import { IconBrowserOff } 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}>
|
||||
@@ -29,8 +24,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
|
||||
loading="lazy"
|
||||
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
|
||||
className={classes.iframe}
|
||||
src={safeEmbedUrl}
|
||||
title={getFrameTitle(safeEmbedUrl)}
|
||||
src={embedUrl}
|
||||
title={getFrameTitle(embedUrl)}
|
||||
allow={allowedPermissions}
|
||||
scrolling={allowScrolling ? "yes" : "no"}
|
||||
sandbox={sandboxFlags.join(" ")}
|
||||
@@ -42,12 +37,6 @@ 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");
|
||||
|
||||
@@ -59,21 +48,6 @@ 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">,
|
||||
) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { getSafeApplicationUrl } from "../common/application-url";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const createOptions = () =>
|
||||
@@ -23,17 +22,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe",
|
||||
supportsAdvancedFocus: false,
|
||||
icon: IconBrowser,
|
||||
contextActions: ({ options }) => {
|
||||
const embedUrl = getSafeApplicationUrl(options.embedUrl);
|
||||
return embedUrl
|
||||
? [
|
||||
{
|
||||
key: "open-iframe",
|
||||
label: "widget.common.openInNewTab.label",
|
||||
icon: IconExternalLink,
|
||||
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
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"),
|
||||
},
|
||||
];
|
||||
},
|
||||
createOptions,
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -63,10 +63,14 @@ export interface WidgetEditModalSize {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface WidgetPreviewDimensions extends WidgetEditModalSize {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
interface WidgetEditPreviewResizeOptions {
|
||||
initialSize: WidgetEditModalSize;
|
||||
maximumSize: WidgetEditModalSize;
|
||||
getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number };
|
||||
getDimensions?: (size: WidgetEditModalSize) => WidgetPreviewDimensions;
|
||||
}
|
||||
|
||||
export interface EmbeddedIntegrationEditFormHandle {
|
||||
@@ -94,7 +98,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
onIntegrationSaved?: () => void;
|
||||
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
|
||||
previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
|
||||
previewDimensions?: { width: number; height: number; scale?: number };
|
||||
previewDimensions?: WidgetPreviewDimensions;
|
||||
previewResize?: WidgetEditPreviewResizeOptions;
|
||||
previewWrapper?: ComponentType<PropsWithChildren>;
|
||||
}
|
||||
@@ -106,7 +110,7 @@ interface WidgetEditPreviewProps {
|
||||
state: WidgetEditModalState;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
dimensions?: { width: number; height: number; scale?: number };
|
||||
dimensions?: WidgetPreviewDimensions;
|
||||
integrationData: IntegrationSelectOption[];
|
||||
onChangeOptions: (newOptions: Record<string, unknown>) => void;
|
||||
resize?: {
|
||||
@@ -174,7 +178,9 @@ const WidgetEditPreview = ({
|
||||
},
|
||||
}));
|
||||
let componentItemId = itemId;
|
||||
if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`;
|
||||
if (kind === "assistant" || kind === "timer") {
|
||||
componentItemId = `widget-preview-${generatedPreviewId}`;
|
||||
}
|
||||
const isPendingCustomWidget = kind === "customApi" && !itemId;
|
||||
const previewOpacity = (board?.opacity ?? 100) / 100;
|
||||
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {
|
||||
|
||||
Generated
+14
@@ -189,6 +189,9 @@ 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
|
||||
@@ -1133,6 +1136,9 @@ 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)
|
||||
@@ -8846,6 +8852,9 @@ 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'}
|
||||
@@ -23948,6 +23957,11 @@ 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)
|
||||
|
||||
@@ -59,6 +59,7 @@ 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
|
||||
|
||||
@@ -76,7 +76,7 @@ func (m Model) instanceTarget(row instanceRow) target {
|
||||
result := target{
|
||||
label: row.container.Name,
|
||||
container: row.container.Name,
|
||||
volume: row.container.Name + "_data",
|
||||
volume: row.volume,
|
||||
running: row.container.Running(),
|
||||
port: row.container.HostPort(),
|
||||
}
|
||||
@@ -315,6 +315,10 @@ 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)
|
||||
|
||||
@@ -65,6 +65,10 @@ 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):
|
||||
|
||||
Reference in new issue
Block a user