Replace entire codebase with homarr-labs/homarr
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { Resource } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { formatUptime } from "../system-health";
|
||||
import { ResourceAccordionItem } from "./resource-accordion-item";
|
||||
import { ResourceTable } from "./resource-table";
|
||||
|
||||
const addBadgeColor = ({
|
||||
activeCount,
|
||||
totalCount,
|
||||
sectionIndicatorRequirement,
|
||||
}: {
|
||||
activeCount: number;
|
||||
totalCount: number;
|
||||
sectionIndicatorRequirement: WidgetComponentProps<"healthMonitoring">["options"]["sectionIndicatorRequirement"];
|
||||
}) => ({
|
||||
color: activeCount === totalCount || (sectionIndicatorRequirement === "any" && activeCount >= 1) ? "green" : "orange",
|
||||
activeCount,
|
||||
totalCount,
|
||||
});
|
||||
|
||||
const running = (total: number, current: Resource) => {
|
||||
return current.isRunning ? total + 1 : total;
|
||||
};
|
||||
|
||||
export const ClusterHealthMonitoring = ({
|
||||
integrationId,
|
||||
options,
|
||||
width,
|
||||
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
|
||||
const t = useI18n();
|
||||
const [healthData] = clientApi.widget.healthMonitoring.getClusterHealthStatus.useSuspenseQuery(
|
||||
{
|
||||
integrationId,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.healthMonitoring.subscribeClusterHealthStatus.useSubscription(
|
||||
{ integrationId },
|
||||
{
|
||||
onData(data) {
|
||||
utils.widget.healthMonitoring.getClusterHealthStatus.setData({ integrationId }, data);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const activeNodes = healthData.nodes.reduce(running, 0);
|
||||
const activeVMs = healthData.vms.reduce(running, 0);
|
||||
const activeLXCs = healthData.lxcs.reduce(running, 0);
|
||||
const activeStorage = healthData.storages.reduce(running, 0);
|
||||
|
||||
const usedMem = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.memory.used + sum : sum), 0);
|
||||
const maxMem = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.memory.total + sum : sum), 0);
|
||||
const maxCpu = healthData.nodes.reduce((sum, item) => (item.isRunning ? item.cpu.cores + sum : sum), 0);
|
||||
const usedCpu = healthData.nodes.reduce(
|
||||
(sum, item) => (item.isRunning ? item.cpu.utilization * item.cpu.cores + sum : sum),
|
||||
0,
|
||||
);
|
||||
const uptime = healthData.nodes.reduce((sum, { uptime }) => (sum > uptime ? sum : uptime), 0);
|
||||
|
||||
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
|
||||
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
|
||||
const defaultValue = [options.visibleClusterSections.at(0) ?? "node"];
|
||||
|
||||
const isTiny = width < 256;
|
||||
return (
|
||||
<Stack h="100%" p="xs" gap={isTiny ? "xs" : "md"}>
|
||||
{options.showUptime && (
|
||||
<Group justify="center" wrap="nowrap">
|
||||
<Text fz={isTiny ? 8 : "xs"} tt="uppercase" fw={700} c="dimmed" ta="center">
|
||||
{formatUptime(uptime, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<SummaryHeader
|
||||
cpu={{
|
||||
value: cpuPercent,
|
||||
hidden: !options.cpu,
|
||||
}}
|
||||
memory={{
|
||||
value: memPercent,
|
||||
hidden: !options.memory,
|
||||
}}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
{options.visibleClusterSections.length >= 1 && (
|
||||
<Accordion variant="contained" chevronPosition="right" multiple defaultValue={defaultValue}>
|
||||
{options.visibleClusterSections.includes("node") && (
|
||||
<ResourceAccordionItem
|
||||
value="node"
|
||||
title={t("widget.healthMonitoring.cluster.resource.node.name")}
|
||||
icon={IconServer}
|
||||
badge={addBadgeColor({
|
||||
activeCount: activeNodes,
|
||||
totalCount: healthData.nodes.length,
|
||||
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||
})}
|
||||
isTiny={isTiny}
|
||||
>
|
||||
<ResourceTable type="node" data={healthData.nodes} isTiny={isTiny} />
|
||||
</ResourceAccordionItem>
|
||||
)}
|
||||
|
||||
{options.visibleClusterSections.includes("qemu") && (
|
||||
<ResourceAccordionItem
|
||||
value="qemu"
|
||||
title={t("widget.healthMonitoring.cluster.resource.qemu.name")}
|
||||
icon={IconDeviceLaptop}
|
||||
badge={addBadgeColor({
|
||||
activeCount: activeVMs,
|
||||
totalCount: healthData.vms.length,
|
||||
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||
})}
|
||||
isTiny={isTiny}
|
||||
>
|
||||
<ResourceTable type="qemu" data={healthData.vms} isTiny={isTiny} />
|
||||
</ResourceAccordionItem>
|
||||
)}
|
||||
|
||||
{options.visibleClusterSections.includes("lxc") && (
|
||||
<ResourceAccordionItem
|
||||
value="lxc"
|
||||
title={t("widget.healthMonitoring.cluster.resource.lxc.name")}
|
||||
icon={IconCube}
|
||||
badge={addBadgeColor({
|
||||
activeCount: activeLXCs,
|
||||
totalCount: healthData.lxcs.length,
|
||||
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||
})}
|
||||
isTiny={isTiny}
|
||||
>
|
||||
<ResourceTable type="lxc" data={healthData.lxcs} isTiny={isTiny} />
|
||||
</ResourceAccordionItem>
|
||||
)}
|
||||
|
||||
{options.visibleClusterSections.includes("storage") && (
|
||||
<ResourceAccordionItem
|
||||
value="storage"
|
||||
title={t("widget.healthMonitoring.cluster.resource.storage.name")}
|
||||
icon={IconDatabase}
|
||||
badge={addBadgeColor({
|
||||
activeCount: activeStorage,
|
||||
totalCount: healthData.storages.length,
|
||||
sectionIndicatorRequirement: options.sectionIndicatorRequirement,
|
||||
})}
|
||||
isTiny={isTiny}
|
||||
>
|
||||
<ResourceTable type="storage" data={healthData.storages} isTiny={isTiny} />
|
||||
</ResourceAccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface SummaryHeaderProps {
|
||||
cpu: { value: number; hidden: boolean };
|
||||
memory: { value: number; hidden: boolean };
|
||||
isTiny: boolean;
|
||||
}
|
||||
|
||||
const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
if (cpu.hidden && memory.hidden) return null;
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Group wrap="wrap" justify="center" gap="xs">
|
||||
{!cpu.hidden && (
|
||||
<Flex direction="row">
|
||||
<RingProgress
|
||||
roundCaps
|
||||
size={isTiny ? 32 : 48}
|
||||
thickness={isTiny ? 2 : 4}
|
||||
label={
|
||||
<Center>
|
||||
<IconCpu size={isTiny ? 12 : 20} />
|
||||
</Center>
|
||||
}
|
||||
sections={[{ value: cpu.value, color: cpu.value > 75 ? "orange" : "green" }]}
|
||||
/>
|
||||
<Stack align="center" justify="center" gap={0}>
|
||||
<Text fw={500} size={isTiny ? "xs" : "sm"}>
|
||||
{t("widget.healthMonitoring.cluster.summary.cpu")}
|
||||
</Text>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{cpu.value.toFixed(1)}%</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
)}
|
||||
{!memory.hidden && (
|
||||
<Flex>
|
||||
<RingProgress
|
||||
roundCaps
|
||||
size={isTiny ? 32 : 48}
|
||||
thickness={isTiny ? 2 : 4}
|
||||
label={
|
||||
<Center>
|
||||
<IconBrain size={isTiny ? 12 : 20} />
|
||||
</Center>
|
||||
}
|
||||
sections={[{ value: memory.value, color: memory.value > 75 ? "orange" : "green" }]}
|
||||
/>
|
||||
<Stack align="center" justify="center" gap={0}>
|
||||
<Text size={isTiny ? "xs" : "sm"} fw={500}>
|
||||
{t("widget.healthMonitoring.cluster.summary.memory")}
|
||||
</Text>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{memory.value.toFixed(1)}%</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
)}
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Accordion, Badge, Group, Text } from "@mantine/core";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
interface ResourceAccordionItemProps {
|
||||
value: string;
|
||||
title: string;
|
||||
icon: TablerIcon;
|
||||
badge: {
|
||||
color: MantineColor;
|
||||
activeCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
isTiny: boolean;
|
||||
}
|
||||
|
||||
export const ResourceAccordionItem = ({
|
||||
value,
|
||||
title,
|
||||
icon: Icon,
|
||||
badge,
|
||||
children,
|
||||
isTiny,
|
||||
}: PropsWithChildren<ResourceAccordionItemProps>) => {
|
||||
return (
|
||||
<Accordion.Item value={value}>
|
||||
<Accordion.Control icon={isTiny ? null : <Icon size={16} />}>
|
||||
<Group style={{ rowGap: "0" }} gap="xs">
|
||||
<Text size="xs">{title}</Text>
|
||||
<Badge variant="dot" color={badge.color} size="xs">
|
||||
{badge.activeCount} / {badge.totalCount}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>{children}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Badge, Center, Divider, Flex, Group, List, Popover, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconArrowNarrowDown,
|
||||
IconArrowNarrowUp,
|
||||
IconBrain,
|
||||
IconClockHour3,
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconDeviceLaptop,
|
||||
IconHeartBolt,
|
||||
IconNetwork,
|
||||
IconQuestionMark,
|
||||
IconServer,
|
||||
} from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
import { capitalize, humanFileSize } from "@homarr/common";
|
||||
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
interface ResourcePopoverProps {
|
||||
item: Resource;
|
||||
}
|
||||
|
||||
export const ResourcePopover = ({ item, children }: PropsWithChildren<ResourcePopoverProps>) => {
|
||||
return (
|
||||
<Popover
|
||||
withArrow
|
||||
withinPortal
|
||||
radius="lg"
|
||||
shadow="sm"
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Popover.Dropdown>
|
||||
<ResourceTypeEntryDetails item={item} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResourceTypeEntryDetails = ({ item }: { item: Resource }) => {
|
||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover");
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Group wrap="nowrap" align="start" justify="apart">
|
||||
<Group wrap="nowrap" align="center">
|
||||
<ResourceIcon type={item.type} size={35} />
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="md">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text c={item.isRunning ? "green" : "yellow"}>{capitalize(item.status)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group align="end">
|
||||
{item.type === "node" && <RightSection label={t("rightSection.node")} value={item.node} />}
|
||||
{item.type === "lxc" && <RightSection label={t("rightSection.vmId")} value={item.vmId} />}
|
||||
{item.type === "qemu" && <RightSection label={t("rightSection.vmId")} value={item.vmId} />}
|
||||
{item.type === "storage" && <RightSection label={t("rightSection.plugin")} value={item.storagePlugin} />}
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider mt={0} mb="xs" />
|
||||
{item.type !== "storage" && <ComputeResourceDetails item={item} />}
|
||||
{item.type === "storage" && <StorageResourceDetails item={item} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface RightSectionProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
const RightSection = ({ label, value }: RightSectionProps) => {
|
||||
return (
|
||||
<Stack align="end" gap={0}>
|
||||
<Text fw={200} size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
|
||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||
return (
|
||||
<List>
|
||||
<List.Item icon={<IconCpu size={16} />}>
|
||||
{t("cpu")} - {item.cpu.cores}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconBrain size={16} />}>
|
||||
{t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
{t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
|
||||
</List.Item>
|
||||
<List.Item icon={<IconClockHour3 size={16} />}>
|
||||
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
|
||||
</List.Item>
|
||||
{item.haState && (
|
||||
<List.Item icon={<IconHeartBolt size={16} />}>
|
||||
{t("haState")} - {capitalize(item.haState)}
|
||||
</List.Item>
|
||||
)}
|
||||
<NetStats item={item} />
|
||||
<DiskStats item={item} />
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
|
||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
|
||||
const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Center>
|
||||
<RingProgress
|
||||
roundCaps
|
||||
size={100}
|
||||
thickness={10}
|
||||
label={<Text ta="center">{storagePercent.toFixed(1)}%</Text>}
|
||||
sections={[{ value: storagePercent, color: storagePercent > 75 ? "orange" : "green" }]}
|
||||
/>
|
||||
<Group align="center" gap={0}>
|
||||
<Text>
|
||||
{t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
<Flex gap="sm" mt={0} justify="end">
|
||||
<StorageType item={item} />
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const DiskStats = ({ item }: { item: ComputeResource }) => {
|
||||
if (!item.storage.read || !item.storage.write) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List.Item icon={<IconDatabase size={16} />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.storage.write)}</Text>
|
||||
<IconArrowNarrowDown size={14} />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.storage.read)}</Text>
|
||||
<IconArrowNarrowUp size={14} />
|
||||
</Group>
|
||||
</Group>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const NetStats = ({ item }: { item: ComputeResource }) => {
|
||||
if (!item.network.in || !item.network.out) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List.Item icon={<IconNetwork size={16} />}>
|
||||
<Group gap="sm">
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.network.in)}</Text>
|
||||
<IconArrowNarrowDown size={14} />
|
||||
</Group>
|
||||
<Group gap={0}>
|
||||
<Text>{humanFileSize(item.network.out)}</Text>
|
||||
<IconArrowNarrowUp size={14} />
|
||||
</Group>
|
||||
</Group>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const StorageType = ({ item }: { item: StorageResource }) => {
|
||||
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail.storageType");
|
||||
if (item.isShared) {
|
||||
return <Badge color="blue">{t("shared")}</Badge>;
|
||||
} else {
|
||||
return <Badge>{t("local")}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const ResourceIcon = ({ type, size }: { type: Resource["type"]; size: number }) => {
|
||||
switch (type) {
|
||||
case "node":
|
||||
return <IconServer size={size} />;
|
||||
case "lxc":
|
||||
return <IconDeviceLaptop size={size} />;
|
||||
case "qemu":
|
||||
return <IconDeviceLaptop size={size} />;
|
||||
case "storage":
|
||||
return <IconDatabase size={size} />;
|
||||
default:
|
||||
console.error(`Unknown resource type: ${type as string}`);
|
||||
return <IconQuestionMark size={size} />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Group, Indicator, Popover, Table, TableTbody, TableThead, TableTr, Text } from "@mantine/core";
|
||||
|
||||
import type { Resource } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ResourcePopover } from "./resource-popover";
|
||||
|
||||
interface ResourceTableProps {
|
||||
type: Resource["type"];
|
||||
data: Resource[];
|
||||
isTiny: boolean;
|
||||
}
|
||||
|
||||
export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Table highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr fz={isTiny ? "8px" : "xs"}>
|
||||
<Table.Th ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.name")}
|
||||
</Table.Th>
|
||||
{type !== "storage" ? (
|
||||
<Table.Th ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.cpu")}
|
||||
</Table.Th>
|
||||
) : null}
|
||||
{type !== "storage" ? (
|
||||
<Table.Th ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.memory")}
|
||||
</Table.Th>
|
||||
) : null}
|
||||
{type === "storage" ? (
|
||||
<Table.Th ta="start" p={0}>
|
||||
{t("widget.healthMonitoring.cluster.table.header.node")}
|
||||
</Table.Th>
|
||||
) : null}
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{data
|
||||
.sort((itemA, itemB) => {
|
||||
const nodeResult = itemA.node.localeCompare(itemB.node);
|
||||
if (nodeResult !== 0) return nodeResult;
|
||||
return itemA.name.localeCompare(itemB.name);
|
||||
})
|
||||
.map((item) => {
|
||||
return (
|
||||
<ResourcePopover key={item.id} item={item}>
|
||||
<Popover.Target>
|
||||
<TableTr fz={isTiny ? "8px" : "xs"}>
|
||||
<td>
|
||||
<Group wrap="nowrap" gap={isTiny ? 8 : "xs"}>
|
||||
<Indicator size={isTiny ? 4 : 8} children={null} color={item.isRunning ? "green" : "yellow"} />
|
||||
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</td>
|
||||
{item.type === "storage" ? (
|
||||
<td style={{ WebkitLineClamp: "1" }}>{item.node}</td>
|
||||
) : (
|
||||
<>
|
||||
<td style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</td>
|
||||
<td style={{ whiteSpace: "nowrap" }}>
|
||||
{(item.memory.total ? (item.memory.used / item.memory.total) * 100 : 0).toFixed(1)}%
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</TableTr>
|
||||
</Popover.Target>
|
||||
</ResourcePopover>
|
||||
);
|
||||
})}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea, Tabs } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
|
||||
import { SystemHealthMonitoring } from "./system-health";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
|
||||
integration.kind === "proxmox" || integration.kind === "mock";
|
||||
|
||||
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
|
||||
const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds);
|
||||
const t = useI18n();
|
||||
|
||||
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
|
||||
|
||||
if (!clusterIntegrationId) {
|
||||
return <SystemHealthMonitoring {...props} />;
|
||||
}
|
||||
|
||||
const otherIntegrationIds = integrations
|
||||
// We want to have the mock integration also in the system tab, so we use it for both
|
||||
.filter((integration) => integration.kind !== "proxmox")
|
||||
.map((integration) => integration.id);
|
||||
if (otherIntegrationIds.length === 0) {
|
||||
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Tabs defaultValue={props.options.defaultTab} variant="outline">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="system" fz="xs">
|
||||
<b>{t("widget.healthMonitoring.tab.system")}</b>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="cluster" fz="xs">
|
||||
<b>{t("widget.healthMonitoring.tab.cluster")}</b>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="system">
|
||||
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="cluster">
|
||||
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
|
||||
icon: IconHeartRateMonitor,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
fahrenheit: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
cpu: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
memory: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
showUptime: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
fileSystem: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
visibleClusterSections: factory.multiSelect({
|
||||
options: [
|
||||
{
|
||||
value: "node",
|
||||
label: (t) => t("widget.healthMonitoring.cluster.resource.node.name"),
|
||||
},
|
||||
{
|
||||
value: "qemu",
|
||||
label: (t) => t("widget.healthMonitoring.cluster.resource.qemu.name"),
|
||||
},
|
||||
{
|
||||
value: "lxc",
|
||||
label: (t) => t("widget.healthMonitoring.cluster.resource.lxc.name"),
|
||||
},
|
||||
{
|
||||
value: "storage",
|
||||
label: (t) => t("widget.healthMonitoring.cluster.resource.storage.name"),
|
||||
},
|
||||
] as const,
|
||||
defaultValue: ["node", "qemu", "lxc", "storage"] as const,
|
||||
}),
|
||||
defaultTab: factory.select({
|
||||
defaultValue: "system",
|
||||
options: [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "cluster", label: "Cluster" },
|
||||
] as const,
|
||||
}),
|
||||
sectionIndicatorRequirement: factory.select({
|
||||
defaultValue: "all",
|
||||
options: [
|
||||
{ value: "all", label: "All active" },
|
||||
{ value: "any", label: "Any active" },
|
||||
] as const,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
fahrenheit: {
|
||||
shouldHide(_, integrationKinds) {
|
||||
// File system is only shown on system health tab
|
||||
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
|
||||
},
|
||||
},
|
||||
fileSystem: {
|
||||
shouldHide(_, integrationKinds) {
|
||||
// File system is only shown on system health tab
|
||||
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
|
||||
},
|
||||
},
|
||||
showUptime: {
|
||||
shouldHide(_, integrationKinds) {
|
||||
// Uptime is only shown on cluster health tab
|
||||
return !integrationKinds.includes("proxmox");
|
||||
},
|
||||
},
|
||||
sectionIndicatorRequirement: {
|
||||
shouldHide(_, integrationKinds) {
|
||||
// Section indicator requirement is only shown on cluster health tab
|
||||
return !integrationKinds.includes("proxmox");
|
||||
},
|
||||
},
|
||||
visibleClusterSections: {
|
||||
shouldHide(_, integrationKinds) {
|
||||
// Cluster sections are only shown on cluster health tab
|
||||
return !integrationKinds.includes("proxmox");
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Center, RingProgress, Text } from "@mantine/core";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
import { progressColor } from "../system-health";
|
||||
|
||||
export const CpuRing = ({ cpuUtilization, isTiny }: { cpuUtilization: number; isTiny: boolean }) => {
|
||||
return (
|
||||
<RingProgress
|
||||
className="health-monitoring-cpu"
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text
|
||||
className="health-monitoring-cpu-utilization-value"
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
>{`${cpuUtilization.toFixed(2)}%`}</Text>
|
||||
<IconCpu className="health-monitoring-cpu-utilization-icon" size={isTiny ? 8 : 16} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: Number(cpuUtilization.toFixed(2)),
|
||||
color: progressColor(Number(cpuUtilization.toFixed(2))),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Center, RingProgress, Text } from "@mantine/core";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
import { progressColor } from "../system-health";
|
||||
|
||||
export const CpuTempRing = ({
|
||||
fahrenheit,
|
||||
cpuTemp,
|
||||
isTiny,
|
||||
}: {
|
||||
fahrenheit: boolean;
|
||||
cpuTemp: number | undefined;
|
||||
isTiny: boolean;
|
||||
}) => {
|
||||
if (!cpuTemp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
className="health-monitoring-cpu-temperature"
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text className="health-monitoring-cpu-temp-value" size={isTiny ? "8px" : "xs"}>
|
||||
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
|
||||
</Text>
|
||||
<IconCpu className="health-monitoring-cpu-temp-icon" size={isTiny ? 8 : 16} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: cpuTemp,
|
||||
color: progressColor(cpuTemp),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Center, RingProgress, Text } from "@mantine/core";
|
||||
import { IconBrain } from "@tabler/icons-react";
|
||||
|
||||
import { progressColor } from "../system-health";
|
||||
|
||||
export const MemoryRing = ({ available, used, isTiny }: { available: number; used: number; isTiny: boolean }) => {
|
||||
const memoryUsage = formatMemoryUsage(available, used);
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
className="health-monitoring-memory"
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text className="health-monitoring-memory-value" size={isTiny ? "8px" : "xs"}>
|
||||
{memoryUsage.memUsed.GB}GiB
|
||||
</Text>
|
||||
<IconBrain className="health-monitoring-memory-icon" size={isTiny ? 8 : 16} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: Number(memoryUsage.memUsed.percent),
|
||||
color: progressColor(Number(memoryUsage.memUsed.percent)),
|
||||
tooltip: `${memoryUsage.memUsed.percent}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const formatMemoryUsage = (memFree: number, memUsed: number) => {
|
||||
const totalMemory = memFree + memUsed;
|
||||
const memFreeGB = (memFree / 1024 ** 3).toFixed(2);
|
||||
const memUsedGB = (memUsed / 1024 ** 3).toFixed(2);
|
||||
const memFreePercent = Math.round((memFree / totalMemory) * 100);
|
||||
const memUsedPercent = Math.round((memUsed / totalMemory) * 100);
|
||||
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
|
||||
|
||||
return {
|
||||
memFree: { percent: memFreePercent, GB: memFreeGB },
|
||||
memUsed: { percent: memUsedPercent, GB: memUsedGB },
|
||||
memTotal: { GB: memTotalGB },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
[data-mantine-color-scheme="light"] .card {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Indicator,
|
||||
List,
|
||||
Modal,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconBrain,
|
||||
IconClock,
|
||||
IconCpu,
|
||||
IconCpu2,
|
||||
IconFileReport,
|
||||
IconInfoCircle,
|
||||
IconServer,
|
||||
IconTemperature,
|
||||
IconVersions,
|
||||
} from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { CpuRing } from "./rings/cpu-ring";
|
||||
import { CpuTempRing } from "./rings/cpu-temp-ring";
|
||||
import { formatMemoryUsage, MemoryRing } from "./rings/memory-ring";
|
||||
import classes from "./system-health.module.css";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export const SystemHealthMonitoring = ({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
}: WidgetComponentProps<"healthMonitoring">) => {
|
||||
const t = useI18n();
|
||||
const [healthData] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
|
||||
{ integrationIds },
|
||||
{
|
||||
onData(data) {
|
||||
utils.widget.healthMonitoring.getSystemHealthStatus.setData({ integrationIds }, (prevData) => {
|
||||
if (!prevData) {
|
||||
return undefined;
|
||||
}
|
||||
return prevData.map((item) =>
|
||||
item.integrationId === data.integrationId
|
||||
? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp }
|
||||
: item,
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isTiny = width < 256;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" className="health-monitoring">
|
||||
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
||||
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
|
||||
return (
|
||||
<Stack
|
||||
gap="sm"
|
||||
key={integrationId}
|
||||
h="100%"
|
||||
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
||||
p="sm"
|
||||
pos="relative"
|
||||
>
|
||||
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
|
||||
<Indicator
|
||||
className="health-monitoring-updates-reboot-indicator"
|
||||
inline
|
||||
processing
|
||||
styles={{ indicator: { pointerEvents: "none" } }}
|
||||
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
|
||||
position="top-end"
|
||||
size={16}
|
||||
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
|
||||
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
|
||||
>
|
||||
<ActionIcon
|
||||
className="health-monitoring-information-icon-avatar"
|
||||
variant={"light"}
|
||||
color="var(--mantine-color-text)"
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
size="auto"
|
||||
title={t("widget.healthMonitoring.popover.information")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="10px" className="health-monitoring-modal-stack">
|
||||
<Divider />
|
||||
<List className="health-monitoring-information-list" center spacing="xs">
|
||||
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
||||
memoryAvailable: memoryUsage.memFree.GB,
|
||||
percent: String(memoryUsage.memFree.percent),
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.version", {
|
||||
version: healthInfo.version,
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
|
||||
{formatUptime(healthInfo.uptime, t)}
|
||||
</List.Item>
|
||||
{healthInfo.loadAverage && (
|
||||
<>
|
||||
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.loadAverage")}
|
||||
</List.Item>
|
||||
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
|
||||
<List.Item className="health-monitoring-information-load-average-1min">
|
||||
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-load-average-5min">
|
||||
{t("widget.healthMonitoring.popover.minutes", { count: "5" })}{" "}
|
||||
{healthInfo.loadAverage["5min"]}%
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-load-average-15min">
|
||||
{t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "}
|
||||
{healthInfo.loadAverage["15min"]}%
|
||||
</List.Item>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
|
||||
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
|
||||
{options.cpu && (
|
||||
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
|
||||
)}
|
||||
{options.memory && (
|
||||
<MemoryRing
|
||||
available={healthInfo.memAvailableInBytes}
|
||||
used={healthInfo.memUsedInBytes}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{
|
||||
<Text className="health-monitoring-status-update-time" c="dimmed" size="xs" ta="center">
|
||||
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
||||
</Text>
|
||||
}
|
||||
{options.fileSystem &&
|
||||
disksData.map((disk) => {
|
||||
return (
|
||||
<Card
|
||||
className={combineClasses(
|
||||
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
|
||||
classes.card,
|
||||
)}
|
||||
style={{ overflow: "visible" }}
|
||||
key={disk.deviceName}
|
||||
radius={board.itemRadius}
|
||||
p="xs"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group
|
||||
className="health-monitoring-disk-status"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
gap={8}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconServer className="health-monitoring-disk-icon" size="1rem" />
|
||||
<Text className="dihealth-monitoring-disk-name" size="xs">
|
||||
{disk.deviceName}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
|
||||
<Text className="health-monitoring-disk-temperature-value" size="xs">
|
||||
{options.fahrenheit
|
||||
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
||||
: `${disk.temperature}°C`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
|
||||
<Text className="health-monitoring-disk-status-value" size="xs">
|
||||
{disk.overallStatus ? disk.overallStatus : "N/A"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} h="md">
|
||||
<Tooltip label={disk.used}>
|
||||
<Progress.Section
|
||||
value={disk.percentage}
|
||||
color={progressColor(disk.percentage)}
|
||||
className="health-monitoring-disk-use-percentage"
|
||||
>
|
||||
<Progress.Label className="health-monitoring-disk-use-value" fz="xs">
|
||||
{t("widget.healthMonitoring.popover.used")}
|
||||
</Progress.Label>
|
||||
</Progress.Section>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={
|
||||
Number(disk.available) / 1024 ** 4 >= 1
|
||||
? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB`
|
||||
: `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB`
|
||||
}
|
||||
>
|
||||
<Progress.Section
|
||||
className="health-monitoring-disk-available-percentage"
|
||||
value={100 - disk.percentage}
|
||||
color="default"
|
||||
>
|
||||
<Progress.Label className="health-monitoring-disk-available-value" fz="xs">
|
||||
{t("widget.healthMonitoring.popover.available")}
|
||||
</Progress.Label>
|
||||
</Progress.Section>
|
||||
</Tooltip>
|
||||
</Progress.Root>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
|
||||
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
|
||||
const months = uptimeDuration.months();
|
||||
const days = uptimeDuration.days();
|
||||
const hours = uptimeDuration.hours();
|
||||
const minutes = uptimeDuration.minutes();
|
||||
|
||||
return t("widget.healthMonitoring.popover.uptime", {
|
||||
months: String(months),
|
||||
days: String(days),
|
||||
hours: String(hours),
|
||||
minutes: String(minutes),
|
||||
});
|
||||
};
|
||||
|
||||
export const progressColor = (percentage: number) => {
|
||||
if (percentage < 40) return "green";
|
||||
else if (percentage < 60) return "yellow";
|
||||
else if (percentage < 90) return "orange";
|
||||
else return "red";
|
||||
};
|
||||
|
||||
interface FileSystem {
|
||||
deviceName: string;
|
||||
used: string;
|
||||
available: string;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface SmartData {
|
||||
deviceName: string;
|
||||
temperature: number | null;
|
||||
overallStatus: string;
|
||||
}
|
||||
|
||||
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
|
||||
return fileSystems
|
||||
.map((fileSystem) => {
|
||||
const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
|
||||
const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
|
||||
|
||||
return {
|
||||
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
|
||||
used: fileSystem.used,
|
||||
available: fileSystem.available,
|
||||
percentage: fileSystem.percentage,
|
||||
temperature: smartDisk?.temperature ?? 0,
|
||||
overallStatus: smartDisk?.overallStatus ?? "",
|
||||
};
|
||||
})
|
||||
.sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
|
||||
};
|
||||
Reference in New Issue
Block a user