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
homarr-renovate[bot] 577950d20e chore(deps): update tiptap monorepo to v3.27.0 (#6047)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 11:10:10 +00:00
homarr-renovate[bot] 82bf71b073 chore(deps): update pnpm to v11.7.0 (#6046)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 11:08:43 +00:00
homarr-renovate[bot] e26883acfa chore(deps): update lucide monorepo (#6042)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 11:05:29 +00:00
homarr-renovate[bot] 8ee747cde8 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.18.0 (#6038)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 10:57:58 +00:00
homarr-renovate[bot] faa7e55b1b chore(deps): update dependency video.js to ^8.23.8 (#6035)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 10:50:43 +00:00
homarr-renovate[bot] ce021a89ac chore(deps): update dependency posthog-node to ^4.18.0 (#6034)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 10:50:04 +00:00
Thomas 55fb227305 fix(beszel): improve stats date display, add click-to-stats modal, fix grid ordering (#6026) 2026-06-20 15:38:04 +02:00
22 changed files with 1399 additions and 689 deletions

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@
}, },
"engines": { "engines": {
"node": ">=24.16.0", "node": ">=24.16.0",
"pnpm": ">=11.6.0" "pnpm": ">=11.7.0"
}, },
"packageManager": "pnpm@11.6.0" "packageManager": "pnpm@11.7.0"
} }
+1 -1
View File
@@ -62,7 +62,7 @@
}, },
"engines": { "engines": {
"node": ">=24.16.0", "node": ">=24.16.0",
"pnpm": ">=11.6.0" "pnpm": ">=11.7.0"
}, },
"packageManager": "pnpm@11.7.0", "packageManager": "pnpm@11.7.0",
"dependencies": { "dependencies": {
+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() }));
+33 -2
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",
}); });
@@ -95,8 +115,8 @@ const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSec
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 }, "1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 }, "12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 }, "24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 }, "1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 }, "30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
}; };
export interface BeszelStatsData { export interface BeszelStatsData {
@@ -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>
); );
} }
@@ -0,0 +1,7 @@
.clickableCard {
transition: background-color 150ms;
}
.clickableCard:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
@@ -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,16 +15,22 @@ 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";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types"; 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";
interface SizeConfig { interface SizeConfig {
iconSize: number; iconSize: number;
@@ -154,6 +160,7 @@ interface SystemCardProps {
size: SizeConfig; size: SizeConfig;
maxMetrics: number; maxMetrics: number;
itemRadius: MantineSize; itemRadius: MantineSize;
onClick?: () => void;
} }
const metricRenderers = [ const metricRenderers = [
@@ -310,7 +317,7 @@ const metricRenderers = [
}, },
] as const; ] as const;
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: SystemCardProps) => { const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
return ( return (
@@ -319,11 +326,14 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: System
radius={itemRadius} radius={itemRadius}
bg="transparent" bg="transparent"
h="100%" h="100%"
onClick={onClick}
className={onClick ? classes.clickableCard : undefined}
style={{ style={{
overflow: "hidden", overflow: "hidden",
display: "flex", display: "flex",
flexDirection: "column" as const, flexDirection: "column" as const,
border: "0.0625rem solid var(--border-color)", border: "0.0625rem solid var(--border-color)",
cursor: onClick ? "pointer" : undefined,
}} }}
> >
<Group gap="xs" mb={2}> <Group gap="xs" mb={2}>
@@ -353,8 +363,13 @@ export default function BeszelSystemGridWidget({
}: WidgetComponentProps<"beszelSystemGrid">) { }: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid"); const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard(); const board = useRequiredBoard();
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 },
); );
@@ -364,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;
@@ -374,28 +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) => ( >
<SystemCard {filteredSystems.map((system) => {
key={system._key} const integrationId = system._key.split(":")[0] ?? "";
system={system} const handleClick = isEditMode
options={options} ? undefined
t={t} : () => openModal({ integrationId, systemId: system.id }, { title: system.name });
size={size} return (
maxMetrics={maxMetrics} <SystemCard
itemRadius={board.itemRadius} key={system._key}
/> system={system}
))} options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
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";
@@ -15,6 +15,7 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors"; import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { import {
BeszelChartPanel, BeszelChartPanel,
CPU_Y_AXIS_DOMAIN, CPU_Y_AXIS_DOMAIN,
@@ -29,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";
@@ -97,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) {
@@ -153,30 +160,31 @@ export default function BeszelSystemStatsWidget({
const systemStats = activeStats?.systemStats; const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats; const containerStatsRaw = activeStats?.containerStats;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive); const timePeriod = options.timePeriod as BeszelTimePeriod;
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive); const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive); const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive); const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive); 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 containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData( const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw), statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames, containerNames,
"cpu", "cpu",
isLive, timePeriod,
); );
const dockerMemData = useDockerChartData( const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw), statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames, containerNames,
"memory", "memory",
isLive, timePeriod,
); );
const dockerNetData = useDockerChartData( const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw), statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames, containerNames,
"network", "network",
isLive, timePeriod,
); );
const containerSeries = useMemo( const containerSeries = useMemo(
@@ -189,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%">
@@ -226,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 {
@@ -20,6 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -27,6 +28,8 @@ 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";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 }; const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -66,7 +69,12 @@ export default function BeszelSystemTableWidget({
width, width,
}: WidgetComponentProps<"beszelSystemTable">) { }: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable"); const t = useScopedI18n("widget.beszelSystemTable");
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( const { openModal } = useModalAction(BeszelSystemStatsModal);
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 },
); );
@@ -255,23 +263,42 @@ export default function BeszelSystemTableWidget({
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[]; return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]); }, [options, t, size]);
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
};
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}
/> noRecordsText={t("noRecords")}
idAccessor="_key"
height="100%"
className="beszel-table"
onRowClick={isEditMode ? undefined : handleRowClick}
/>
</div>
); );
} }
@@ -6,14 +6,23 @@
.beszel-table .mantine-datatable-table thead, .beszel-table .mantine-datatable-table thead,
.beszel-table .mantine-datatable-table tbody, .beszel-table .mantine-datatable-table tbody,
.beszel-table .mantine-datatable-table tfoot, .beszel-table .mantine-datatable-table tfoot,
.beszel-table .mantine-datatable-table tr,
.beszel-table th, .beszel-table th,
.beszel-table td { .beszel-table td {
background-color: transparent !important; background-color: transparent !important;
} }
.beszel-table .mantine-datatable-table tr {
background-color: transparent;
}
.beszel-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.beszel-table th { .beszel-table th {
white-space: nowrap; white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
} }
.beszel-table td { .beszel-table td {
+51 -16
View File
@@ -8,14 +8,46 @@ import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss");
function prepareRecords<T>(records: T[], live: boolean) { const timeFormats: Record<BeszelTimePeriod, string> = {
if (live) { "1m": "HH:mm:ss",
return { fmt: formatTimeLive, ordered: records }; "1h": "HH:mm",
"12h": "HH:mm",
"24h": "HH:mm",
"1w": "MMM D",
"30d": "MMM D",
};
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
if (timePeriod === "1m") {
return { fmt, ordered: records };
} }
return { fmt: formatTime, ordered: [...records].toReversed() }; return { fmt, ordered: [...records].toReversed() };
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
const days = periodDays[timePeriod];
if (!days) return data;
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
const now = dayjs();
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
const result = [...data];
for (let i = days; i >= 0; i--) {
const d = now.subtract(i, "day").startOf("day");
const key = d.format("YYYY-MM-DD");
if (!existingDays.has(key)) {
const iso = d.toISOString();
result.push({ time: fmt(iso), rawTime: iso });
}
}
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
} }
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
@@ -77,7 +109,7 @@ const BeszelAreaChart = memo(
dataKey="time" dataKey="time"
curveType="monotone" curveType="monotone"
withGradient={false} withGradient={false}
connectNulls connectNulls={false}
withDots={false} withDots={false}
type={type} type={type}
strokeWidth={1} strokeWidth={1}
@@ -96,16 +128,18 @@ const BeszelAreaChart = memo(
export const useSystemChartData = ( export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined, systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>, mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
live = false, timePeriod: BeszelTimePeriod = "1h",
) => ) =>
useMemo(() => { useMemo(() => {
if (!systemStats) return []; if (!systemStats) return [];
const { fmt, ordered } = prepareRecords(systemStats, live); const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
return ordered.map((r) => ({ const mapped = ordered.map((r) => ({
time: fmt(r.created), time: fmt(r.created),
rawTime: r.created,
...mapFn(r.stats), ...mapFn(r.stats),
})); }));
}, [systemStats, mapFn, live]); return padTimeGrid(mapped, timePeriod);
}, [systemStats, mapFn, timePeriod]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => { export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
const prevRef = useRef<string[]>([]); const prevRef = useRef<string[]>([]);
@@ -144,19 +178,20 @@ export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined, containerStats: BeszelContainerStatsRecord[] | undefined,
containerNames: string[], containerNames: string[],
metric: "cpu" | "memory" | "network", metric: "cpu" | "memory" | "network",
live = false, timePeriod: BeszelTimePeriod = "1h",
) => ) =>
useMemo(() => { useMemo(() => {
if (!containerStats?.length) return []; if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric]; const extract = defaultContainerExtractors[metric];
if (!extract) return []; if (!extract) return [];
const { fmt, ordered } = prepareRecords(containerStats, live); const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
return ordered.map((record) => { const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created) }; const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const byName = new Map(record.stats.map((c) => [c.n, c])); const byName = new Map(record.stats.map((c) => [c.n, c]));
for (const name of containerNames) { for (const name of containerNames) {
point[name] = extract(byName.get(name)); point[name] = extract(byName.get(name));
} }
return point; return point;
}); });
}, [containerStats, containerNames, metric, live]); return padTimeGrid(mapped, timePeriod);
}, [containerStats, containerNames, metric, timePeriod]);
@@ -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>
);
}
+4 -1
View File
@@ -31,7 +31,10 @@ export const useBeszelFilteredSystems = (
statusFilter: string, statusFilter: string,
): SystemRowWithKey[] => { ): SystemRowWithKey[] => {
const allSystems = useMemo( const allSystems = useMemo(
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))), () =>
results
.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` })))
.toSorted((a, b) => a.name.localeCompare(b.name)),
[results], [results],
); );
@@ -0,0 +1,173 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, 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;
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
{ value: "1w", label: "1W" },
{ value: "30d", label: "30D" },
];
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,
},
{ staleTime: 30_000 },
);
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)}
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>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("widget.beszelSystemStats.name"),
size: 1200,
});
@@ -3,6 +3,10 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
dayjs.extend(localizedFormat);
const styles: Record<string, CSSProperties> = { const styles: Record<string, CSSProperties> = {
wrapper: { wrapper: {
@@ -40,6 +44,7 @@ export interface TooltipPayloadItem {
value: number; value: number;
color: string; color: string;
dataKey: string; dataKey: string;
payload?: Record<string, unknown>;
} }
interface PortalTooltipProps { interface PortalTooltipProps {
@@ -50,16 +55,39 @@ interface PortalTooltipProps {
showTotal?: boolean; showTotal?: boolean;
} }
const MARGIN = 14;
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => { const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const mouseRef = useRef({ x: 0, y: 0 }); const mouseRef = useRef({ x: 0, y: 0 });
const [mouse, setMouse] = useState({ x: 0, y: 0 }); const [pos, setPos] = useState({ x: 0, y: 0 });
const rafRef = useRef(0); const rafRef = useRef(0);
const tooltipRef = useRef<HTMLDivElement>(null);
const handleMouse = useCallback((e: MouseEvent) => { const handleMouse = useCallback((e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY }; mouseRef.current = { x: e.clientX, y: e.clientY };
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
setMouse(mouseRef.current); const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
if (!el) {
setPos({ x: mx + MARGIN, y: my });
return;
}
const { width, height } = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = mx + MARGIN;
let y = my - height / 2;
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
if (y < MARGIN) y = MARGIN;
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
setPos({ x, y });
}); });
}, []); }, []);
@@ -84,21 +112,22 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
if (!sorted.length) return null; if (!sorted.length) return null;
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
const total = sorted.reduce((sum, p) => sum + p.value, 0); const total = sorted.reduce((sum, p) => sum + p.value, 0);
const portalStyle: CSSProperties = { const portalStyle: CSSProperties = {
position: "fixed", position: "fixed",
left: mouse.x + 14, left: pos.x,
top: mouse.y, top: pos.y,
transform: "translateY(-50%)",
zIndex: 10000, zIndex: 10000,
pointerEvents: "none", pointerEvents: "none",
}; };
return createPortal( return createPortal(
<div style={portalStyle}> <div ref={tooltipRef} style={portalStyle}>
<div style={styles.wrapper}> <div style={styles.wrapper}>
<div style={styles.header}>{label}</div> <div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => ( {sorted.map((item) => (
<div key={item.dataKey} style={styles.row}> <div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} /> <div style={{ ...styles.indicator, background: item.color }} />
+335 -315
View File
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -52,22 +52,22 @@ catalog:
"@testcontainers/mysql": ^12.0.2 "@testcontainers/mysql": ^12.0.2
"@testcontainers/postgresql": ^12.0.2 "@testcontainers/postgresql": ^12.0.2
"@testcontainers/redis": ^12.0.2 "@testcontainers/redis": ^12.0.2
"@tiptap/extension-color": 3.26.1 "@tiptap/extension-color": 3.27.0
"@tiptap/extension-highlight": 3.26.1 "@tiptap/extension-highlight": 3.27.0
"@tiptap/extension-image": 3.26.1 "@tiptap/extension-image": 3.27.0
"@tiptap/extension-link": ^3.26.1 "@tiptap/extension-link": ^3.27.0
"@tiptap/extension-placeholder": ^3.26.1 "@tiptap/extension-placeholder": ^3.27.0
"@tiptap/extension-table": 3.26.1 "@tiptap/extension-table": 3.27.0
"@tiptap/extension-table-cell": 3.26.1 "@tiptap/extension-table-cell": 3.27.0
"@tiptap/extension-table-header": 3.26.1 "@tiptap/extension-table-header": 3.27.0
"@tiptap/extension-table-row": 3.26.1 "@tiptap/extension-table-row": 3.27.0
"@tiptap/extension-task-item": 3.26.1 "@tiptap/extension-task-item": 3.27.0
"@tiptap/extension-task-list": 3.26.1 "@tiptap/extension-task-list": 3.27.0
"@tiptap/extension-text-align": 3.26.1 "@tiptap/extension-text-align": 3.27.0
"@tiptap/extension-text-style": 3.26.1 "@tiptap/extension-text-style": 3.27.0
"@tiptap/extension-underline": 3.26.1 "@tiptap/extension-underline": 3.27.0
"@tiptap/react": ^3.26.1 "@tiptap/react": ^3.27.0
"@tiptap/starter-kit": ^3.26.1 "@tiptap/starter-kit": ^3.27.0
"@trpc/client": ^11.17.0 "@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0 "@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0 "@trpc/react-query": ^11.17.0
@@ -93,7 +93,7 @@ catalog:
"@types/ws": ^8.18.1 "@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14 "@types/xml2js": ^0.4.14
posthog-js: ^1.255.0 posthog-js: ^1.255.0
posthog-node: ^4.17.0 posthog-node: ^4.18.0
"@vitejs/plugin-react": ^6.0.2 "@vitejs/plugin-react": ^6.0.2
"@vitest/coverage-v8": ^4.1.8 "@vitest/coverage-v8": ^4.1.8
"@vitest/ui": ^4.1.8 "@vitest/ui": ^4.1.8
@@ -138,7 +138,7 @@ catalog:
jsonpath-plus: ^10.4.0 jsonpath-plus: ^10.4.0
ldapts: 8.1.8 ldapts: 8.1.8
lodash.clonedeep: ^4.5.0 lodash.clonedeep: ^4.5.0
lucide-react: ^1.18.0 lucide-react: ^1.20.0
"mantine-contextmenu": ^9.3.0 "mantine-contextmenu": ^9.3.0
mantine-datatable: ^9.3.0 mantine-datatable: ^9.3.0
mantine-react-table: npm:mantine-react-table-open@9.0.3 mantine-react-table: npm:mantine-react-table-open@9.0.3
@@ -180,7 +180,7 @@ catalog:
vite: ^8.0.16 vite: ^8.0.16
undici: 7.27.2 undici: 7.27.2
use-deep-compare-effect: ^1.8.1 use-deep-compare-effect: ^1.8.1
video.js: ^8.23.7 video.js: ^8.23.8
vite-tsconfig-paths: ^6.1.1 vite-tsconfig-paths: ^6.1.1
vitest: ^4.1.8 vitest: ^4.1.8
winston: 3.19.0 winston: 3.19.0
@@ -206,7 +206,7 @@ minimumReleaseAgeExclude:
overrides: overrides:
"@babel/helpers@<7.26.10": ">=7.29.7" "@babel/helpers@<7.26.10": ">=7.29.7"
"@babel/runtime@<7.26.10": ">=7.29.7" "@babel/runtime@<7.26.10": ">=7.29.7"
"axios@>=1.0.0 <1.8.2": ">=1.17.0" "axios@>=1.0.0 <1.8.2": ">=1.18.0"
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6" "brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6"
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6" "brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
"esbuild@<=0.24.2": ">=0.28.0" "esbuild@<=0.24.2": ">=0.28.0"