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:
6 files changed
+272
-49
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||
@@ -12,17 +13,21 @@ import type {
|
||||
BeszelAlertHistory,
|
||||
BeszelAuthResponse,
|
||||
BeszelContainer,
|
||||
BeszelContainerStats,
|
||||
BeszelContainerStatsRecord,
|
||||
BeszelSmartDevice,
|
||||
BeszelSystem,
|
||||
BeszelSystemDetails,
|
||||
BeszelSystemdService,
|
||||
BeszelSystemStats,
|
||||
BeszelSystemStatsRecord,
|
||||
CreateAlertInput,
|
||||
PocketBaseListResponse,
|
||||
UpdateAlertInput,
|
||||
} from "./beszel-types";
|
||||
|
||||
const logger = createLogger({ module: "beszel-integration" });
|
||||
|
||||
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
|
||||
interface BeszelSession {
|
||||
@@ -245,6 +250,103 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
}
|
||||
|
||||
public async subscribeRealtimeMetrics(
|
||||
systemId: string,
|
||||
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const session = await this.authenticateAsync();
|
||||
const sseUrl = this.url("/api/realtime");
|
||||
|
||||
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
|
||||
headers: { Authorization: session.token },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!sseResponse.ok || !sseResponse.body) {
|
||||
throw new ResponseError(sseResponse);
|
||||
}
|
||||
|
||||
const reader = sseResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let clientId: string | null = null;
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.startsWith("data:")) return;
|
||||
const jsonStr = line.slice(5).trim();
|
||||
if (!jsonStr) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||
|
||||
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 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ("stats" in parsed) {
|
||||
const stats: BeszelSystemStatsRecord = {
|
||||
id: "",
|
||||
system: systemId,
|
||||
stats: parsed.stats as BeszelSystemStats,
|
||||
type: "1m",
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
const containerStats: BeszelContainerStatsRecord | null =
|
||||
parsed.container && Array.isArray(parsed.container) && (parsed.container as BeszelContainerStats[]).length > 0
|
||||
? {
|
||||
id: "",
|
||||
system: systemId,
|
||||
stats: parsed.container as BeszelContainerStats[],
|
||||
type: "1m",
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
}
|
||||
: null;
|
||||
onMessage({ stats, containerStats });
|
||||
}
|
||||
} catch {
|
||||
logger.debug("Failed to parse SSE message", { line });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
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) {
|
||||
processLine(line);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribeTopic(clientId: string, systemId: string, token: string): Promise<void> {
|
||||
const topic = `rt_metrics?options=${JSON.stringify({ query: { system: systemId } })}`;
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/realtime"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ clientId, subscriptions: [topic] }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||
method: "POST",
|
||||
|
||||
@@ -330,6 +330,35 @@ export class BeszelMockService {
|
||||
];
|
||||
}
|
||||
|
||||
public async subscribeRealtimeMetrics(
|
||||
systemId: string,
|
||||
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const sys = resolveSystem(systemId);
|
||||
const emit = () => {
|
||||
if (signal.aborted) return;
|
||||
const [stats] = generateSystemStatsSeries(1, sys);
|
||||
if (!stats) return;
|
||||
const record: BeszelSystemStatsRecord = {
|
||||
id: `mock-rt-${Date.now()}`,
|
||||
system: systemId,
|
||||
stats,
|
||||
type: "1m",
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
onMessage({ stats: record, containerStats: null });
|
||||
};
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(emit, 1000);
|
||||
signal.addEventListener("abort", () => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise<BeszelAlertHistory[]> {
|
||||
const now = Date.now();
|
||||
const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
|
||||
|
||||
@@ -140,4 +140,5 @@ export class MockIntegration
|
||||
getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
|
||||
getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
|
||||
getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
|
||||
subscribeRealtimeMetrics = MockIntegration.beszel.subscribeRealtimeMetrics.bind(MockIntegration.beszel);
|
||||
}
|
||||
Reference in new issue
Block a user