fix(beszel): improve live subscription error handling and cleanup

- Surface subscription errors in useLiveStats via onError + error state
- Show error UI in live mode when SSE connection fails
- Abort SSE reader on subscribeTopic failure instead of silent log
- Guard mock realtime against pre-aborted signal to prevent interval leak
- Remove dead includeDocker from subscription input schema
This commit is contained in:
Thomas Camlong committed 2026-06-15 15:49:14 +02:00
1 parent 2d4832dc96
commit a68eee24eb
5 files changed
+28 -7

No files matched your search

@@ -154,7 +154,6 @@ export const beszelRouter = createTRPCRouter({
.input(
z.object({
systemId: z.string(),
includeDocker: z.boolean().default(true),
}),
)
.subscription(({ ctx, input }) => {
@@ -283,7 +283,8 @@ export class BeszelIntegration extends Integration {
if (!clientId && typeof parsed.clientId === "string") {
clientId = parsed.clientId;
this.subscribeTopic(clientId, systemId, session.token).catch((err) => {
logger.warn("Failed to subscribe to rt_metrics topic", { error: err });
logger.warn("Failed to subscribe to rt_metrics topic, aborting SSE", { error: err });
reader.cancel().catch(() => {});
});
return;
}
@@ -350,12 +350,13 @@ export class BeszelMockService {
};
onMessage({ stats: record, containerStats: null });
};
if (signal.aborted) return;
await new Promise<void>((resolve) => {
const interval = setInterval(emit, 1000);
signal.addEventListener("abort", () => {
clearInterval(interval);
resolve();
});
}, { once: true });
});
}
@@ -2,7 +2,7 @@
import { useCallback, useMemo } from "react";
import { ActionIcon, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconServer, IconQuestionMark } from "@tabler/icons-react";
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
@@ -77,7 +77,7 @@ export default function BeszelSystemStatsWidget({
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "" },
);
const liveData = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
const activeStats = isLive ? liveData : statsResult;
@@ -142,6 +142,19 @@ export default function BeszelSystemStatsWidget({
);
}
if (isLive && liveError && !liveData) {
return (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.internalServerError")}
</Text>
</Stack>
</Center>
);
}
return (
<ScrollArea
h="100%"
@@ -14,14 +14,17 @@ interface LiveStatsData {
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [data, setData] = useState<LiveStatsData | null>(null);
const [error, setError] = useState<Error | null>(null);
const integrationKey = integrationIds.join(",");
useEffect(() => {
setData(null);
setError(null);
}, [systemId, enabled, integrationKey]);
const onData = useCallback(
(incoming: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => {
setError(null);
setData((prev) => {
const systemStats = [...(prev?.systemStats ?? []), incoming.stats].slice(-MAX_BUFFER);
const containerStats = incoming.containerStats
@@ -33,10 +36,14 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
[],
);
const onError = useCallback((err: unknown) => {
setError(err instanceof Error ? err : new Error(String(err)));
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
{ integrationIds, systemId },
{ enabled: enabled && systemId !== "", onData },
{ enabled: enabled && systemId !== "", onData, onError },
);
return data;
return { data, error };
};