diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index bd3d3bf27..2003a5bf6 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -13,15 +13,13 @@ export const healthMonitoringRouter = createTRPCRouter({ return await Promise.all( ctx.integrations.map(async (integration) => { const channel = createItemAndIntegrationChannel("healthMonitoring", integration.id); - const data = await channel.getAsync(); - if (!data) { - return null; - } + const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; return { integrationId: integration.id, integrationName: integration.name, - healthInfo: data.data, + healthInfo, + timestamp, }; }), ); @@ -30,7 +28,7 @@ export const healthMonitoringRouter = createTRPCRouter({ subscribeHealthStatus: publicProcedure .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault")) .subscription(({ ctx }) => { - return observable<{ integrationId: string; healthInfo: HealthMonitoring }>((emit) => { + return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => { const unsubscribes: (() => void)[] = []; for (const integration of ctx.integrations) { const channel = createItemAndIntegrationChannel("healthMonitoring", integration.id); @@ -38,6 +36,7 @@ export const healthMonitoringRouter = createTRPCRouter({ emit.next({ integrationId: integration.id, healthInfo, + timestamp: new Date(0), }); }); unsubscribes.push(unsubscribe); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 8aecc2fb2..69ed66a7a 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -584,6 +584,9 @@ export default { information: { min: "Min", max: "Max", + days: "Days", + hours: "Hours", + minutes: "Minutes", }, notification: { create: { @@ -1119,16 +1122,17 @@ export default { }, popover: { information: "Information", - processor: "Processor:", - memory: "Memory:", - version: "Version:", - uptime: "Uptime: {days} days, {hours} hours", + processor: "Processor: {cpuModelName}", + memory: "Memory: {memory}GiB", + memoryAvailable: "Available: {memoryAvailable}GiB ({percent}%)", + version: "Version: {version}", + uptime: "Uptime: {days} Days, {hours} Hours, {minutes} Minutes", loadAverage: "Load average:", - minute: "1 minute:", - minutes: "{count} minutes:", + minute: "1 minute", + minutes: "{count} minutes", used: "Used", - diskAvailable: "Available", - memAvailable: "Available:", + available: "Available", + lastSeen: "Last status update: {lastSeen}", }, memory: {}, error: { diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index 9e55872aa..52a951c71 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -29,14 +29,19 @@ import { IconTemperature, IconVersions, } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; import { clientApi } from "@homarr/api/client"; +import type { HealthMonitoring } from "@homarr/integrations"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationSelectedError } from "../errors"; +dayjs.extend(duration); + export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) { const t = useI18n(); const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery( @@ -48,17 +53,56 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, - select: (data) => data.filter((health) => health !== null), + select: (data) => + data.filter( + ( + health, + ): health is { + integrationId: string; + integrationName: string; + healthInfo: HealthMonitoring; + timestamp: Date; + } => health.healthInfo !== null, + ), }, ); const [opened, { open, close }] = useDisclosure(false); + const utils = clientApi.useUtils(); + + clientApi.widget.healthMonitoring.subscribeHealthStatus.useSubscription( + { integrationIds }, + { + onData(data) { + utils.widget.healthMonitoring.getHealthStatus.setData({ integrationIds }, (prevData) => { + if (!prevData) { + return undefined; + } + const newData = prevData.map((item) => + item.integrationId === data.integrationId + ? { ...item, healthInfo: data.healthInfo, timestamp: new Date(0) } + : item, + ); + return newData.filter( + ( + health, + ): health is { + integrationId: string; + integrationName: string; + healthInfo: HealthMonitoring; + timestamp: Date; + } => health.healthInfo !== null, + ); + }); + }, + }, + ); if (integrationIds.length === 0) { throw new NoIntegrationSelectedError(); } return ( - {healthData.map(({ integrationId, integrationName, healthInfo }) => { + {healthData.map(({ integrationId, integrationName, healthInfo, timestamp }) => { const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed); return ( @@ -107,21 +151,30 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg className="health-monitoring-information-processor" icon={} > - {t("widget.healthMonitoring.popover.processor")} {healthInfo.cpuModelName} + {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })} } > - {t("widget.healthMonitoring.popover.memory")} {memoryUsage.memTotal.GB}GiB -{" "} - {t("widget.healthMonitoring.popover.memAvailable")} {memoryUsage.memFree.GB}GiB ( - {memoryUsage.memFree.percent}%) + {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })} + + } + > + {t("widget.healthMonitoring.popover.memoryAvailable", { + memoryAvailable: memoryUsage.memFree.GB, + percent: memoryUsage.memFree.percent, + })} } > - {t("widget.healthMonitoring.popover.version")} {healthInfo.version} + {t("widget.healthMonitoring.popover.version", { + version: healthInfo.version, + })} } + + {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })} + {options.fileSystem && disksData.map((disk) => { return ( - + @@ -211,7 +278,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg color="default" > - {t("widget.healthMonitoring.popover.diskAvailable")} + {t("widget.healthMonitoring.popover.available")} @@ -227,9 +294,12 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg } export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => { - const days = Math.floor(uptimeInSeconds / (60 * 60 * 24)); - const remainingHours = Math.floor((uptimeInSeconds % (60 * 60 * 24)) / 3600); - return t("widget.healthMonitoring.popover.uptime", { days, hours: remainingHours }); + const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds"); + const days = uptimeDuration.days(); + const hours = uptimeDuration.hours(); + const minutes = uptimeDuration.minutes(); + + return t("widget.healthMonitoring.popover.uptime", { days, hours, minutes }); }; export const progressColor = (percentage: number) => {