Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e578ac70 | ||
|
|
9bfb8f273c | ||
|
|
f3ac40c527 |
No files matched your search
@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
|
|||||||
|
|
||||||
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
||||||
|
|
||||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { BoundedAsyncQueue } from "../bounded-async-queue";
|
||||||
|
|
||||||
|
describe("BoundedAsyncQueue", () => {
|
||||||
|
test("keeps only the newest values when the queue is full", async () => {
|
||||||
|
const queue = new BoundedAsyncQueue<number>(2);
|
||||||
|
queue.push(1);
|
||||||
|
queue.push(2);
|
||||||
|
queue.push(3);
|
||||||
|
queue.close();
|
||||||
|
|
||||||
|
await expect(queue.next()).resolves.toEqual({ value: 2, done: false });
|
||||||
|
await expect(queue.next()).resolves.toEqual({ value: 3, done: false });
|
||||||
|
await expect(queue.next()).resolves.toEqual({ value: undefined, done: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves a pending read as soon as a value arrives", async () => {
|
||||||
|
const queue = new BoundedAsyncQueue<string>(1);
|
||||||
|
const next = queue.next();
|
||||||
|
|
||||||
|
queue.push("latest");
|
||||||
|
|
||||||
|
await expect(next).resolves.toEqual({ value: "latest", done: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves a pending read when the producer closes", async () => {
|
||||||
|
const queue = new BoundedAsyncQueue<number>(1);
|
||||||
|
const next = queue.next();
|
||||||
|
|
||||||
|
queue.close();
|
||||||
|
|
||||||
|
await expect(next).resolves.toEqual({ value: undefined, done: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a pending read when the producer fails", async () => {
|
||||||
|
const queue = new BoundedAsyncQueue<number>(1);
|
||||||
|
const next = queue.next();
|
||||||
|
const error = new Error("upstream failed");
|
||||||
|
|
||||||
|
queue.fail(error);
|
||||||
|
|
||||||
|
await expect(next).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
@@ -14,8 +13,10 @@ import {
|
|||||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
import { BoundedAsyncQueue } from "./bounded-async-queue";
|
||||||
|
|
||||||
const logger = createLogger({ module: "beszelRouter" });
|
const logger = createLogger({ module: "beszelRouter" });
|
||||||
|
const MAX_PENDING_LIVE_EVENTS = 4;
|
||||||
|
|
||||||
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
||||||
|
|
||||||
@@ -203,34 +204,44 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
systemId: z.string(),
|
systemId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscription(({ ctx, input }) => {
|
.subscription(async function* ({ ctx, input, signal }) {
|
||||||
return observable<LiveStatsEvent>((emit) => {
|
const integration = ctx.integrations[0];
|
||||||
|
if (!integration) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = new BoundedAsyncQueue<LiveStatsEvent>(MAX_PENDING_LIVE_EVENTS);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let isActive = true;
|
|
||||||
let emittedEventCount = 0;
|
let emittedEventCount = 0;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
controller.abort();
|
||||||
|
void queue.return();
|
||||||
|
};
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
signal?.addEventListener("abort", stop, { once: true });
|
||||||
|
|
||||||
logger.debug("Beszel realtime subscription started", {
|
logger.debug("Beszel realtime subscription started", {
|
||||||
userId: ctx.session?.user?.id,
|
userId: ctx.session?.user?.id,
|
||||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
integrationIds: ctx.integrations.map((candidate) => candidate.id),
|
||||||
systemId: input.systemId,
|
systemId: input.systemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
if (typeof instance.subscribeRealtimeMetrics === "function") {
|
if (controller.signal.aborted) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (typeof instance.subscribeRealtimeMetrics !== "function") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "METHOD_NOT_SUPPORTED",
|
||||||
|
message: `Integration ${integration.kind} does not support realtime metrics`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await instance.subscribeRealtimeMetrics(
|
await instance.subscribeRealtimeMetrics(
|
||||||
input.systemId,
|
input.systemId,
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!isActive) return;
|
|
||||||
emittedEventCount += 1;
|
emittedEventCount += 1;
|
||||||
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
|
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
|
||||||
logger.debug("Forwarding Beszel realtime events", {
|
logger.debug("Forwarding Beszel realtime events", {
|
||||||
@@ -242,20 +253,17 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
|
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
emit.next(event);
|
queue.push(event);
|
||||||
},
|
},
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
);
|
||||||
} else {
|
queue.close();
|
||||||
emit.error(
|
|
||||||
new TRPCError({
|
|
||||||
code: "METHOD_NOT_SUPPORTED",
|
|
||||||
message: `Integration ${integration.kind} does not support realtime metrics`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isActive) {
|
if (controller.signal.aborted) {
|
||||||
|
queue.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn("Beszel realtime subscription failed", {
|
logger.warn("Beszel realtime subscription failed", {
|
||||||
userId: ctx.session?.user?.id,
|
userId: ctx.session?.user?.id,
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
@@ -263,7 +271,7 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
emittedEventCount,
|
emittedEventCount,
|
||||||
error: errorMessage(error),
|
error: errorMessage(error),
|
||||||
});
|
});
|
||||||
emit.error(
|
queue.fail(
|
||||||
error instanceof TRPCError
|
error instanceof TRPCError
|
||||||
? error
|
? error
|
||||||
: new TRPCError({
|
: new TRPCError({
|
||||||
@@ -272,29 +280,18 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
|
|
||||||
if (isActive) {
|
for await (const event of queue) yield event;
|
||||||
logger.debug("Beszel realtime subscription completed", {
|
} finally {
|
||||||
|
stop();
|
||||||
|
signal?.removeEventListener("abort", stop);
|
||||||
|
logger.debug("Beszel realtime subscription stopped", {
|
||||||
userId: ctx.session?.user?.id,
|
userId: ctx.session?.user?.id,
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
systemId: input.systemId,
|
systemId: input.systemId,
|
||||||
emittedEventCount,
|
emittedEventCount,
|
||||||
});
|
});
|
||||||
emit.complete();
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false;
|
|
||||||
logger.debug("Beszel realtime subscription cancelled", {
|
|
||||||
userId: ctx.session?.user?.id,
|
|
||||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
|
||||||
systemId: input.systemId,
|
|
||||||
emittedEventCount,
|
|
||||||
});
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
type QueueResult<T> = IteratorResult<T, undefined>;
|
||||||
|
|
||||||
|
type PendingRead<T> = {
|
||||||
|
resolve: (result: QueueResult<T>) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small async queue for live streams.
|
||||||
|
*
|
||||||
|
* Live metrics are snapshots, so retaining every snapshot while a client is
|
||||||
|
* slow is both unnecessary and dangerous. When the queue is full, discard
|
||||||
|
* the oldest pending snapshot and keep the newest one.
|
||||||
|
*/
|
||||||
|
export class BoundedAsyncQueue<T> implements AsyncIterableIterator<T> {
|
||||||
|
private readonly values: T[] = [];
|
||||||
|
private pendingRead: PendingRead<T> | undefined;
|
||||||
|
private closed = false;
|
||||||
|
private failed = false;
|
||||||
|
private failure: unknown;
|
||||||
|
|
||||||
|
public constructor(private readonly maxSize: number) {
|
||||||
|
if (!Number.isInteger(maxSize) || maxSize < 1) {
|
||||||
|
throw new Error("A bounded async queue requires a positive integer max size");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public push(value: T): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
|
||||||
|
if (this.pendingRead) {
|
||||||
|
const pendingRead = this.pendingRead;
|
||||||
|
this.pendingRead = undefined;
|
||||||
|
pendingRead.resolve({ value, done: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.length >= this.maxSize) this.values.shift();
|
||||||
|
this.values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
|
||||||
|
if (this.pendingRead) {
|
||||||
|
const pendingRead = this.pendingRead;
|
||||||
|
this.pendingRead = undefined;
|
||||||
|
pendingRead.resolve({ value: undefined, done: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fail(error: unknown): void {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
this.failed = true;
|
||||||
|
this.failure = error;
|
||||||
|
this.values.length = 0;
|
||||||
|
|
||||||
|
if (this.pendingRead) {
|
||||||
|
const pendingRead = this.pendingRead;
|
||||||
|
this.pendingRead = undefined;
|
||||||
|
pendingRead.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public next(): Promise<QueueResult<T>> {
|
||||||
|
if (this.values.length > 0) {
|
||||||
|
return Promise.resolve({ value: this.values.shift() as T, done: false });
|
||||||
|
}
|
||||||
|
if (this.failed) return Promise.reject(this.failure);
|
||||||
|
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
||||||
|
|
||||||
|
return new Promise<QueueResult<T>>((resolve, reject) => {
|
||||||
|
this.pendingRead = { resolve, reject };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public return(): Promise<QueueResult<T>> {
|
||||||
|
this.values.length = 0;
|
||||||
|
this.close();
|
||||||
|
return Promise.resolve({ value: undefined, done: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public [Symbol.asyncIterator](): AsyncIterableIterator<T> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,12 +67,14 @@ export const fetchWithTrustedCertificatesAsync = async (
|
|||||||
url: RequestInfo,
|
url: RequestInfo,
|
||||||
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
|
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const agent = await createCertificateAgentAsync(
|
const agent =
|
||||||
|
options?.dispatcher ??
|
||||||
|
(await createCertificateAgentAsync(
|
||||||
undefined,
|
undefined,
|
||||||
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
||||||
);
|
));
|
||||||
if (options?.timeout) {
|
if (options?.timeout) {
|
||||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
|
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options;
|
||||||
return await withTimeoutAsync(
|
return await withTimeoutAsync(
|
||||||
async (signal) =>
|
async (signal) =>
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
@@ -84,7 +86,7 @@ export const fetchWithTrustedCertificatesAsync = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
|
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
dispatcher: agent,
|
dispatcher: agent,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
import type { Response as UndiciResponse } from "undici";
|
||||||
|
|
||||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
@@ -357,10 +358,24 @@ export class BeszelIntegration extends Integration {
|
|||||||
let emittedSnapshotCount = 0;
|
let emittedSnapshotCount = 0;
|
||||||
|
|
||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
|
let streamResponse: UndiciResponse | undefined;
|
||||||
|
let streamAgent: Awaited<ReturnType<typeof createCertificateAgentAsync>> | undefined;
|
||||||
|
const closeStreamAsync = async () => {
|
||||||
|
const response = streamResponse;
|
||||||
|
streamResponse = undefined;
|
||||||
|
const agent = streamAgent;
|
||||||
|
streamAgent = undefined;
|
||||||
|
|
||||||
|
if (response?.body) await response.body.cancel().catch(() => {});
|
||||||
|
if (agent) await agent.close().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let session = await this.authenticateAsync();
|
let session = await this.authenticateAsync();
|
||||||
const openStream = async () =>
|
const openStream = async () => {
|
||||||
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
streamAgent = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
Authorization: session.token,
|
Authorization: session.token,
|
||||||
@@ -369,7 +384,15 @@ export class BeszelIntegration extends Integration {
|
|||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
bodyTimeout: 0,
|
bodyTimeout: 0,
|
||||||
|
dispatcher: streamAgent,
|
||||||
});
|
});
|
||||||
|
streamResponse = response;
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
await closeStreamAsync();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
logger.debug("Opening Beszel rt_metrics SSE connection", {
|
logger.debug("Opening Beszel rt_metrics SSE connection", {
|
||||||
integrationId: this.integration.id,
|
integrationId: this.integration.id,
|
||||||
@@ -378,6 +401,7 @@ export class BeszelIntegration extends Integration {
|
|||||||
});
|
});
|
||||||
let response = await openStream();
|
let response = await openStream();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
await closeStreamAsync();
|
||||||
await this.sessionStore.clearAsync();
|
await this.sessionStore.clearAsync();
|
||||||
session = await this.authenticateAsync();
|
session = await this.authenticateAsync();
|
||||||
response = await openStream();
|
response = await openStream();
|
||||||
@@ -402,14 +426,21 @@ export class BeszelIntegration extends Integration {
|
|||||||
clientId = frame.id || fallbackClientId;
|
clientId = frame.id || fallbackClientId;
|
||||||
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
|
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
|
||||||
|
|
||||||
|
if (!streamAgent) throw new Error("Beszel realtime stream agent is not available");
|
||||||
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: session.token, "Content-Type": "application/json" },
|
headers: { Authorization: session.token, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ clientId, subscriptions: [topic] }),
|
body: JSON.stringify({ clientId, subscriptions: [topic] }),
|
||||||
signal,
|
signal,
|
||||||
|
dispatcher: streamAgent,
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
|
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
|
||||||
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
|
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
|
||||||
|
await subscribeResponse.arrayBuffer();
|
||||||
|
} finally {
|
||||||
|
if (subscribeResponse.body) await subscribeResponse.body.cancel().catch(() => {});
|
||||||
|
}
|
||||||
logger.debug("Subscribed to Beszel rt_metrics topic", {
|
logger.debug("Subscribed to Beszel rt_metrics topic", {
|
||||||
integrationId: this.integration.id,
|
integrationId: this.integration.id,
|
||||||
systemId,
|
systemId,
|
||||||
@@ -461,10 +492,12 @@ export class BeszelIntegration extends Integration {
|
|||||||
} finally {
|
} finally {
|
||||||
await reader.cancel().catch(() => {});
|
await reader.cancel().catch(() => {});
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
|
await closeStreamAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
|
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await closeStreamAsync();
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
|
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
|
||||||
retryAttempt += 1;
|
retryAttempt += 1;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ vi.hoisted(() => {
|
|||||||
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
|
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
|
||||||
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
|
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
|
||||||
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
|
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
|
||||||
|
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
|
||||||
vi.mock("@homarr/redis", () => ({
|
vi.mock("@homarr/redis", () => ({
|
||||||
createGetSetChannel: () => ({
|
createGetSetChannel: () => ({
|
||||||
@@ -32,7 +33,7 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
|
|||||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||||
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
|
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
|
||||||
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
|
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
|
||||||
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
|
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: closeAgent }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||||
@@ -153,6 +154,7 @@ describe("subscribeRealtimeMetrics", () => {
|
|||||||
|
|
||||||
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
|
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
|
||||||
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
|
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
|
||||||
|
expect(closeAgent).toHaveBeenCalled();
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ const optionMapping: OptionMapping = {
|
|||||||
useCustomTimezone: () => true,
|
useCustomTimezone: () => true,
|
||||||
customTimeFormat: () => undefined,
|
customTimeFormat: () => undefined,
|
||||||
customDateFormat: () => undefined,
|
customDateFormat: () => undefined,
|
||||||
|
showWeather: () => undefined,
|
||||||
|
weatherLocation: () => undefined,
|
||||||
|
isWeatherFormatFahrenheit: () => undefined,
|
||||||
},
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
activeTorrentThreshold: (oldOptions) =>
|
activeTorrentThreshold: (oldOptions) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { skipToken } from "@tanstack/react-query";
|
import { skipToken } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -10,10 +10,22 @@ import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homar
|
|||||||
// without synthesizing points for missed updates.
|
// without synthesizing points for missed updates.
|
||||||
const MAX_BUFFER = 60;
|
const MAX_BUFFER = 60;
|
||||||
|
|
||||||
|
const subscribeToVisibility = (onChange: () => void) => {
|
||||||
|
if (typeof document === "undefined") return () => {};
|
||||||
|
document.addEventListener("visibilitychange", onChange);
|
||||||
|
return () => document.removeEventListener("visibilitychange", onChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageVisibility = () => typeof document === "undefined" || document.visibilityState === "visible";
|
||||||
|
|
||||||
|
const usePageVisible = () => useSyncExternalStore(subscribeToVisibility, getPageVisibility, () => true);
|
||||||
|
|
||||||
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
|
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
|
||||||
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
|
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
|
||||||
const [containerStats, setContainerStats] = useState<BeszelContainerStatsRecord[]>([]);
|
const [containerStats, setContainerStats] = useState<BeszelContainerStatsRecord[]>([]);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const pageVisible = usePageVisible();
|
||||||
|
const subscriptionEnabled = enabled && pageVisible && systemId !== "";
|
||||||
|
|
||||||
// Append to buffer, trimming to MAX_BUFFER
|
// Append to buffer, trimming to MAX_BUFFER
|
||||||
const appendSystemStats = useCallback((record: BeszelSystemStatsRecord) => {
|
const appendSystemStats = useCallback((record: BeszelSystemStatsRecord) => {
|
||||||
@@ -31,7 +43,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
|
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
|
||||||
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
|
subscriptionEnabled ? { integrationIds, systemId } : skipToken,
|
||||||
{
|
{
|
||||||
onData(event) {
|
onData(event) {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -49,7 +61,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
|
|||||||
|
|
||||||
// Reset buffers when system changes or integration changes
|
// Reset buffers when system changes or integration changes
|
||||||
const prevKeyRef = useRef<string>("");
|
const prevKeyRef = useRef<string>("");
|
||||||
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
|
const currentKey = `${integrationIds.join(",")}:${systemId}:${subscriptionEnabled}`;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevKeyRef.current !== currentKey) {
|
if (prevKeyRef.current !== currentKey) {
|
||||||
prevKeyRef.current = currentKey;
|
prevKeyRef.current = currentKey;
|
||||||
|
|||||||
Reference in new issue
Block a user