feat(integrations): add PatchMon integration and widget (#6119)
Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
1 parent
174c5f98f8
commit
33395ae226
41 files changed
+2982
-17
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { ParseError, ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { PatchMonStats } from "./patchmon-types";
|
||||
import { mapPatchMonStats, parsePatchMonStatsResponseAsync } from "./patchmon-types";
|
||||
|
||||
const STATS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class PatchMonIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/v1/gethomepage/stats"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
try {
|
||||
await parsePatchMonStatsResponseAsync(response);
|
||||
} catch (error) {
|
||||
if (error instanceof ParseError) {
|
||||
return TestConnectionError.ParseResult(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatsAsync(): Promise<PatchMonStats> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/gethomepage/stats"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
timeout: STATS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = await parsePatchMonStatsResponseAsync(response);
|
||||
|
||||
return mapPatchMonStats(data);
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const credentials = Buffer.from(
|
||||
`${this.getSecretValue("patchmonApiKey")}:${this.getSecretValue("patchmonApiSecret")}`,
|
||||
).toString("base64");
|
||||
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const PATCHMON_STATS_PARSE_ERROR_MESSAGE = "Invalid PatchMon stats response";
|
||||
|
||||
export const patchmonOsDistributionEntrySchema = z.object({
|
||||
name: z.string(),
|
||||
count: z.number(),
|
||||
os_type: z.string().optional(),
|
||||
os_version: z.string().optional(),
|
||||
});
|
||||
|
||||
export const patchmonStatsResponseSchema = z.object({
|
||||
total_hosts: z.number(),
|
||||
hosts_needing_updates: z.number(),
|
||||
security_updates: z.number(),
|
||||
up_to_date_hosts: z.number(),
|
||||
hosts_with_security_updates: z.number(),
|
||||
recent_updates_24h: z.number(),
|
||||
total_outdated_packages: z.number(),
|
||||
total_repos: z.number(),
|
||||
last_updated: z.string(),
|
||||
os_distribution: z.array(patchmonOsDistributionEntrySchema).default([]),
|
||||
});
|
||||
|
||||
export interface PatchMonOsDistributionEntry {
|
||||
name: string;
|
||||
count: number;
|
||||
osType?: string;
|
||||
osVersion?: string;
|
||||
}
|
||||
|
||||
export interface PatchMonStats {
|
||||
totalHosts: number;
|
||||
hostsNeedingUpdates: number;
|
||||
securityUpdates: number;
|
||||
upToDateHosts: number;
|
||||
hostsWithSecurityUpdates: number;
|
||||
recentUpdates24h: number;
|
||||
totalOutdatedPackages: number;
|
||||
totalRepos: number;
|
||||
lastUpdated: string;
|
||||
osDistribution: PatchMonOsDistributionEntry[];
|
||||
}
|
||||
|
||||
const mapOsDistributionEntry = (
|
||||
entry: z.infer<typeof patchmonOsDistributionEntrySchema>,
|
||||
): PatchMonOsDistributionEntry => ({
|
||||
name: entry.name,
|
||||
count: entry.count,
|
||||
osType: entry.os_type,
|
||||
osVersion: entry.os_version,
|
||||
});
|
||||
|
||||
export const parsePatchMonStatsResponseAsync = async (
|
||||
response: { json: () => Promise<unknown> },
|
||||
): Promise<z.infer<typeof patchmonStatsResponseSchema>> => {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (error) {
|
||||
throw new ParseError(PATCHMON_STATS_PARSE_ERROR_MESSAGE, {
|
||||
cause: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
|
||||
const parseResult = await patchmonStatsResponseSchema.safeParseAsync(json);
|
||||
if (!parseResult.success) {
|
||||
throw new ParseError(PATCHMON_STATS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
};
|
||||
|
||||
export const mapPatchMonStats = (data: z.infer<typeof patchmonStatsResponseSchema>): PatchMonStats => ({
|
||||
totalHosts: data.total_hosts,
|
||||
hostsNeedingUpdates: data.hosts_needing_updates,
|
||||
securityUpdates: data.security_updates,
|
||||
upToDateHosts: data.up_to_date_hosts,
|
||||
hostsWithSecurityUpdates: data.hosts_with_security_updates,
|
||||
recentUpdates24h: data.recent_updates_24h,
|
||||
totalOutdatedPackages: data.total_outdated_packages,
|
||||
totalRepos: data.total_repos,
|
||||
lastUpdated: data.last_updated,
|
||||
osDistribution: [...data.os_distribution]
|
||||
.map(mapOsDistributionEntry)
|
||||
.sort((a, b) => b.count - a.count),
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment node
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { PatchMonIntegration } from "../patchmon-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://patchmon.example.com";
|
||||
const TEST_API_KEY = "test-api-key";
|
||||
const TEST_API_SECRET = "test-api-secret";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const sampleStatsResponse = {
|
||||
total_hosts: 42,
|
||||
hosts_needing_updates: 15,
|
||||
security_updates: 23,
|
||||
up_to_date_hosts: 27,
|
||||
hosts_with_security_updates: 8,
|
||||
recent_updates_24h: 34,
|
||||
total_outdated_packages: 156,
|
||||
total_repos: 12,
|
||||
last_updated: "2025-10-11T12:34:56.789Z",
|
||||
os_distribution: [
|
||||
{ name: "Debian", count: 12, os_type: "linux", os_version: "12" },
|
||||
{ name: "Ubuntu", count: 20, os_type: "linux", os_version: "22.04" },
|
||||
{ name: "Rocky Linux", count: 10, os_type: "linux", os_version: "9" },
|
||||
],
|
||||
};
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
|
||||
new PatchMonIntegration({
|
||||
id: "test-patchmon",
|
||||
name: "Test PatchMon",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets:
|
||||
decryptedSecrets.length > 0
|
||||
? decryptedSecrets
|
||||
: [
|
||||
{ kind: "patchmonApiKey", value: TEST_API_KEY },
|
||||
{ kind: "patchmonApiSecret", value: TEST_API_SECRET },
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("PatchMonIntegration getStatsAsync", () => {
|
||||
test("maps API response fields to camelCase stats", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleStatsResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats).toStrictEqual({
|
||||
totalHosts: 42,
|
||||
hostsNeedingUpdates: 15,
|
||||
securityUpdates: 23,
|
||||
upToDateHosts: 27,
|
||||
hostsWithSecurityUpdates: 8,
|
||||
recentUpdates24h: 34,
|
||||
totalOutdatedPackages: 156,
|
||||
totalRepos: 12,
|
||||
lastUpdated: "2025-10-11T12:34:56.789Z",
|
||||
osDistribution: [
|
||||
{ name: "Ubuntu", count: 20, osType: "linux", osVersion: "22.04" },
|
||||
{ name: "Debian", count: 12, osType: "linux", osVersion: "12" },
|
||||
{ name: "Rocky Linux", count: 10, osType: "linux", osVersion: "9" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults os distribution to empty array when absent", async () => {
|
||||
const { os_distribution: _osDistribution, ...responseWithoutOs } = sampleStatsResponse;
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(responseWithoutOs), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
const stats = await createIntegration().getStatsAsync();
|
||||
|
||||
expect(stats.osDistribution).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test("throws ParseError when API response is not valid JSON", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid PatchMon stats response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ParseError when API response does not match schema", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ invalid: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
|
||||
if (!(error instanceof IntegrationParseError)) return false;
|
||||
|
||||
const cause = error.cause;
|
||||
return cause instanceof ParseError && cause.message.includes("Invalid PatchMon stats response");
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when API returns an error", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PatchMonIntegration authentication", () => {
|
||||
test("sends a Basic auth header with api key and secret", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleStatsResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegration().getStatsAsync();
|
||||
|
||||
const expected = `Basic ${Buffer.from(`${TEST_API_KEY}:${TEST_API_SECRET}`).toString("base64")}`;
|
||||
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(String(url)).toContain("/api/v1/gethomepage/stats");
|
||||
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
|
||||
expect(requestInit?.timeout).toBe(10_000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user