Compare commits

..
Author SHA1 Message Date
ajnart ab6a0f3b96 style: auto-fix lint and format 2026-06-22 12:08:39 +00:00
ajnart d6b6287d6d fix(beszel): harden cache layer and prevent blank widgets
- Add debug logging across Beszel integration, request handler, and tRPC
  router (auth, fetch, SSE, per-procedure entry/exit) gated behind
  LOG_LEVEL=debug
- cached-request-handler: retry with backoff, isValid predicate to skip
  caching invalid data, in-flight dedup (single-flight per channel) to
  avoid thundering herd after cache invalidation
- redis channel: set-then-publish so storage is always consistent even
  when pubsub publish fails
- Beszel handlers: opt into fallbackToStaleOnError and retry so brief
  upstream hiccups don't throw away good cached data
- beszel-system-stats: add refetchInterval matching each time period's
  cache TTL so historical charts refresh in the background
- tRPC router: switch Promise.all -> Promise.allSettled for getSystems
  and getAlerts; getSystemStats returns an error object instead of
  throwing. Each result carries an optional error field so one failed
  integration no longer nukes the whole widget
- Widgets: add explicit isPending loader, empty state for grid and
  stats, and a shared per-integration error indicator (tooltip with
  failed integration names). Alerts and table now only show their
  empty state after loading completes
- Translation: add empty.noSystems key to beszelSystemGrid and
  beszelSystemStats

Addresses #6030
2026-06-22 14:07:16 +02:00
16 changed files with 839 additions and 892 deletions

No files matched your search

