fix(beszel): disable request handler cache for stats to make live mode work (#6244)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-10 17:16:23 +02:00
1 parent 0e513fffd5
commit 24c5d04bfa
8 files changed
+269 -9

No files matched your search

+67
View File
@@ -1,7 +1,10 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
@@ -201,4 +204,68 @@ export const beszelRouter = createTRPCRouter({
};
}
}),
subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
}),
)
.subscription(({ ctx, input }) => {
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
void (async () => {
const integration = ctx.integrations[0];
if (!integration) {
emit.error(
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
);
return;
}
try {
const instance = await createIntegrationAsync(integration);
if (typeof instance.subscribeRealtimeMetrics === "function") {
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (isActive) emit.next(event);
},
controller.signal,
);
} else {
emit.error(
new TRPCError({
code: "METHOD_NOT_SUPPORTED",
message: `Integration ${integration.kind} does not support realtime metrics`,
}),
);
}
} catch (error) {
if (isActive) {
emit.error(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
);
}
}
if (isActive) {
emit.complete();
}
})();
return () => {
isActive = false;
controller.abort();
};
});
}),
});
@@ -200,6 +200,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr"
| "/docs/integrations/traefik"
| "/docs/integrations/transmission"
| "/docs/integrations/truenas"
| "/docs/integrations/umami"
@@ -262,6 +263,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/system-resources"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/traefik"
| "/docs/widgets/umami"
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
@@ -20,6 +20,7 @@ import type {
BeszelSystemdService,
BeszelSystemStatsRecord,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
@@ -284,6 +285,125 @@ export class BeszelIntegration extends Integration {
});
}
/**
* Subscribe to real-time system and container stats via PocketBase SSE.
* Connects to the PocketBase Realtime API and parses incoming SSE events,
* filtering by systemId. This provides live-updating metrics (≤1s latency
* from agent) instead of polling the REST API every 5s.
*/
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const session = await this.authenticateAsync();
const realtimeUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection for realtime metrics", {
integrationId: this.integration.id,
systemId,
url: realtimeUrl.pathname,
});
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { Authorization: session.token },
signal,
});
if (!response.ok) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: response.status,
});
throw new ResponseError(response);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body in SSE stream");
const decoder = new TextDecoder();
let buffer = "";
let subscribed = false;
const processLine = async (line: string) => {
if (!line.startsWith("data: ")) return;
try {
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
// PB_CONNECT delivers the clientId — use it to POST our subscriptions
if (!subscribed && typeof parsed.clientId === "string") {
subscribed = true;
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: {
Authorization: session.token,
"Content-Type": "application/json",
},
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal,
});
if (!subscribeResponse.ok) {
logger.warn("Beszel SSE subscription POST failed", {
integrationId: this.integration.id,
systemId,
status: subscribeResponse.status,
});
throw new ResponseError(subscribeResponse);
}
logger.debug("Beszel SSE subscribed to collections", {
integrationId: this.integration.id,
systemId,
clientId: parsed.clientId,
});
return;
}
const record = parsed.record as Record<string, unknown> | undefined;
if (!record || record.system !== systemId) return;
if (Array.isArray(record.stats)) {
onMessage({
type: "container_stats",
record: record as unknown as BeszelContainerStatsRecord,
});
} else if (record.stats && typeof record.stats === "object") {
onMessage({
type: "system_stats",
record: record as unknown as BeszelSystemStatsRecord,
});
}
} catch (error) {
if (error instanceof ResponseError) throw error;
// skip malformed SSE data lines
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
await processLine(line);
}
}
} finally {
reader.releaseLock();
logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
}
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
@@ -343,6 +343,22 @@ export interface UpdateAlertInput {
min?: number;
}
/**
* Live SSE subscription event types for real-time Beszel metrics.
* Discriminated union — clients accumulate each type into its own buffer.
*/
export interface LiveSystemStatsEvent {
type: "system_stats";
record: BeszelSystemStatsRecord;
}
export interface LiveContainerStatsEvent {
type: "container_stats";
record: BeszelContainerStatsRecord;
}
export type LiveStatsEvent = LiveSystemStatsEvent | LiveContainerStatsEvent;
export interface PocketBaseListResponse<T> {
page: number;
perPage: number;
@@ -7,6 +7,7 @@ import type {
BeszelSystemDetails,
BeszelSystemStats,
BeszelSystemStatsRecord,
LiveStatsEvent,
} from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024;
@@ -332,7 +333,7 @@ export class BeszelMockService {
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const sys = resolveSystem(systemId);
@@ -348,7 +349,7 @@ export class BeszelMockService {
created: new Date().toISOString(),
updated: new Date().toISOString(),
};
onMessage({ stats: record, containerStats: null });
onMessage({ type: "system_stats", record });
};
if (signal.aborted) return;
await new Promise<void>((resolve) => {
+3
View File
@@ -122,6 +122,9 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler<
"beszel" | "mock",
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
// No cache — the widget polls every 5s for live updates;
// a TTL here would serve stale records between polls.
cacheTtlMs: 0,
async requestAsync(integration, input) {
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
@@ -12,6 +12,7 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
}
export const createIntegrationRequestHandler = <
@@ -30,6 +31,7 @@ export const createIntegrationRequestHandler = <
}
return options.requestAsync(integration, input.options);
},
cacheTtlMs: options.cacheTtlMs,
});
return {
@@ -1,15 +1,64 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
// 120 records at ~1 per second = 2 minutes of rolling data for charts
const MAX_BUFFER = 120;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const { data, error } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod: "1m" as const, includeDocker: true },
{ enabled: enabled && systemId !== "", refetchInterval: 5_000 },
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
const [containerStats, setContainerStats] = useState<BeszelContainerStatsRecord[]>([]);
const [error, setError] = useState<Error | null>(null);
// Append to buffer, trimming to MAX_BUFFER
const appendSystemStats = useCallback((record: BeszelSystemStatsRecord) => {
setSystemStats((prev) => {
const next = [...prev, record];
return next.length > MAX_BUFFER ? next.slice(-MAX_BUFFER) : next;
});
}, []);
const appendContainerStats = useCallback((record: BeszelContainerStatsRecord) => {
setContainerStats((prev) => {
const next = [...prev, record];
return next.length > MAX_BUFFER ? next.slice(-MAX_BUFFER) : next;
});
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
{ integrationIds, systemId },
{
enabled: enabled && systemId !== "",
onData(event) {
setError(null);
if (event.type === "system_stats") {
appendSystemStats(event.record);
} else {
appendContainerStats(event.record);
}
},
onError(err) {
setError(err instanceof Error ? err : new Error(String(err)));
},
},
);
return {
data: data ? { systemStats: data.systemStats, containerStats: data.containerStats } : null,
error: error ?? (data?.error ? new Error(data.error) : null),
};
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}`;
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;
setSystemStats([]);
setContainerStats([]);
setError(null);
}
}, [currentKey]);
const data = systemStats.length > 0 || containerStats.length > 0 ? { systemStats, containerStats } : null;
return { data, error };
};