diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index b2593aaf7..16266f889 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -1,4 +1,4 @@ -import { IconKey, IconPassword, IconUser } from "@tabler/icons-react"; +import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react"; import type { IntegrationSecretKind } from "@homarr/definitions"; import type { TablerIcon } from "@homarr/ui"; @@ -7,4 +7,6 @@ export const integrationSecretIcons = { username: IconUser, apiKey: IconKey, password: IconPassword, + realm: IconServer, + tokenId: IconGrid3x3, } satisfies Record; diff --git a/package.json b/package.json index 8afa6d4b2..69144e093 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,9 @@ }, "pnpm": { "allowNonAppliedPatches": true, + "overrides": { + "proxmox-api>undici": "7.2.3" + }, "patchedDependencies": { "pretty-print-error": "patches/pretty-print-error.patch" } diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 9b309afcc..4ad169bf8 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -130,6 +130,16 @@ export const integrationRouter = createTRPCRouter({ limit: input.limit, }); }), + // This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards + byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => { + return await ctx.db.query.integrations.findMany({ + where: inArray(integrations.id, input), + columns: { + id: true, + kind: true, + }, + }); + }), byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); const integration = await ctx.db.query.integrations.findFirst({ diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 2169efafd..a51cfed2e 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,15 +1,15 @@ import { observable } from "@trpc/server/observable"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { HealthMonitoring } from "@homarr/integrations"; -import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; +import type { ProxmoxClusterInfo } from "@homarr/integrations/types"; +import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const healthMonitoringRouter = createTRPCRouter({ - getHealthStatus: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring"))) + getSystemHealthStatus: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot")) .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.map(async (integration) => { @@ -25,9 +25,8 @@ export const healthMonitoringRouter = createTRPCRouter({ }), ); }), - - subscribeHealthStatus: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring"))) + subscribeSystemHealthStatus: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot")) .subscription(({ ctx }) => { return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => { const unsubscribes: (() => void)[] = []; @@ -49,4 +48,26 @@ export const healthMonitoringRouter = createTRPCRouter({ }; }); }), + getClusterHealthStatus: publicProcedure + .unstable_concat(createOneIntegrationMiddleware("query", "proxmox")) + .query(async ({ ctx }) => { + const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data; + }), + subscribeClusterHealthStatus: publicProcedure + .unstable_concat(createOneIntegrationMiddleware("query", "proxmox")) + .subscription(({ ctx }) => { + return observable((emit) => { + const unsubscribes: (() => void)[] = []; + const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); + const unsubscribe = innerHandler.subscribe((healthInfo) => { + emit.next(healthInfo); + }); + unsubscribes.push(unsubscribe); + return () => { + unsubscribe(); + }; + }); + }), }); diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index 50c80ce84..6e87a57fb 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -1,14 +1,23 @@ import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; -import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; +import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; import { createCronJob } from "../../lib"; export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback( - createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, { - widgetKinds: ["healthMonitoring"], - getInput: { - healthMonitoring: () => ({}), + createRequestIntegrationJobHandler( + (integration, itemOptions: Record) => { + const { kind } = integration; + if (kind !== "proxmox") { + return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions); + } + return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions); }, - }), + { + widgetKinds: ["healthMonitoring"], + getInput: { + healthMonitoring: () => ({}), + }, + }, + ), ); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 82f6e2080..17fa806d1 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -5,6 +5,8 @@ export const integrationSecretKindObject = { apiKey: { isPublic: false }, username: { isPublic: true }, password: { isPublic: false }, + tokenId: { isPublic: true }, + realm: { isPublic: true }, } satisfies Record; export const integrationSecretKinds = objectKeys(integrationSecretKindObject); @@ -137,6 +139,12 @@ export const integrationDefs = { category: ["mediaTranscoding"], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png", }, + proxmox: { + name: "Proxmox", + secretKinds: [["username", "tokenId", "apiKey", "realm"]], + category: ["healthMonitoring"], + iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png", + }, } as const satisfies Record; export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index de505b975..ea88e109f 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -36,6 +36,7 @@ "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@jellyfin/sdk": "^0.11.0", + "proxmox-api": "1.1.1", "undici": "7.2.3", "xml2js": "^0.6.2" }, diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index c6782127a..ecf184ba8 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -23,6 +23,7 @@ import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; +import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; import type { Integration, IntegrationInput } from "./integration"; export const integrationCreator = ( @@ -72,4 +73,5 @@ export const integrationCreators = { readarr: ReadarrIntegration, dashDot: DashDotIntegration, tdarr: TdarrIntegration, + proxmox: ProxmoxIntegration, } satisfies Record Integration>; diff --git a/packages/integrations/src/proxmox/proxmox-integration.ts b/packages/integrations/src/proxmox/proxmox-integration.ts new file mode 100644 index 000000000..fceab7642 --- /dev/null +++ b/packages/integrations/src/proxmox/proxmox-integration.ts @@ -0,0 +1,125 @@ +import type { Proxmox } from "proxmox-api"; +import proxmoxApi from "proxmox-api"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { extractErrorMessage } from "@homarr/common"; +import { logger } from "@homarr/log"; + +import { Integration } from "../base/integration"; +import { IntegrationTestConnectionError } from "../base/test-connection-error"; +import type { + ComputeResourceBase, + LxcResource, + NodeResource, + QemuResource, + Resource, + StorageResource, +} from "./proxmox-types"; + +export class ProxmoxIntegration extends Integration { + public async testConnectionAsync(): Promise { + const proxmox = this.getPromoxApi(); + await proxmox.nodes.$get().catch((error) => { + throw new IntegrationTestConnectionError("internalServerError", extractErrorMessage(error)); + }); + } + + public async getClusterInfoAsync() { + const proxmox = this.getPromoxApi(); + const resources = await proxmox.cluster.resources.$get(); + + logger.info( + `Found ${resources.length} resources in Proxmox cluster node=${resources.filter((resource) => resource.type === "node").length} lxc=${resources.filter((resource) => resource.type === "lxc").length} qemu=${resources.filter((resource) => resource.type === "qemu").length} storage=${resources.filter((resource) => resource.type === "storage").length}`, + ); + + const mappedResources = resources.map(mapResource).filter((resource) => resource !== null); + return { + nodes: mappedResources.filter((resource): resource is NodeResource => resource.type === "node"), + lxcs: mappedResources.filter((resource): resource is LxcResource => resource.type === "lxc"), + vms: mappedResources.filter((resource): resource is QemuResource => resource.type === "qemu"), + storages: mappedResources.filter((resource): resource is StorageResource => resource.type === "storage"), + }; + } + + private getPromoxApi() { + return proxmoxApi({ + host: this.url("/").host, + tokenID: `${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}`, + tokenSecret: this.getSecretValue("apiKey"), + fetch: fetchWithTrustedCertificatesAsync, + }); + } +} + +const mapResource = (resource: Proxmox.clusterResourcesResources): Resource | null => { + switch (resource.type) { + case "node": + return mapNodeResource(resource); + case "lxc": + case "qemu": + return mapVmResource(resource); + case "storage": + return mapStorageResource(resource); + } + + return null; +}; + +const mapComputeResource = (resource: Proxmox.clusterResourcesResources): Omit, "type"> => { + return { + cpu: { + utilization: resource.cpu ?? 0, + cores: resource.maxcpu ?? 0, + }, + memory: { + used: resource.mem ?? 0, + total: resource.maxmem ?? 0, + }, + storage: { + used: resource.disk ?? 0, + total: resource.maxdisk ?? 0, + read: (resource.diskread as number | null) ?? null, + write: (resource.diskwrite as number | null) ?? null, + }, + network: { + in: (resource.netin as number | null) ?? null, + out: (resource.netout as number | null) ?? null, + }, + haState: resource.hastate ?? null, + isRunning: resource.status === "running" || resource.status === "online", + name: resource.name ?? "", + node: resource.node ?? "", + status: resource.status ?? (resource.type === "node" ? "offline" : "stopped"), + uptime: resource.uptime ?? 0, + }; +}; + +const mapNodeResource = (resource: Proxmox.clusterResourcesResources): NodeResource => { + return { + type: "node", + ...mapComputeResource(resource), + name: resource.node ?? "", + }; +}; + +const mapVmResource = (resource: Proxmox.clusterResourcesResources): LxcResource | QemuResource => { + return { + type: resource.type as "lxc" | "qemu", + vmId: resource.vmid ?? 0, + ...mapComputeResource(resource), + }; +}; + +const mapStorageResource = (resource: Proxmox.clusterResourcesResources): StorageResource => { + return { + type: "storage", + name: resource.storage ?? "", + node: resource.node ?? "", + isRunning: resource.status === "available", + status: resource.status ?? "offline", + storagePlugin: resource.storage ?? "", + total: resource.maxdisk ?? 0, + used: resource.disk ?? 0, + isShared: resource.shared === 1, + }; +}; diff --git a/packages/integrations/src/proxmox/proxmox-types.ts b/packages/integrations/src/proxmox/proxmox-types.ts new file mode 100644 index 000000000..af0db647b --- /dev/null +++ b/packages/integrations/src/proxmox/proxmox-types.ts @@ -0,0 +1,57 @@ +interface ResourceBase { + type: TType; + name: string; + node: string; + isRunning: boolean; + status: string; +} + +export interface ComputeResourceBase extends ResourceBase { + cpu: { + utilization: number; // previously cpu (0-1) + cores: number; // previously cpuCores + }; + memory: { + used: number; // previously mem + total: number; // previously maxMem + }; + storage: { + used: number; // previously disk + total: number; // previously maxDisk + read: number | null; // previously diskRead + write: number | null; // previously diskWrite + }; + network: { + in: number | null; // previously netIn + out: number | null; // previously netOut + }; + uptime: number; // expressed in seconds + haState: string | null; // HA service status (for HA managed VMs). +} + +export type NodeResource = ComputeResourceBase<"node">; + +export interface LxcResource extends ComputeResourceBase<"lxc"> { + vmId: number; +} + +export interface QemuResource extends ComputeResourceBase<"qemu"> { + vmId: number; +} + +export interface StorageResource extends ResourceBase<"storage"> { + storagePlugin: string; + used: number; // previously disk + total: number; // previously maxDisk + isShared: boolean; // previously storageShared +} + +export type ComputeResource = NodeResource | LxcResource | QemuResource; +export type Resource = ComputeResource | StorageResource; + +export interface ProxmoxClusterInfo { + nodes: NodeResource[]; + lxcs: LxcResource[]; + vms: QemuResource[]; + storages: StorageResource[]; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 2c1f72c39..d3760cadb 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -6,3 +6,4 @@ export * from "./interfaces/media-requests/media-request"; export * from "./pi-hole/pi-hole-types"; export * from "./base/searchable-integration"; export * from "./homeassistant/homeassistant-types"; +export * from "./proxmox/proxmox-types"; diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index d969e62ee..6c9b44d7c 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -141,6 +141,9 @@ const optionMapping: OptionMapping = { "fileSystem" in oldOptions ? oldOptions.fileSystem : oldOptions.graphsOrder.some((graph) => graph.key === "storage" && graph.subValues.enabled), + defaultTab: (oldOptions) => ("defaultTabState" in oldOptions ? oldOptions.defaultTabState : undefined), + sectionIndicatorRequirement: (oldOptions) => + "sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined, }, mediaTranscoding: { defaultView: (oldOptions) => oldOptions.defaultView, diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index cd3b364d1..f57b9ee30 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -2,13 +2,13 @@ import dayjs from "dayjs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; import { integrationCreator } from "@homarr/integrations"; -import type { HealthMonitoring } from "@homarr/integrations/types"; +import type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types"; import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< HealthMonitoring, - IntegrationKindByCategory<"healthMonitoring">, + Exclude, "proxmox">, Record >({ async requestAsync(integration, _input) { @@ -18,3 +18,16 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler< cacheDuration: dayjs.duration(5, "seconds"), queryKey: "systemInfo", }); + +export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler< + ProxmoxClusterInfo, + "proxmox", + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = integrationCreator(integration); + return await integrationInstance.getClusterInfoAsync(); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "clusterInfo", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index cb8deef4b..897f03dc6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -733,6 +733,14 @@ "password": { "label": "Password", "newLabel": "New password" + }, + "tokenId": { + "label": "Token ID", + "newLabel": "New token ID" + }, + "realm": { + "label": "Realm", + "newLabel": "New realm" } } }, @@ -1411,6 +1419,12 @@ }, "fileSystem": { "label": "Show Filesystem Info" + }, + "defaultTab": { + "label": "Default tab" + }, + "sectionIndicatorRequirement": { + "label": "Section indicator requirement" } }, "popover": { @@ -1430,6 +1444,52 @@ "memory": {}, "error": { "internalServerError": "Failed to fetch health status" + }, + "cluster": { + "summary": { + "cpu": "CPU", + "memory": "RAM" + }, + "resource": { + "node": { + "name": "Nodes" + }, + "qemu": { + "name": "VMs" + }, + "lxc": { + "name": "LXCs" + }, + "storage": { + "name": "Storage" + } + }, + "popover": { + "rightSection": { + "node": "Node", + "vmId": "VM ID", + "plugin": "Plugin" + }, + "detail": { + "cpu": "Cores", + "memory": "Memory", + "storage": "Storage", + "uptime": "Uptime", + "haState": "HA State", + "storageType": { + "local": "Local storage", + "shared": "Shared storage" + } + } + }, + "table": { + "header": { + "name": "Name", + "cpu": "CPU", + "memory": "RAM", + "node": "Node" + } + } } }, "common": { diff --git a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx new file mode 100644 index 000000000..c2abec2b8 --- /dev/null +++ b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx @@ -0,0 +1,187 @@ +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, +}: 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; + + return ( + + + + {formatUptime(uptime, t)} + + + + + + + + + + + + + + + + + + + + + + ); +}; + +interface SummaryHeaderProps { + cpu: number; + memory: number; +} + +const SummaryHeader = ({ cpu, memory }: SummaryHeaderProps) => { + const t = useI18n(); + return ( +
+ + + + +
+ } + sections={[{ value: cpu, color: cpu > 75 ? "orange" : "green" }]} + /> + + {t("widget.healthMonitoring.cluster.summary.cpu")} + {cpu.toFixed(1)}% + + + + + + + } + sections={[{ value: memory, color: memory > 75 ? "orange" : "green" }]} + /> + + {t("widget.healthMonitoring.cluster.summary.memory")} + {memory.toFixed(1)}% + + + + + ); +}; diff --git a/packages/widgets/src/health-monitoring/cluster/resource-accordion-item.tsx b/packages/widgets/src/health-monitoring/cluster/resource-accordion-item.tsx new file mode 100644 index 000000000..717822e60 --- /dev/null +++ b/packages/widgets/src/health-monitoring/cluster/resource-accordion-item.tsx @@ -0,0 +1,38 @@ +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; + }; +} + +export const ResourceAccordionItem = ({ + value, + title, + icon: Icon, + badge, + children, +}: PropsWithChildren) => { + return ( + + }> + + {title} + + {badge.activeCount} / {badge.totalCount} + + + + {children} + + ); +}; diff --git a/packages/widgets/src/health-monitoring/cluster/resource-popover.tsx b/packages/widgets/src/health-monitoring/cluster/resource-popover.tsx new file mode 100644 index 000000000..1ce8f54fc --- /dev/null +++ b/packages/widgets/src/health-monitoring/cluster/resource-popover.tsx @@ -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) => { + return ( + + {children} + + + + + ); +}; + +export const ResourceTypeEntryDetails = ({ item }: { item: Resource }) => { + const t = useScopedI18n("widget.healthMonitoring.cluster.popover"); + return ( + + + + + + + {item.name} + + {capitalize(item.status)} + + + + {item.type === "node" && } + {item.type === "lxc" && } + {item.type === "qemu" && } + {item.type === "storage" && } + + + + {item.type !== "storage" && } + {item.type === "storage" && } + + ); +}; + +interface RightSectionProps { + label: string; + value: string | number; +} + +const RightSection = ({ label, value }: RightSectionProps) => { + return ( + + + {label} + + + {value} + + + ); +}; + +const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => { + const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail"); + return ( + + }> + {t("cpu")} - {item.cpu.cores} + + }> + {t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)} + + }> + {t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)} + + }> + {t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)} + + {item.haState && ( + }> + {t("haState")} - {capitalize(item.haState)} + + )} + + + + ); +}; + +const StorageResourceDetails = ({ item }: { item: StorageResource }) => { + const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail"); + const storagePercent = item.total ? (item.used / item.total) * 100 : 0; + return ( + +
+ {storagePercent.toFixed(1)}%} + sections={[{ value: storagePercent, color: storagePercent > 75 ? "orange" : "green" }]} + /> + + + {t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)} + + +
+ + + +
+ ); +}; + +const DiskStats = ({ item }: { item: ComputeResource }) => { + if (!item.storage.read || !item.storage.write) { + return null; + } + return ( + }> + + + {humanFileSize(item.storage.write)} + + + + {humanFileSize(item.storage.read)} + + + + + ); +}; + +const NetStats = ({ item }: { item: ComputeResource }) => { + if (!item.network.in || !item.network.out) { + return null; + } + return ( + }> + + + {humanFileSize(item.network.in)} + + + + {humanFileSize(item.network.out)} + + + + + ); +}; + +const StorageType = ({ item }: { item: StorageResource }) => { + const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail.storageType"); + if (item.isShared) { + return {t("shared")}; + } else { + return {t("local")}; + } +}; + +const ResourceIcon = ({ type, size }: { type: Resource["type"]; size: number }) => { + switch (type) { + case "node": + return ; + case "lxc": + return ; + case "qemu": + return ; + case "storage": + return ; + default: + console.error(`Unknown resource type: ${type as string}`); + return ; + } +}; diff --git a/packages/widgets/src/health-monitoring/cluster/resource-table.tsx b/packages/widgets/src/health-monitoring/cluster/resource-table.tsx new file mode 100644 index 000000000..bc8226f37 --- /dev/null +++ b/packages/widgets/src/health-monitoring/cluster/resource-table.tsx @@ -0,0 +1,61 @@ +import { Group, Indicator, Popover, Table, 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[]; +} + +export const ResourceTable = ({ type, data }: ResourceTableProps) => { + const t = useI18n(); + return ( + + + + {t("widget.healthMonitoring.cluster.table.header.name")} + {type !== "storage" ? ( + {t("widget.healthMonitoring.cluster.table.header.cpu")} + ) : null} + {type !== "storage" ? ( + {t("widget.healthMonitoring.cluster.table.header.memory")} + ) : null} + {type === "storage" ? ( + {t("widget.healthMonitoring.cluster.table.header.node")} + ) : null} + + + + {data.map((item) => { + return ( + + + + + {item.type === "storage" ? ( + + ) : ( + <> + + + + )} + + + + ); + })} + +
+ + + {item.name} + + {item.node}{(item.cpu.utilization * 100).toFixed(1)}% + {(item.memory.total ? (item.memory.used / item.memory.total) * 100 : 0).toFixed(1)}% +
+ ); +}; diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index d4a597f4f..0c44f999b 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -1,425 +1,61 @@ "use client"; -import { - Avatar, - Box, - Card, - Center, - Divider, - Flex, - Group, - Indicator, - List, - Modal, - Progress, - RingProgress, - Stack, - Text, - Tooltip, -} from "@mantine/core"; -import { useDisclosure, useElementSize } from "@mantine/hooks"; -import { - IconBrain, - IconClock, - IconCpu, - IconCpu2, - IconFileReport, - IconInfoCircle, - IconServer, - IconTemperature, - IconVersions, -} from "@tabler/icons-react"; +import { ScrollArea, Tabs } from "@mantine/core"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import { clientApi } from "@homarr/api/client"; -import type { TranslationFunction } from "@homarr/translation"; -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); -export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) { - const t = useI18n(); - const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery( - { - integrationIds, - }, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: 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, updatedAt: data.timestamp } - : item, - ); - return newData; - }); - }, - }, - ); - - return ( - - {healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => { - const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); - const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed); - return ( - - - - - 0 ? "blue" : "gray"} - position="top-end" - size="4cqmin" - label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined} - disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0} - > - - - - - - - - - } - > - {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })} - - } - > - {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", { - version: healthInfo.version, - })} - - } - > - {formatUptime(healthInfo.uptime, t)} - - } - > - {t("widget.healthMonitoring.popover.loadAverage")} - - }> - - {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "} - {healthInfo.loadAverage["5min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "} - {healthInfo.loadAverage["15min"]}% - - - - - - - {options.cpu && } - {healthInfo.cpuTemp && options.cpu && ( - - )} - {options.memory && } - - { - - {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })} - - } - - {options.fileSystem && - disksData.map((disk) => { - return ( - - - - - - {disk.deviceName} - - - - - - {options.fahrenheit - ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F` - : `${disk.temperature}°C`} - - - - - - {disk.overallStatus} - - - - - - - - {t("widget.healthMonitoring.popover.used")} - - - - - = 1 - ? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB` - : `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB` - } - > - - - {t("widget.healthMonitoring.popover.available")} - - - - - - ); - })} - - ); - })} - - ); -} - -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, days, hours, 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"; -}; +export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) { + const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds); -interface FileSystem { - deviceName: string; - used: string; - available: string; - percentage: number; -} - -interface SmartData { - deviceName: string; - temperature: number; - overallStatus: string; -} + const proxmoxIntegrationId = integrations.find((integration) => integration.kind === "proxmox")?.id; -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); + if (!proxmoxIntegrationId) { + return ; + } - 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)); -}; - -const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => { - const { width, ref } = useElementSize(); + const otherIntegrationIds = integrations + .filter((integration) => integration.kind !== "proxmox") + .map((integration) => integration.id); + if (otherIntegrationIds.length === 0) { + return ; + } return ( - - - {`${cpuUtilization.toFixed(2)}%`} - - - } - sections={[ - { - value: Number(cpuUtilization.toFixed(2)), - color: progressColor(Number(cpuUtilization.toFixed(2))), + - + }, + }} + > + + + + System + + + Cluster + + + + + + + + + + ); -}; - -const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number }) => { - const { width, ref } = useElementSize(); - return ( - - - - {fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`} - - - - } - sections={[ - { - value: cpuTemp, - color: progressColor(cpuTemp), - }, - ]} - /> - - ); -}; - -const MemoryRing = ({ available, used }: { available: string; used: string }) => { - const { width, ref } = useElementSize(); - const memoryUsage = formatMemoryUsage(available, used); - - return ( - - - - {memoryUsage.memUsed.GB}GiB - - - - } - sections={[ - { - value: Number(memoryUsage.memUsed.percent), - color: progressColor(Number(memoryUsage.memUsed.percent)), - tooltip: `${memoryUsage.memUsed.percent}%`, - }, - ]} - /> - - ); -}; - -export const formatMemoryUsage = (memFree: string, memUsed: string) => { - const memFreeBytes = Number(memFree); - const memUsedBytes = Number(memUsed); - const totalMemory = memFreeBytes + memUsedBytes; - const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2); - const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2); - const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100); - const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100); - const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2); - - return { - memFree: { percent: memFreePercent, GB: memFreeGB }, - memUsed: { percent: memUsedPercent, GB: memUsedGB }, - memTotal: { GB: memTotalGB }, - }; -}; +} diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index 970f827f4..2d9c9e895 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -20,6 +20,20 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon fileSystem: factory.switch({ defaultValue: true, }), + 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, + }), })), supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"), errors: { diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx new file mode 100644 index 000000000..c2ed07564 --- /dev/null +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { + Avatar, + Box, + Card, + Center, + Divider, + Flex, + Group, + Indicator, + List, + Modal, + Progress, + RingProgress, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { useDisclosure, useElementSize } from "@mantine/hooks"; +import { + IconBrain, + IconClock, + IconCpu, + IconCpu2, + IconFileReport, + IconInfoCircle, + IconServer, + IconTemperature, + IconVersions, +} from "@tabler/icons-react"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; + +import { clientApi } from "@homarr/api/client"; +import type { TranslationFunction } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +dayjs.extend(duration); + +export const SystemHealthMonitoring = ({ options, integrationIds }: 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(); + + clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription( + { integrationIds }, + { + onData(data) { + utils.widget.healthMonitoring.getSystemHealthStatus.setData({ integrationIds }, (prevData) => { + if (!prevData) { + return undefined; + } + const newData = prevData.map((item) => + item.integrationId === data.integrationId + ? { ...item, healthInfo: data.healthInfo, updatedAt: data.timestamp } + : item, + ); + return newData; + }); + }, + }, + ); + + return ( + + {healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => { + const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart); + const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed); + return ( + + + + + 0 ? "blue" : "gray"} + position="top-end" + size="4cqmin" + label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined} + disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0} + > + + + + + + + + + } + > + {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })} + + } + > + {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", { + version: healthInfo.version, + })} + + } + > + {formatUptime(healthInfo.uptime, t)} + + } + > + {t("widget.healthMonitoring.popover.loadAverage")} + + }> + + {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% + + + {t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "} + {healthInfo.loadAverage["5min"]}% + + + {t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "} + {healthInfo.loadAverage["15min"]}% + + + + + + + {options.cpu && } + {healthInfo.cpuTemp && options.cpu && ( + + )} + {options.memory && } + + { + + {t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })} + + } + + {options.fileSystem && + disksData.map((disk) => { + return ( + + + + + + {disk.deviceName} + + + + + + {options.fahrenheit + ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F` + : `${disk.temperature}°C`} + + + + + + {disk.overallStatus} + + + + + + + + {t("widget.healthMonitoring.popover.used")} + + + + + = 1 + ? `${(Number(disk.available) / 1024 ** 4).toFixed(2)} TiB` + : `${(Number(disk.available) / 1024 ** 3).toFixed(2)} GiB` + } + > + + + {t("widget.healthMonitoring.popover.available")} + + + + + + ); + })} + + ); + })} + + ); +}; + +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, days, hours, 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; + 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)); +}; + +const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => { + const { width, ref } = useElementSize(); + + return ( + + + {`${cpuUtilization.toFixed(2)}%`} + + + } + sections={[ + { + value: Number(cpuUtilization.toFixed(2)), + color: progressColor(Number(cpuUtilization.toFixed(2))), + }, + ]} + /> + + ); +}; + +const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number }) => { + const { width, ref } = useElementSize(); + return ( + + + + {fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`} + + + + } + sections={[ + { + value: cpuTemp, + color: progressColor(cpuTemp), + }, + ]} + /> + + ); +}; + +const MemoryRing = ({ available, used }: { available: string; used: string }) => { + const { width, ref } = useElementSize(); + const memoryUsage = formatMemoryUsage(available, used); + + return ( + + + + {memoryUsage.memUsed.GB}GiB + + + + } + sections={[ + { + value: Number(memoryUsage.memUsed.percent), + color: progressColor(Number(memoryUsage.memUsed.percent)), + tooltip: `${memoryUsage.memUsed.percent}%`, + }, + ]} + /> + + ); +}; + +export const formatMemoryUsage = (memFree: string, memUsed: string) => { + const memFreeBytes = Number(memFree); + const memUsedBytes = Number(memUsed); + const totalMemory = memFreeBytes + memUsedBytes; + const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2); + const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2); + const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100); + const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100); + const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2); + + return { + memFree: { percent: memFreePercent, GB: memFreeGB }, + memUsed: { percent: memUsedPercent, GB: memUsedGB }, + memTotal: { GB: memTotalGB }, + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79ca9d3d0..b942d7fb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + proxmox-api>undici: 7.2.3 + patchedDependencies: pretty-print-error: hash: 4arrfgbz7em6s4gqywse7esg4u @@ -1113,6 +1116,9 @@ importers: '@jellyfin/sdk': specifier: ^0.11.0 version: 0.11.0(axios@1.7.7) + proxmox-api: + specifier: 1.1.1 + version: 1.1.1 undici: specifier: 7.2.3 version: 7.2.3 @@ -8031,6 +8037,9 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proxmox-api@1.1.1: + resolution: {integrity: sha512-2qH7pxKBBHa7WtEBmxPaBY2FZEH2R04hqr9zD9PmErLzJ7RGGcfNcXoS/v5G4vBM2Igmnx0EAYBstPwwfDwHnA==} + proxy-agent@6.4.0: resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} engines: {node: '>= 14'} @@ -16318,6 +16327,10 @@ snapshots: proto-list@1.2.4: {} + proxmox-api@1.1.1: + dependencies: + undici: 7.2.3 + proxy-agent@6.4.0: dependencies: agent-base: 7.1.1