Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb238ed4f | ||
|
|
449adc5b12 | ||
|
|
240397e7ac | ||
|
|
3efb719eee | ||
|
|
382cffc015 | ||
|
|
0073dfe044 | ||
|
|
a22fb60647 | ||
|
|
89e4cb843d | ||
|
|
65a96b6b14 | ||
|
|
357355283c | ||
|
|
a2a408410a |
No files matched your search
@@ -30,6 +30,26 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"reportUnusedDisableDirectives": "off"
|
"reportUnusedDisableDirectives": "off"
|
||||||
},
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
|
||||||
|
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
|
||||||
|
"rules": {
|
||||||
|
"eslint/no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "@homarr/widgets",
|
||||||
|
"allowTypeImports": true,
|
||||||
|
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"**/*.config.js",
|
"**/*.config.js",
|
||||||
"**/*.config.mjs",
|
"**/*.config.mjs",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
|
"@tanstack/react-query-persist-client": "catalog:",
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "catalog:",
|
"@trpc/next": "catalog:",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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,
|
||||||
|
getQueryPersistenceStorageKey,
|
||||||
|
queryPersistenceBuster,
|
||||||
|
queryPersistenceMaxDataBytes,
|
||||||
|
shouldPersistDashboardQuery,
|
||||||
|
} from "./query-persistence";
|
||||||
|
|
||||||
|
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
queryClient.setQueryData(queryKey, data, { updatedAt });
|
||||||
|
const query = queryClient.getQueryCache().find({ queryKey });
|
||||||
|
if (!query) throw new Error("Expected query to be present");
|
||||||
|
return { query, queryClient };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMemoryStorage = (): Storage => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return values.size;
|
||||||
|
},
|
||||||
|
clear: () => values.clear(),
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||||
|
removeItem: (key) => values.delete(key),
|
||||||
|
setItem: (key, value) => values.set(key, value),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
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 pendingClient = new QueryClient();
|
||||||
|
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
||||||
|
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||||
|
queryFn: () => Promise.resolve([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
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(pending)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = "user-a";
|
||||||
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
|
const persister = createSessionQueryPersister(scope);
|
||||||
|
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||||
|
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
||||||
|
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||||
|
const persistedClient: PersistedClient = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||||
|
};
|
||||||
|
|
||||||
|
persister.persistClient(persistedClient);
|
||||||
|
persister.flush();
|
||||||
|
const restored = await persister.restoreClient();
|
||||||
|
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
||||||
|
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
||||||
|
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||||
|
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||||
|
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||||
|
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||||
|
expect(persister.restoreClient()).toBeUndefined();
|
||||||
|
expect(window.sessionStorage.getItem(key)).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(() => {
|
||||||
|
throw new DOMException("blocked", "SecurityError");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||||
|
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
|
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||||
|
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||||
|
import { parse, stringify } from "superjson";
|
||||||
|
|
||||||
|
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
|
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||||
|
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||||
|
export const queryPersistenceBuster = "v2-session-superjson";
|
||||||
|
|
||||||
|
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||||
|
|
||||||
|
export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||||
|
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
|
||||||
|
|
||||||
|
interface PersistableQuery {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
state: {
|
||||||
|
data: unknown;
|
||||||
|
dataUpdatedAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
const budgetVerdictCache = new WeakMap<object, boolean>();
|
||||||
|
|
||||||
|
const measureFitsBudget = (data: unknown) => {
|
||||||
|
try {
|
||||||
|
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitsStorageBudget = (data: unknown) => {
|
||||||
|
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
||||||
|
|
||||||
|
const cached = budgetVerdictCache.get(data);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const verdict = measureFitsBudget(data);
|
||||||
|
budgetVerdictCache.set(data, verdict);
|
||||||
|
return verdict;
|
||||||
|
};
|
||||||
|
|
||||||
|
const holdsDashboardData = (query: PersistableQuery) =>
|
||||||
|
query.state.data !== undefined &&
|
||||||
|
Number.isFinite(query.state.dataUpdatedAt) &&
|
||||||
|
query.state.dataUpdatedAt > 0 &&
|
||||||
|
isWidgetDataQueryKey(query.queryKey);
|
||||||
|
|
||||||
|
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||||
|
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||||
|
|
||||||
|
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
||||||
|
|
||||||
|
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||||
|
...query,
|
||||||
|
state: {
|
||||||
|
...query.state,
|
||||||
|
error: null,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
fetchFailureCount: 0,
|
||||||
|
fetchFailureReason: null,
|
||||||
|
fetchStatus: "idle",
|
||||||
|
isInvalidated: true,
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
||||||
|
...client,
|
||||||
|
clientState: {
|
||||||
|
mutations: [],
|
||||||
|
queries: client.clientState.queries.map(sanitizePersistedQuery),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
|
||||||
|
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
|
||||||
|
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
|
||||||
|
|
||||||
|
return holdsDashboardData(value as unknown as PersistableQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
|
||||||
|
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
|
||||||
|
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: value.timestamp as number,
|
||||||
|
buster: value.buster,
|
||||||
|
clientState: {
|
||||||
|
mutations: [],
|
||||||
|
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionStorage = (): Storage | undefined => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
||||||
|
try {
|
||||||
|
storage?.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const foreignKeys: string[] = [];
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
const storedKey = storage.key(index);
|
||||||
|
if (storedKey === null || storedKey === activeKey) continue;
|
||||||
|
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSessionQueryPersister = (
|
||||||
|
scope: string | null,
|
||||||
|
storage: Storage | undefined = getSessionStorage(),
|
||||||
|
): SessionQueryPersister => {
|
||||||
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
|
removeForeignScopes(storage, key);
|
||||||
|
let pendingClient: PersistedClient | undefined;
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const persistNow = (client: PersistedClient) => {
|
||||||
|
if (!storage) return;
|
||||||
|
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
if (candidate.clientState.queries.length === 0) {
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (candidate) {
|
||||||
|
try {
|
||||||
|
storage.setItem(key, stringify(candidate));
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
errorCount += 1;
|
||||||
|
candidate = removeOldestQuery({
|
||||||
|
persistedClient: candidate,
|
||||||
|
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
|
||||||
|
errorCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPersistTimer = () => {
|
||||||
|
if (persistTimer === undefined) return;
|
||||||
|
clearTimeout(persistTimer);
|
||||||
|
persistTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
clearPersistTimer();
|
||||||
|
const client = pendingClient;
|
||||||
|
pendingClient = undefined;
|
||||||
|
if (client) persistNow(client);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
persistClient(client) {
|
||||||
|
if (!storage || stopped) return;
|
||||||
|
pendingClient = client;
|
||||||
|
if (persistTimer !== undefined) return;
|
||||||
|
persistTimer = setTimeout(flush, 0);
|
||||||
|
},
|
||||||
|
restoreClient() {
|
||||||
|
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;
|
||||||
|
} catch {
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeClient() {
|
||||||
|
clearPersistTimer();
|
||||||
|
pendingClient = undefined;
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
},
|
||||||
|
flush,
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearPersistTimer();
|
||||||
|
pendingClient = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import {
|
import {
|
||||||
createWSClient,
|
createWSClient,
|
||||||
httpBatchStreamLink,
|
httpBatchStreamLink,
|
||||||
@@ -35,6 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
|
|||||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||||
|
|
||||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||||
|
import {
|
||||||
|
createSessionQueryPersister,
|
||||||
|
queryPersistenceBuster,
|
||||||
|
queryPersistenceMaxAgeMs,
|
||||||
|
shouldPersistDashboardQuery,
|
||||||
|
} from "./query-persistence";
|
||||||
|
import type { SessionQueryPersister } from "./query-persistence";
|
||||||
import { createQueryRetry } from "./query-retry";
|
import { createQueryRetry } from "./query-retry";
|
||||||
|
|
||||||
const DevelopmentTools =
|
const DevelopmentTools =
|
||||||
@@ -67,21 +75,29 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const sessionQueryScope = getSessionQueryScope(session);
|
const sessionQueryScope = getSessionQueryScope(session);
|
||||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||||
|
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||||
|
const handleScopeChange = useCallback(() => {
|
||||||
|
void queryPersister.removeClient();
|
||||||
|
reloadPage();
|
||||||
|
}, [queryPersister]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionQueryScopeGuard
|
<SessionQueryScopeGuard
|
||||||
initialScope={initialSessionQueryScope}
|
initialScope={initialSessionQueryScope}
|
||||||
currentScope={sessionQueryScope}
|
currentScope={sessionQueryScope}
|
||||||
onScopeChange={reloadPage}
|
onScopeChange={handleScopeChange}
|
||||||
>
|
>
|
||||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||||
</SessionQueryScopeGuard>
|
</SessionQueryScopeGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload();
|
const reloadPage = () => window.location.reload();
|
||||||
|
|
||||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
const ScopedTRPCReactProvider = ({
|
||||||
|
children,
|
||||||
|
queryPersister,
|
||||||
|
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||||
const wsClient = useMemo(
|
const wsClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createWSClient({
|
createWSClient({
|
||||||
@@ -136,6 +152,15 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => queryPersister.flush();
|
||||||
|
window.addEventListener("pagehide", flush);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", flush);
|
||||||
|
flush();
|
||||||
|
queryPersister.stop();
|
||||||
|
};
|
||||||
|
}, [queryPersister]);
|
||||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||||
|
|
||||||
const trpcClient = useMemo(() => {
|
const trpcClient = useMemo(() => {
|
||||||
@@ -186,10 +211,18 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: queryPersister,
|
||||||
|
maxAge: queryPersistenceMaxAgeMs,
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -53,13 +53,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
<TourTarget id="board-settings">
|
||||||
<TourTarget id="board-settings">
|
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
<IconSettings stroke={1.5} />
|
||||||
<IconSettings stroke={1.5} />
|
</HeaderButton>
|
||||||
</HeaderButton>
|
</TourTarget>
|
||||||
</TourTarget>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica
|
|||||||
import { closeSpotlight } from "@homarr/spotlight/store";
|
import { closeSpotlight } from "@homarr/spotlight/store";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
|
||||||
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
||||||
|
|
||||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.notebook.definition,
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -41,6 +42,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.audioStats.definition,
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -88,11 +90,13 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||||
const mediaServer = getAssistantWidgetConfiguration(
|
const mediaServer = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.mediaServer.definition,
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const calendar = getAssistantWidgetConfiguration(
|
const calendar = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.calendar.definition,
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { Suspense, use, useMemo } from "react";
|
||||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -13,7 +15,8 @@ import { useModalAction } from "@homarr/modals";
|
|||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
@@ -29,11 +32,11 @@ interface AssistantIntegration extends IntegrationSelectOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getAssistantWidgetConfiguration = (
|
export const getAssistantWidgetConfiguration = (
|
||||||
|
definition: WidgetDefinition,
|
||||||
args: ConfigureWidgetArgs,
|
args: ConfigureWidgetArgs,
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||||
integrations: AssistantIntegration[],
|
integrations: AssistantIntegration[],
|
||||||
) => {
|
) => {
|
||||||
const definition = widgetImports[args.kind].definition;
|
|
||||||
const integrationSupport = "supportedIntegrations" in definition;
|
const integrationSupport = "supportedIntegrations" in definition;
|
||||||
const supportedIntegrations = integrationSupport
|
const supportedIntegrations = integrationSupport
|
||||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
||||||
@@ -54,35 +57,91 @@ export const getAssistantWidgetConfiguration = (
|
|||||||
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||||
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
|
options: reduceWidgetOptionsWithDefinition(definition, settings, args.options),
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AssistantConfigureWidgetTool = ({
|
type AssistantConfigureWidgetToolProps = ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>;
|
||||||
|
|
||||||
|
export const AssistantConfigureWidgetTool = (props: AssistantConfigureWidgetToolProps) => {
|
||||||
|
const hasCompleteArguments = hasCompleteAssistantToolArguments(props.status);
|
||||||
|
const completeArgs =
|
||||||
|
props.result === undefined && !hasFailedAssistantToolArguments(props.status) && hasCompleteArguments
|
||||||
|
? props.args
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!completeArgs) return <AssistantConfigureWidgetToolContent {...props} definition={undefined} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary FallbackComponent={AssistantWidgetDefinitionErrorFallback} resetKeys={[completeArgs.kind]}>
|
||||||
|
<Suspense fallback={<AssistantConfigureWidgetSkeleton />}>
|
||||||
|
<LoadedAssistantConfigureWidgetTool {...props} args={completeArgs} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantWidgetDefinitionErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
|
||||||
|
const t = useI18n("assistant.configureWidget");
|
||||||
|
const actionT = useI18n("common.action");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert color="red" variant="light" title={t("definitionLoadErrorTitle")} icon={<IconAlertTriangle size={18} />}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">{t("definitionLoadErrorDescription")}</Text>
|
||||||
|
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={resetErrorBoundary}>
|
||||||
|
{actionT("tryAgain")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadedAssistantConfigureWidgetTool = (
|
||||||
|
props: AssistantConfigureWidgetToolProps & { args: ConfigureWidgetArgs },
|
||||||
|
) => {
|
||||||
|
const definition = use(loadWidgetDefinition(props.args.kind));
|
||||||
|
return <AssistantConfigureWidgetToolContent {...props} definition={definition} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantConfigureWidgetSkeleton = () => {
|
||||||
|
const t = useI18n("assistant.configureWidget");
|
||||||
|
return (
|
||||||
|
<Box className={classes.appTool} aria-label={t("preparing")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Skeleton height={18} width="45%" />
|
||||||
|
<Skeleton height={14} width="76%" />
|
||||||
|
<Skeleton height={38} />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantConfigureWidgetToolContent = ({
|
||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
addResult,
|
addResult,
|
||||||
status,
|
status,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
definition,
|
||||||
|
}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => {
|
||||||
const t = useI18n("assistant.configureWidget");
|
const t = useI18n("assistant.configureWidget");
|
||||||
const actionT = useI18n("common.action");
|
const actionT = useI18n("common.action");
|
||||||
const fullT = useI18n();
|
const fullT = useI18n();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
|
||||||
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
||||||
const integrations = clientApi.integration.all.useQuery(undefined, {
|
const integrations = clientApi.integration.all.useQuery(undefined, {
|
||||||
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const configuration = useMemo(() => {
|
const configuration = useMemo(() => {
|
||||||
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||||
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
|
return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []);
|
||||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
}, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||||
const missingRequiredIntegration =
|
const missingRequiredIntegration =
|
||||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
||||||
@@ -134,15 +193,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
||||||
return (
|
return <AssistantConfigureWidgetSkeleton />;
|
||||||
<Box className={classes.appTool} aria-label={t("preparing")}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Skeleton height={18} width="45%" />
|
|
||||||
<Skeleton height={14} width="76%" />
|
|
||||||
<Skeleton height={38} />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (integrations.isError) {
|
if (integrations.isError) {
|
||||||
@@ -158,7 +209,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configuration) return null;
|
if (!configuration || !definition) return null;
|
||||||
|
|
||||||
const openConfigurationModal = (
|
const openConfigurationModal = (
|
||||||
value = configuration.value,
|
value = configuration.value,
|
||||||
@@ -167,7 +218,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
openWidgetModal(
|
openWidgetModal(
|
||||||
{
|
{
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
definition: widgetImports[args.kind].definition,
|
definition,
|
||||||
value,
|
value,
|
||||||
integrationData,
|
integrationData,
|
||||||
integrationSupport: configuration.integrationSupport,
|
integrationSupport: configuration.integrationSupport,
|
||||||
@@ -240,7 +291,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetIcon = widgetImports[args.kind].definition.icon;
|
const WidgetIcon = definition.icon;
|
||||||
return (
|
return (
|
||||||
<Box className={classes.appTool}>
|
<Box className={classes.appTool}>
|
||||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
.refreshIndicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settingsButton {
|
.settingsButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -34px;
|
top: -34px;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
||||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||||
import type { FallbackProps } from "react-error-boundary";
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
@@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors";
|
|||||||
import {
|
import {
|
||||||
createWidgetRuntimeState,
|
createWidgetRuntimeState,
|
||||||
getWidgetQueryKeys,
|
getWidgetQueryKeys,
|
||||||
|
getWidgetRuntimeQueries,
|
||||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||||
} from "@homarr/widgets/definition";
|
} from "@homarr/widgets/definition";
|
||||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||||
@@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
|
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||||
|
|
||||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -156,6 +158,24 @@ const LoadedBoardItemContent = ({
|
|||||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||||
[definition, item, settings],
|
[definition, item, settings],
|
||||||
);
|
);
|
||||||
|
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||||
|
const isWidgetRefreshing =
|
||||||
|
useIsFetching({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.state.data !== undefined &&
|
||||||
|
matchesWidgetItemQuery(
|
||||||
|
query.queryKey,
|
||||||
|
widgetQueryKeys,
|
||||||
|
{
|
||||||
|
itemId: item.id,
|
||||||
|
boardId: board.id,
|
||||||
|
integrationIds: item.integrationIds,
|
||||||
|
options: menuItem.options,
|
||||||
|
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||||
|
},
|
||||||
|
definition.queryMatcher,
|
||||||
|
),
|
||||||
|
}) > 0;
|
||||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||||
@@ -322,6 +342,11 @@ const LoadedBoardItemContent = ({
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
|
{isWidgetRefreshing && (
|
||||||
|
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</WidgetCardShell>
|
</WidgetCardShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+114
-28
@@ -1,5 +1,6 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { chromium, expect } from "@playwright/test";
|
import { chromium, expect } from "@playwright/test";
|
||||||
|
import type { Route } from "@playwright/test";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { stringify } from "superjson";
|
import { stringify } from "superjson";
|
||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
@@ -21,7 +22,9 @@ describe("lazy widget application graph", () => {
|
|||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
const sectionId = createId();
|
const sectionId = createId();
|
||||||
const itemId = createId();
|
const clockItemId = createId();
|
||||||
|
const downloadsItemId = createId();
|
||||||
|
const mockIntegrationId = createId();
|
||||||
|
|
||||||
await db.insert(sqliteSchema.boards).values({
|
await db.insert(sqliteSchema.boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
@@ -41,26 +44,56 @@ describe("lazy widget application graph", () => {
|
|||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.items).values({
|
await db.insert(sqliteSchema.integrations).values({
|
||||||
id: itemId,
|
id: mockIntegrationId,
|
||||||
boardId,
|
name: "Mock downloads",
|
||||||
kind: "clock",
|
url: "http://mock.local",
|
||||||
options: stringify({ showSeconds: true }),
|
kind: "mock",
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.itemLayouts).values({
|
await db.insert(sqliteSchema.items).values([
|
||||||
itemId,
|
{
|
||||||
sectionId,
|
id: clockItemId,
|
||||||
layoutId,
|
boardId,
|
||||||
xOffset: 0,
|
kind: "clock",
|
||||||
yOffset: 0,
|
options: stringify({ showSeconds: true }),
|
||||||
width: 4,
|
},
|
||||||
height: 2,
|
{
|
||||||
|
id: downloadsItemId,
|
||||||
|
boardId,
|
||||||
|
kind: "downloads",
|
||||||
|
options: stringify({}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.itemLayouts).values([
|
||||||
|
{
|
||||||
|
itemId: clockItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 4,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 2,
|
||||||
|
width: 8,
|
||||||
|
height: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.integrationItems).values({
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
integrationId: mockIntegrationId,
|
||||||
});
|
});
|
||||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||||
|
|
||||||
const homarrContainer = await createHomarrContainer({
|
const homarrContainer = await createHomarrContainer({
|
||||||
environment: {
|
environment: {
|
||||||
AUTH_PROVIDERS: "credentials",
|
AUTH_PROVIDERS: "credentials",
|
||||||
|
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||||
},
|
},
|
||||||
mounts: {
|
mounts: {
|
||||||
"/appdata": localMountPath,
|
"/appdata": localMountPath,
|
||||||
@@ -69,10 +102,18 @@ describe("lazy widget application graph", () => {
|
|||||||
|
|
||||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const pageErrors: Error[] = [];
|
const pageErrors: Error[] = [];
|
||||||
|
const hydrationErrors: string[] = [];
|
||||||
page.on("pageerror", (error) => pageErrors.push(error));
|
page.on("pageerror", (error) => pageErrors.push(error));
|
||||||
|
const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i;
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error" && hydrationErrorPattern.test(message.text())) {
|
||||||
|
hydrationErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let releaseRefresh: (() => void) | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`${baseUrl}/auth/login`);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
@@ -88,9 +129,18 @@ describe("lazy widget application graph", () => {
|
|||||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||||
const clockWidget = page.locator(
|
const clockWidget = page.locator(
|
||||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
`[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`,
|
||||||
);
|
);
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const downloadsWidget = page
|
||||||
|
.locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264");
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
@@ -101,23 +151,59 @@ describe("lazy widget application graph", () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||||
|
|
||||||
await expect
|
let refreshRequestStarted = false;
|
||||||
.poll(
|
const refreshGate = new Promise<void>((resolve) => {
|
||||||
async () =>
|
releaseRefresh = resolve;
|
||||||
await page.evaluate(async () => {
|
});
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
||||||
return registrations[0]?.scope ?? null;
|
const delayDownloadsRefresh = async (route: Route) => {
|
||||||
}),
|
refreshRequestStarted = true;
|
||||||
{ timeout: 15_000 },
|
await refreshGate;
|
||||||
)
|
await route.continue();
|
||||||
.toBe(`${baseUrl}/`);
|
};
|
||||||
|
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
||||||
|
|
||||||
|
const refreshedDownloadsResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||||
|
);
|
||||||
|
releaseRefresh();
|
||||||
|
releaseRefresh = undefined;
|
||||||
|
await refreshedDownloadsResponse;
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
||||||
|
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
|
expect(hydrationErrors).toEqual([]);
|
||||||
|
|
||||||
|
const serviceWorkerContext = await browser.newContext();
|
||||||
|
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
||||||
|
try {
|
||||||
|
await serviceWorkerPage.goto(`${baseUrl}/auth/login`);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
await serviceWorkerPage.evaluate(async () => {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
return registrations[0]?.scope ?? null;
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${baseUrl}/`);
|
||||||
|
} finally {
|
||||||
|
await serviceWorkerContext.close();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
releaseRefresh?.();
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await homarrContainer.stop();
|
await homarrContainer.stop();
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 180_000);
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,8 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
|||||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||||
|
|
||||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||||
|
// 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"]);
|
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||||
|
|
||||||
export const isWidgetDataTrpcPath = (path: string) => {
|
export const isWidgetDataTrpcPath = (path: string) => {
|
||||||
|
|||||||
@@ -7771,6 +7771,8 @@
|
|||||||
"ready": "Widget configuration ready",
|
"ready": "Widget configuration ready",
|
||||||
"cancelled": "Widget was not added",
|
"cancelled": "Widget was not added",
|
||||||
"automaticContinue": "Accepting the proposed widget configuration automatically",
|
"automaticContinue": "Accepting the proposed widget configuration automatically",
|
||||||
|
"definitionLoadErrorTitle": "Widget settings could not be loaded",
|
||||||
|
"definitionLoadErrorDescription": "Homarr could not load this widget's settings. Try again.",
|
||||||
"loadErrorTitle": "Integrations could not be loaded",
|
"loadErrorTitle": "Integrations could not be loaded",
|
||||||
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
|
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
|
||||||
"noIntegrationTitle": "A compatible integration is required",
|
"noIntegrationTitle": "A compatible integration is required",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
import { IntegrationAvatar } from "../components/integration-avatar";
|
||||||
import { SelectableCard } from "../components/selectable-card";
|
import { SelectableCard } from "../components/selectable-card";
|
||||||
import { selectGridCols } from "../select-grid-layout";
|
import { selectGridCols } from "../select-grid-layout";
|
||||||
import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared";
|
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||||
import type { IntegrationGridItem } from "./integration-grid-shared";
|
import type { IntegrationGridItem } from "./integration-grid-shared";
|
||||||
|
|
||||||
export interface IntegrationSelectGridProps {
|
export interface IntegrationSelectGridProps {
|
||||||
@@ -134,11 +134,8 @@ const IntegrationCard = ({
|
|||||||
onClick={() => onSelect(integration.kind)}
|
onClick={() => onSelect(integration.kind)}
|
||||||
aria-label={integration.name}
|
aria-label={integration.name}
|
||||||
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
|
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
|
||||||
title={
|
title={integration.name}
|
||||||
<Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
|
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
|
||||||
{integration.name}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
footerLeft={
|
footerLeft={
|
||||||
connectedCount > 0 ? (
|
connectedCount > 0 ? (
|
||||||
<Badge variant="light" color="teal" size="xs" radius="xs">
|
<Badge variant="light" color="teal" size="xs" radius="xs">
|
||||||
@@ -235,3 +232,24 @@ const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; lim
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const IntegrationCategoryBadges = ({
|
||||||
|
categories,
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
categories: IntegrationGridItem["categories"];
|
||||||
|
limit?: number;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const items = limit ? categories.slice(0, limit) : categories;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={4} wrap="wrap">
|
||||||
|
{items.map((category) => (
|
||||||
|
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
|
||||||
|
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,7 +23,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
import type { BookmarkItem } from "./bookmark-item";
|
import type { BookmarkItem } from "./bookmark-item";
|
||||||
@@ -43,14 +44,12 @@ export default function BookmarksWidget({
|
|||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const advanced = displayMode === "advanced";
|
const advanced = displayMode === "advanced";
|
||||||
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
|
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
|
||||||
const apps =
|
const appsQuery = clientApi.app.byIds.useQuery(appIds, {
|
||||||
getUsableWidgetQueryData(
|
select(selectedApps) {
|
||||||
clientApi.app.byIds.useQuery(appIds, {
|
return selectedApps;
|
||||||
select(selectedApps) {
|
},
|
||||||
return selectedApps;
|
});
|
||||||
},
|
const apps = getUsableWidgetQueryData(appsQuery) ?? [];
|
||||||
}),
|
|
||||||
) ?? [];
|
|
||||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||||
const configuredItems = options.items.flatMap((value) => {
|
const configuredItems = options.items.flatMap((value) => {
|
||||||
const directUrl = getDirectBookmarkUrl(value);
|
const directUrl = getDirectBookmarkUrl(value);
|
||||||
@@ -127,6 +126,8 @@ export default function BookmarksWidget({
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
|
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
|
||||||
{options.title.length > 0 ? (
|
{options.title.length > 0 ? (
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { CalendarDay } from "./calender-day";
|
import { CalendarDay } from "./calender-day";
|
||||||
@@ -98,6 +99,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
|||||||
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(calendarQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarBase
|
<CalendarBase
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
|||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import TimerModal from "./TimerModal";
|
import TimerModal from "./TimerModal";
|
||||||
@@ -67,7 +68,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
||||||
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
|
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||||
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
|
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
|
||||||
const { isPending: isSummaryPending } = summaryQuery;
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -122,7 +122,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const t = useI18n("widget.dnsHoleControls");
|
const t = useI18n("widget.dnsHoleControls");
|
||||||
const tCommon = useI18n("common");
|
|
||||||
|
|
||||||
// Timer modal setup
|
// Timer modal setup
|
||||||
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
||||||
@@ -141,15 +140,7 @@ export default function DnsHoleControlsWidget({
|
|||||||
setBulkPending(false);
|
setBulkPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isSummaryPending) {
|
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
|
||||||
return (
|
|
||||||
<Stack h="100%" justify="center" align="center">
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{tCommon("action.loading")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
|
|
||||||
export default function DnsHoleSummaryWidget({
|
export default function DnsHoleSummaryWidget({
|
||||||
options,
|
options,
|
||||||
@@ -43,10 +44,8 @@ export default function DnsHoleSummaryWidget({
|
|||||||
integrationIds,
|
integrationIds,
|
||||||
});
|
});
|
||||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||||
const { isPending } = summaryQuery;
|
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const tDns = useI18n("widget.dnsHoleSummary");
|
const tDns = useI18n("widget.dnsHoleSummary");
|
||||||
const tWidgetCommon = useI18n("widget.common");
|
const tWidgetCommon = useI18n("widget.common");
|
||||||
|
|
||||||
@@ -55,15 +54,7 @@ export default function DnsHoleSummaryWidget({
|
|||||||
const layoutProps = boxPropsByLayout(options.layout);
|
const layoutProps = boxPropsByLayout(options.layout);
|
||||||
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
|
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
|
||||||
|
|
||||||
if (isPending) {
|
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
|
||||||
return (
|
|
||||||
<Stack h="100%" justify="center" align="center">
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{tCommon("action.loading")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap={0} pos="relative">
|
<Stack h="100%" gap={0} pos="relative">
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||||
import { formatLocalizedDateTime } from "../common/locale";
|
import { formatLocalizedDateTime } from "../common/locale";
|
||||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||||
@@ -741,6 +742,8 @@ export default function DownloadClientsWidget({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(downloadsQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
||||||
if (isEditMode) rowContextMenuHandler = undefined;
|
if (isEditMode) rowContextMenuHandler = undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import type { Loader } from "next/dynamic";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Center, Loader as UiLoader } from "@mantine/core";
|
import { Center, Loader as UiLoader } from "@mantine/core";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
|
||||||
|
|
||||||
import * as anchorNote from "./anchor-note";
|
import * as anchorNote from "./anchor-note";
|
||||||
import * as audioStats from "./audio-stats";
|
import * as audioStats from "./audio-stats";
|
||||||
@@ -41,7 +39,6 @@ import * as notebook from "./notebook";
|
|||||||
import * as paperlessNgx from "./paperless-ngx";
|
import * as paperlessNgx from "./paperless-ngx";
|
||||||
import * as patchmon from "./patchmon";
|
import * as patchmon from "./patchmon";
|
||||||
import * as notifications from "./notifications";
|
import * as notifications from "./notifications";
|
||||||
import type { WidgetOptionDefinition } from "./options";
|
|
||||||
import * as releases from "./releases";
|
import * as releases from "./releases";
|
||||||
import * as rssFeed from "./rssFeed";
|
import * as rssFeed from "./rssFeed";
|
||||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||||
@@ -191,19 +188,3 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
|
|||||||
}
|
}
|
||||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||||
: never[])[number];
|
: never[])[number];
|
||||||
|
|
||||||
export const reduceWidgetOptionsWithDefaultValues = (
|
|
||||||
kind: WidgetKind,
|
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
|
||||||
currentValue: Record<string, unknown> = {},
|
|
||||||
) => {
|
|
||||||
const definition = widgetImports[kind].definition;
|
|
||||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
|
||||||
return objectEntries(options).reduce(
|
|
||||||
(prev, [key, value]) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: currentValue[key] ?? value.defaultValue,
|
|
||||||
}),
|
|
||||||
{} as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -15,7 +15,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -35,17 +36,17 @@ export default function MediaServerWidget({
|
|||||||
.filter(({ id }) => integrationIds.includes(id))
|
.filter(({ id }) => integrationIds.includes(id))
|
||||||
.map(({ id }) => id),
|
.map(({ id }) => id),
|
||||||
);
|
);
|
||||||
const mediaRequestData = getUsableWidgetQueryData(
|
const mediaRequestQuery = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||||
clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
integrationIds,
|
||||||
integrationIds,
|
statuses:
|
||||||
statuses:
|
options.statusFilter.length > 0
|
||||||
options.statusFilter.length > 0
|
? options.statusFilter
|
||||||
? options.statusFilter
|
: ["pending", "approved", "declined", "failed", "completed"],
|
||||||
: ["pending", "approved", "declined", "failed", "completed"],
|
recentDays: options.recentDays,
|
||||||
recentDays: options.recentDays,
|
});
|
||||||
}),
|
const mediaRequestData = getUsableWidgetQueryData(mediaRequestQuery);
|
||||||
);
|
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!mediaRequestData) return <WidgetEmptyState />;
|
if (!mediaRequestData) return <WidgetEmptyState />;
|
||||||
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
||||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -42,10 +43,12 @@ export default function MediaServerWidget({
|
|||||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||||
const t = useI18n("widget.mediaRequests-requestStats");
|
const t = useI18n("widget.mediaRequests-requestStats");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }));
|
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
|
||||||
|
const requestStats = getUsableWidgetQueryData(requestStatsQuery);
|
||||||
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(requestStatsQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!requestStats) return <WidgetEmptyState />;
|
if (!requestStats) return <WidgetEmptyState />;
|
||||||
if (
|
if (
|
||||||
requestStats.users.length === 0 &&
|
requestStats.users.length === 0 &&
|
||||||
|
|||||||
Generated
+25
@@ -201,6 +201,9 @@ catalogs:
|
|||||||
'@tanstack/react-query-next-experimental':
|
'@tanstack/react-query-next-experimental':
|
||||||
specifier: ^5.101.2
|
specifier: ^5.101.2
|
||||||
version: 5.101.2
|
version: 5.101.2
|
||||||
|
'@tanstack/react-query-persist-client':
|
||||||
|
specifier: ^5.101.2
|
||||||
|
version: 5.101.2
|
||||||
'@testcontainers/mysql':
|
'@testcontainers/mysql':
|
||||||
specifier: ^12.0.4
|
specifier: ^12.0.4
|
||||||
version: 12.0.4
|
version: 12.0.4
|
||||||
@@ -1142,6 +1145,9 @@ importers:
|
|||||||
'@tanstack/react-query-next-experimental':
|
'@tanstack/react-query-next-experimental':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
|
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
|
||||||
|
'@tanstack/react-query-persist-client':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
|
||||||
'@trpc/client':
|
'@trpc/client':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2)
|
version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2)
|
||||||
@@ -8831,6 +8837,9 @@ packages:
|
|||||||
'@tanstack/query-devtools@5.101.2':
|
'@tanstack/query-devtools@5.101.2':
|
||||||
resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==}
|
resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.101.2':
|
||||||
|
resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==}
|
||||||
|
|
||||||
'@tanstack/react-devtools@0.10.9':
|
'@tanstack/react-devtools@0.10.9':
|
||||||
resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
|
resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -8853,6 +8862,12 @@ packages:
|
|||||||
next: ^13 || ^14 || ^15 || ^16
|
next: ^13 || ^14 || ^15 || ^16
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
|
|
||||||
|
'@tanstack/react-query-persist-client@5.101.2':
|
||||||
|
resolution: {integrity: sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tanstack/react-query': ^5.101.2
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@tanstack/react-query@5.101.2':
|
'@tanstack/react-query@5.101.2':
|
||||||
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
|
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -23923,6 +23938,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/query-devtools@5.101.2': {}
|
'@tanstack/query-devtools@5.101.2': {}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.101.2':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-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)':
|
'@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:
|
dependencies:
|
||||||
'@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
|
'@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
|
||||||
@@ -23948,6 +23967,12 @@ snapshots:
|
|||||||
next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
|
next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|
||||||
|
'@tanstack/react-query-persist-client@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-persist-client-core': 5.101.2
|
||||||
|
'@tanstack/react-query': 5.101.2(react@19.2.7)
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
'@tanstack/react-query@5.101.2(react@19.2.7)':
|
'@tanstack/react-query@5.101.2(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.101.2
|
'@tanstack/query-core': 5.101.2
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ catalog:
|
|||||||
"@tanstack/react-query": ^5.101.2
|
"@tanstack/react-query": ^5.101.2
|
||||||
"@tanstack/react-query-devtools": ^5.101.2
|
"@tanstack/react-query-devtools": ^5.101.2
|
||||||
"@tanstack/react-query-next-experimental": ^5.101.2
|
"@tanstack/react-query-next-experimental": ^5.101.2
|
||||||
|
"@tanstack/react-query-persist-client": ^5.101.2
|
||||||
"@testcontainers/mysql": ^12.0.4
|
"@testcontainers/mysql": ^12.0.4
|
||||||
"@testcontainers/postgresql": ^12.0.4
|
"@testcontainers/postgresql": ^12.0.4
|
||||||
"@testcontainers/redis": ^12.0.4
|
"@testcontainers/redis": ^12.0.4
|
||||||
|
|||||||
Reference in new issue
Block a user