fix(beszel): add reliable live stats and filesystem UI (#6289)
This commit is contained in:
1 parent
def3ede3bc
commit
576856d634
35 files changed
+1272
-690
No files matched your search
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
|
||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
||||
|
||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
|
||||
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
const first = queryKey[0];
|
||||
|
||||
@@ -207,6 +207,13 @@ export const beszelRouter = createTRPCRouter({
|
||||
return observable<LiveStatsEvent>((emit) => {
|
||||
const controller = new AbortController();
|
||||
let isActive = true;
|
||||
let emittedEventCount = 0;
|
||||
|
||||
logger.debug("Beszel realtime subscription started", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const integration = ctx.integrations[0];
|
||||
@@ -223,7 +230,19 @@ export const beszelRouter = createTRPCRouter({
|
||||
await instance.subscribeRealtimeMetrics(
|
||||
input.systemId,
|
||||
(event) => {
|
||||
if (isActive) emit.next(event);
|
||||
if (!isActive) return;
|
||||
emittedEventCount += 1;
|
||||
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
|
||||
logger.debug("Forwarding Beszel realtime events", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
eventType: event.type,
|
||||
emittedEventCount,
|
||||
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
|
||||
});
|
||||
}
|
||||
emit.next(event);
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
@@ -237,6 +256,13 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
logger.warn("Beszel realtime subscription failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
emit.error(
|
||||
error instanceof TRPCError
|
||||
? error
|
||||
@@ -249,12 +275,24 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
logger.debug("Beszel realtime subscription completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
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();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
|
||||
[[["docker", "getContainers"], { type: "query" }], true],
|
||||
[[["integration", "byIds"], { type: "query" }], true],
|
||||
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
|
||||
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
|
||||
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
|
||||
[[["app", "selectable"], { type: "query" }], false],
|
||||
[[["board", "getBoardByName"], { type: "query" }], false],
|
||||
[["widget"], false],
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import { checkServerIdentity } from "node:tls";
|
||||
import axios from "axios";
|
||||
import type { RequestInfo, RequestInit, Response } from "undici";
|
||||
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import {
|
||||
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
|
||||
};
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async (override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
}) => {
|
||||
export const createCertificateAgentAsync = async (
|
||||
override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
},
|
||||
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
|
||||
) => {
|
||||
return new UndiciHttpAgent({
|
||||
...agentOptions,
|
||||
connect: override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
|
||||
|
||||
export const fetchWithTrustedCertificatesAsync = async (
|
||||
url: RequestInfo,
|
||||
options?: RequestInit & { timeout?: number },
|
||||
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
|
||||
): Promise<Response> => {
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
const agent = await createCertificateAgentAsync(
|
||||
undefined,
|
||||
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
||||
);
|
||||
if (options?.timeout) {
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
|
||||
return await withTimeoutAsync(
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -19,15 +19,66 @@ import type {
|
||||
BeszelSystemDetails,
|
||||
BeszelSystemdService,
|
||||
BeszelSystemStatsRecord,
|
||||
BeszelSystemStats,
|
||||
BeszelContainerStats,
|
||||
CreateAlertInput,
|
||||
LiveStatsEvent,
|
||||
PocketBaseListResponse,
|
||||
UpdateAlertInput,
|
||||
} from "./beszel-types";
|
||||
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "./pocketbase-realtime";
|
||||
|
||||
const logger = createLogger({ module: "beszel-integration" });
|
||||
|
||||
const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const realtimeRetryDelaysMs = [200, 300, 500, 1_000, 1_500, 2_000] as const;
|
||||
|
||||
const supportsRealtimeMetrics = (version: string) => {
|
||||
const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
|
||||
return major > 0 || minor >= 13;
|
||||
};
|
||||
|
||||
const waitForRetryAsync = async (delayMs: number, signal: AbortSignal) =>
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const timeout = setTimeout(done, delayMs);
|
||||
signal.addEventListener("abort", done, { once: true });
|
||||
function done() {
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
interface BeszelRealtimeSnapshot {
|
||||
stats?: BeszelSystemStats;
|
||||
container?: BeszelContainerStats[];
|
||||
}
|
||||
|
||||
export const normalizeRealtimeSnapshot = (
|
||||
payload: BeszelRealtimeSnapshot,
|
||||
systemId: string,
|
||||
receivedAt = new Date(),
|
||||
): LiveStatsEvent[] => {
|
||||
const created = receivedAt.toISOString();
|
||||
const id = `realtime-${receivedAt.getTime()}`;
|
||||
const events: LiveStatsEvent[] = [];
|
||||
|
||||
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
|
||||
events.push({
|
||||
type: "system_stats",
|
||||
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
|
||||
});
|
||||
}
|
||||
if (Array.isArray(payload.container)) {
|
||||
events.push({
|
||||
type: "container_stats",
|
||||
record: { id, system: systemId, stats: payload.container, type: "1m", created, updated: created },
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
|
||||
interface BeszelSession {
|
||||
token: string;
|
||||
@@ -285,123 +336,163 @@ 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.
|
||||
*/
|
||||
/** Subscribe to Beszel's custom one-second `rt_metrics` PocketBase topic. */
|
||||
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 systems = await this.getSystemsAsync();
|
||||
const system = systems.find((candidate) => candidate.id === systemId);
|
||||
if (!system) throw new Error("Selected Beszel system was not found");
|
||||
if (system.status !== "up")
|
||||
throw new Error(`Beszel system is ${system.status}; Live mode requires an online system`);
|
||||
if (!supportsRealtimeMetrics(system.info.v)) {
|
||||
throw new Error(`Beszel agent ${system.info.v} does not support Live mode; version 0.13.0 or newer is required`);
|
||||
}
|
||||
|
||||
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;
|
||||
const realtimeUrl = this.url("/api/realtime");
|
||||
const topic = createRealtimeMetricsTopic(systemId);
|
||||
let retryAttempt = 0;
|
||||
let emittedSnapshotCount = 0;
|
||||
|
||||
while (!signal.aborted) {
|
||||
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",
|
||||
let session = await this.authenticateAsync();
|
||||
const openStream = async () =>
|
||||
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Authorization: session.token,
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: parsed.clientId,
|
||||
subscriptions: ["system_stats", "container_stats"],
|
||||
}),
|
||||
signal,
|
||||
bodyTimeout: 0,
|
||||
});
|
||||
|
||||
if (!subscribeResponse.ok) {
|
||||
logger.warn("Beszel SSE subscription POST failed", {
|
||||
logger.debug("Opening Beszel rt_metrics SSE connection", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
retryAttempt,
|
||||
});
|
||||
let response = await openStream();
|
||||
if (response.status === 401) {
|
||||
await this.sessionStore.clearAsync();
|
||||
session = await this.authenticateAsync();
|
||||
response = await openStream();
|
||||
}
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Beszel realtime response has no body");
|
||||
const decoder = new TextDecoder();
|
||||
const parser = new PocketBaseSseParser();
|
||||
let clientId: string | undefined;
|
||||
|
||||
const processFrameAsync = async (frame: ReturnType<PocketBaseSseParser["push"]>[number]) => {
|
||||
if (frame.event === "PB_CONNECT") {
|
||||
let fallbackClientId: string | undefined;
|
||||
try {
|
||||
const connectData = JSON.parse(frame.data) as { clientId?: string };
|
||||
fallbackClientId = connectData.clientId;
|
||||
} catch {
|
||||
// PocketBase normally provides the client ID in the SSE id field.
|
||||
}
|
||||
clientId = frame.id || fallbackClientId;
|
||||
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
|
||||
|
||||
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
method: "POST",
|
||||
headers: { Authorization: session.token, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId, subscriptions: [topic] }),
|
||||
signal,
|
||||
});
|
||||
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
|
||||
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
|
||||
logger.debug("Subscribed to Beszel rt_metrics topic", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
status: subscribeResponse.status,
|
||||
clientId,
|
||||
});
|
||||
throw new ResponseError(subscribeResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Beszel SSE subscribed to collections", {
|
||||
if (!frame.event.startsWith("rt_metrics")) return;
|
||||
try {
|
||||
const snapshot = JSON.parse(frame.data) as BeszelRealtimeSnapshot;
|
||||
const events = normalizeRealtimeSnapshot(snapshot, systemId);
|
||||
if (events.length === 0) return;
|
||||
emittedSnapshotCount += 1;
|
||||
retryAttempt = 0;
|
||||
for (const event of events) onMessage(event);
|
||||
if (emittedSnapshotCount === 1 || emittedSnapshotCount % 30 === 0) {
|
||||
logger.debug("Forwarded Beszel rt_metrics snapshots", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
emittedSnapshotCount,
|
||||
containerCount: snapshot.container?.length ?? 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Failed to parse Beszel rt_metrics payload", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
logger.debug("Beszel rt_metrics SSE connection established", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
contentType: response.headers.get("content-type"),
|
||||
});
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
|
||||
await processFrameAsync(frame);
|
||||
}
|
||||
}
|
||||
for (const frame of parser.push(decoder.decode())) await processFrameAsync(frame);
|
||||
for (const frame of parser.finish()) await processFrameAsync(frame);
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
|
||||
} catch (error) {
|
||||
if (signal.aborted) break;
|
||||
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
|
||||
retryAttempt += 1;
|
||||
logger.debug("Retrying Beszel rt_metrics handshake after reauthentication", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
clientId: parsed.clientId,
|
||||
});
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
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
|
||||
if (error instanceof ResponseError && error.statusCode < 500 && error.statusCode !== 429) throw error;
|
||||
const delayMs = realtimeRetryDelaysMs[Math.min(retryAttempt, realtimeRetryDelaysMs.length - 1)] ?? 2_000;
|
||||
retryAttempt += 1;
|
||||
logger.warn("Beszel rt_metrics connection lost; retrying", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
retryAttempt,
|
||||
delayMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await waitForRetryAsync(delayMs, signal);
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
logger.debug("Beszel rt_metrics subscription stopped", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
emittedSnapshotCount,
|
||||
});
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
|
||||
@@ -133,9 +133,9 @@ export interface BeszelSystemStats {
|
||||
/** disk usage (%) */
|
||||
dp: number;
|
||||
/** disk read (MB/s) */
|
||||
dr: number;
|
||||
dr?: number;
|
||||
/** disk write (MB/s) */
|
||||
dw: number;
|
||||
dw?: number;
|
||||
/** disk read max (MB/s) */
|
||||
drm?: number;
|
||||
/** disk write max (MB/s) */
|
||||
@@ -145,9 +145,9 @@ export interface BeszelSystemStats {
|
||||
/** disk IOPS max [read, write] */
|
||||
diom?: [number, number];
|
||||
/** network sent — all interfaces (bytes/s) */
|
||||
ns: number;
|
||||
ns?: number;
|
||||
/** network received — all interfaces (bytes/s) */
|
||||
nr: number;
|
||||
nr?: number;
|
||||
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
|
||||
b?: [number, number];
|
||||
/** network sent max (bytes/s) */
|
||||
@@ -169,26 +169,26 @@ export interface BeszelSystemStats {
|
||||
}
|
||||
|
||||
export interface BeszelExtraFsStats {
|
||||
/** total disk (bytes) */
|
||||
/** total disk (GiB) */
|
||||
d: number;
|
||||
/** disk used (bytes) */
|
||||
/** disk used (GiB) */
|
||||
du: number;
|
||||
/** read (bytes/s) */
|
||||
r: number;
|
||||
/** write (bytes/s) */
|
||||
w: number;
|
||||
/** read max (bytes/s) */
|
||||
rm: number;
|
||||
rm?: number;
|
||||
/** write max (bytes/s) */
|
||||
wm: number;
|
||||
wm?: number;
|
||||
/** read (IOPS) */
|
||||
rb: number;
|
||||
rb?: number;
|
||||
/** write (IOPS) */
|
||||
wb: number;
|
||||
wb?: number;
|
||||
/** read max (IOPS) */
|
||||
rbm: number;
|
||||
rbm?: number;
|
||||
/** write max (IOPS) */
|
||||
wbm: number;
|
||||
wbm?: number;
|
||||
}
|
||||
|
||||
export interface BeszelGPUData {
|
||||
@@ -382,6 +382,8 @@ export interface BeszelSystemRow {
|
||||
memory: number;
|
||||
/** disk usage (%) */
|
||||
disk: number;
|
||||
/** extra filesystem usage (%) keyed by mount path */
|
||||
extraFilesystems: Record<string, number>;
|
||||
/** GPU usage (%) */
|
||||
gpu: number;
|
||||
/** load average [1m, 5m, 15m] */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface PocketBaseSseFrame {
|
||||
event: string;
|
||||
id?: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export const createRealtimeMetricsTopic = (systemId: string) =>
|
||||
`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: systemId } }))}`;
|
||||
|
||||
export class PocketBaseSseParser {
|
||||
private buffer = "";
|
||||
private event = "message";
|
||||
private id: string | undefined;
|
||||
private dataLines: string[] = [];
|
||||
|
||||
public push(chunk: string): PocketBaseSseFrame[] {
|
||||
this.buffer += chunk;
|
||||
const frames: PocketBaseSseFrame[] = [];
|
||||
const lines = this.buffer.split(/\r?\n/);
|
||||
this.buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const frame = this.processLine(line);
|
||||
if (frame) frames.push(frame);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
public finish(): PocketBaseSseFrame[] {
|
||||
const frames: PocketBaseSseFrame[] = [];
|
||||
if (this.buffer !== "") {
|
||||
const frame = this.processLine(this.buffer);
|
||||
if (frame) frames.push(frame);
|
||||
this.buffer = "";
|
||||
}
|
||||
const trailingFrame = this.dispatch();
|
||||
if (trailingFrame) frames.push(trailingFrame);
|
||||
return frames;
|
||||
}
|
||||
|
||||
private processLine(line: string): PocketBaseSseFrame | null {
|
||||
if (line === "") return this.dispatch();
|
||||
if (line.startsWith(":")) return null;
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
|
||||
let value = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
|
||||
if (field === "event") this.event = value;
|
||||
else if (field === "id") this.id = value;
|
||||
else if (field === "data") this.dataLines.push(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
private dispatch(): PocketBaseSseFrame | null {
|
||||
if (this.dataLines.length === 0 && this.event === "message" && this.id === undefined) return null;
|
||||
const frame = { event: this.event, id: this.id, data: this.dataLines.join("\n") };
|
||||
this.event = "message";
|
||||
this.id = undefined;
|
||||
this.dataLines = [];
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,124 @@ vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import { BeszelIntegration } from "../beszel-integration";
|
||||
import { BeszelIntegration, normalizeRealtimeSnapshot } from "../beszel-integration";
|
||||
import type { LiveStatsEvent } from "../beszel-types";
|
||||
import { createRealtimeMetricsTopic } from "../pocketbase-realtime";
|
||||
|
||||
describe("normalizeRealtimeSnapshot", () => {
|
||||
test("normalizes current Beszel host and container snapshots", () => {
|
||||
const receivedAt = new Date("2026-07-11T13:51:30.217Z");
|
||||
const events = normalizeRealtimeSnapshot(
|
||||
{
|
||||
stats: {
|
||||
cpu: 18.07,
|
||||
m: 31.09,
|
||||
mu: 16.53,
|
||||
mp: 53.18,
|
||||
mb: 14.32,
|
||||
s: 4,
|
||||
su: 3.9,
|
||||
d: 938.8,
|
||||
du: 455.81,
|
||||
dp: 51.15,
|
||||
efs: { sda: { d: 1906.8, du: 1656.45, r: 25.63, w: 0 } },
|
||||
},
|
||||
container: [{ n: "homarr", c: 1.65, m: 650.56, b: [356066, 19702867] }],
|
||||
},
|
||||
"system-1",
|
||||
receivedAt,
|
||||
);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "system_stats",
|
||||
record: { system: "system-1", created: receivedAt.toISOString(), stats: { cpu: 18.07 } },
|
||||
});
|
||||
expect(events[1]).toMatchObject({
|
||||
type: "container_stats",
|
||||
record: { system: "system-1", stats: [{ n: "homarr", m: 650.56 }] },
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores snapshots without metric data", () => {
|
||||
expect(normalizeRealtimeSnapshot({}, "system-1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeRealtimeMetrics", () => {
|
||||
test("subscribes to Beszel rt_metrics and forwards one-second snapshots", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const topic = createRealtimeMetricsTopic("system-1");
|
||||
const subscriptionBodies: unknown[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/collections/users/auth-with-password")) {
|
||||
return Response.json({ token: "test-token", record: { id: "user-1" } });
|
||||
}
|
||||
if (url.includes("/api/collections/systems/records")) {
|
||||
return Response.json({
|
||||
page: 1,
|
||||
perPage: 500,
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
items: [
|
||||
{
|
||||
id: "system-1",
|
||||
name: "Server",
|
||||
host: "localhost",
|
||||
port: "45876",
|
||||
status: "up",
|
||||
info: { cpu: 1, mp: 2, dp: 3, u: 4, v: "0.18.7" },
|
||||
created: "2026-07-11T00:00:00.000Z",
|
||||
updated: "2026-07-11T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/realtime") && init?.method === "POST") {
|
||||
subscriptionBodies.push(JSON.parse(String(init.body)));
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe("test-token");
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.endsWith("/api/realtime")) {
|
||||
const snapshot = JSON.stringify({
|
||||
stats: { cpu: 5, m: 10, mu: 4, mp: 40, mb: 1, s: 0, su: 0, d: 20, du: 8, dp: 40 },
|
||||
container: [{ n: "homarr", c: 1, m: 256 }],
|
||||
});
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("event: PB_CONNECT\nid: client-1\ndata: {}\n\n"));
|
||||
controller.enqueue(encoder.encode(`event: ${topic}\ndata: ${snapshot}\n\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
|
||||
}
|
||||
throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const integration = createBeszelIntegration();
|
||||
const controller = new AbortController();
|
||||
const events: LiveStatsEvent[] = [];
|
||||
await integration.subscribeRealtimeMetrics(
|
||||
"system-1",
|
||||
(event) => {
|
||||
events.push(event);
|
||||
if (events.length === 2) controller.abort();
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
|
||||
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createBeszelIntegration() {
|
||||
return new BeszelIntegration({
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createRealtimeMetricsTopic, PocketBaseSseParser } from "../pocketbase-realtime";
|
||||
|
||||
describe("PocketBaseSseParser", () => {
|
||||
test("parses PB_CONNECT client IDs and CRLF-delimited frames split across chunks", () => {
|
||||
const parser = new PocketBaseSseParser();
|
||||
expect(parser.push("event: PB_CONNECT\r\nid: client-")).toEqual([]);
|
||||
expect(parser.push("123\r\ndata: {}\r\n\r\n")).toEqual([{ event: "PB_CONNECT", id: "client-123", data: "{}" }]);
|
||||
});
|
||||
|
||||
test("joins multiline data and ignores keepalive comments", () => {
|
||||
const parser = new PocketBaseSseParser();
|
||||
expect(parser.push(': keepalive\n\nevent: rt_metrics\ndata: {"stats":\ndata: {}}\n\n')).toEqual([
|
||||
{ event: "rt_metrics", id: undefined, data: '{"stats":\n{}}' },
|
||||
]);
|
||||
});
|
||||
|
||||
test("flushes a trailing frame when the stream ends without a blank line", () => {
|
||||
const parser = new PocketBaseSseParser();
|
||||
expect(parser.push("event: rt_metrics\ndata: {}")).toEqual([]);
|
||||
expect(parser.finish()).toEqual([{ event: "rt_metrics", id: undefined, data: "{}" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRealtimeMetricsTopic", () => {
|
||||
test("matches the PocketBase custom-topic options encoding used by Beszel", () => {
|
||||
const topic = createRealtimeMetricsTopic("system-1");
|
||||
expect(topic).toBe(`rt_metrics?options=${encodeURIComponent(JSON.stringify({ query: { system: "system-1" } }))}`);
|
||||
});
|
||||
});
|
||||
@@ -226,6 +226,7 @@ export class BeszelMockService {
|
||||
u: randInt(86400, 2_000_000),
|
||||
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
|
||||
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
|
||||
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
|
||||
bb: rand(200, 3000) * KB,
|
||||
v: "0.8.2",
|
||||
g: i === 3 ? rand(15, 45) : 0,
|
||||
|
||||
@@ -34,6 +34,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
|
||||
cpu: info.cpu,
|
||||
memory: info.mp,
|
||||
disk: info.dp,
|
||||
extraFilesystems: info.efs ?? {},
|
||||
gpu: info.g ?? 0,
|
||||
loadAvg: info.la ?? null,
|
||||
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
|
||||
|
||||
@@ -4031,9 +4031,6 @@
|
||||
},
|
||||
"showDockerNetwork": {
|
||||
"label": "Show Docker network chart"
|
||||
},
|
||||
"showStorage": {
|
||||
"label": "Show storage chart"
|
||||
}
|
||||
},
|
||||
"selectSystem": "Select system",
|
||||
@@ -4060,8 +4057,8 @@
|
||||
},
|
||||
"disk": {
|
||||
"title": "Disk Usage",
|
||||
"subtitle": "Usage of root partition",
|
||||
"series": "Used"
|
||||
"subtitle": "Usage by filesystem",
|
||||
"series": "Root"
|
||||
},
|
||||
"diskIO": {
|
||||
"title": "Disk I/O",
|
||||
|
||||
@@ -27,10 +27,18 @@ import classes from "./component.module.css";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||
import {
|
||||
formatByteRate,
|
||||
formatLoadAvg,
|
||||
formatPercent,
|
||||
formatTemp,
|
||||
formatUptime,
|
||||
getProgressTrackSize,
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
@@ -106,22 +114,14 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => {
|
||||
return Math.max(1, Math.floor(available / size.rowHeight));
|
||||
};
|
||||
|
||||
const MIN_CELL_WIDTH = 120;
|
||||
const MIN_CELL_WIDTH = 180;
|
||||
const MIN_CELL_HEIGHT = 80;
|
||||
|
||||
const getColCount = (width: number, itemCount: number): number => {
|
||||
const getColCount = (width: number, height: number, itemCount: number): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH)));
|
||||
|
||||
let best = 1;
|
||||
for (let c = 1; c <= maxCols; c++) {
|
||||
const emptyCells = (c - (itemCount % c)) % c;
|
||||
const bestEmpty = (best - (itemCount % best)) % best;
|
||||
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
|
||||
return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio))));
|
||||
};
|
||||
|
||||
interface MetricRowProps {
|
||||
@@ -133,20 +133,25 @@ interface MetricRowProps {
|
||||
}
|
||||
|
||||
const MetricValue = ({ value, progress, size }: Pick<MetricRowProps, "value" | "progress" | "size">) => (
|
||||
<Group gap={6} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right">
|
||||
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
|
||||
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{value}
|
||||
</Text>
|
||||
{progress && (
|
||||
<Progress value={progress.value} color={progress.color} size={size.progressSize} style={{ flex: 1 }} />
|
||||
<Progress
|
||||
value={progress.value}
|
||||
color={progress.color}
|
||||
size={getProgressTrackSize(size.progressSize)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => (
|
||||
<Group gap="xs" wrap="nowrap" style={{ minHeight: size.rowHeight }}>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: size.rowHeight }}>
|
||||
{icon}
|
||||
<Text size={size.fontSize} c="dimmed" miw={size.labelMiw}>
|
||||
<Text size={size.fontSize} c="dimmed" w={size.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<MetricValue value={value} progress={progress} size={size} />
|
||||
@@ -195,14 +200,13 @@ const metricRenderers = [
|
||||
{
|
||||
key: "showDisk",
|
||||
render: (s: BeszelSystemRow, t: SystemCardProps["t"], sz: SizeConfig) => (
|
||||
<MetricRow
|
||||
key="disk"
|
||||
icon={<HardDrive size={sz.iconSize} />}
|
||||
label={t("metric.disk")}
|
||||
value={formatPercent(s.disk)}
|
||||
progress={{ value: s.disk, color: thresholdColor(s.disk) }}
|
||||
size={sz}
|
||||
/>
|
||||
<Group key="disk" gap="xs" wrap="nowrap" justify="space-between" style={{ minHeight: sz.rowHeight }}>
|
||||
<HardDrive size={sz.iconSize} />
|
||||
<Text size={sz.fontSize} c="dimmed" w={sz.labelMiw} style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{t("metric.disk")}
|
||||
</Text>
|
||||
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
|
||||
</Group>
|
||||
),
|
||||
visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk,
|
||||
},
|
||||
@@ -399,7 +403,7 @@ export default function BeszelSystemGridWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const cols = getColCount(width, filteredSystems.length);
|
||||
const cols = getColCount(width, height, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
|
||||
icon: IconLayoutGrid,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -1,54 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
|
||||
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { containerColors } from "../beszel/_shared/colors";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import {
|
||||
BeszelChartPanel,
|
||||
CPU_Y_AXIS_DOMAIN,
|
||||
useContainerNames,
|
||||
useDockerChartData,
|
||||
useEfsChartData,
|
||||
useSystemChartData,
|
||||
} from "../beszel/_shared/chart";
|
||||
import {
|
||||
chartAxisFormatters,
|
||||
formatByteRate,
|
||||
formatGB,
|
||||
formatPercent,
|
||||
formatStorageBytes,
|
||||
} from "../beszel/_shared/format";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||
import { useLiveStats } from "../beszel/_shared/use-live-stats";
|
||||
|
||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
||||
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
|
||||
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
|
||||
|
||||
const CHART_HEIGHT = 180;
|
||||
const MB = 1024 * 1024;
|
||||
const gridColumns = [1, 2] as const;
|
||||
|
||||
function statsWhenShown<T>(show: boolean | undefined, data: T | undefined): T | undefined {
|
||||
if (!show || !data) return undefined;
|
||||
return data;
|
||||
}
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
@@ -71,14 +38,12 @@ export default function BeszelSystemStatsWidget({
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
|
||||
const systems = useMemo(
|
||||
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
|
||||
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
|
||||
[systemsResult],
|
||||
);
|
||||
|
||||
const selectedSystem = options.systemId || systems[0]?.value || "";
|
||||
const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label;
|
||||
const systemExists = systems.some((s) => s.value === selectedSystem);
|
||||
const systemReady = !systemsPending && (selectedSystem === "" || systemExists);
|
||||
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
|
||||
const systemExists = systems.some((system) => system.value === selectedSystem);
|
||||
|
||||
const handleSelectSystem = useCallback(
|
||||
(value: string) => {
|
||||
@@ -90,139 +55,13 @@ export default function BeszelSystemStatsWidget({
|
||||
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
|
||||
);
|
||||
|
||||
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
|
||||
const isLive = options.timePeriod === "1m";
|
||||
|
||||
const { data: statsResult, error: statsError } = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
{
|
||||
integrationIds,
|
||||
systemId: selectedSystem,
|
||||
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
|
||||
includeDocker: showDocker,
|
||||
},
|
||||
{
|
||||
refetchInterval: isLive ? false : 5_000,
|
||||
enabled: !isLive && systemReady && selectedSystem !== "",
|
||||
},
|
||||
);
|
||||
|
||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
|
||||
|
||||
let activeStats:
|
||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
|
||||
| null
|
||||
| undefined = statsResult;
|
||||
if (isLive) {
|
||||
activeStats = liveData;
|
||||
}
|
||||
|
||||
const mappers = useMemo(
|
||||
() => ({
|
||||
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
|
||||
memory: (s: { mu: number; mb: number }) => ({
|
||||
[t("chart.memory.series")]: s.mu,
|
||||
[t("chart.memory.cache")]: s.mb ?? 0,
|
||||
}),
|
||||
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
|
||||
diskIO: (s: { dr?: number; dw?: number }) => ({
|
||||
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
|
||||
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
|
||||
}),
|
||||
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
|
||||
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
|
||||
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const series = useMemo(
|
||||
() => ({
|
||||
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
|
||||
memory: [
|
||||
{ name: t("chart.memory.series"), color: "teal.5" },
|
||||
{ name: t("chart.memory.cache"), color: "teal.8" },
|
||||
],
|
||||
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
|
||||
diskIO: [
|
||||
{ name: t("chart.diskIO.write"), color: "orange.6" },
|
||||
{ name: t("chart.diskIO.read"), color: "blue.6" },
|
||||
],
|
||||
network: [
|
||||
{ name: t("chart.network.sent"), color: "blue.6" },
|
||||
{ name: t("chart.network.recv"), color: "teal.6" },
|
||||
],
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const systemStats = activeStats?.systemStats;
|
||||
const containerStatsRaw = activeStats?.containerStats;
|
||||
|
||||
const timePeriod = options.timePeriod as BeszelTimePeriod;
|
||||
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
|
||||
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
|
||||
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
|
||||
|
||||
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
|
||||
const dockerCpuData = useDockerChartData(
|
||||
statsWhenShown(options.showDockerCpu, containerStatsRaw),
|
||||
containerNames,
|
||||
"cpu",
|
||||
timePeriod,
|
||||
);
|
||||
const dockerMemData = useDockerChartData(
|
||||
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
||||
containerNames,
|
||||
"memory",
|
||||
timePeriod,
|
||||
);
|
||||
const dockerNetData = useDockerChartData(
|
||||
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
||||
containerNames,
|
||||
"network",
|
||||
timePeriod,
|
||||
);
|
||||
|
||||
const efsPaths = useMemo(() => {
|
||||
if (!systemStats) return [];
|
||||
const paths = new Set<string>();
|
||||
for (const record of systemStats) {
|
||||
if (record.stats.efs) {
|
||||
for (const path of Object.keys(record.stats.efs)) {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}, [systemStats]);
|
||||
|
||||
const storageData = useEfsChartData(statsWhenShown(options.showStorage, systemStats), efsPaths, timePeriod);
|
||||
|
||||
const containerSeries = useMemo(
|
||||
() => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })),
|
||||
[containerNames],
|
||||
);
|
||||
|
||||
const storageSeries = useMemo(
|
||||
() => efsPaths.map((path, i) => ({ name: path, color: containerColors[i % containerColors.length] as string })),
|
||||
[efsPaths],
|
||||
);
|
||||
|
||||
const cols = gridColumns[Number(width > 600)];
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
if (!isLive && statsError) throw statsError;
|
||||
|
||||
if (systemsPending) {
|
||||
if (systemsPending)
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (systems.length === 0) {
|
||||
return (
|
||||
@@ -240,7 +79,7 @@ export default function BeszelSystemStatsWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemsPending && !systemExists && systems.length > 0) {
|
||||
if (!systemExists) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="sm" p="md">
|
||||
@@ -260,22 +99,6 @@ 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.liveConnectionFailed")}
|
||||
</Text>
|
||||
<Button size="xs" variant="light" onClick={() => setOptions({ newOptions: { timePeriod: "1h" } })}>
|
||||
{t("error.switchToHistorical")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
@@ -284,7 +107,7 @@ export default function BeszelSystemStatsWidget({
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
className={classes.beszelStatsContainer}
|
||||
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap="md" p="sm">
|
||||
{!isEditMode && systems.length > 1 && (
|
||||
@@ -300,176 +123,37 @@ export default function BeszelSystemStatsWidget({
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((s) => (
|
||||
{systems.map((system) => (
|
||||
<Menu.Item
|
||||
key={s.value}
|
||||
key={system.value}
|
||||
fz="xs"
|
||||
fw={s.value === selectedSystem ? 600 : 400}
|
||||
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(s.value)}
|
||||
fw={system.value === selectedSystem ? 600 : 400}
|
||||
c={system.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(system.value)}
|
||||
>
|
||||
{s.label}
|
||||
{system.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{activeStats.error}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{options.showCpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showMemory && memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDisk && diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
series: series.disk,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDiskIO && diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showNetwork && networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showStorage && storageData.length > 0 && storageSeries.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.storage.title")}
|
||||
subtitle={t("chart.storage.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: storageData,
|
||||
type: "stacked",
|
||||
series: storageSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
tooltipProps: tooltipPercentTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerMemData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerNetData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<BeszelStatsView
|
||||
integrationIds={integrationIds}
|
||||
systemId={selectedSystem}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDiskIO,
|
||||
network: options.showNetwork,
|
||||
dockerCpu: options.showDockerCpu,
|
||||
dockerMemory: options.showDockerMemory,
|
||||
dockerNetwork: options.showDockerNetwork,
|
||||
}}
|
||||
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
|
||||
/>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
@@ -16,6 +16,7 @@ const timePeriodOptions = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
|
||||
icon: IconChartAreaLine,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
@@ -45,7 +46,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
|
||||
showDockerCpu: factory.switch({ defaultValue: true }),
|
||||
showDockerMemory: factory.switch({ defaultValue: true }),
|
||||
showDockerNetwork: factory.switch({ defaultValue: true }),
|
||||
showStorage: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -26,10 +26,18 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||
import {
|
||||
formatByteRate,
|
||||
formatLoadAvg,
|
||||
formatPercent,
|
||||
formatTemp,
|
||||
formatUptime,
|
||||
getProgressTrackSize,
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||
|
||||
@@ -97,11 +105,16 @@ export default function BeszelSystemTableWidget({
|
||||
}, [filteredSystems, sortStatus]);
|
||||
|
||||
const PercentCell = ({ value }: { value: number }) => (
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} miw={size.valueMiw} ta="right" style={{ whiteSpace: "nowrap" }}>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress value={value} color={thresholdColor(value)} size={size.progressSize} style={{ flex: 1 }} />
|
||||
<Progress
|
||||
value={value}
|
||||
color={thresholdColor(value)}
|
||||
size={getProgressTrackSize(size.progressSize)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -156,7 +169,15 @@ export default function BeszelSystemTableWidget({
|
||||
</Group>
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.disk} />,
|
||||
render: (record) => (
|
||||
<DiskUsage
|
||||
system={record}
|
||||
fontSize={size.fontSize}
|
||||
progressSize={size.progressSize}
|
||||
valueMiw={size.valueMiw}
|
||||
valueGap={8}
|
||||
/>
|
||||
),
|
||||
},
|
||||
options.showGpu && {
|
||||
accessor: "gpu",
|
||||
|
||||
@@ -25,6 +25,7 @@ const sortDirectionOptions = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
|
||||
icon: IconTable,
|
||||
queryKey: [["widget", "beszel"]],
|
||||
supportedIntegrations: ["beszel", "mock"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
|
||||
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
|
||||
|
||||
const record = (
|
||||
created: string,
|
||||
du: number,
|
||||
efs: BeszelSystemStatsRecord["stats"]["efs"],
|
||||
): BeszelSystemStatsRecord => ({
|
||||
id: created,
|
||||
system: "system-1",
|
||||
type: "1m",
|
||||
created,
|
||||
updated: created,
|
||||
stats: {
|
||||
cpu: 0,
|
||||
m: 0,
|
||||
mu: 0,
|
||||
mp: 0,
|
||||
mb: 0,
|
||||
s: 0,
|
||||
su: 0,
|
||||
d: 938.8,
|
||||
du,
|
||||
dp: 51.15,
|
||||
efs,
|
||||
},
|
||||
});
|
||||
|
||||
describe("buildDiskChartData", () => {
|
||||
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
|
||||
const data = buildDiskChartData(
|
||||
[
|
||||
record("2026-07-11T13:46:00.000Z", 455.81, {
|
||||
sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
|
||||
sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
|
||||
sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
|
||||
}),
|
||||
],
|
||||
["sda", "sdb", "sdc"],
|
||||
"Root",
|
||||
"1h",
|
||||
);
|
||||
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
|
||||
});
|
||||
|
||||
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
|
||||
const data = buildDiskChartData(
|
||||
[
|
||||
record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
|
||||
record("2026-07-11T13:46:00.000Z", 1, undefined),
|
||||
],
|
||||
["sda"],
|
||||
"Root",
|
||||
"1h",
|
||||
);
|
||||
|
||||
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
|
||||
{ Root: 1, sda: 0 },
|
||||
{ Root: 2, sda: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("padLiveTimeGrid", () => {
|
||||
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
|
||||
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
|
||||
|
||||
expect(data).toHaveLength(60);
|
||||
expect(data[0]).not.toHaveProperty("CPU");
|
||||
expect(data[58]).not.toHaveProperty("CPU");
|
||||
expect(data[59]).toMatchObject({ CPU: 18.07 });
|
||||
});
|
||||
|
||||
test("leaves null gaps for missing seconds instead of stretching existing points", () => {
|
||||
const data = padLiveTimeGrid([
|
||||
{ time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
|
||||
{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
|
||||
]);
|
||||
|
||||
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
|
||||
}
|
||||
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
|
||||
if (timePeriod === "1m") return padLiveTimeGrid(data);
|
||||
const days = periodDays[timePeriod];
|
||||
if (!days) return data;
|
||||
|
||||
@@ -50,6 +51,38 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
|
||||
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
|
||||
}
|
||||
|
||||
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
|
||||
if (data.length === 0) return data;
|
||||
|
||||
const end = dayjs(
|
||||
data.reduce((latest, point) => {
|
||||
const timestamp = new Date(point.rawTime as string).getTime();
|
||||
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
|
||||
}, 0),
|
||||
).startOf("second");
|
||||
const pointsBySecond = new Map(
|
||||
data.map(
|
||||
(point) =>
|
||||
[
|
||||
dayjs(point.rawTime as string)
|
||||
.startOf("second")
|
||||
.valueOf(),
|
||||
point,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
|
||||
return Array.from({ length: pointCount }, (_, index) => {
|
||||
const timestamp = end.subtract(pointCount - index - 1, "second");
|
||||
return (
|
||||
pointsBySecond.get(timestamp.valueOf()) ?? {
|
||||
time: timestamp.format(timeFormats["1m"]),
|
||||
rawTime: timestamp.toISOString(),
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
|
||||
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
|
||||
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
|
||||
@@ -174,24 +207,39 @@ const defaultContainerExtractors: Record<string, ContainerExtractor> = {
|
||||
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
|
||||
};
|
||||
|
||||
export const useEfsChartData = (
|
||||
export const useDiskChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!systemStats?.length) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
||||
const efs = record.stats.efs ?? {};
|
||||
for (const path of efsPaths) {
|
||||
point[path] = efs[path]?.du ?? 0;
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [systemStats, efsPaths, timePeriod]);
|
||||
useMemo(
|
||||
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
|
||||
[systemStats, efsPaths, rootSeriesName, timePeriod],
|
||||
);
|
||||
|
||||
export const buildDiskChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
efsPaths: string[],
|
||||
rootSeriesName: string,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
) => {
|
||||
if (!systemStats?.length) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = {
|
||||
time: fmt(record.created),
|
||||
rawTime: record.created,
|
||||
[rootSeriesName]: record.stats.du,
|
||||
};
|
||||
const efs = record.stats.efs ?? {};
|
||||
for (const path of efsPaths) {
|
||||
point[path] = efs[path]?.du ?? 0;
|
||||
}
|
||||
return point;
|
||||
});
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
};
|
||||
|
||||
export const useDockerChartData = (
|
||||
containerStats: BeszelContainerStatsRecord[] | undefined,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import type { BeszelSystemRow } from "./types";
|
||||
import { thresholdColor } from "./colors";
|
||||
import { formatPercent, getProgressTrackSize } from "./format";
|
||||
|
||||
interface DiskUsageProps {
|
||||
system: BeszelSystemRow;
|
||||
fontSize: string;
|
||||
progressSize: "xs" | "sm";
|
||||
valueMiw: number;
|
||||
valueGap?: number;
|
||||
}
|
||||
|
||||
const mountLabel = (path: string) =>
|
||||
path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
|
||||
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
|
||||
|
||||
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
|
||||
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
|
||||
const trackSize = getProgressTrackSize(progressSize);
|
||||
const dotSize = progressSize === "xs" ? 2 : 3;
|
||||
const dotGap = 2;
|
||||
|
||||
return (
|
||||
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
|
||||
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{formatPercent(system.disk)}
|
||||
</Text>
|
||||
<HoverCard
|
||||
position="right"
|
||||
withArrow
|
||||
shadow="md"
|
||||
openDelay={200}
|
||||
closeDelay={100}
|
||||
disabled={filesystems.length === 0}
|
||||
>
|
||||
<HoverCard.Target>
|
||||
{filesystems.length > 0 ? (
|
||||
<UnstyledButton
|
||||
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
minWidth: 24,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
||||
<Group
|
||||
gap={dotGap}
|
||||
wrap="nowrap"
|
||||
aria-hidden
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
right={Math.max(2, dotGap)}
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
>
|
||||
{filesystems.map(([path, value]) => (
|
||||
<Box
|
||||
key={path}
|
||||
w={dotSize}
|
||||
h={dotSize}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
backgroundColor: severityColor(value),
|
||||
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
|
||||
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
|
||||
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown p={8}>
|
||||
<Stack gap={6} miw={145}>
|
||||
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
|
||||
<Stack key={path} gap={1}>
|
||||
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
|
||||
{mountLabel(path)}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600} miw={48} lh={1.25}>
|
||||
{formatPercent(value)}
|
||||
</Text>
|
||||
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
|
||||
|
||||
describe("Beszel storage formatting", () => {
|
||||
test("keeps smaller values in GB", () => {
|
||||
expect(formatGB(455.81)).toBe("455.81 GB");
|
||||
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
|
||||
});
|
||||
|
||||
test("promotes large GiB values to TB", () => {
|
||||
expect(formatGB(3323)).toBe("3.25 TB");
|
||||
expect(formatGB(3936.86)).toBe("3.84 TB");
|
||||
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
|
||||
});
|
||||
|
||||
test("maps progress sizes consistently", () => {
|
||||
expect(getProgressTrackSize("xs")).toBe(6);
|
||||
expect(getProgressTrackSize("sm")).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
|
||||
|
||||
export const formatGB = (value: number): string => {
|
||||
if (!value || !Number.isFinite(value)) return "0 GB";
|
||||
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
|
||||
return `${value.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
|
||||
|
||||
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
|
||||
|
||||
export const chartAxisFormatters = {
|
||||
percent: formatPercent,
|
||||
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
|
||||
gb: (value: number) => {
|
||||
const numericValue = Number(value);
|
||||
if (!numericValue) return "0";
|
||||
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
|
||||
return `${numericValue.toFixed(0)}G`;
|
||||
},
|
||||
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
|
||||
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
|
||||
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { containerColors } from "./colors";
|
||||
import type { BeszelTimePeriod } from "./chart";
|
||||
import {
|
||||
BeszelChartPanel,
|
||||
CPU_Y_AXIS_DOMAIN,
|
||||
useContainerNames,
|
||||
useDiskChartData,
|
||||
useDockerChartData,
|
||||
useSystemChartData,
|
||||
} from "./chart";
|
||||
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
|
||||
import { makeTooltipProps } from "./tooltip";
|
||||
import { useLiveStats } from "./use-live-stats";
|
||||
|
||||
const CHART_HEIGHT = 180;
|
||||
const MB = 1024 * 1024;
|
||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
||||
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
|
||||
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
|
||||
|
||||
const ChartSkeleton = () => (
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Skeleton h={14} w={72} radius="sm" />
|
||||
<Skeleton h={10} w={110} radius="sm" />
|
||||
</Group>
|
||||
<Skeleton h={CHART_HEIGHT} radius="sm" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export interface BeszelStatsVisibility {
|
||||
cpu: boolean;
|
||||
memory: boolean;
|
||||
disk: boolean;
|
||||
diskIO: boolean;
|
||||
network: boolean;
|
||||
dockerCpu: boolean;
|
||||
dockerMemory: boolean;
|
||||
dockerNetwork: boolean;
|
||||
}
|
||||
|
||||
interface BeszelStatsViewProps {
|
||||
integrationIds: string[];
|
||||
systemId: string;
|
||||
timePeriod: BeszelTimePeriod;
|
||||
visibility: BeszelStatsVisibility;
|
||||
columns: 1 | 2;
|
||||
onSwitchToHistorical?: () => void;
|
||||
}
|
||||
|
||||
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
|
||||
|
||||
export function BeszelStatsView({
|
||||
integrationIds,
|
||||
systemId,
|
||||
timePeriod,
|
||||
visibility,
|
||||
columns,
|
||||
onSwitchToHistorical,
|
||||
}: BeszelStatsViewProps) {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
|
||||
const isLive = timePeriod === "1m";
|
||||
|
||||
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
|
||||
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
|
||||
);
|
||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
|
||||
|
||||
const activeStats:
|
||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
|
||||
| null
|
||||
| undefined = isLive ? liveData : historicalData;
|
||||
|
||||
const mappers = useMemo(
|
||||
() => ({
|
||||
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
|
||||
memory: (s: { mu: number; mb: number }) => ({
|
||||
[t("chart.memory.series")]: s.mu,
|
||||
[t("chart.memory.cache")]: s.mb ?? 0,
|
||||
}),
|
||||
diskIO: (s: { dr?: number; dw?: number }) => ({
|
||||
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
|
||||
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
|
||||
}),
|
||||
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
|
||||
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
|
||||
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const series = useMemo(
|
||||
() => ({
|
||||
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
|
||||
memory: [
|
||||
{ name: t("chart.memory.series"), color: "teal.5" },
|
||||
{ name: t("chart.memory.cache"), color: "teal.8" },
|
||||
],
|
||||
diskIO: [
|
||||
{ name: t("chart.diskIO.write"), color: "orange.6" },
|
||||
{ name: t("chart.diskIO.read"), color: "blue.6" },
|
||||
],
|
||||
network: [
|
||||
{ name: t("chart.network.sent"), color: "blue.6" },
|
||||
{ name: t("chart.network.recv"), color: "teal.6" },
|
||||
],
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const systemStats = activeStats?.systemStats;
|
||||
const containerStats = activeStats?.containerStats;
|
||||
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
|
||||
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
|
||||
|
||||
const efsPaths = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
for (const record of systemStats ?? []) {
|
||||
for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
|
||||
}
|
||||
return [...paths];
|
||||
}, [systemStats]);
|
||||
const rootSeriesName = t("chart.disk.series");
|
||||
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
|
||||
const diskSeries = useMemo(
|
||||
() => [
|
||||
{ name: rootSeriesName, color: "grape.6" },
|
||||
...efsPaths.map((path, index) => ({
|
||||
name: path,
|
||||
color: containerColors[(index + 1) % containerColors.length] as string,
|
||||
})),
|
||||
],
|
||||
[efsPaths, rootSeriesName],
|
||||
);
|
||||
|
||||
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
|
||||
const containerSeries = useMemo(
|
||||
() =>
|
||||
containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
|
||||
[containerNames],
|
||||
);
|
||||
const dockerCpuData = useDockerChartData(
|
||||
whenVisible(visibility.dockerCpu, containerStats),
|
||||
containerNames,
|
||||
"cpu",
|
||||
timePeriod,
|
||||
);
|
||||
const dockerMemoryData = useDockerChartData(
|
||||
whenVisible(visibility.dockerMemory, containerStats),
|
||||
containerNames,
|
||||
"memory",
|
||||
timePeriod,
|
||||
);
|
||||
const dockerNetworkData = useDockerChartData(
|
||||
whenVisible(visibility.dockerNetwork, containerStats),
|
||||
containerNames,
|
||||
"network",
|
||||
timePeriod,
|
||||
);
|
||||
|
||||
if (historicalError) throw historicalError;
|
||||
|
||||
if (isLive && liveError) {
|
||||
return (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Stack align="center" gap="sm" p="md">
|
||||
<IconPlugConnectedX size={40} />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("error.liveConnectionFailed")}
|
||||
</Text>
|
||||
{onSwitchToHistorical && (
|
||||
<Button size="xs" variant="light" onClick={onSwitchToHistorical}>
|
||||
{t("error.switchToHistorical")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeStats) {
|
||||
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
|
||||
{Array.from({ length: visibleChartCount }, (_, index) => (
|
||||
<ChartSkeleton key={index} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
if ("error" in activeStats && activeStats.error) {
|
||||
return (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{activeStats.error}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={columns} spacing="md">
|
||||
{visibility.cpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibility.memory && memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibility.disk && diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
type: "stacked",
|
||||
series: diskSeries,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibility.diskIO && diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibility.network && networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{visibility.dockerCpu && dockerCpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
tooltipProps: tooltipPercentTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibility.dockerMemory && dockerMemoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerMemoryData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{visibility.dockerNetwork && dockerNetworkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerNetworkData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { SegmentedControl, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { BeszelTimePeriod } from "./chart";
|
||||
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
|
||||
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
|
||||
import { makeTooltipProps } from "./tooltip";
|
||||
|
||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
||||
|
||||
const CHART_HEIGHT = 180;
|
||||
const MB = 1024 * 1024;
|
||||
import { BeszelStatsView } from "./stats-view";
|
||||
|
||||
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
|
||||
{ value: "1m", label: "Live" },
|
||||
{ value: "1h", label: "1H" },
|
||||
{ value: "12h", label: "12H" },
|
||||
{ value: "24h", label: "24H" },
|
||||
@@ -27,141 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
|
||||
{ value: "30d", label: "30D" },
|
||||
];
|
||||
|
||||
const allCharts = {
|
||||
cpu: true,
|
||||
memory: true,
|
||||
disk: true,
|
||||
diskIO: true,
|
||||
network: true,
|
||||
dockerCpu: true,
|
||||
dockerMemory: true,
|
||||
dockerNetwork: true,
|
||||
} as const;
|
||||
|
||||
interface BeszelSystemStatsModalProps {
|
||||
integrationId: string;
|
||||
systemId: string;
|
||||
}
|
||||
|
||||
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
|
||||
|
||||
const { data } = clientApi.widget.beszel.getSystemStats.useQuery({
|
||||
integrationIds: [innerProps.integrationId],
|
||||
systemId: innerProps.systemId,
|
||||
timePeriod,
|
||||
includeDocker: false,
|
||||
});
|
||||
|
||||
const mappers = useMemo(
|
||||
() => ({
|
||||
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
|
||||
memory: (s: { mu: number; mb: number }) => ({
|
||||
[t("chart.memory.series")]: s.mu,
|
||||
[t("chart.memory.cache")]: s.mb ?? 0,
|
||||
}),
|
||||
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
|
||||
diskIO: (s: { dr?: number; dw?: number }) => ({
|
||||
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
|
||||
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
|
||||
}),
|
||||
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
|
||||
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
|
||||
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const series = useMemo(
|
||||
() => ({
|
||||
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
|
||||
memory: [
|
||||
{ name: t("chart.memory.series"), color: "teal.5" },
|
||||
{ name: t("chart.memory.cache"), color: "teal.8" },
|
||||
],
|
||||
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
|
||||
diskIO: [
|
||||
{ name: t("chart.diskIO.write"), color: "orange.6" },
|
||||
{ name: t("chart.diskIO.read"), color: "blue.6" },
|
||||
],
|
||||
network: [
|
||||
{ name: t("chart.network.sent"), color: "blue.6" },
|
||||
{ name: t("chart.network.recv"), color: "teal.6" },
|
||||
],
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const systemStats = data?.systemStats;
|
||||
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
|
||||
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
|
||||
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={timePeriod}
|
||||
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
|
||||
onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
|
||||
data={timePeriodOptions}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
series: series.disk,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<BeszelStatsView
|
||||
integrationIds={[innerProps.integrationId]}
|
||||
systemId={innerProps.systemId}
|
||||
timePeriod={timePeriod}
|
||||
visibility={allCharts}
|
||||
columns={2}
|
||||
onSwitchToHistorical={() => setTimePeriod("1h")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { skipToken } from "@tanstack/react-query";
|
||||
|
||||
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;
|
||||
// Beszel emits roughly one record per second. Keep a sliding one-minute window
|
||||
// without synthesizing points for missed updates.
|
||||
const MAX_BUFFER = 60;
|
||||
|
||||
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
|
||||
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
|
||||
@@ -29,9 +31,8 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
|
||||
}, []);
|
||||
|
||||
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
|
||||
{ integrationIds, systemId },
|
||||
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
|
||||
{
|
||||
enabled: enabled && systemId !== "",
|
||||
onData(event) {
|
||||
setError(null);
|
||||
if (event.type === "system_stats") {
|
||||
@@ -48,7 +49,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
|
||||
|
||||
// Reset buffers when system changes or integration changes
|
||||
const prevKeyRef = useRef<string>("");
|
||||
const currentKey = `${integrationIds.join(",")}:${systemId}`;
|
||||
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
|
||||
useEffect(() => {
|
||||
if (prevKeyRef.current !== currentKey) {
|
||||
prevKeyRef.current = currentKey;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from "react";
|
||||
import type { LoaderComponent } from "next/dynamic";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
@@ -69,6 +69,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
|
||||
|
||||
export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
queryKey?: QueryKey;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
maxIntegrations?: number;
|
||||
|
||||
Reference in new issue
Block a user