Compare commits

...
Author SHA1 Message Date
ajnart ab6a0f3b96 style: auto-fix lint and format 2026-06-22 12:08:39 +00:00
ajnart d6b6287d6d fix(beszel): harden cache layer and prevent blank widgets
- Add debug logging across Beszel integration, request handler, and tRPC
  router (auth, fetch, SSE, per-procedure entry/exit) gated behind
  LOG_LEVEL=debug
- cached-request-handler: retry with backoff, isValid predicate to skip
  caching invalid data, in-flight dedup (single-flight per channel) to
  avoid thundering herd after cache invalidation
- redis channel: set-then-publish so storage is always consistent even
  when pubsub publish fails
- Beszel handlers: opt into fallbackToStaleOnError and retry so brief
  upstream hiccups don't throw away good cached data
- beszel-system-stats: add refetchInterval matching each time period's
  cache TTL so historical charts refresh in the background
- tRPC router: switch Promise.all -> Promise.allSettled for getSystems
  and getAlerts; getSystemStats returns an error object instead of
  throwing. Each result carries an optional error field so one failed
  integration no longer nukes the whole widget
- Widgets: add explicit isPending loader, empty state for grid and
  stats, and a shared per-integration error indicator (tooltip with
  failed integration names). Alerts and table now only show their
  empty state after loading completes
- Translation: add empty.noSystems key to beszelSystemGrid and
  beszelSystemStats

Addresses #6030
2026-06-22 14:07:16 +02:00
12 changed files with 730 additions and 323 deletions

No files matched your search