-14
View File
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
RUN corepack enable pnpm && pnpm build
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
# See https://github.com/homarr-labs/homarr/issues/5891
RUN cd apps/nextjs/.next/server && \
find . -name '*.map' | while IFS= read -r map; do \
dest="../standalone/apps/nextjs/.next/server/$map"; \
mkdir -p "$(dirname "$dest")"; \
cp "$map" "$dest"; \
done
FROM base AS runner
WORKDIR /app
@@ -75,9 +64,6 @@ ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
ENV NODE_OPTIONS='--enable-source-maps'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
-4
View File
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
},
output: "standalone",
reactStrictMode: true,
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
productionBrowserSourceMaps: true,
// react compiler breaks mantine-react-table, so disabled for now
//reactCompiler: true,
/** We already do typechecking as separate tasks in CI */
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
serverSourceMaps: true,
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
+9 -25
View File
@@ -15,36 +15,20 @@ export const iconsRouter = createTRPCRouter({
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? "";
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
let whereCondition = undefined;
if (term.length > 0) {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: { id: true, name: true, url: true },
where: whereCondition,
orderBy: (table, { asc, sql }) => {
// ponytail: SVG first, then alphabetical; with a search term,
// prepend a relevance tier (exact > prefix > substring).
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
return [svgFirst, nameAsc];
}
const tier = sql`CASE
WHEN LOWER(${table.name}) = ${term} THEN 0
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
ELSE 3
END`;
return [tier, svgFirst, nameAsc];
columns: {
id: true,
name: true,
url: true,
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
},
@@ -1,11 +1,8 @@
"use client";
import type { FocusEventHandler } from "react";
import { startTransition, useEffect, useState } from "react";
import { startTransition } from "react";
import {
ActionIcon,
Box,
Button,
Card,
Combobox,
Flex,
@@ -21,7 +18,7 @@ import {
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -37,6 +34,7 @@ interface IconPickerProps {
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
withAsterisk?: boolean;
@@ -52,30 +50,29 @@ export const IconPicker = ({
label,
placeholder,
}: IconPickerProps) => {
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
const [query, setQuery] = useState("");
// ponytail: reset the typed query when the committed value changes
// externally (form reset, parent re-render) so the input reverts to the URL.
useEffect(() => {
setQuery("");
}, [value]);
const [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
// ponytail: only search when the typed query differs from the committed
// value. On mount the input shows the existing URL (edit mode), which
// would never match the bare filenames in the icon table.
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
const [debouncedSearch] = useDebouncedValue(search, 100);
// ponytail: not useSuspenseQuery — an above Suspense boundary would
// trigger on every keystroke and the dropdown flash is bad UX.
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText },
{ placeholderData: (prev) => prev },
);
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
const { data } = clientApi.icon.findIcons.useQuery({
searchText: debouncedSearch,
});
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
@@ -93,15 +90,25 @@ export const IconPicker = ({
>
<UnstyledButton
onClick={() => {
const value = item.url;
startTransition(() => {
setValue(item.url);
setQuery("");
setPreviewUrl(value);
setSearch(value);
setValue(value);
combobox.closeDropdown();
});
}}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
<Card
p="sm"
pos="relative"
style={{
overflow: "visible",
cursor: "pointer",
}}
className={classes.iconCard}
>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} />
</Box>
@@ -123,48 +130,6 @@ export const IconPicker = ({
);
});
// ponytail: show the typed query if non-empty, else fall back to the URL.
const inputValue = query || value || "";
// ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
// ponytail: prop override -> real count -> loading text.
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const renderDropdown = () => {
if (isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
</Stack>
);
};
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
@@ -172,11 +137,16 @@ export const IconPicker = ({
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={leftSection}
value={inputValue}
leftSection={
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
setQuery(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -186,21 +156,25 @@ export const IconPicker = ({
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setQuery("");
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
placeholder={placeholderText}
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
setValue(first.url);
setPreviewUrl(first.url);
setSearch(first.url);
});
}}
>
@@ -216,7 +190,17 @@ export const IconPicker = ({
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{renderDropdown()}
{totalOptions > 0 ? (
<Stack gap={4}>{groups}</Stack>
) : (
Array(15)
.fill(0)
.map((_, index: number) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
@@ -225,8 +209,8 @@ export const IconPicker = ({
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
// See https://github.com/homarr-labs/homarr/issues/3070
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => {
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
const shouldShowPreview = (value: string | null): value is string => {
if (!value) return false;
return !localizationPathRegex.test(value);
};
+16 -11
View File
@@ -75,7 +75,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
});
return enriched;
},
cacheDuration: dayjs.duration(1, "second"),
cacheDuration: dayjs.duration(5, "seconds"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelSystems",
@@ -104,19 +104,19 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
});
return { alerts, history };
},
cacheDuration: dayjs.duration(1, "second"),
cacheDuration: dayjs.duration(15, "seconds"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1m": { type: "1m", perPage: 60 },
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "480m", perPage: 21 },
"30d": { type: "480m", perPage: 90 },
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
};
export interface BeszelStatsData {
@@ -131,7 +131,7 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
>({
async requestAsync(integration, input) {
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
@@ -147,8 +147,13 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
});
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(1, "second"),
cacheDuration: dayjs.duration(60, "seconds"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelStats",
cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod];
if (!config) return undefined;
return dayjs.duration(config.cacheSeconds, "seconds");
},
});
+7 -7
View File
@@ -3374,27 +3374,27 @@
"pausedCount": "Pausiert"
},
"error": {
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
"internalServerError": ""
}
},
"audioStats": {
"name": "Audiostatistiken",
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
"description": "",
"option": {
"showArtists": {
"label": "Künstler anzeigen",
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
"description": ""
},
"showAlbums": {
"label": "Alben anzeigen",
"label": "",
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
},
"showSongs": {
"label": "Songs anzeigen",
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
"description": ""
},
"showNowPlaying": {
"label": "\"Sie hören\" anzeigen",
"label": "",
"description": ""
},
"showNowPlayingList": {
@@ -3720,7 +3720,7 @@
},
"chart": {
"cpu": {
"title": "CPU Auslastung",
"title": "CPU-Auslastung",
"subtitle": "",
"series": "CPU"
},
+33 -33
View File
@@ -177,7 +177,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Password suggestions",
"suggestionsTitle": "",
"suggestion": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
@@ -687,28 +687,28 @@
"customWidget": {
"page": {
"list": {
"title": "Custom Widgets",
"noResults": "No custom widgets yet. Create your first one!",
"loading": "Loading..."
"title": "",
"noResults": "",
"loading": ""
},
"create": {
"title": "Create Custom Widget",
"subtitle": "Define an API URL, authentication, and how to display the response data."
"title": "",
"subtitle": ""
},
"edit": {
"title": "Edit Custom Widget"
"title": ""
}
},
"action": {
"create": "Create",
"import": "Import",
"save": "Save",
"delete": "Delete Custom Widget",
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"export": "Export",
"addMapping": "Add Mapping",
"addColumn": "Add Column",
"edit": "Edit",
"create": "",
"import": "",
"save": "",
"delete": "",
"deleteConfirm": "",
"export": "",
"addMapping": "",
"addColumn": "",
"edit": "",
"removeMapping": "",
"removeColumn": "",
"addItem": "",
@@ -5599,71 +5599,71 @@
"label": "",
"children": {
"detail": {
"title": "Select your colour scheme"
"title": ""
}
}
},
"locale": {
"label": "Language",
"label": "",
"children": {
"detail": {
"title": "Select your preferred language"
"title": ""
}
}
},
"defaultSearchEngineId": {
"label": "Default search engine",
"label": "",
"children": {
"detail": {
"title": "Select your default search engine"
"title": ""
}
}
},
"openSearchInNewTab": {
"label": "Open search results in new tab",
"label": "",
"children": {
"detail": {
"title": "Open search results in new tab"
"title": ""
}
}
},
"ddgBangs": {
"label": "Enable DuckDuckGo bangs",
"label": "",
"children": {
"detail": {
"title": "Enable DuckDuckGo bangs"
"title": ""
}
}
},
"firstDayOfWeek": {
"label": "First day of the week",
"label": "",
"children": {
"detail": {
"title": "Select the first day of the week"
"title": ""
}
}
},
"homeBoardId": {
"label": "Home board",
"label": "",
"children": {
"detail": {
"title": "Select your home board"
"title": ""
}
}
},
"mobileHomeBoardId": {
"label": "Mobile home board",
"label": "",
"children": {
"detail": {
"title": "Select your mobile home board"
"title": ""
}
}
},
"pingIconsEnabled": {
"label": "Use icons for pings",
"label": "",
"children": {
"detail": {
"title": "Use icons for pings"
"title": ""
}
}
},
+1 -4
View File
@@ -1412,10 +1412,7 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
"headerLoading": "Type to search icons…",
"noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons."
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
},
"colorScheme": {
"options": {
+2 -2
View File
@@ -4943,8 +4943,8 @@
"search": "Cerca {count} container",
"selected": "{selectCount} di {totalCount} containers selezionati",
"footer": "Totale container {count}",
"totalCpu": "CPU Totale: {cpu}%",
"totalMemory": "Mem Totale: {memory}"
"totalCpu": "",
"totalMemory": ""
},
"field": {
"name": {
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -687,7 +687,7 @@
"customWidget": {
"page": {
"list": {
"title": "Власні віджети",
"title": "",
"noResults": "",
"loading": ""
},
@@ -1021,7 +1021,7 @@
"mediaMonitoring": "",
"speedtest": "",
"analytics": "",
"mediaLibrary": "Медіа бібліотека",
"mediaLibrary": "",
"uptimeMonitoring": ""
},
"testConnection": {
@@ -1139,7 +1139,7 @@
"description": {
"connection": {
"hostUnreachable": "",
"networkUnreachable": "Мережа недоступна. Перевірте підключення до Інтернету або налаштування мережі.",
"networkUnreachable": "",
"refused": "",
"reset": ""
},
@@ -1328,7 +1328,7 @@
}
},
"paperlessNgx": {
"name": "Безпаперовий ngx",
"name": "",
"description": ""
}
},
@@ -1679,9 +1679,9 @@
"label": "",
"option": {
"row": "",
"row-reverse": "Горизонтально (зворотнє)",
"row-reverse": "",
"column": "",
"column-reverse": "Вертикальна (перевернута)"
"column-reverse": ""
}
},
"descriptionDisplayMode": {
@@ -1690,7 +1690,7 @@
"option": {
"normal": "",
"tooltip": "",
"hidden": "Приховати"
"hidden": ""
}
}
},
@@ -1703,7 +1703,7 @@
},
"bookmarks": {
"name": "Закладки",
"description": "Показує декілька посилань додатка",
"description": "",
"option": {
"title": {
"label": "Назва"
@@ -1747,8 +1747,8 @@
}
},
"archiveTeamWarrior": {
"name": "АрхівTeam Warrior",
"description": "Слідкуйте за станом, проєктами та активними архівними завданнями ArchiveTeam Warrior.",
"name": "",
"description": "",
"option": {
"showBroadcastMessage": {
"label": ""
@@ -1778,7 +1778,7 @@
}
},
"error": {
"internalServerError": "Не вдалося отримати резюме DNS Hole",
"internalServerError": "",
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
},
"data": {
@@ -1808,7 +1808,7 @@
}
},
"showToggleAllButtons": {
"label": "Показати всі кнопки"
"label": ""
}
},
"error": {
@@ -46,9 +46,8 @@ export default function BeszelAlertsWidget({
error: alertsError,
isPending,
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
staleTime: 10_000,
gcTime: 48 * 60 * 60 * 1000,
refetchInterval: 10_000,
staleTime: 15_000,
refetchInterval: 15_000,
retry: false,
});
const utils = clientApi.useUtils();
@@ -371,7 +371,7 @@ export default function BeszelSystemGridWidget({
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
);
useBeszelSystemsSubscription(integrationIds, !isEditMode);
@@ -67,10 +67,7 @@ export default function BeszelSystemStatsWidget({
data: systemsResult = [],
isPending: systemsPending,
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, retry: false },
);
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { staleTime: 30 * 1000, retry: false });
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
@@ -103,9 +100,8 @@ export default function BeszelSystemStatsWidget({
includeDocker: showDocker,
},
{
staleTime: 10_000,
gcTime: 48 * 60 * 60 * 1000,
refetchInterval: isLive ? false : 10_000,
staleTime: 30 * 1000,
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
retry: false,
},
@@ -76,7 +76,7 @@ export default function BeszelSystemTableWidget({
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
);
const size = getSizeConfig(width);
@@ -43,7 +43,7 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
timePeriod,
includeDocker: false,
},
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000 },
{ staleTime: 30_000 },
);
const mappers = useMemo(