feat(beszel): add SSE subscription endpoint and per-period cache TTL

- Add subscribeSystemStats tRPC subscription bridging PocketBase SSE
- Add subscribeRealtimeMetrics to BeszelIntegration with SSE parsing
- Add error handling: try/catch in subscription, response check in topic POST
- Add cacheDurationForInput to request handler for per-period cache TTL
- Add mock subscribeRealtimeMetrics with 1s interval for dev testing
This commit is contained in:
Thomas Camlong committed 2026-06-15 14:58:47 +02:00
1 parent 5035c26dbd
commit c9769bf530
6 files changed
+272 -49

No files matched your search

+50 -31
View File
@@ -24,7 +24,10 @@ export type { BeszelSystemRow } from "@homarr/integrations/types";
* bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery,
* sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads
*/
function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
function mapToSystemRow(
system: BeszelSystem,
details: BeszelSystemDetails | null,
): BeszelSystemRow {
const info = system.info;
return {
id: system.id,
@@ -50,25 +53,28 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
};
}
export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
BeszelSystemRow[],
"beszel" | "mock",
Record<string, never>
>({
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
systems.map(async (system) => {
const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
return mapToSystemRow(system, details);
}),
);
return enriched;
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "beszelSystems",
});
export const beszelSystemsRequestHandler =
createCachedIntegrationRequestHandler<
BeszelSystemRow[],
"beszel" | "mock",
Record<string, never>
>({
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
systems.map(async (system) => {
const details = await instance
.getSystemDetailsAsync(system.id)
.catch(() => null);
return mapToSystemRow(system, details);
}),
);
return enriched;
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "beszelSystems",
});
export interface BeszelAlertsData {
alerts: BeszelAlert[];
@@ -83,19 +89,22 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
const alerts = await instance.getAlertsAsync();
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
const history = input.includeHistory
? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems)
: [];
return { alerts, history };
},
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "120m", perPage: 84 },
"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: "120m", perPage: 84, cacheSeconds: 1800 },
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
};
export interface BeszelStatsData {
@@ -109,14 +118,24 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
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 systemStats = await instance.getSystemStatsAsync(
input.systemId,
config.type,
config.perPage,
);
const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
? await instance
.getContainerStatsAsync(input.systemId, config.type, config.perPage)
.catch(() => [])
: [];
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(30, "seconds"),
cacheDuration: dayjs.duration(60, "seconds"),
queryKey: "beszelStats",
cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod];
return config ? dayjs.duration(config.cacheSeconds, "seconds") : undefined;
},
});
@@ -14,10 +14,10 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
};
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheDuration: Duration;
cacheDurationForInput?: (input: TInput) => Duration | undefined;
}
export const createCachedIntegrationRequestHandler = <
@@ -34,9 +34,9 @@ export const createCachedIntegrationRequestHandler = <
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
return await options.requestAsync(input.integration, input.options);
},
cacheDuration: options.cacheDuration,
createRedisChannel(input, options) {
return createIntegrationOptionsChannel<TData>(input.integration.id, options.queryKey, input.options);
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
createRedisChannel(input, handlerOptions) {
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
},
}).handler({ options: itemOptions, integration }),
};