+109 -5
View File
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { import {
@@ -14,6 +15,8 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "beszelRouter" });
export const beszelRouter = createTRPCRouter({ export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure getSystems: publicProcedure
.meta({ .meta({
@@ -25,7 +28,9 @@ export const beszelRouter = createTRPCRouter({
}) })
.concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results = await Promise.all( const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
@@ -40,6 +45,29 @@ export const beszelRouter = createTRPCRouter({
}; };
}), }),
); );
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getSystems integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
integrationUrl: integration?.url ?? "",
systems: [],
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getSystems completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results; return results;
}), }),
@@ -83,7 +111,14 @@ export const beszelRouter = createTRPCRouter({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const results = await Promise.all( const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getAlerts called", {
userId: ctx.session?.user?.id,
integrationIds,
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, { const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory, includeHistory: input.includeHistory,
@@ -100,6 +135,7 @@ export const beszelRouter = createTRPCRouter({
} }
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name,
alerts: alertsResult.data.alerts, alerts: alertsResult.data.alerts,
history: alertsResult.data.history, history: alertsResult.data.history,
systemNameMap, systemNameMap,
@@ -107,6 +143,30 @@ export const beszelRouter = createTRPCRouter({
}; };
}), }),
); );
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getAlerts integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getAlerts completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results; return results;
}), }),
@@ -164,13 +224,41 @@ export const beszelRouter = createTRPCRouter({
if (!integration) { if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }); throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
} }
const innerHandler = beszelStatsRequestHandler.handler(integration, { logger.debug("getSystemStats called", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId, systemId: input.systemId,
timePeriod: input.timePeriod, timePeriod: input.timePeriod,
includeDocker: input.includeDocker, includeDocker: input.includeDocker,
}); });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); try {
return { integrationId: integration.id, ...data, updatedAt: timestamp }; const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
logger.debug("getSystemStats completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
return { integrationId: integration.id, ...data, updatedAt: timestamp };
} catch (error) {
logger.warn("getSystemStats failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
error,
});
return {
integrationId: integration.id,
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: error instanceof Error ? error.message : String(error),
};
}
}), }),
subscribeSystemStats: publicProcedure subscribeSystemStats: publicProcedure
@@ -190,6 +278,11 @@ export const beszelRouter = createTRPCRouter({
emit.error(new Error("No Beszel integration configured")); emit.error(new Error("No Beszel integration configured"));
return () => {}; return () => {};
} }
logger.debug("subscribeSystemStats opened", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
const abortController = new AbortController(); const abortController = new AbortController();
@@ -199,6 +292,12 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal); await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
} catch (error) { } catch (error) {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
logger.warn("subscribeSystemStats error", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
error,
});
if (error instanceof Error) { if (error instanceof Error) {
emit.error(error); emit.error(error);
} else { } else {
@@ -210,6 +309,11 @@ export const beszelRouter = createTRPCRouter({
return () => { return () => {
abortController.abort(); abortController.abort();
logger.debug("subscribeSystemStats closed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
}; };
}); });
}), }),
@@ -46,10 +46,13 @@ export class BeszelIntegration extends Integration {
private async authenticateAsync(): Promise<BeszelSession> { private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync(); const existingSession = await this.sessionStore.getAsync();
if (existingSession) { if (existingSession) {
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
return existingSession; return existingSession;
} }
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), { const authUrl = this.url("/api/collections/users/auth-with-password");
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -57,23 +60,28 @@ export class BeszelIntegration extends Integration {
password: this.getSecretValue("password"), password: this.getSecretValue("password"),
}), }),
}); });
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
if (!response.ok) { if (!response.ok) {
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
throw new ResponseError(response); throw new ResponseError(response);
} }
const data = (await response.json()) as BeszelAuthResponse; const data = (await response.json()) as BeszelAuthResponse;
const session: BeszelSession = { token: data.token, userId: data.record.id }; const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session); await this.sessionStore.setAsync(session);
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
return session; return session;
} }
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) { private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
const method = options.method ?? "GET";
const start = performance.now();
let session = await this.authenticateAsync(); let session = await this.authenticateAsync();
const doFetch = (token: string) => const doFetch = (token: string) =>
fetchWithTrustedCertificatesAsync(url, { fetchWithTrustedCertificatesAsync(url, {
method: options.method ?? "GET", method,
headers: { headers: {
Authorization: token, Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.body ? { "Content-Type": "application/json" } : {}),
@@ -82,14 +90,42 @@ export class BeszelIntegration extends Integration {
}); });
let response = await doFetch(session.token); let response = await doFetch(session.token);
logger.debug("Beszel response", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
attempt: 1,
});
if (response.status === 401) { if (response.status === 401) {
logger.warn("Beszel 401, clearing session and retrying", {
integrationId: this.integration.id,
method,
path: url.pathname,
});
await this.sessionStore.clearAsync(); await this.sessionStore.clearAsync();
session = await this.authenticateAsync(); session = await this.authenticateAsync();
response = await doFetch(session.token); response = await doFetch(session.token);
logger.debug("Beszel response (after re-auth)", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
attempt: 2,
});
} }
if (!response.ok) { if (!response.ok) {
logger.warn("Beszel request failed", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
});
throw new ResponseError(response); throw new ResponseError(response);
} }
@@ -257,6 +293,7 @@ export class BeszelIntegration extends Integration {
): Promise<void> { ): Promise<void> {
const session = await this.authenticateAsync(); const session = await this.authenticateAsync();
const sseUrl = this.url("/api/realtime"); const sseUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, { const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
headers: { Authorization: session.token }, headers: { Authorization: session.token },
@@ -264,8 +301,14 @@ export class BeszelIntegration extends Integration {
}); });
if (!sseResponse.ok || !sseResponse.body) { if (!sseResponse.ok || !sseResponse.body) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: sseResponse.status,
});
throw new ResponseError(sseResponse); throw new ResponseError(sseResponse);
} }
logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
const reader = sseResponse.body.getReader(); const reader = sseResponse.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@@ -322,6 +365,10 @@ export class BeszelIntegration extends Integration {
} }
} }
if (!signal.aborted) { if (!signal.aborted) {
logger.warn("Beszel SSE stream ended unexpectedly", {
integrationId: this.integration.id,
systemId,
});
throw new Error("Beszel SSE stream ended unexpectedly"); throw new Error("Beszel SSE stream ended unexpectedly");
} }
} finally { } finally {
+12 -2
View File
@@ -415,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
}); });
}, },
publishAndUpdateLastStateAsync: async (data: TData) => { publishAndUpdateLastStateAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data)); // Set first, then publish. If publish fails the storage is still consistent
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() })); // and subscribers will catch up on the next event.
const payload = superjson.stringify({ data, timestamp: new Date() });
await getSetClient.set(channelName, payload);
try {
await publisher.publish(channelName, superjson.stringify(data));
} catch (error) {
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
channel: channelName,
error: error instanceof Error ? error.message : String(error),
});
}
}, },
setAsync: async (data: TData) => { setAsync: async (data: TData) => {
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() })); await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
+31
View File
@@ -1,5 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import type { import type {
BeszelAlert, BeszelAlert,
@@ -13,6 +14,8 @@ import type {
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
export type { BeszelSystemRow } from "@homarr/integrations/types"; export type { BeszelSystemRow } from "@homarr/integrations/types";
/** /**
@@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
Record<string, never> Record<string, never>
>({ >({
async requestAsync(integration) { async requestAsync(integration) {
const start = performance.now();
const instance = await createIntegrationAsync(integration); const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync(); const systems = await instance.getSystemsAsync();
const enriched = await Promise.all( const enriched = await Promise.all(
@@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
return mapToSystemRow(system, details); return mapToSystemRow(system, details);
}), }),
); );
logger.debug("beszelSystems fetch completed", {
integrationId: integration.id,
durationMs: Math.round(performance.now() - start),
count: enriched.length,
});
return enriched; return enriched;
}, },
cacheDuration: dayjs.duration(5, "seconds"), cacheDuration: dayjs.duration(5, "seconds"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelSystems", queryKey: "beszelSystems",
}); });
@@ -81,12 +92,21 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
{ includeHistory: boolean; maxHistoryItems: number } { includeHistory: boolean; maxHistoryItems: number }
>({ >({
async requestAsync(integration, input) { async requestAsync(integration, input) {
const start = performance.now();
const instance = await createIntegrationAsync(integration); const instance = await createIntegrationAsync(integration);
const alerts = await instance.getAlertsAsync(); const alerts = await instance.getAlertsAsync();
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : []; const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
logger.debug("beszelAlerts fetch completed", {
integrationId: integration.id,
durationMs: Math.round(performance.now() - start),
alertsCount: alerts.length,
historyCount: history.length,
});
return { alerts, history }; return { alerts, history };
}, },
cacheDuration: dayjs.duration(15, "seconds"), cacheDuration: dayjs.duration(15, "seconds"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelAlerts", queryKey: "beszelAlerts",
}); });
@@ -110,15 +130,26 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
{ systemId: string; timePeriod: string; includeDocker: boolean } { systemId: string; timePeriod: string; includeDocker: boolean }
>({ >({
async requestAsync(integration, input) { async requestAsync(integration, input) {
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 }; const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
const instance = await createIntegrationAsync(integration); const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => []) ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
: []; : [];
logger.debug("beszelStats fetch completed", {
integrationId: integration.id,
systemId: input.systemId,
timePeriod: input.timePeriod,
durationMs: Math.round(performance.now() - start),
systemStatsCount: systemStats.length,
containerStatsCount: containerStats.length,
});
return { systemStats, containerStats }; return { systemStats, containerStats };
}, },
cacheDuration: dayjs.duration(60, "seconds"), cacheDuration: dayjs.duration(60, "seconds"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelStats", queryKey: "beszelStats",
cacheDurationForInput(input) { cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod]; const config = timePeriodConfig[input.timePeriod];
@@ -18,6 +18,9 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>; requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheDuration: Duration; cacheDuration: Duration;
cacheDurationForInput?: (input: TInput) => Duration | undefined; cacheDurationForInput?: (input: TInput) => Duration | undefined;
fallbackToStaleOnError?: boolean;
retry?: { attempts?: number; delayMs?: number };
isValid?: (data: TData) => boolean;
} }
export const createCachedIntegrationRequestHandler = < export const createCachedIntegrationRequestHandler = <
@@ -35,6 +38,9 @@ export const createCachedIntegrationRequestHandler = <
return await options.requestAsync(input.integration, input.options); return await options.requestAsync(input.integration, input.options);
}, },
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration, cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
fallbackToStaleOnError: options.fallbackToStaleOnError,
retry: options.retry,
isValid: options.isValid,
createRedisChannel(input, handlerOptions) { createRedisChannel(input, handlerOptions) {
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options); return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
}, },
@@ -7,6 +7,16 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
const logger = createLogger({ module: "cachedRequestHandler" }); const logger = createLogger({ module: "cachedRequestHandler" });
type FetchResult<TData> = { data: TData; timestamp: Date };
type InFlightEntry = Promise<FetchResult<unknown>>;
const inFlightFetches = new Map<string, InFlightEntry>();
const isAbortedError = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
return error.name === "AbortError" || error.name === "TimeoutError";
};
interface Options<TData, TInput extends Record<string, unknown>> { interface Options<TData, TInput extends Record<string, unknown>> {
// Unique key for this request handler // Unique key for this request handler
queryKey: string; queryKey: string;
@@ -17,28 +27,70 @@ interface Options<TData, TInput extends Record<string, unknown>> {
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>; ) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
cacheDuration: Duration; cacheDuration: Duration;
fallbackToStaleOnError?: boolean; fallbackToStaleOnError?: boolean;
// Retry the upstream call on failure. attempts=1 means no retry (default).
// AbortError / TimeoutError are never retried.
retry?: { attempts?: number; delayMs?: number };
// Predicate to decide whether a fetched response is worth caching.
// Return false to skip the cache write (next call will refetch).
isValid?: (data: TData) => boolean;
} }
const defaultRetry = { attempts: 1, delayMs: 0 };
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>( export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>, options: Options<TData, TInput>,
) => { ) => {
return { return {
handler: (input: TInput) => { handler: (input: TInput) => {
const channel = options.createRedisChannel(input, options); const channel = options.createRedisChannel(input, options);
const retryConfig = { ...defaultRetry, ...options.retry };
return { return {
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) { async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
const channelData = await channel.getAsync(); const channelData = await channel.getAsync();
const requestNewDataAsync = async () => { const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
try { const inflight = inFlightFetches.get(channel.name);
const data = await options.requestAsync(input); if (inflight) {
await channel.publishAndUpdateLastStateAsync(data); logger.debug("Cached request handler joining in-flight fetch", {
return { channel: channel.name,
data, queryKey: options.queryKey,
timestamp: new Date(), });
}; return inflight as Promise<FetchResult<TData>>;
} catch (error) { }
const promise = (async (): Promise<FetchResult<TData>> => {
let lastError: unknown;
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
try {
const data = await options.requestAsync(input);
if (options.isValid && !options.isValid(data)) {
logger.warn("Cached request handler received invalid data, not caching", {
channel: channel.name,
queryKey: options.queryKey,
attempt,
});
return { data, timestamp: new Date() };
}
await channel.publishAndUpdateLastStateAsync(data);
return { data, timestamp: new Date() };
} catch (error) {
lastError = error;
if (isAbortedError(error) || attempt === retryConfig.attempts) {
break;
}
logger.warn("Cached request handler fetch failed, retrying", {
channel: channel.name,
queryKey: options.queryKey,
attempt,
nextDelayMs: retryConfig.delayMs,
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
});
await sleep(retryConfig.delayMs);
}
}
if (options.fallbackToStaleOnError && channelData) { if (options.fallbackToStaleOnError && channelData) {
logger.warn( logger.warn(
new ErrorWithMetadata( new ErrorWithMetadata(
@@ -46,15 +98,21 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
{ {
channel: channel.name, channel: channel.name,
queryKey: options.queryKey, queryKey: options.queryKey,
attempts: retryConfig.attempts,
}, },
{ cause: error }, { cause: lastError },
), ),
); );
return channelData; return channelData;
} }
throw error; throw lastError;
} })().finally(() => {
inFlightFetches.delete(channel.name);
});
inFlightFetches.set(channel.name, promise as InFlightEntry);
return promise;
}; };
if (forceUpdate) { if (forceUpdate) {
+6
View File
@@ -3639,6 +3639,9 @@
"error": { "error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings." "internalServerError": "Unable to connect to Beszel. Check your integration settings."
}, },
"empty": {
"noSystems": "No systems found"
},
"metric": { "metric": {
"cpu": "CPU", "cpu": "CPU",
"memory": "Memory", "memory": "Memory",
@@ -3712,6 +3715,9 @@
} }
}, },
"selectSystem": "Select system", "selectSystem": "Select system",
"empty": {
"noSystems": "No systems found"
},
"error": { "error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings.", "internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.", "liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
+117 -99
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core"; import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
@@ -12,6 +12,7 @@ import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
const alertIconMap: Record<string, LucideIcon> = { const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu, CPU: Cpu,
@@ -40,7 +41,11 @@ export default function BeszelAlertsWidget({
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems], [integrationIds, options.showHistory, options.maxHistoryItems],
); );
const { data: results = [], error: alertsError } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, { const {
data: results = [],
error: alertsError,
isPending,
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
staleTime: 15_000, staleTime: 15_000,
refetchInterval: 15_000, refetchInterval: 15_000,
retry: false, retry: false,
@@ -93,113 +98,126 @@ export default function BeszelAlertsWidget({
if (alertsError) throw alertsError; if (alertsError) throw alertsError;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
return ( return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}> <Box h="100%" pos="relative">
<Stack gap="sm" p="sm"> <Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
{alerts.length === 0 && ( <BeszelIntegrationErrorIndicator results={results} />
<Stack align="center" justify="center" py="xl" gap="xs"> </Box>
<ThemeIcon variant="light" color="gray" size="lg" radius="xl"> <ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<IconBellOff size={18} /> <Stack gap="sm" p="sm">
</ThemeIcon> {alerts.length === 0 && (
<Text size="sm" c="dimmed"> <Stack align="center" justify="center" py="xl" gap="xs">
{t("empty")} <ThemeIcon variant="light" color="gray" size="lg" radius="xl">
</Text> <IconBellOff size={18} />
</Stack> </ThemeIcon>
)} <Text size="sm" c="dimmed">
{t("empty")}
{triggeredAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="red">
{t("status.triggered")} ({triggeredAlerts.length})
</Text> </Text>
</Group> </Stack>
{triggeredAlerts.map((alert) => ( )}
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
/>
))}
</Stack>
)}
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />} {triggeredAlerts.length > 0 && (
{okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
/>
))}
</Stack>
)}
{options.showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}> <Stack gap={6}>
<Group gap={6}> <Group gap={6}>
<IconHistory size={14} opacity={0.5} /> <IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="dimmed"> <Text size="xs" fw={600} tt="uppercase" c="red">
{t("history")} {t("status.triggered")} ({triggeredAlerts.length})
</Text> </Text>
</Group> </Group>
{history.map((entry) => { {triggeredAlerts.map((alert) => (
const systemName = systemNameMap[entry.system] ?? entry.system; <AlertRow
const isResolved = !!entry.resolved; key={alert._key}
const HistoryIcon = alertIconMap[entry.name] ?? Server; name={alert.name}
return ( value={alert.value}
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}> min={alert.min}
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}> systemName={systemNameMap[alert.system] ?? alert.system}
<Box triggered
w={3} />
h={24} ))}
style={{ borderRadius: 2, flexShrink: 0 }} </Stack>
bg={isResolved ? "green.6" : "red.6"} )}
/>
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} /> {triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate> {okAlerts.length > 0 && (
{entry.name} <Stack gap={6}>
</Text> <Group gap={6}>
<Text size="xs" c="dimmed" truncate> <IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
{systemName} <Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
/>
))}
</Stack>
)}
{options.showHistory && history.length > 0 && (
<>
<Divider />
<Stack gap={6}>
<Group gap={6}>
<IconHistory size={14} opacity={0.5} />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("history")}
</Text>
</Group>
{history.map((entry) => {
const systemName = systemNameMap[entry.system] ?? entry.system;
const isResolved = !!entry.resolved;
const HistoryIcon = alertIconMap[entry.name] ?? Server;
return (
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
h={24}
style={{ borderRadius: 2, flexShrink: 0 }}
bg={isResolved ? "green.6" : "red.6"}
/>
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate>
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemName}
</Text>
</Stack>
</Group>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
{isResolved ? t("resolved") : t("status.triggered")}
</Badge>
<Text size="10px" c="dimmed">
{dayjs(entry.created).fromNow()}
</Text> </Text>
</Stack> </Stack>
</Group> </Group>
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}> );
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}> })}
{isResolved ? t("resolved") : t("status.triggered")} </Stack>
</Badge> </>
<Text size="10px" c="dimmed"> )}
{dayjs(entry.created).fromNow()} </Stack>
</Text> </ScrollArea>
</Stack> </Box>
</Group>
);
})}
</Stack>
</>
)}
</Stack>
</ScrollArea>
); );
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { MantineSize } from "@mantine/core"; import type { MantineSize } from "@mantine/core";
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core"; import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
import { import {
Activity, Activity,
Battery, Battery,
@@ -15,6 +15,7 @@ import {
Thermometer, Thermometer,
Wifi, Wifi,
} from "lucide-react"; } from "lucide-react";
import { IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -28,6 +29,7 @@ import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
interface SizeConfig { interface SizeConfig {
@@ -363,7 +365,11 @@ export default function BeszelSystemGridWidget({
const board = useRequiredBoard(); const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal); const { openModal } = useModalAction(BeszelSystemStatsModal);
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds }, { integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false }, { staleTime: 5_000, refetchInterval: 5_000, retry: false },
); );
@@ -373,6 +379,30 @@ export default function BeszelSystemGridWidget({
if (systemsError) throw systemsError; if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (filteredSystems.length === 0) {
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
<Text size="sm" c="dimmed">
{t("empty.noSystems")}
</Text>
</Stack>
</Center>
</Box>
);
}
const cols = getColCount(width, filteredSystems.length); const cols = getColCount(width, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1; const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows; const rawCellHeight = height / rows;
@@ -383,35 +413,37 @@ export default function BeszelSystemGridWidget({
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return ( return (
<Box <Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
h="100%" <BeszelIntegrationErrorIndicator results={results} />
style={{ <Box
pointerEvents: isEditMode ? "none" : undefined, h="100%"
display: "grid", style={{
gridTemplateColumns: `repeat(${cols}, 1fr)`, display: "grid",
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`, gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap: size.gap, gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
overflow: scrollEnabled ? "auto" : "hidden", gap: size.gap,
}} overflow: scrollEnabled ? "auto" : "hidden",
> }}
{filteredSystems.map((system) => { >
const integrationId = system._key.split(":")[0] ?? ""; {filteredSystems.map((system) => {
const handleClick = isEditMode const integrationId = system._key.split(":")[0] ?? "";
? undefined const handleClick = isEditMode
: () => openModal({ integrationId, systemId: system.id }, { title: system.name }); ? undefined
return ( : () => openModal({ integrationId, systemId: system.id }, { title: system.name });
<SystemCard return (
key={system._key} <SystemCard
system={system} key={system._key}
options={options} system={system}
t={t} options={options}
size={size} t={t}
maxMetrics={maxMetrics} size={size}
itemRadius={board.itemRadius} maxMetrics={maxMetrics}
onClick={handleClick} itemRadius={board.itemRadius}
/> onClick={handleClick}
); />
})} );
})}
</Box>
</Box> </Box>
); );
} }
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react"; import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
@@ -30,6 +30,7 @@ import {
formatPercent, formatPercent,
formatStorageBytes, formatStorageBytes,
} from "../beszel/_shared/format"; } from "../beszel/_shared/format";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { makeTooltipProps } from "../beszel/_shared/tooltip"; import { makeTooltipProps } from "../beszel/_shared/tooltip";
import { useLiveStats } from "../beszel/_shared/use-live-stats"; import { useLiveStats } from "../beszel/_shared/use-live-stats";
@@ -98,13 +99,18 @@ export default function BeszelSystemStatsWidget({
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d", timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker, includeDocker: showDocker,
}, },
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "", retry: false }, {
staleTime: 30 * 1000,
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
retry: false,
},
); );
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady); const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats: let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] } | { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null | null
| undefined = statsResult; | undefined = statsResult;
if (isLive) { if (isLive) {
@@ -191,6 +197,30 @@ export default function BeszelSystemStatsWidget({
if (systemsError) throw systemsError; if (systemsError) throw systemsError;
if (!isLive && statsError) throw statsError; if (!isLive && statsError) throw statsError;
if (systemsPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<BeszelIntegrationErrorIndicator results={systemsResult} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
<Text size="sm" c="dimmed">
{t("empty.noSystems")}
</Text>
</Stack>
</Center>
</Box>
);
}
if (!systemsPending && !systemExists && systems.length > 0) { if (!systemsPending && !systemExists && systems.length > 0) {
return ( return (
<Center h="100%"> <Center h="100%">
@@ -228,164 +258,186 @@ export default function BeszelSystemStatsWidget({
} }
return ( return (
<ScrollArea <Box h="100%" pos="relative">
h="100%" <Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
className={classes.beszelStatsContainer} <BeszelIntegrationErrorIndicator results={systemsResult} />
style={{ pointerEvents: (isEditMode && "none") || undefined }} </Box>
> <ScrollArea
<Stack gap="md" p="sm"> h="100%"
{!isEditMode && systems.length > 1 && ( className={classes.beszelStatsContainer}
<Menu position="bottom-start" withArrow shadow="md" withinPortal> style={{ pointerEvents: (isEditMode && "none") || undefined }}
<Menu.Target> >
<Button <Stack gap="md" p="sm">
variant="default" {!isEditMode && systems.length > 1 && (
size="compact-xs" <Menu position="bottom-start" withArrow shadow="md" withinPortal>
leftSection={<IconServer size={14} />} <Menu.Target>
className={classes.beszelStatsSystemToggle} <Button
> variant="default"
{selectedLabel} size="compact-xs"
</Button> leftSection={<IconServer size={14} />}
</Menu.Target> className={classes.beszelStatsSystemToggle}
<Menu.Dropdown>
{systems.map((s) => (
<Menu.Item
key={s.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
> >
{s.label} {selectedLabel}
</Menu.Item> </Button>
))} </Menu.Target>
</Menu.Dropdown> <Menu.Dropdown>
</Menu> {systems.map((s) => (
)} <Menu.Item
key={s.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value !== selectedSystem ? "dimmed" : undefined}
onClick={() => handleSelectSystem(s.value)}
>
{s.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{activeStats && ( {!isLive && !activeStats && systemReady && selectedSystem !== "" && (
<SimpleGrid cols={cols} spacing="md"> <Center h={CHART_HEIGHT}>
{options.showCpu && cpuData.length > 0 && ( <Loader size="sm" />
<BeszelChartPanel </Center>
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 && ( {!isLive && activeStats && "error" in activeStats && activeStats.error && (
<BeszelChartPanel <Center h={CHART_HEIGHT}>
title={t("chart.memory.title")} <Stack align="center" gap="xs">
subtitle={t("chart.memory.subtitle")} <IconServerOff size={24} opacity={0.5} />
chartProps={{ <Text size="sm" c="dimmed">
h: CHART_HEIGHT, {activeStats.error}
data: memoryData, </Text>
type: "stacked", </Stack>
series: series.memory, </Center>
yAxisFormatter: chartAxisFormatters.gb, )}
tooltipProps: tooltipGB,
}}
/>
)}
{options.showDisk && diskData.length > 0 && ( {activeStats && (!("error" in activeStats) || !activeStats.error) && (
<BeszelChartPanel <SimpleGrid cols={cols} spacing="md">
title={t("chart.disk.title")} {options.showCpu && cpuData.length > 0 && (
subtitle={t("chart.disk.subtitle")} <BeszelChartPanel
chartProps={{ title={t("chart.cpu.title")}
h: CHART_HEIGHT, subtitle={t("chart.cpu.subtitle")}
data: diskData, chartProps={{
series: series.disk, h: CHART_HEIGHT,
yAxisFormatter: chartAxisFormatters.gb, data: cpuData,
tooltipProps: tooltipGB, series: series.cpu,
}} yAxisFormatter: chartAxisFormatters.percent,
/> yAxisDomain: CPU_Y_AXIS_DOMAIN,
)} tooltipProps: tooltipPercent,
}}
/>
)}
{options.showDiskIO && diskIOData.length > 0 && ( {options.showMemory && memoryData.length > 0 && (
<BeszelChartPanel <BeszelChartPanel
title={t("chart.diskIO.title")} title={t("chart.memory.title")}
subtitle={t("chart.diskIO.subtitle")} subtitle={t("chart.memory.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: CHART_HEIGHT,
data: diskIOData, data: memoryData,
series: series.diskIO, type: "stacked",
yAxisFormatter: chartAxisFormatters.rate, series: series.memory,
tooltipProps: tooltipRate, yAxisFormatter: chartAxisFormatters.gb,
}} tooltipProps: tooltipGB,
/> }}
)} />
)}
{options.showNetwork && networkData.length > 0 && ( {options.showDisk && diskData.length > 0 && (
<BeszelChartPanel <BeszelChartPanel
title={t("chart.network.title")} title={t("chart.disk.title")}
subtitle={t("chart.network.subtitle")} subtitle={t("chart.disk.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: CHART_HEIGHT,
data: networkData, data: diskData,
series: series.network, series: series.disk,
yAxisFormatter: chartAxisFormatters.rate, yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipRate, tooltipProps: tooltipGB,
}} }}
/> />
)} )}
{showDocker && containerSeries.length > 0 && ( {options.showDiskIO && diskIOData.length > 0 && (
<> <BeszelChartPanel
{options.showDockerCpu && dockerCpuData.length > 0 && ( title={t("chart.diskIO.title")}
<BeszelChartPanel subtitle={t("chart.diskIO.subtitle")}
title={t("chart.dockerCpu.title")} chartProps={{
subtitle={t("chart.dockerCpu.subtitle")} h: CHART_HEIGHT,
chartProps={{ data: diskIOData,
h: CHART_HEIGHT, series: series.diskIO,
data: dockerCpuData, yAxisFormatter: chartAxisFormatters.rate,
type: "stacked", tooltipProps: tooltipRate,
series: containerSeries, }}
yAxisFormatter: chartAxisFormatters.percent, />
tooltipProps: tooltipPercentTotal, )}
}}
/>
)}
{options.showDockerMemory && dockerMemData.length > 0 && ( {options.showNetwork && networkData.length > 0 && (
<BeszelChartPanel <BeszelChartPanel
title={t("chart.dockerMemory.title")} title={t("chart.network.title")}
subtitle={t("chart.dockerMemory.subtitle")} subtitle={t("chart.network.subtitle")}
chartProps={{ chartProps={{
h: CHART_HEIGHT, h: CHART_HEIGHT,
data: dockerMemData, data: networkData,
type: "stacked", series: series.network,
series: containerSeries, yAxisFormatter: chartAxisFormatters.rate,
yAxisFormatter: chartAxisFormatters.bytes, tooltipProps: tooltipRate,
tooltipProps: tooltipBytesTotal, }}
}} />
/> )}
)}
{options.showDockerNetwork && dockerNetData.length > 0 && ( {showDocker && containerSeries.length > 0 && (
<BeszelChartPanel <>
title={t("chart.dockerNetwork.title")} {options.showDockerCpu && dockerCpuData.length > 0 && (
subtitle={t("chart.dockerNetwork.subtitle")} <BeszelChartPanel
chartProps={{ title={t("chart.dockerCpu.title")}
h: CHART_HEIGHT, subtitle={t("chart.dockerCpu.subtitle")}
data: dockerNetData, chartProps={{
series: containerSeries, h: CHART_HEIGHT,
yAxisFormatter: chartAxisFormatters.rate, data: dockerCpuData,
tooltipProps: tooltipRate, type: "stacked",
}} series: containerSeries,
/> yAxisFormatter: chartAxisFormatters.percent,
)} tooltipProps: tooltipPercentTotal,
</> }}
)} />
</SimpleGrid> )}
)}
</Stack> {options.showDockerMemory && dockerMemData.length > 0 && (
</ScrollArea> <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>
)}
</Stack>
</ScrollArea>
</Box>
); );
} }
@@ -3,7 +3,7 @@
import "./styles.css"; import "./styles.css";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Group, Indicator, Progress, Text } from "@mantine/core"; import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable"; import { DataTable } from "mantine-datatable";
import { import {
@@ -28,6 +28,7 @@ import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 }; const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -69,7 +70,11 @@ export default function BeszelSystemTableWidget({
}: WidgetComponentProps<"beszelSystemTable">) { }: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable"); const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal); const { openModal } = useModalAction(BeszelSystemStatsModal);
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds }, { integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false }, { staleTime: 5_000, refetchInterval: 5_000, retry: false },
); );
@@ -265,22 +270,35 @@ export default function BeszelSystemTableWidget({
if (systemsError) throw systemsError; if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
return ( return (
<DataTable <div style={{ position: "relative", height: "100%" }}>
style={{ pointerEvents: isEditMode ? "none" : undefined }} <div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
withTableBorder={false} <BeszelIntegrationErrorIndicator results={results} />
borderRadius={0} </div>
highlightOnHover <DataTable
fz={size.fontSize} style={{ pointerEvents: isEditMode ? "none" : undefined }}
records={sortedSystems} withTableBorder={false}
columns={columns} borderRadius={0}
sortStatus={sortStatus} highlightOnHover
onSortStatusChange={setSortStatus} fz={size.fontSize}
noRecordsText={t("noRecords")} records={sortedSystems}
idAccessor="_key" columns={columns}
height="100%" sortStatus={sortStatus}
className="beszel-table" onSortStatusChange={setSortStatus}
onRowClick={isEditMode ? undefined : handleRowClick} noRecordsText={t("noRecords")}
/> idAccessor="_key"
height="100%"
className="beszel-table"
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
); );
} }
@@ -0,0 +1,25 @@
"use client";
import { Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
error?: string;
}
interface BeszelIntegrationErrorIndicatorProps {
results: IntegrationResult[];
}
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
const failed = results.filter((r) => "error" in r && r.error);
if (failed.length === 0) return null;
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
return (
<Tooltip label={label} position="left" withArrow>
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
</Tooltip>
);
}