fix: add subscription to health monitoring widget (#1210)

* fix: add subscription

* fix: add time stamped

* fix: rtl, timestamp, scrollArea

* fix: common.rtl

* fix: rtl

* fix: reviewed changes

* fix: translation

* fix: reviewed changes

* fix: deepScource

* fix: reviewed changes

* fix: add last seen
This commit is contained in:
Yossi Hillali
2024-10-18 21:42:33 +03:00
committed by GitHub
parent c52fd972b7
commit ce67fcd57c
3 changed files with 99 additions and 26 deletions

View File

@@ -13,15 +13,13 @@ export const healthMonitoringRouter = createTRPCRouter({
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id); const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
const data = await channel.getAsync(); const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
if (!data) {
return null;
}
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
healthInfo: data.data, healthInfo,
timestamp,
}; };
}), }),
); );
@@ -30,7 +28,7 @@ export const healthMonitoringRouter = createTRPCRouter({
subscribeHealthStatus: publicProcedure subscribeHealthStatus: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault")) .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
.subscription(({ ctx }) => { .subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring }>((emit) => { return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) { for (const integration of ctx.integrations) {
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id); const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
@@ -38,6 +36,7 @@ export const healthMonitoringRouter = createTRPCRouter({
emit.next({ emit.next({
integrationId: integration.id, integrationId: integration.id,
healthInfo, healthInfo,
timestamp: new Date(0),
}); });
}); });
unsubscribes.push(unsubscribe); unsubscribes.push(unsubscribe);

View File

@@ -584,6 +584,9 @@ export default {
information: { information: {
min: "Min", min: "Min",
max: "Max", max: "Max",
days: "Days",
hours: "Hours",
minutes: "Minutes",
}, },
notification: { notification: {
create: { create: {
@@ -1119,16 +1122,17 @@ export default {
}, },
popover: { popover: {
information: "Information", information: "Information",
processor: "Processor:", processor: "Processor: {cpuModelName}",
memory: "Memory:", memory: "Memory: {memory}GiB",
version: "Version:", memoryAvailable: "Available: {memoryAvailable}GiB ({percent}%)",
uptime: "Uptime: {days} days, {hours} hours", version: "Version: {version}",
uptime: "Uptime: {days} Days, {hours} Hours, {minutes} Minutes",
loadAverage: "Load average:", loadAverage: "Load average:",
minute: "1 minute:", minute: "1 minute",
minutes: "{count} minutes:", minutes: "{count} minutes",
used: "Used", used: "Used",
diskAvailable: "Available", available: "Available",
memAvailable: "Available:", lastSeen: "Last status update: {lastSeen}",
}, },
memory: {}, memory: {},
error: { error: {

View File

@@ -29,14 +29,19 @@ import {
IconTemperature, IconTemperature,
IconVersions, IconVersions,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { HealthMonitoring } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors"; import { NoIntegrationSelectedError } from "../errors";
dayjs.extend(duration);
export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) { export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
const t = useI18n(); const t = useI18n();
const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery( const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery(
@@ -48,17 +53,56 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
retry: 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 [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) { if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError(); throw new NoIntegrationSelectedError();
} }
return ( return (
<Stack h="100%" gap="2.5cqmin" className="health-monitoring"> <Stack h="100%" gap="2.5cqmin" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => { {healthData.map(({ integrationId, integrationName, healthInfo, timestamp }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed); const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
return ( return (
@@ -107,21 +151,30 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
className="health-monitoring-information-processor" className="health-monitoring-information-processor"
icon={<IconCpu2 size="1.5cqmin" />} icon={<IconCpu2 size="1.5cqmin" />}
> >
{t("widget.healthMonitoring.popover.processor")} {healthInfo.cpuModelName} {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
</List.Item> </List.Item>
<List.Item <List.Item
className="health-monitoring-information-memory" className="health-monitoring-information-memory"
icon={<IconBrain size="1.5cqmin" />} icon={<IconBrain size="1.5cqmin" />}
> >
{t("widget.healthMonitoring.popover.memory")} {memoryUsage.memTotal.GB}GiB -{" "} {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
{t("widget.healthMonitoring.popover.memAvailable")} {memoryUsage.memFree.GB}GiB ( </List.Item>
{memoryUsage.memFree.percent}%) <List.Item
className="health-monitoring-information-memory"
icon={<IconBrain size="1.5cqmin" />}
>
{t("widget.healthMonitoring.popover.memoryAvailable", {
memoryAvailable: memoryUsage.memFree.GB,
percent: memoryUsage.memFree.percent,
})}
</List.Item> </List.Item>
<List.Item <List.Item
className="health-monitoring-information-version" className="health-monitoring-information-version"
icon={<IconVersions size="1.5cqmin" />} icon={<IconVersions size="1.5cqmin" />}
> >
{t("widget.healthMonitoring.popover.version")} {healthInfo.version} {t("widget.healthMonitoring.popover.version", {
version: healthInfo.version,
})}
</List.Item> </List.Item>
<List.Item <List.Item
className="health-monitoring-information-uptime" className="health-monitoring-information-uptime"
@@ -158,11 +211,25 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
)} )}
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />} {options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
</Flex> </Flex>
<Text
className="health-monitoring-status-update-time"
c="dimmed"
size="3.5cqmin"
ta="center"
mb="2.5cqmin"
>
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })}
</Text>
</Card> </Card>
{options.fileSystem && {options.fileSystem &&
disksData.map((disk) => { disksData.map((disk) => {
return ( return (
<Card className="health-monitoring-disk-card" key={disk.deviceName} p="2.5cqmin" withBorder> <Card
className={`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`}
key={disk.deviceName}
p="2.5cqmin"
withBorder
>
<Flex className="health-monitoring-disk-status" justify="space-between" align="center" m="1.5cqmin"> <Flex className="health-monitoring-disk-status" justify="space-between" align="center" m="1.5cqmin">
<Group gap="1cqmin"> <Group gap="1cqmin">
<IconServer className="health-monitoring-disk-icon" size="5cqmin" /> <IconServer className="health-monitoring-disk-icon" size="5cqmin" />
@@ -211,7 +278,7 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
color="default" color="default"
> >
<Progress.Label className="health-monitoring-disk-available-value" fz="2.5cqmin"> <Progress.Label className="health-monitoring-disk-available-value" fz="2.5cqmin">
{t("widget.healthMonitoring.popover.diskAvailable")} {t("widget.healthMonitoring.popover.available")}
</Progress.Label> </Progress.Label>
</Progress.Section> </Progress.Section>
</Tooltip> </Tooltip>
@@ -227,9 +294,12 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
} }
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => { export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const days = Math.floor(uptimeInSeconds / (60 * 60 * 24)); const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const remainingHours = Math.floor((uptimeInSeconds % (60 * 60 * 24)) / 3600); const days = uptimeDuration.days();
return t("widget.healthMonitoring.popover.uptime", { days, hours: remainingHours }); const hours = uptimeDuration.hours();
const minutes = uptimeDuration.minutes();
return t("widget.healthMonitoring.popover.uptime", { days, hours, minutes });
}; };
export const progressColor = (percentage: number) => { export const progressColor = (percentage: number) => {