diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 25ede9289..cbb453d03 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { createIntegrationAsync } from "@homarr/integrations"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; import { @@ -14,6 +15,8 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/ import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const logger = createLogger({ module: "beszelRouter" }); + export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure .meta({ @@ -25,7 +28,9 @@ export const beszelRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .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) => { const innerHandler = beszelSystemsRequestHandler.handler(integration, {}); 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; }), @@ -83,7 +111,14 @@ export const beszelRouter = createTRPCRouter({ }), ) .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) => { const alertsHandler = beszelAlertsRequestHandler.handler(integration, { includeHistory: input.includeHistory, @@ -100,6 +135,7 @@ export const beszelRouter = createTRPCRouter({ } return { integrationId: integration.id, + integrationName: integration.name, alerts: alertsResult.data.alerts, history: alertsResult.data.history, 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; }), @@ -164,13 +224,41 @@ export const beszelRouter = createTRPCRouter({ if (!integration) { 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, timePeriod: input.timePeriod, includeDocker: input.includeDocker, }); - const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return { integrationId: integration.id, ...data, updatedAt: timestamp }; + try { + 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 @@ -190,6 +278,11 @@ export const beszelRouter = createTRPCRouter({ emit.error(new Error("No Beszel integration configured")); return () => {}; } + logger.debug("subscribeSystemStats opened", { + userId: ctx.session?.user?.id, + integrationId: integration.id, + systemId: input.systemId, + }); const abortController = new AbortController(); @@ -199,6 +292,12 @@ export const beszelRouter = createTRPCRouter({ await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal); } catch (error) { if (!abortController.signal.aborted) { + logger.warn("subscribeSystemStats error", { + userId: ctx.session?.user?.id, + integrationId: integration.id, + systemId: input.systemId, + error, + }); if (error instanceof Error) { emit.error(error); } else { @@ -210,6 +309,11 @@ export const beszelRouter = createTRPCRouter({ return () => { abortController.abort(); + logger.debug("subscribeSystemStats closed", { + userId: ctx.session?.user?.id, + integrationId: integration.id, + systemId: input.systemId, + }); }; }); }), diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index 50f64668c..bf6f3640c 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -46,10 +46,13 @@ export class BeszelIntegration extends Integration { private async authenticateAsync(): Promise { const existingSession = await this.sessionStore.getAsync(); if (existingSession) { + logger.debug("Using stored Beszel session", { integrationId: this.integration.id }); 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -57,23 +60,28 @@ export class BeszelIntegration extends Integration { password: this.getSecretValue("password"), }), }); + logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status }); if (!response.ok) { + logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status }); throw new ResponseError(response); } const data = (await response.json()) as BeszelAuthResponse; const session: BeszelSession = { token: data.token, userId: data.record.id }; await this.sessionStore.setAsync(session); + logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId }); return session; } private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) { + const method = options.method ?? "GET"; + const start = performance.now(); let session = await this.authenticateAsync(); const doFetch = (token: string) => fetchWithTrustedCertificatesAsync(url, { - method: options.method ?? "GET", + method, headers: { Authorization: token, ...(options.body ? { "Content-Type": "application/json" } : {}), @@ -82,14 +90,42 @@ export class BeszelIntegration extends Integration { }); 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) { + logger.warn("Beszel 401, clearing session and retrying", { + integrationId: this.integration.id, + method, + path: url.pathname, + }); await this.sessionStore.clearAsync(); session = await this.authenticateAsync(); 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) { + 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); } @@ -257,6 +293,7 @@ export class BeszelIntegration extends Integration { ): Promise { const session = await this.authenticateAsync(); const sseUrl = this.url("/api/realtime"); + logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId }); const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, { headers: { Authorization: session.token }, @@ -264,8 +301,14 @@ export class BeszelIntegration extends Integration { }); if (!sseResponse.ok || !sseResponse.body) { + logger.warn("Beszel SSE connection failed", { + integrationId: this.integration.id, + systemId, + status: sseResponse.status, + }); throw new ResponseError(sseResponse); } + logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId }); const reader = sseResponse.body.getReader(); const decoder = new TextDecoder(); @@ -322,6 +365,10 @@ export class BeszelIntegration extends Integration { } } if (!signal.aborted) { + logger.warn("Beszel SSE stream ended unexpectedly", { + integrationId: this.integration.id, + systemId, + }); throw new Error("Beszel SSE stream ended unexpectedly"); } } finally { diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 3348ba84b..d04d1499a 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -415,8 +415,18 @@ export const createChannelWithLatestAndEvents = (channelName: string) => }); }, publishAndUpdateLastStateAsync: async (data: TData) => { - await publisher.publish(channelName, superjson.stringify(data)); - await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() })); + // Set first, then publish. If publish fails the storage is still consistent + // 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) => { await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() })); diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index 2f6d0b7ef..235677682 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -1,5 +1,6 @@ import dayjs from "dayjs"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { createIntegrationAsync } from "@homarr/integrations"; import type { BeszelAlert, @@ -13,6 +14,8 @@ import type { import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +const logger = createLogger({ module: "beszelRequestHandler" }); + export type { BeszelSystemRow } from "@homarr/integrations/types"; /** @@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler Record >({ async requestAsync(integration) { + const start = performance.now(); const instance = await createIntegrationAsync(integration); const systems = await instance.getSystemsAsync(); const enriched = await Promise.all( @@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler return mapToSystemRow(system, details); }), ); + logger.debug("beszelSystems fetch completed", { + integrationId: integration.id, + durationMs: Math.round(performance.now() - start), + count: enriched.length, + }); return enriched; }, cacheDuration: dayjs.duration(5, "seconds"), + fallbackToStaleOnError: true, + retry: { attempts: 2, delayMs: 500 }, queryKey: "beszelSystems", }); @@ -81,12 +92,21 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler< { includeHistory: boolean; maxHistoryItems: number } >({ async requestAsync(integration, input) { + const start = performance.now(); const instance = await createIntegrationAsync(integration); const alerts = await instance.getAlertsAsync(); 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 }; }, cacheDuration: dayjs.duration(15, "seconds"), + fallbackToStaleOnError: true, + retry: { attempts: 2, delayMs: 500 }, queryKey: "beszelAlerts", }); @@ -110,15 +130,26 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler< { systemId: string; timePeriod: string; includeDocker: boolean } >({ async requestAsync(integration, input) { + const start = performance.now(); const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 }; const instance = await createIntegrationAsync(integration); const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage); const containerStats = input.includeDocker ? 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 }; }, cacheDuration: dayjs.duration(60, "seconds"), + fallbackToStaleOnError: true, + retry: { attempts: 2, delayMs: 500 }, queryKey: "beszelStats", cacheDurationForInput(input) { const config = timePeriodConfig[input.timePeriod]; diff --git a/packages/request-handler/src/lib/cached-integration-request-handler.ts b/packages/request-handler/src/lib/cached-integration-request-handler.ts index 8c26fcbe6..aee69e39c 100644 --- a/packages/request-handler/src/lib/cached-integration-request-handler.ts +++ b/packages/request-handler/src/lib/cached-integration-request-handler.ts @@ -18,6 +18,9 @@ interface Options, input: TInput) => Promise; cacheDuration: Duration; cacheDurationForInput?: (input: TInput) => Duration | undefined; + fallbackToStaleOnError?: boolean; + retry?: { attempts?: number; delayMs?: number }; + isValid?: (data: TData) => boolean; } export const createCachedIntegrationRequestHandler = < @@ -35,6 +38,9 @@ export const createCachedIntegrationRequestHandler = < return await options.requestAsync(input.integration, input.options); }, cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration, + fallbackToStaleOnError: options.fallbackToStaleOnError, + retry: options.retry, + isValid: options.isValid, createRedisChannel(input, handlerOptions) { return createIntegrationOptionsChannel(input.integration.id, handlerOptions.queryKey, input.options); }, diff --git a/packages/request-handler/src/lib/cached-request-handler.ts b/packages/request-handler/src/lib/cached-request-handler.ts index 975448914..c2342eed3 100644 --- a/packages/request-handler/src/lib/cached-request-handler.ts +++ b/packages/request-handler/src/lib/cached-request-handler.ts @@ -7,6 +7,16 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis"; const logger = createLogger({ module: "cachedRequestHandler" }); +type FetchResult = { data: TData; timestamp: Date }; +type InFlightEntry = Promise>; + +const inFlightFetches = new Map(); + +const isAbortedError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + return error.name === "AbortError" || error.name === "TimeoutError"; +}; + interface Options> { // Unique key for this request handler queryKey: string; @@ -17,28 +27,70 @@ interface Options> { ) => ReturnType>; cacheDuration: Duration; 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((resolve) => setTimeout(resolve, ms)); + export const createCachedRequestHandler = >( options: Options, ) => { return { handler: (input: TInput) => { const channel = options.createRedisChannel(input, options); + const retryConfig = { ...defaultRetry, ...options.retry }; return { async getCachedOrUpdatedDataAsync({ forceUpdate = false }) { const channelData = await channel.getAsync(); - const requestNewDataAsync = async () => { - try { - const data = await options.requestAsync(input); - await channel.publishAndUpdateLastStateAsync(data); - return { - data, - timestamp: new Date(), - }; - } catch (error) { + const requestNewDataAsync = (): Promise> => { + const inflight = inFlightFetches.get(channel.name); + if (inflight) { + logger.debug("Cached request handler joining in-flight fetch", { + channel: channel.name, + queryKey: options.queryKey, + }); + return inflight as Promise>; + } + + const promise = (async (): Promise> => { + 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) { logger.warn( new ErrorWithMetadata( @@ -46,15 +98,21 @@ export const createCachedRequestHandler = { + inFlightFetches.delete(channel.name); + }); + + inFlightFetches.set(channel.name, promise as InFlightEntry); + return promise; }; if (forceUpdate) { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 7bb00c5f0..8ec28198b 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3639,6 +3639,9 @@ "error": { "internalServerError": "Unable to connect to Beszel. Check your integration settings." }, + "empty": { + "noSystems": "No systems found" + }, "metric": { "cpu": "CPU", "memory": "Memory", @@ -3712,6 +3715,9 @@ } }, "selectSystem": "Select system", + "empty": { + "noSystems": "No systems found" + }, "error": { "internalServerError": "Unable to connect to Beszel. Check your integration settings.", "liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.", diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 6d8d9a651..faaa42771 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -1,7 +1,7 @@ "use client"; 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 dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -12,6 +12,7 @@ import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; const alertIconMap: Record = { CPU: Cpu, @@ -40,7 +41,11 @@ export default function BeszelAlertsWidget({ () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: 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, refetchInterval: 15_000, retry: false, @@ -93,113 +98,126 @@ export default function BeszelAlertsWidget({ if (alertsError) throw alertsError; + if (isPending) { + return ( +
+ +
+ ); + } + return ( - - - {alerts.length === 0 && ( - - - - - - {t("empty")} - - - )} - - {triggeredAlerts.length > 0 && ( - - - - - {t("status.triggered")} ({triggeredAlerts.length}) + + + + + + + {alerts.length === 0 && ( + + + + + + {t("empty")} - - {triggeredAlerts.map((alert) => ( - - ))} - - )} + + )} - {triggeredAlerts.length > 0 && okAlerts.length > 0 && } - - {okAlerts.length > 0 && ( - - - - - {t("status.ok")} ({okAlerts.length}) - - - {okAlerts.map((alert) => ( - - ))} - - )} - - {options.showHistory && history.length > 0 && ( - <> - + {triggeredAlerts.length > 0 && ( - - - {t("history")} + + + {t("status.triggered")} ({triggeredAlerts.length}) - {history.map((entry) => { - const systemName = systemNameMap[entry.system] ?? entry.system; - const isResolved = !!entry.resolved; - const HistoryIcon = alertIconMap[entry.name] ?? Server; - return ( - - - - - - - {entry.name} - - - {systemName} + {triggeredAlerts.map((alert) => ( + + ))} + + )} + + {triggeredAlerts.length > 0 && okAlerts.length > 0 && } + + {okAlerts.length > 0 && ( + + + + + {t("status.ok")} ({okAlerts.length}) + + + {okAlerts.map((alert) => ( + + ))} + + )} + + {options.showHistory && history.length > 0 && ( + <> + + + + + + {t("history")} + + + {history.map((entry) => { + const systemName = systemNameMap[entry.system] ?? entry.system; + const isResolved = !!entry.resolved; + const HistoryIcon = alertIconMap[entry.name] ?? Server; + return ( + + + + + + + {entry.name} + + + {systemName} + + + + + + {isResolved ? t("resolved") : t("status.triggered")} + + + {dayjs(entry.created).fromNow()} - - - {isResolved ? t("resolved") : t("status.triggered")} - - - {dayjs(entry.created).fromNow()} - - - - ); - })} - - - )} - - + ); + })} + + + )} + + + ); } diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 00fb3e076..68df8f207 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -1,7 +1,7 @@ "use client"; 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 { Activity, Battery, @@ -15,6 +15,7 @@ import { Thermometer, Wifi, } from "lucide-react"; +import { IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; 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 { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; +import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; interface SizeConfig { @@ -363,7 +365,11 @@ export default function BeszelSystemGridWidget({ 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 }, { staleTime: 5_000, refetchInterval: 5_000, retry: false }, ); @@ -373,6 +379,30 @@ export default function BeszelSystemGridWidget({ if (systemsError) throw systemsError; + if (isPending) { + return ( +
+ +
+ ); + } + + if (filteredSystems.length === 0) { + return ( + + +
+ + + + {t("empty.noSystems")} + + +
+
+ ); + } + const cols = getColCount(width, filteredSystems.length); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; @@ -383,35 +413,37 @@ export default function BeszelSystemGridWidget({ const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); return ( - - {filteredSystems.map((system) => { - const integrationId = system._key.split(":")[0] ?? ""; - const handleClick = isEditMode - ? undefined - : () => openModal({ integrationId, systemId: system.id }, { title: system.name }); - return ( - - ); - })} + + + + {filteredSystems.map((system) => { + const integrationId = system._key.split(":")[0] ?? ""; + const handleClick = isEditMode + ? undefined + : () => openModal({ integrationId, systemId: system.id }, { title: system.name }); + return ( + + ); + })} + ); } diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index dca533848..4784877a5 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -1,8 +1,8 @@ "use client"; import { useCallback, useMemo } from "react"; -import { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; -import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react"; +import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; +import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -30,6 +30,7 @@ import { formatPercent, formatStorageBytes, } from "../beszel/_shared/format"; +import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { makeTooltipProps } from "../beszel/_shared/tooltip"; import { useLiveStats } from "../beszel/_shared/use-live-stats"; @@ -98,13 +99,18 @@ export default function BeszelSystemStatsWidget({ timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d", 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); let activeStats: - | { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] } + | { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string } | null | undefined = statsResult; if (isLive) { @@ -191,6 +197,30 @@ export default function BeszelSystemStatsWidget({ if (systemsError) throw systemsError; if (!isLive && statsError) throw statsError; + if (systemsPending) { + return ( +
+ +
+ ); + } + + if (systems.length === 0) { + return ( + + +
+ + + + {t("empty.noSystems")} + + +
+
+ ); + } + if (!systemsPending && !systemExists && systems.length > 0) { return (
@@ -228,164 +258,186 @@ export default function BeszelSystemStatsWidget({ } return ( - - - {!isEditMode && systems.length > 1 && ( - - - - - - {systems.map((s) => ( - handleSelectSystem(s.value)} + + + + + + + {!isEditMode && systems.length > 1 && ( + + + - )} + {selectedLabel} + + + + {systems.map((s) => ( + handleSelectSystem(s.value)} + > + {s.label} + + ))} + + + )} - {activeStats && ( - - {options.showCpu && cpuData.length > 0 && ( - - )} + {!isLive && !activeStats && systemReady && selectedSystem !== "" && ( +
+ +
+ )} - {options.showMemory && memoryData.length > 0 && ( - - )} + {!isLive && activeStats && "error" in activeStats && activeStats.error && ( +
+ + + + {activeStats.error} + + +
+ )} - {options.showDisk && diskData.length > 0 && ( - - )} + {activeStats && (!("error" in activeStats) || !activeStats.error) && ( + + {options.showCpu && cpuData.length > 0 && ( + + )} - {options.showDiskIO && diskIOData.length > 0 && ( - - )} + {options.showMemory && memoryData.length > 0 && ( + + )} - {options.showNetwork && networkData.length > 0 && ( - - )} + {options.showDisk && diskData.length > 0 && ( + + )} - {showDocker && containerSeries.length > 0 && ( - <> - {options.showDockerCpu && dockerCpuData.length > 0 && ( - - )} + {options.showDiskIO && diskIOData.length > 0 && ( + + )} - {options.showDockerMemory && dockerMemData.length > 0 && ( - - )} + {options.showNetwork && networkData.length > 0 && ( + + )} - {options.showDockerNetwork && dockerNetData.length > 0 && ( - - )} - - )} - - )} -
-
+ {showDocker && containerSeries.length > 0 && ( + <> + {options.showDockerCpu && dockerCpuData.length > 0 && ( + + )} + + {options.showDockerMemory && dockerMemData.length > 0 && ( + + )} + + {options.showDockerNetwork && dockerNetData.length > 0 && ( + + )} + + )} + + )} + + + ); } diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index a540fbee4..2604b5a0b 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -3,7 +3,7 @@ import "./styles.css"; 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 { DataTable } from "mantine-datatable"; import { @@ -28,6 +28,7 @@ import type { BeszelSystemRow } from "../beszel/_shared/types"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; +import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; const directionMultiplier: Record = { asc: 1, desc: -1 }; @@ -69,7 +70,11 @@ export default function BeszelSystemTableWidget({ }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); 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 }, { staleTime: 5_000, refetchInterval: 5_000, retry: false }, ); @@ -265,22 +270,35 @@ export default function BeszelSystemTableWidget({ if (systemsError) throw systemsError; + if (isPending) { + return ( +
+ +
+ ); + } + return ( - +
+
+ +
+ +
); } diff --git a/packages/widgets/src/beszel/_shared/error-indicator.tsx b/packages/widgets/src/beszel/_shared/error-indicator.tsx new file mode 100644 index 000000000..7d539baac --- /dev/null +++ b/packages/widgets/src/beszel/_shared/error-indicator.tsx @@ -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 ( + + + + ); +}