fix: address release v2 review findings
This commit is contained in:
1 parent
f0eddda573
commit
16e437decc
35 files changed
+587
-258
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
getQueryPersistenceStorageKey,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxDataBytes,
|
||||
queryPersistenceThrottleMs,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
|
||||
@@ -54,6 +55,15 @@ describe("widget query persistence", () => {
|
||||
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
});
|
||||
const forbidden = createSuccessfulQuery(
|
||||
[["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||
[{ title: "revoked" }],
|
||||
);
|
||||
forbidden.query.setState({
|
||||
...forbidden.query.state,
|
||||
error: Object.assign(new Error("access denied"), { data: { code: "FORBIDDEN" } }),
|
||||
status: "error",
|
||||
});
|
||||
|
||||
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
|
||||
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
||||
@@ -61,6 +71,7 @@ describe("widget query persistence", () => {
|
||||
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||
expect(shouldPersistDashboardQuery(forbidden.query)).toBe(false);
|
||||
});
|
||||
|
||||
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
|
||||
@@ -170,6 +181,19 @@ describe("widget query persistence", () => {
|
||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||
|
||||
query.setState({
|
||||
...query.state,
|
||||
error: Object.assign(new Error("access denied"), { data: { code: "FORBIDDEN" } }),
|
||||
status: "error",
|
||||
});
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
|
||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
@@ -280,6 +304,10 @@ describe("widget query persistence", () => {
|
||||
const restored = await persister.restoreClient();
|
||||
expect(restored?.clientState.queries).toHaveLength(1);
|
||||
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
|
||||
|
||||
persister.persistClient(persistedClient);
|
||||
persister.flush();
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
|
||||
@@ -302,10 +330,12 @@ describe("widget query persistence", () => {
|
||||
clientState: dehydrate(latest.queryClient),
|
||||
});
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(queryPersistenceThrottleMs - 1);
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
persister.flush();
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(queryPersistenceThrottleMs);
|
||||
expect(setItem).toHaveBeenCalledOnce();
|
||||
|
||||
const restored = await persister.restoreClient();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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 queryPersistenceMaxCacheBytes = 2 * 1024 * 1024;
|
||||
export const queryPersistenceThrottleMs = 1_000;
|
||||
export const queryPersistenceIdleTimeoutMs = 1_000;
|
||||
export const queryPersistenceBuster = "v2-session-superjson";
|
||||
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||
@@ -19,6 +21,7 @@ interface PersistableQuery {
|
||||
state: {
|
||||
data: unknown;
|
||||
dataUpdatedAt: number;
|
||||
error?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +30,28 @@ export type SessionQueryPersister = Persister & { flush: () => void; stop: () =>
|
||||
const textEncoder = new TextEncoder();
|
||||
const budgetVerdictCache = new WeakMap<object, boolean>();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
interface ForbiddenTrpcError {
|
||||
data: { code: "FORBIDDEN" };
|
||||
}
|
||||
|
||||
const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => {
|
||||
if (!isRecord(error) || !isRecord(error.data)) return false;
|
||||
return error.data.code === "FORBIDDEN";
|
||||
};
|
||||
|
||||
const isQuotaExceededError = (error: unknown): boolean => {
|
||||
if (!isRecord(error)) return false;
|
||||
return (
|
||||
error.name === "QuotaExceededError" ||
|
||||
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
error.code === 22 ||
|
||||
error.code === 1014
|
||||
);
|
||||
};
|
||||
|
||||
const measureFitsBudget = (data: unknown) => {
|
||||
try {
|
||||
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
||||
@@ -35,6 +60,8 @@ const measureFitsBudget = (data: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
const measureSerializedBytes = (data: unknown) => textEncoder.encode(stringify(data)).byteLength;
|
||||
|
||||
const fitsStorageBudget = (data: unknown) => {
|
||||
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
||||
|
||||
@@ -53,10 +80,25 @@ const holdsDashboardData = (query: PersistableQuery) =>
|
||||
isWidgetDataQueryKey(query.queryKey);
|
||||
|
||||
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||
holdsDashboardData(query) && !isTrpcForbiddenError(query.state.error) && fitsStorageBudget(query.state.data);
|
||||
|
||||
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
||||
|
||||
const keepNewestQueries = (client: PersistedClient, maximumQueries: number): PersistedClient | undefined => {
|
||||
if (maximumQueries <= 0) return undefined;
|
||||
if (client.clientState.queries.length <= maximumQueries) return client;
|
||||
|
||||
return {
|
||||
...client,
|
||||
clientState: {
|
||||
...client.clientState,
|
||||
queries: client.clientState.queries
|
||||
.toSorted((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt)
|
||||
.slice(0, maximumQueries),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||
...query,
|
||||
state: {
|
||||
@@ -76,13 +118,12 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
||||
...client,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: client.clientState.queries.map(sanitizePersistedQuery),
|
||||
queries: client.clientState.queries
|
||||
.filter((query) => !isTrpcForbiddenError(query.state.error))
|
||||
.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;
|
||||
@@ -146,15 +187,53 @@ export const createSessionQueryPersister = (
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
removeForeignScopes(storage, key);
|
||||
let pendingClient: PersistedClient | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistDelayTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistIdleCallback: number | undefined;
|
||||
let persistedQueryLimit = Number.POSITIVE_INFINITY;
|
||||
let persistenceDisabled = false;
|
||||
let stopped = false;
|
||||
|
||||
const persistNow = (client: PersistedClient) => {
|
||||
if (!storage) return;
|
||||
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
|
||||
let errorCount = 0;
|
||||
const trimToQueryLimit = (client: PersistedClient): PersistedClient | undefined => {
|
||||
const limitedClient = keepNewestQueries(client, persistedQueryLimit);
|
||||
if (!limitedClient || measureSerializedBytes(limitedClient) <= queryPersistenceMaxCacheBytes) return limitedClient;
|
||||
|
||||
if (candidate.clientState.queries.length === 0) {
|
||||
const emptyClient: PersistedClient = {
|
||||
...limitedClient,
|
||||
clientState: { ...limitedClient.clientState, queries: [] },
|
||||
};
|
||||
let remainingBytes = queryPersistenceMaxCacheBytes - measureSerializedBytes(emptyClient);
|
||||
const selectedQueries: DehydratedQuery[] = [];
|
||||
const newestQueries = limitedClient.clientState.queries.toSorted(
|
||||
(left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt,
|
||||
);
|
||||
|
||||
for (const query of newestQueries) {
|
||||
const queryBytes = measureSerializedBytes(query) + 1;
|
||||
if (queryBytes > remainingBytes) continue;
|
||||
selectedQueries.push(query);
|
||||
remainingBytes -= queryBytes;
|
||||
}
|
||||
|
||||
return {
|
||||
...limitedClient,
|
||||
clientState: { ...limitedClient.clientState, queries: selectedQueries },
|
||||
};
|
||||
};
|
||||
|
||||
const persistNow = (client: PersistedClient) => {
|
||||
if (!storage || persistenceDisabled) return;
|
||||
let candidate: PersistedClient | undefined;
|
||||
let sanitizedQueryCount = 0;
|
||||
try {
|
||||
const sanitizedClient = sanitizePersistedClient(client);
|
||||
sanitizedQueryCount = sanitizedClient.clientState.queries.length;
|
||||
candidate = trimToQueryLimit(sanitizedClient);
|
||||
} catch {
|
||||
persistenceDisabled = true;
|
||||
removeStorageItem(storage, key);
|
||||
return;
|
||||
}
|
||||
if (!candidate || candidate.clientState.queries.length === 0) {
|
||||
removeStorageItem(storage, key);
|
||||
return;
|
||||
}
|
||||
@@ -162,37 +241,56 @@ export const createSessionQueryPersister = (
|
||||
while (candidate) {
|
||||
try {
|
||||
storage.setItem(key, stringify(candidate));
|
||||
if (candidate.clientState.queries.length < sanitizedQueryCount) {
|
||||
persistedQueryLimit = Math.min(persistedQueryLimit, candidate.clientState.queries.length);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
errorCount += 1;
|
||||
candidate = removeOldestQuery({
|
||||
persistedClient: candidate,
|
||||
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
|
||||
errorCount,
|
||||
});
|
||||
removeStorageItem(storage, key);
|
||||
if (!isQuotaExceededError(error)) {
|
||||
persistenceDisabled = true;
|
||||
return;
|
||||
}
|
||||
candidate = keepNewestQueries(candidate, Math.floor(candidate.clientState.queries.length / 2));
|
||||
}
|
||||
}
|
||||
|
||||
persistenceDisabled = true;
|
||||
};
|
||||
|
||||
const clearScheduledPersist = () => {
|
||||
if (persistDelayTimer !== undefined) {
|
||||
clearTimeout(persistDelayTimer);
|
||||
persistDelayTimer = undefined;
|
||||
}
|
||||
if (persistIdleCallback !== undefined && typeof window !== "undefined") {
|
||||
window.cancelIdleCallback?.(persistIdleCallback);
|
||||
persistIdleCallback = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPersistTimer = () => {
|
||||
if (persistTimer === undefined) return;
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
clearPersistTimer();
|
||||
clearScheduledPersist();
|
||||
const client = pendingClient;
|
||||
pendingClient = undefined;
|
||||
if (client) persistNow(client);
|
||||
};
|
||||
|
||||
const scheduleIdlePersist = () => {
|
||||
persistDelayTimer = undefined;
|
||||
if (typeof window !== "undefined" && window.requestIdleCallback) {
|
||||
persistIdleCallback = window.requestIdleCallback(flush, { timeout: queryPersistenceIdleTimeoutMs });
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
};
|
||||
|
||||
return {
|
||||
persistClient(client) {
|
||||
if (!storage || stopped) return;
|
||||
if (!storage || stopped || persistenceDisabled) return;
|
||||
pendingClient = client;
|
||||
if (persistTimer !== undefined) return;
|
||||
persistTimer = setTimeout(flush, 0);
|
||||
if (persistDelayTimer !== undefined || persistIdleCallback !== undefined) return;
|
||||
persistDelayTimer = setTimeout(scheduleIdlePersist, queryPersistenceThrottleMs);
|
||||
},
|
||||
restoreClient() {
|
||||
try {
|
||||
@@ -212,14 +310,14 @@ export const createSessionQueryPersister = (
|
||||
}
|
||||
},
|
||||
removeClient() {
|
||||
clearPersistTimer();
|
||||
clearScheduledPersist();
|
||||
pendingClient = undefined;
|
||||
removeStorageItem(storage, key);
|
||||
},
|
||||
flush,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearPersistTimer();
|
||||
clearScheduledPersist();
|
||||
pendingClient = undefined;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
interface CopyAiPromptButtonBaseProps {
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
request?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
getDraft: () => CustomWidgetAiDraft;
|
||||
getDiagnostics: () => readonly CustomWidgetAiDiagnostic[];
|
||||
}
|
||||
|
||||
type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps &
|
||||
({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) &
|
||||
(
|
||||
| { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never }
|
||||
| { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] }
|
||||
);
|
||||
|
||||
export const CopyAiPromptButton = ({
|
||||
rawResponse,
|
||||
draft,
|
||||
getDraft,
|
||||
diagnostics,
|
||||
getDiagnostics,
|
||||
request,
|
||||
documentationUrl,
|
||||
@@ -35,23 +28,14 @@ export const CopyAiPromptButton = ({
|
||||
const t = useI18n("customWidget");
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
|
||||
const readDraft = () => {
|
||||
if (getDraft) return getDraft();
|
||||
return draft;
|
||||
};
|
||||
const readDiagnostics = () => {
|
||||
if (getDiagnostics) return getDiagnostics();
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
const handleAssistant = () => {
|
||||
const prompt = buildCustomWidgetAssistantPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
readDraft(),
|
||||
getDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
getDiagnostics(),
|
||||
);
|
||||
if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return;
|
||||
showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") });
|
||||
@@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({
|
||||
const value = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
rawResponse,
|
||||
readDraft(),
|
||||
getDraft(),
|
||||
request,
|
||||
documentationUrl,
|
||||
readDiagnostics(),
|
||||
getDiagnostics(),
|
||||
);
|
||||
await navigator.clipboard.writeText(value);
|
||||
showSuccessNotification({
|
||||
|
||||
+3
-7
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ConfigMapsTableComponentProps {
|
||||
@@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialConfigMaps,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialConfigMaps),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialIngresses,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialIngresses),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
interface QueryWithStatus {
|
||||
state: {
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
const getErrorRefetchInterval = (query: QueryWithStatus) => {
|
||||
if (query.state.status === "error") return 30_000;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({
|
||||
initialData,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: getErrorRefetchInterval,
|
||||
});
|
||||
+3
-7
@@ -15,6 +15,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface NamespacesTableComponentProps {
|
||||
@@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl
|
||||
|
||||
const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNamespaces,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialNamespaces),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import KubernetesErrorPage from "../cluster-dashboard/error";
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps)
|
||||
|
||||
const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialNodes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialNodes),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface PodsTableComponentProps {
|
||||
@@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) {
|
||||
|
||||
const { data } = clientApi.kubernetes.pods.getPods.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialPods,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialPods),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface SecretsTableComponentProps {
|
||||
@@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialSecrets,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialSecrets),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface ServicesTableComponentProps {
|
||||
@@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo
|
||||
|
||||
const { data } = clientApi.kubernetes.services.getServices.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialServices,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialServices),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface VolumesTableComponentProps {
|
||||
@@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen
|
||||
|
||||
const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(
|
||||
{ contextId },
|
||||
{
|
||||
initialData: initialVolumes,
|
||||
refetchOnMount: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false),
|
||||
},
|
||||
createKubernetesResourceQueryOptions(initialVolumes),
|
||||
);
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const overview = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
color: #f8f9fa;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 18% 15%, rgba(116, 143, 252, .32), transparent 36%),
|
||||
radial-gradient(circle at 82% 80%, rgba(59, 201, 219, .22), transparent 42%),
|
||||
#15121f;
|
||||
}
|
||||
main { width: min(86%, 34rem); text-align: center; }
|
||||
.mark {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 1.25rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #748ffc, #3bc9db);
|
||||
box-shadow: 0 1rem 3rem rgba(116, 143, 252, .34);
|
||||
}
|
||||
h1 { margin: 0; font-size: clamp(1.5rem, 8vw, 2.5rem); letter-spacing: -.04em; }
|
||||
p { margin: .65rem auto 0; max-width: 28rem; color: #c1c2c5; line-height: 1.55; }
|
||||
.signals { display: flex; justify-content: center; gap: .55rem; margin-top: 1.25rem; flex-wrap: wrap; }
|
||||
span { padding: .38rem .7rem; border: 1px solid rgba(255,255,255,.13); border-radius: 999px; background: rgba(255,255,255,.06); font-size: .75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="mark">H</div>
|
||||
<h1>Your home, at a glance.</h1>
|
||||
<p>Bring services, media, infrastructure, notes and everyday tools together on one practical dashboard.</p>
|
||||
<div class="signals"><span>59 widgets</span><span>Mock data included</span><span>Built for self-hosters</span></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const GET = () =>
|
||||
new Response(overview, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from "../route";
|
||||
@@ -0,0 +1,10 @@
|
||||
const videoBase64 =
|
||||
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAWDbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAC7gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAABK10cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAC7gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAoAAAAFoAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAu4AAAEAAABAAAAAAQlbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAyAAAAlgBVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAD0G1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAA5BzdGJsAAAAxHN0c2QAAAAAAAAAAQAAALRhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAoABaABIAAAASAAAAAAAAAABFUxhdmM2MC4zMS4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBZAAf/+EAHGdkAB+scgRAoC/5cBEAAAMAAQAAAwAyDxgxhGABAAdo6EOESyLA/fj4AAAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAADSIAAA0iAAAABhzdHRzAAAAAAAAAAEAAABLAAACAAAAABRzdHNzAAAAAAAAAAEAAAABAAABKGN0dHMAAAAAAAAAIwAAAAEAAAQAAAAAAQAAFAAAAAABAAAIAAAAAAMAAAAAAAAABAAAAgAAAAABAAAUAAAAAAEAAAgAAAAAAwAAAAAAAAAEAAACAAAAAAEAABQAAAAAAQAACAAAAAADAAAAAAAAAAQAAAIAAAAAAQAAFAAAAAABAAAIAAAAAAMAAAAAAAAABAAAAgAAAAABAAAUAAAAAAEAAAgAAAAAAwAAAAAAAAAEAAACAAAAAAEAABQAAAAAAQAACAAAAAADAAAAAAAAAAQAAAIAAAAAAQAAFAAAAAABAAAIAAAAAAMAAAAAAAAABAAAAgAAAAABAAAUAAAAAAEAAAgAAAAAAwAAAAAAAAAEAAACAAAAAAEAAAYAAAAAAQAAAgAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAEsAAAABAAABQHN0c3oAAAAAAAAAAAAAAEsAAAp/AAAAKAAAADYAAAAuAAAALgAAAC4AAAAVAAAAFQAAACsAAAArAAAAHgAAAC0AAAASAAAAEgAAABIAAAASAAAAEgAAACsAAAArAAAAHQAAAC0AAAApAAAAEgAAABIAAAASAAAAEgAAACsAAAArAAAAHwAAAC0AAAApAAAAEgAAABIAAAASAAAAEgAAACsAAAArAAAAIAAAAC0AAAApAAAAEgAAABIAAAASAAAAEgAAACsAAAArAAAAIgAAAC0AAAApAAAAEgAAABIAAAASAAAAEgAAACsAAAArAAAAIwAAAC0AAAApAAAAEgAAABIAAAASAAAAEgAAACsAAAArAAAAJAAAADAAAAApAAAAEgAAABIAAAASAAAAEgAAACsAAAArAAAAJAAAAC0AAAAUc3RjbwAAAAAAAAABAAAFswAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWlsc3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNjAuMTYuMTAwAAAACGZyZWUAABO7bWRhdAAAArEGBf//rdxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjQgcjMxMDggMzFlMTlmOSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjMgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0xIHJlZj0xNiBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgzOjB4MTMzIG1lPXVtaCBzdWJtZT0xMCBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTI0IGNocm9tYV9tZT0xIHRyZWxsaXM9MiA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTExIGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJheV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9OCBiX3B5cmFtaWQ9MiBiX2FkYXB0PTIgYl9iaWFzPTAgZGlyZWN0PTMgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtleWludD0yNTAga2V5aW50X21pbj0yNSBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTYwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MzAuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAB8ZliIEAAh/+1GnzLKQyqJdyUGf7u3t1RgUL4NxlmVrUgAAAAwAAAwADHPYATo+S1JI9TzlVqGg7VLww20dzBUqkpT62SLJ0AAADAOGIQAF3gHqYQ7gsCCL6IPIj5ilEQaZBZPx1vH8TE9WObB8bzOPrkI1gQnLGiQ8SMNGbqlhpFM7/XBvyWVGZO21to5CK8+ksF8emrDvbn4gC54pJ4gtz2Blw4MMWkHIjJvN342q86JQ4LUCAhPS9HmS0tBK3yHn0KdJx6drx9GBjeuDYwc4Sx0/ca0JZ/W44Hd0irgfzuBCgJOb/BoJUbKSu2MVByWo2sdl6hTU23zYGhze+61rkYWtAFeGOXd7SGSQG/3/FfZ6uEQFybALfmAycn+jbhTNZLOGy3QXb9BrpLocmSmeJHsoMQTP0TeWXhUVNa1FNahXtmruRwAKk0lV+pIpo1j+TXfzxg5AthnqfQVNOEzUSjM+OslmJXFOiI8Bnhvg/fEJ+Iz5tNWRswWgu5RUuVFkVIE9QM1sZWmp7pi0d3HrRpWU0KkznmT2u1gf7YSnnVaxoKzNmFNJ5LU5OUvr4vJntruNpiRGvP79WrC7+q/12LKre4TMxv5f5GlhsNAocJSo0f260IOHGPVmkvA4yUrLo82AWOJZ4yrkC9tA9y7919Xj5LmRaCNu/o4209U1Ex5RdTvRLy2b9Z+6KFJa5po5qygglYokNtkupr2SFrE63bz2GQvIXVNZunOZZu+l/5JiB+Sm0Ruy/ciZBv/+ONM0kFMoVBP+xZkUcB2xWTiG/Mz8VyjlxcqGLpRiOVy70R2uTu4Ho5kPztAYPY9mfOdGqybryu4AVuDrVF0BBPY3FKEzAbk7/U9ZwGPXKomIa1q5w4iF7ufwOA6tTYLqxGEdI4X6XGemE2JsdRBGF1xxSmaJ40I8NYDVKk8KepKGw1j2HWsJ+PbQ50kXWMY/8Jsbsm9+Ia7g51YRM4Orj2PTJlLTALLMsq5JTymRcpkou17XApdJsf1BfwYLdUeNVu9gR4QsdRP0AmTEY4G7mrndk4BSqJ2auJgrB91h3E5FUFuYCa79qzA3rdN+CJuIAeKN3lakuPzwpCWc5+DJ8Fd94HCxmB8F9RRDSalUY2B2eu65P0FbZbwkVMn+LfR69/2YpuYgwVUS4RB7/xRVmx0Bb3E8zJ0H4qpsV5bc7lRvr/cwXdOiNpKRzQOzcYU4kaxbjhaNIw5VBclI5lvZJweejWxLTvqVac2AJ1NyIXyGFC9Cjc5jAkruatDteFnbsGyBgrPnlsKZC4aMhzrFyqx8xzvva2lALTyUt9nfOElCFD7AOCugOrZh8Rrqw65lK7O8C0i3NwjsbXiU0VSbYXitubfy4Bb3gz0JM/w+FDZ3xcWIHhtEy2rBqUrtN9vdSxI+75z4QXa4YymNqWU6nblqFJH4vwdRspfiKG+Lef7nrwjkXoU87T9sJrft66VIBwQcfc0/wF4hu+RW/CXrueByhe/UwOnrm/QDqRcV86TN5iQWQdC/fwZv1PkHy2ul0PVSDW6CPnHsKDC1Xg1hwZUbBL7vSf7PSE4mdSugiI1nL1k5mxYIhUdG3BNkP7IiOcqjMxPbRTdpDSOIQjTdLua+424k3//mGGQMy9DwLBSRQo+cIhyCfxCwR69EYJm6y/1ERhYX2hMy0lWJqqLETg8JiMFbvDfj8431Si2dUgN+0OLqKn17bNrckRI9bPlzAKugSlRBGn9DTwYJvDbbdLnpcN97FLldAoFP+sSDAqugNPVjfmzvc8PDVHO7tzXHhQ/lGxzo7V+cMH1BXb9R4D23HGSyCGTN86uTESdWXBp1M45EwwHlwk47eXhp0zIsZRXDNI++aumypyQNgJfyf6cM9/yZshiiy4Om1E7vAmSv2/W7pe0tw9szIQk9e8kzwQ+zzY2XBCzbyKsTjygEJq3/qPZHbaK1NByTYJZoURb0vBq29z/+YOMQ7Dc04aF/tlozRN99kqMqDNHZbXTsmdWV656KUBqip2Tth/X+C2zLdkFfRDoDXuE+HwI2ZZG8A2WL1riAVH8l3a/vFOYyvXYqu1SE7kfK0Z9TjDJ96IwqcHEv3do8M9phRlTgdmWAS9dpDB695k5i9
Line truncated
|
||||
|
||||
export const GET = () =>
|
||||
new Response(Buffer.from(videoBase64, "base64"), {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
});
|
||||
@@ -11,16 +11,17 @@ import classes from "./board-layout-thumbnail.module.css";
|
||||
interface BoardLayoutThumbnailProps {
|
||||
preview: BoardPreviewData | null;
|
||||
label: string;
|
||||
fitFullLayout?: boolean;
|
||||
previewRowLimit?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const maxThumbnailRows = 12;
|
||||
const compactBoardLayoutThumbnailRows = 12;
|
||||
export const maxBoardLayoutThumbnailRows = 48;
|
||||
|
||||
export const BoardLayoutThumbnail = ({
|
||||
preview,
|
||||
label,
|
||||
fitFullLayout = false,
|
||||
previewRowLimit = compactBoardLayoutThumbnailRows,
|
||||
className,
|
||||
}: BoardLayoutThumbnailProps) => {
|
||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||
@@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({
|
||||
}
|
||||
|
||||
const elements = projectBoardLayout(preview, layout, layout);
|
||||
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
|
||||
const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows));
|
||||
const roots = preview.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
@@ -94,9 +95,21 @@ export const BoardLayoutThumbnail = ({
|
||||
}}
|
||||
>
|
||||
{laneElements.map((element) => {
|
||||
const renderedWidth = Math.min(element.width, columnCount - element.xOffset);
|
||||
const renderedHeight = Math.min(element.height, rowCount - element.yOffset);
|
||||
const nestedItems =
|
||||
element.type === "section"
|
||||
? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id)
|
||||
? elements.filter(
|
||||
(candidate) =>
|
||||
candidate.type === "item" &&
|
||||
candidate.sectionId === element.id &&
|
||||
candidate.xOffset >= 0 &&
|
||||
candidate.xOffset < renderedWidth &&
|
||||
candidate.yOffset >= 0 &&
|
||||
candidate.yOffset < renderedHeight &&
|
||||
candidate.width > 0 &&
|
||||
candidate.height > 0,
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<span
|
||||
@@ -104,16 +117,16 @@ export const BoardLayoutThumbnail = ({
|
||||
aria-hidden
|
||||
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
|
||||
style={{
|
||||
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
|
||||
gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${renderedHeight}`,
|
||||
}}
|
||||
>
|
||||
{element.type === "section" ? (
|
||||
<span
|
||||
className={classes.containerContents}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{nestedItems.map((nestedItem) => (
|
||||
@@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({
|
||||
style={{
|
||||
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
|
||||
nestedItem.width,
|
||||
element.width - nestedItem.xOffset,
|
||||
renderedWidth - nestedItem.xOffset,
|
||||
)}`,
|
||||
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
|
||||
nestedItem.height,
|
||||
element.height - nestedItem.yOffset,
|
||||
renderedHeight - nestedItem.yOffset,
|
||||
)}`,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail";
|
||||
|
||||
import classes from "./board-switcher.module.css";
|
||||
|
||||
export const boardSwitcherHotkey = "shift+c";
|
||||
const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const;
|
||||
|
||||
export interface BoardSwitcherControls {
|
||||
open: () => void;
|
||||
@@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
data: boards = [],
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.board.getManageOverview.useQuery(
|
||||
{ fullPreview: true },
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
} = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const switcherBoards = useMemo(
|
||||
() => [
|
||||
...boards.filter((board) => board.id !== currentBoard?.id),
|
||||
...boards.filter((board) => board.id === currentBoard?.id),
|
||||
],
|
||||
() => boards.filter((board) => board.id !== currentBoard?.id),
|
||||
[boards, currentBoard?.id],
|
||||
);
|
||||
const filteredBoards = useMemo(() => {
|
||||
@@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview);
|
||||
|
||||
const spotlightAction = useMemo(
|
||||
() => ({
|
||||
@@ -335,7 +330,7 @@ const getBoardSwitcherResults = ({
|
||||
<BoardLayoutThumbnail
|
||||
preview={board.preview}
|
||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||
fitFullLayout
|
||||
previewRowLimit={boardSwitcherPreview.previewRowLimit}
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Section withBorder px="sm" py={6}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
|
||||
@@ -21,7 +22,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
|
||||
interface BoardItemMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
previewDimensions: WidgetPreviewDimensions;
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definit
|
||||
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
|
||||
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
@@ -35,7 +36,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
previewDimensions: { width: number; height: number; scale?: number };
|
||||
previewDimensions: WidgetPreviewDimensions;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
|
||||
@@ -54,11 +54,13 @@ export const LazySpotlight = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMounted || isPreloaded ? (
|
||||
if (!isMounted && !isPreloaded) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
|
||||
<Spotlight />
|
||||
{isMounted && <Spotlight />}
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user