Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6a0f3b96 | ||
|
|
d6b6287d6d |
No files matched your search
@@ -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" });
|
||||||
}
|
}
|
||||||
|
logger.debug("getSystemStats called", {
|
||||||
|
userId: ctx.session?.user?.id,
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
timePeriod: input.timePeriod,
|
||||||
|
includeDocker: input.includeDocker,
|
||||||
|
});
|
||||||
|
try {
|
||||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||||
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 });
|
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 };
|
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 {
|
||||||
|
|||||||
@@ -415,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||||
|
// 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));
|
await publisher.publish(channelName, superjson.stringify(data));
|
||||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
} 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() }));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type {
|
import type {
|
||||||
BeszelAlert,
|
BeszelAlert,
|
||||||
@@ -13,6 +14,8 @@ import type {
|
|||||||
|
|
||||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||||
|
|
||||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
Record<string, never>
|
Record<string, never>
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration) {
|
async requestAsync(integration) {
|
||||||
|
const start = performance.now();
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const systems = await instance.getSystemsAsync();
|
const systems = await instance.getSystemsAsync();
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
@@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
return mapToSystemRow(system, details);
|
return mapToSystemRow(system, details);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
logger.debug("beszelSystems fetch completed", {
|
||||||
|
integrationId: integration.id,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
count: enriched.length,
|
||||||
|
});
|
||||||
return enriched;
|
return enriched;
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(5, "seconds"),
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelSystems",
|
queryKey: "beszelSystems",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,12 +92,21 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
{ includeHistory: boolean; maxHistoryItems: number }
|
{ includeHistory: boolean; maxHistoryItems: number }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
|
const start = performance.now();
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const alerts = await instance.getAlertsAsync();
|
const alerts = await instance.getAlertsAsync();
|
||||||
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
||||||
|
logger.debug("beszelAlerts fetch completed", {
|
||||||
|
integrationId: integration.id,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
alertsCount: alerts.length,
|
||||||
|
historyCount: history.length,
|
||||||
|
});
|
||||||
return { alerts, history };
|
return { alerts, history };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(15, "seconds"),
|
cacheDuration: dayjs.duration(15, "seconds"),
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelAlerts",
|
queryKey: "beszelAlerts",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,15 +130,26 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
|
const start = performance.now();
|
||||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||||
const containerStats = input.includeDocker
|
const containerStats = input.includeDocker
|
||||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
|
logger.debug("beszelStats fetch completed", {
|
||||||
|
integrationId: integration.id,
|
||||||
|
systemId: input.systemId,
|
||||||
|
timePeriod: input.timePeriod,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
systemStatsCount: systemStats.length,
|
||||||
|
containerStatsCount: containerStats.length,
|
||||||
|
});
|
||||||
return { systemStats, containerStats };
|
return { systemStats, containerStats };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(60, "seconds"),
|
cacheDuration: dayjs.duration(60, "seconds"),
|
||||||
|
fallbackToStaleOnError: true,
|
||||||
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelStats",
|
queryKey: "beszelStats",
|
||||||
cacheDurationForInput(input) {
|
cacheDurationForInput(input) {
|
||||||
const config = timePeriodConfig[input.timePeriod];
|
const config = timePeriodConfig[input.timePeriod];
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
|||||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||||
cacheDuration: Duration;
|
cacheDuration: Duration;
|
||||||
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
||||||
|
fallbackToStaleOnError?: boolean;
|
||||||
|
retry?: { attempts?: number; delayMs?: number };
|
||||||
|
isValid?: (data: TData) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createCachedIntegrationRequestHandler = <
|
export const createCachedIntegrationRequestHandler = <
|
||||||
@@ -35,6 +38,9 @@ export const createCachedIntegrationRequestHandler = <
|
|||||||
return await options.requestAsync(input.integration, input.options);
|
return await options.requestAsync(input.integration, input.options);
|
||||||
},
|
},
|
||||||
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
||||||
|
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||||
|
retry: options.retry,
|
||||||
|
isValid: options.isValid,
|
||||||
createRedisChannel(input, handlerOptions) {
|
createRedisChannel(input, handlerOptions) {
|
||||||
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
|||||||
|
|
||||||
const logger = createLogger({ module: "cachedRequestHandler" });
|
const logger = createLogger({ module: "cachedRequestHandler" });
|
||||||
|
|
||||||
|
type FetchResult<TData> = { data: TData; timestamp: Date };
|
||||||
|
type InFlightEntry = Promise<FetchResult<unknown>>;
|
||||||
|
|
||||||
|
const inFlightFetches = new Map<string, InFlightEntry>();
|
||||||
|
|
||||||
|
const isAbortedError = (error: unknown): boolean => {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
return error.name === "AbortError" || error.name === "TimeoutError";
|
||||||
|
};
|
||||||
|
|
||||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||||
// Unique key for this request handler
|
// Unique key for this request handler
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
@@ -17,28 +27,70 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
|||||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||||
cacheDuration: Duration;
|
cacheDuration: Duration;
|
||||||
fallbackToStaleOnError?: boolean;
|
fallbackToStaleOnError?: boolean;
|
||||||
|
// Retry the upstream call on failure. attempts=1 means no retry (default).
|
||||||
|
// AbortError / TimeoutError are never retried.
|
||||||
|
retry?: { attempts?: number; delayMs?: number };
|
||||||
|
// Predicate to decide whether a fetched response is worth caching.
|
||||||
|
// Return false to skip the cache write (next call will refetch).
|
||||||
|
isValid?: (data: TData) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultRetry = { attempts: 1, delayMs: 0 };
|
||||||
|
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||||
options: Options<TData, TInput>,
|
options: Options<TData, TInput>,
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
handler: (input: TInput) => {
|
handler: (input: TInput) => {
|
||||||
const channel = options.createRedisChannel(input, options);
|
const channel = options.createRedisChannel(input, options);
|
||||||
|
const retryConfig = { ...defaultRetry, ...options.retry };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||||
const channelData = await channel.getAsync();
|
const channelData = await channel.getAsync();
|
||||||
|
|
||||||
const requestNewDataAsync = async () => {
|
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
|
||||||
|
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<FetchResult<TData>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async (): Promise<FetchResult<TData>> => {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const data = await options.requestAsync(input);
|
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);
|
await channel.publishAndUpdateLastStateAsync(data);
|
||||||
return {
|
return { data, timestamp: new Date() };
|
||||||
data,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} 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) {
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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,7 +98,19 @@ export default function BeszelAlertsWidget({
|
|||||||
|
|
||||||
if (alertsError) throw alertsError;
|
if (alertsError) throw alertsError;
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
|
<Center h="100%">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" pos="relative">
|
||||||
|
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||||
|
<BeszelIntegrationErrorIndicator results={results} />
|
||||||
|
</Box>
|
||||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
<Stack gap="sm" p="sm">
|
<Stack gap="sm" p="sm">
|
||||||
{alerts.length === 0 && (
|
{alerts.length === 0 && (
|
||||||
@@ -200,6 +217,7 @@ export default function BeszelAlertsWidget({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MantineSize } from "@mantine/core";
|
import type { MantineSize } from "@mantine/core";
|
||||||
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
|
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Battery,
|
Battery,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Thermometer,
|
Thermometer,
|
||||||
Wifi,
|
Wifi,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { IconServerOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
@@ -28,6 +29,7 @@ import type { BeszelSystemRow } from "../beszel/_shared/types";
|
|||||||
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||||
|
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
|
|
||||||
interface SizeConfig {
|
interface SizeConfig {
|
||||||
@@ -363,7 +365,11 @@ export default function BeszelSystemGridWidget({
|
|||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||||
|
|
||||||
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
const {
|
||||||
|
data: results = [],
|
||||||
|
error: systemsError,
|
||||||
|
isPending,
|
||||||
|
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
||||||
);
|
);
|
||||||
@@ -373,6 +379,30 @@ export default function BeszelSystemGridWidget({
|
|||||||
|
|
||||||
if (systemsError) throw systemsError;
|
if (systemsError) throw systemsError;
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<Center h="100%">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredSystems.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
|
<BeszelIntegrationErrorIndicator results={results} />
|
||||||
|
<Center h="100%">
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<IconServerOff size={28} opacity={0.5} />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("empty.noSystems")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const cols = getColCount(width, filteredSystems.length);
|
const cols = getColCount(width, filteredSystems.length);
|
||||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||||
const rawCellHeight = height / rows;
|
const rawCellHeight = height / rows;
|
||||||
@@ -383,10 +413,11 @@ export default function BeszelSystemGridWidget({
|
|||||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
|
<BeszelIntegrationErrorIndicator results={results} />
|
||||||
<Box
|
<Box
|
||||||
h="100%"
|
h="100%"
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: isEditMode ? "none" : undefined,
|
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||||
@@ -413,5 +444,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react";
|
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
formatPercent,
|
formatPercent,
|
||||||
formatStorageBytes,
|
formatStorageBytes,
|
||||||
} from "../beszel/_shared/format";
|
} from "../beszel/_shared/format";
|
||||||
|
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||||
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||||
import { useLiveStats } from "../beszel/_shared/use-live-stats";
|
import { useLiveStats } from "../beszel/_shared/use-live-stats";
|
||||||
|
|
||||||
@@ -98,13 +99,18 @@ export default function BeszelSystemStatsWidget({
|
|||||||
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
|
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
|
||||||
includeDocker: showDocker,
|
includeDocker: showDocker,
|
||||||
},
|
},
|
||||||
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "", retry: false },
|
{
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
refetchInterval: isLive ? false : 5_000,
|
||||||
|
enabled: !isLive && systemReady && selectedSystem !== "",
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
|
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
|
||||||
|
|
||||||
let activeStats:
|
let activeStats:
|
||||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] }
|
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
|
||||||
| null
|
| null
|
||||||
| undefined = statsResult;
|
| undefined = statsResult;
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
@@ -191,6 +197,30 @@ export default function BeszelSystemStatsWidget({
|
|||||||
if (systemsError) throw systemsError;
|
if (systemsError) throw systemsError;
|
||||||
if (!isLive && statsError) throw statsError;
|
if (!isLive && statsError) throw statsError;
|
||||||
|
|
||||||
|
if (systemsPending) {
|
||||||
|
return (
|
||||||
|
<Center h="100%">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systems.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box h="100%" pos="relative">
|
||||||
|
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||||
|
<Center h="100%">
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<IconServerOff size={28} opacity={0.5} />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("empty.noSystems")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!systemsPending && !systemExists && systems.length > 0) {
|
if (!systemsPending && !systemExists && systems.length > 0) {
|
||||||
return (
|
return (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -228,6 +258,10 @@ export default function BeszelSystemStatsWidget({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box h="100%" pos="relative">
|
||||||
|
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||||
|
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||||
|
</Box>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
h="100%"
|
h="100%"
|
||||||
className={classes.beszelStatsContainer}
|
className={classes.beszelStatsContainer}
|
||||||
@@ -262,7 +296,24 @@ export default function BeszelSystemStatsWidget({
|
|||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStats && (
|
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
|
||||||
|
<Center h={CHART_HEIGHT}>
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
|
||||||
|
<Center h={CHART_HEIGHT}>
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<IconServerOff size={24} opacity={0.5} />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{activeStats.error}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
|
||||||
<SimpleGrid cols={cols} spacing="md">
|
<SimpleGrid cols={cols} spacing="md">
|
||||||
{options.showCpu && cpuData.length > 0 && (
|
{options.showCpu && cpuData.length > 0 && (
|
||||||
<BeszelChartPanel
|
<BeszelChartPanel
|
||||||
@@ -387,5 +438,6 @@ export default function BeszelSystemStatsWidget({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Group, Indicator, Progress, Text } from "@mantine/core";
|
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
|
||||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||||
import { DataTable } from "mantine-datatable";
|
import { DataTable } from "mantine-datatable";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,7 @@ import type { BeszelSystemRow } from "../beszel/_shared/types";
|
|||||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||||
|
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
|
|
||||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||||
@@ -69,7 +70,11 @@ export default function BeszelSystemTableWidget({
|
|||||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||||
const t = useScopedI18n("widget.beszelSystemTable");
|
const t = useScopedI18n("widget.beszelSystemTable");
|
||||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||||
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
const {
|
||||||
|
data: results = [],
|
||||||
|
error: systemsError,
|
||||||
|
isPending,
|
||||||
|
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
||||||
);
|
);
|
||||||
@@ -265,7 +270,19 @@ export default function BeszelSystemTableWidget({
|
|||||||
|
|
||||||
if (systemsError) throw systemsError;
|
if (systemsError) throw systemsError;
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
|
<Center h="100%">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", height: "100%" }}>
|
||||||
|
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||||
|
<BeszelIntegrationErrorIndicator results={results} />
|
||||||
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
withTableBorder={false}
|
withTableBorder={false}
|
||||||
@@ -282,5 +299,6 @@ export default function BeszelSystemTableWidget({
|
|||||||
className="beszel-table"
|
className="beszel-table"
|
||||||
onRowClick={isEditMode ? undefined : handleRowClick}
|
onRowClick={isEditMode ? undefined : handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tooltip } from "@mantine/core";
|
||||||
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
interface IntegrationResult {
|
||||||
|
integrationId: string;
|
||||||
|
integrationName?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeszelIntegrationErrorIndicatorProps {
|
||||||
|
results: IntegrationResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
|
||||||
|
const failed = results.filter((r) => "error" in r && r.error);
|
||||||
|
if (failed.length === 0) return null;
|
||||||
|
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} position="left" withArrow>
|
||||||
|
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user