Skip to content

Commit

Permalink
feat(widget): add proxmox integration (#1969)
Browse files Browse the repository at this point in the history
* feat(widget): add proxmox integration

* fix: broken lock file

* fix: ci issues

* fix: ci issues

* fix: ci issues

* chore: debug temporary

* fix: name is not used correctly for nodes and storage in proxmox

* fix: remove temporary debu logs

* fix: job runs for both cluster and system health and throws error

* fix: ts-expect-error is unnecessary

* fix: remove unused import
  • Loading branch information
Meierschlumpf authored Jan 17, 2025
1 parent a31c6a9 commit 3ed46ae
Show file tree
Hide file tree
Showing 22 changed files with 1,323 additions and 424 deletions.
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,4 +7,6 @@ export const integrationSecretIcons = {
username: IconUser,
apiKey: IconKey,
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
},
"pnpm": {
"allowNonAppliedPatches": true,
"overrides": {
"proxmox-api>undici": "7.2.3"
},
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
}
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/router/integration/integration-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
37 changes: 29 additions & 8 deletions packages/api/src/router/widgets/health-monitoring.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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)[] = [];
Expand All @@ -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<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const unsubscribe = innerHandler.subscribe((healthInfo) => {
emit.next(healthInfo);
});
unsubscribes.push(unsubscribe);
return () => {
unsubscribe();
};
});
}),
});
21 changes: 15 additions & 6 deletions packages/cron-jobs/src/jobs/integrations/health-monitoring.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>) => {
const { kind } = integration;
if (kind !== "proxmox") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
},
}),
{
widgetKinds: ["healthMonitoring"],
getInput: {
healthMonitoring: () => ({}),
},
},
),
);
8 changes: 8 additions & 0 deletions packages/definitions/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const integrationSecretKindObject = {
apiKey: { isPublic: false },
username: { isPublic: true },
password: { isPublic: false },
tokenId: { isPublic: true },
realm: { isPublic: true },
} satisfies Record<string, { isPublic: boolean }>;

export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
Expand Down Expand Up @@ -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<string, integrationDefinition>;

export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <TKind extends keyof typeof integrationCreators>(
Expand Down Expand Up @@ -72,4 +73,5 @@ export const integrationCreators = {
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
125 changes: 125 additions & 0 deletions packages/integrations/src/proxmox/proxmox-integration.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<ComputeResourceBase<string>, "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,
};
};
57 changes: 57 additions & 0 deletions packages/integrations/src/proxmox/proxmox-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
interface ResourceBase<TType extends string> {
type: TType;
name: string;
node: string;
isRunning: boolean;
status: string;
}

export interface ComputeResourceBase<TType extends string> extends ResourceBase<TType> {
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[];
}
1 change: 1 addition & 0 deletions packages/integrations/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
3 changes: 3 additions & 0 deletions packages/old-import/src/widgets/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 3ed46ae

Please sign in to comment.