From 98bb1aecf8fd4b65b1cd4cad4a6ae24c362f6246 Mon Sep 17 00:00:00 2001 From: Timon <64960609+tiimonn@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:42:23 +0200 Subject: [PATCH] feat(integrations): add What's Up Docker (WUD) integration and widget (#6523) Co-authored-by: Thomas --- CODEOWNERS | 8 + apps/docs/README.md | 4 +- .../integrations/whats-up-docker/index.mdx | 48 ++++ .../integrations/whats-up-docker/index.ts | 9 + .../docs/widgets/whats-up-docker/index.mdx | 35 +++ .../docs/widgets/whats-up-docker/index.ts | 37 +++ docs/README.md | 7 + packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/wud.ts | 25 ++ .../src/docs/homarr-docs-sitemap.ts | 4 +- .../src/docs/integration-doc-slugs.ts | 1 + .../definitions/src/docs/widget-doc-slugs.ts | 1 + packages/definitions/src/integration.ts | 8 + .../definitions/src/widget-integration-map.ts | 5 +- packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 2 + .../src/wud/test/wud-integration.spec.ts | 269 ++++++++++++++++++ .../integrations/src/wud/wud-integration.ts | 68 +++++ packages/integrations/src/wud/wud-types.ts | 81 ++++++ .../request-handler/src/health-monitoring.ts | 2 +- packages/request-handler/src/wud.ts | 11 + packages/translation/src/lang/en.json | 36 +++ packages/widgets/src/catalog.ts | 1 + .../widgets/src/health-monitoring/index.ts | 4 +- packages/widgets/src/index.tsx | 2 + packages/widgets/src/manifest.ts | 2 + packages/widgets/src/refetch-intervals.ts | 1 + packages/widgets/src/wud/component.module.css | 7 + packages/widgets/src/wud/component.tsx | 222 +++++++++++++++ packages/widgets/src/wud/index.ts | 24 ++ 31 files changed, 922 insertions(+), 6 deletions(-) create mode 100644 apps/docs/docs/integrations/whats-up-docker/index.mdx create mode 100644 apps/docs/docs/integrations/whats-up-docker/index.ts create mode 100644 apps/docs/docs/widgets/whats-up-docker/index.mdx create mode 100644 apps/docs/docs/widgets/whats-up-docker/index.ts create mode 100644 packages/api/src/router/widgets/wud.ts create mode 100644 packages/integrations/src/wud/test/wud-integration.spec.ts create mode 100644 packages/integrations/src/wud/wud-integration.ts create mode 100644 packages/integrations/src/wud/wud-types.ts create mode 100644 packages/request-handler/src/wud.ts create mode 100644 packages/widgets/src/wud/component.module.css create mode 100644 packages/widgets/src/wud/component.tsx create mode 100644 packages/widgets/src/wud/index.ts diff --git a/CODEOWNERS b/CODEOWNERS index 7082b9bac..5565a85fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw apps/docs/docs/integrations/uptime-kuma @ajnart packages/integrations/src/uptime-kuma @ajnart +# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn +apps/docs/docs/integrations/whats-up-docker @tiimonn +apps/docs/docs/widgets/whats-up-docker @tiimonn +packages/api/src/router/widgets/wud.ts @tiimonn +packages/integrations/src/wud @tiimonn +packages/request-handler/src/wud.ts @tiimonn +packages/widgets/src/wud @tiimonn + # Traefik - by @Nerdy-Technician apps/docs/docs/integrations/traefik @Nerdy-Technician apps/docs/docs/widgets/traefik @Nerdy-Technician diff --git a/apps/docs/README.md b/apps/docs/README.md index b66530488..118c04aef 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -55,8 +55,8 @@ pnpm typecheck docs/ ├── getting-started/ # Installation guides, glossary, prerequisites ├── management/ # Boards, apps, users, integrations, settings, tasks, API -├── integrations/ # 55 integration guides (one folder each) -├── widgets/ # 42 widget guides (one folder each) +├── integrations/ # 56 integration guides (one folder each) +├── widgets/ # 43 widget guides (one folder each) ├── advanced/ # SSO, env vars, proxy, styling, CLI, development └── community/ # FAQ, donate, license, translations, get-in-touch ``` diff --git a/apps/docs/docs/integrations/whats-up-docker/index.mdx b/apps/docs/docs/integrations/whats-up-docker/index.mdx new file mode 100644 index 000000000..31a0127f5 --- /dev/null +++ b/apps/docs/docs/integrations/whats-up-docker/index.mdx @@ -0,0 +1,48 @@ +--- +title: "What's Up Docker" +description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available." +hide_title: true +--- + +import { IntegrationHeader } from "@site/src/components/integrations/header"; +import { IntegrationCapabilites } from "@site/src/components/integrations/widgets"; +import { AddingIntegration } from "@site/src/components/integrations/adding"; +import { IntegrationSecrets } from "@site/src/components/integrations/secrets"; +import { wudIntegration } from "."; +import { wudWidget } from "@site/docs/widgets/whats-up-docker"; + + + +The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard. + +### Widgets & Capabilities + + + +### Adding the integration + + + +:::info URL configuration +Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically. +::: + +### Secrets + +WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password. + + diff --git a/apps/docs/docs/integrations/whats-up-docker/index.ts b/apps/docs/docs/integrations/whats-up-docker/index.ts new file mode 100644 index 000000000..a69dcafa4 --- /dev/null +++ b/apps/docs/docs/integrations/whats-up-docker/index.ts @@ -0,0 +1,9 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const wudIntegration: IntegrationDefinition = { + name: "What's Up Docker", + description: + "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg", + path: "../../integrations/whats-up-docker", +}; diff --git a/apps/docs/docs/widgets/whats-up-docker/index.mdx b/apps/docs/docs/widgets/whats-up-docker/index.mdx new file mode 100644 index 000000000..0786be36b --- /dev/null +++ b/apps/docs/docs/widgets/whats-up-docker/index.mdx @@ -0,0 +1,35 @@ +--- +title: "What's Up Docker" +description: "Displays how many watched containers have image updates available." +hide_title: true +--- + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { wudWidget } from "."; +import { wudIntegration } from "@site/docs/integrations/whats-up-docker"; + + + +This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one. + +### Supported Integrations + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/whats-up-docker/index.ts b/apps/docs/docs/widgets/whats-up-docker/index.ts new file mode 100644 index 000000000..80a516328 --- /dev/null +++ b/apps/docs/docs/widgets/whats-up-docker/index.ts @@ -0,0 +1,37 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconBrandDocker } from "@tabler/icons-react"; + +export const wudWidget: WidgetDefinition = { + icon: IconBrandDocker, + name: "What's Up Docker", + description: "Displays how many watched containers have image updates available.", + path: "../../widgets/whats-up-docker", + configuration: { + items: [ + { + name: "Show title", + description: "Displays the icon and \"What's Up Docker\" label above the stats", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show update ring", + description: "Displays a ring showing the percentage of monitored containers with a pending update", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Layout", + description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)", + values: { type: "select", options: ["Horizontal", "Vertical"] }, + defaultValue: "Horizontal", + }, + { + name: "Show container update list", + description: "Lists each monitored container that has an image update available, linking to its release notes when known", + values: { type: "boolean" }, + defaultValue: "no", + }, + ], + }, +}; diff --git a/docs/README.md b/docs/README.md index 6f281b0f3..6dc6b6ff5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -429,6 +429,13 @@

Uptime
Kuma

+ + + + What's Up Docker +
+

What's
Up
Docker

+
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 5db62a93c..ba5987463 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -44,4 +44,5 @@ export const widgetRouter = createTRPCRouter({ traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)), + wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)), }); diff --git a/packages/api/src/router/widgets/wud.ts b/packages/api/src/router/widgets/wud.ts new file mode 100644 index 000000000..4b510e660 --- /dev/null +++ b/packages/api/src/router/widgets/wud.ts @@ -0,0 +1,25 @@ +import { wudStatsRequestHandler } from "@homarr/request-handler/wud"; + +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const wudRouter = createTRPCRouter({ + getStats: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.", + }, + }) + .concat(createOneIntegrationMiddleware("query", "wud")) + .query(async ({ ctx }) => { + const handler = wudStatsRequestHandler.handler(ctx.integration, {}); + const { data, timestamp } = await handler.getDataAsync(); + + return { + stats: data, + updatedAt: timestamp, + }; + }), +}); diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 7185a2fdd..292a6cd90 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -330,6 +330,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/peanut" | "/docs/integrations/beszel" | "/docs/integrations/traefik" + | "/docs/integrations/whats-up-docker" | "/docs/widgets/clock" | "/docs/widgets/weather" | "/docs/widgets/app" @@ -382,4 +383,5 @@ export type HomarrDocumentationPath = | "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-system-stats" | "/docs/widgets/traefik" - | "/docs/widgets/custom-api"; + | "/docs/widgets/custom-api" + | "/docs/widgets/whats-up-docker"; diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 41ea8eb33..e03c20034 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -54,5 +54,6 @@ export const integrationDocSlugs: Record = { beszel: "beszel", traefik: "traefik", archiveTeamWarrior: null, + wud: "whats-up-docker", mock: null, }; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 8e51bbddb..cf72dbda4 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -55,4 +55,5 @@ export const widgetDocSlugs: Record = { beszelSystemStats: "beszel-system-stats", traefik: "traefik", customApi: "custom-api", + wud: "whats-up-docker", }; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index e2772fb94..5450c9bda 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -468,6 +468,14 @@ export const integrationDefs = { category: ["archiving"], documentationUrl: null, }, + wud: { + name: "What's Up Docker", + secretKinds: [[], ["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg", + category: ["healthMonitoring"], + documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"), + defaultPort: 3000, + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 6a6fb32c6..beb6d0f02 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -24,7 +24,9 @@ export const widgetIntegrationSupport: Partial kind !== "patchmon"), + healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter( + (kind) => kind !== "patchmon" && kind !== "wud", + ), firewall: getIntegrationKindsByCategory("firewall"), notifications: getIntegrationKindsByCategory("notifications"), mediaReleases: ["mock", "emby", "jellyfin", "plex"], @@ -50,6 +52,7 @@ export const widgetIntegrationSupport: Partial { diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index c5c050b83..e95dbca77 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -53,6 +53,7 @@ export const widgetKinds = [ "beszelSystemStats", "traefik", "customApi", + "wud", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 8a9dc8b5f..9421e043d 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration"; import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnraidIntegration } from "../unraid/unraid-integration"; +import { WudIntegration } from "../wud/wud-integration"; import type { Integration, IntegrationInput } from "./integration"; export const createIntegrationAsync = async ( @@ -133,6 +134,7 @@ export const integrationCreators = { beszel: BeszelIntegration, bazarr: BazarrIntegration, traefik: TraefikIntegration, + wud: WudIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index f172cec36..96482c908 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,5 +1,6 @@ // General integrations export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration"; +export { WudIntegration } from "./wud/wud-integration"; export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; export { AnchorIntegration } from "./anchor/anchor-integration"; @@ -91,6 +92,7 @@ export type { ArchiveTeamWarriorItem, ArchiveTeamWarriorStatus, } from "./archive-team-warrior/archive-team-warrior-types"; +export type { WudStats, WudContainerUpdate } from "./wud/wud-types"; // Schemas export { anchorNotesListInputSchema } from "./anchor/anchor-types"; diff --git a/packages/integrations/src/wud/test/wud-integration.spec.ts b/packages/integrations/src/wud/test/wud-integration.spec.ts new file mode 100644 index 000000000..902707fd8 --- /dev/null +++ b/packages/integrations/src/wud/test/wud-integration.spec.ts @@ -0,0 +1,269 @@ +// @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 type { IntegrationTestingInput } from "../../base/integration"; +import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error"; +import type { IntegrationSecret } from "../../base/types"; +import { WudIntegration } from "../wud-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const TEST_URL = "https://wud.example.com"; +const TEST_USERNAME = "user"; +const TEST_PASSWORD = "p@ss"; + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const sampleContainersResponse = [ + { + id: "1", + name: "homeassistant", + displayName: "Home Assistant", + updateAvailable: true, + link: "https://github.com/home-assistant/core/releases/tag/2024.1.0", + image: { tag: { value: "2023.12.0" } }, + updateKind: { remoteValue: "2024.1.0" }, + }, + { id: "2", name: "traefik", updateAvailable: false }, + { + id: "3", + name: "grafana", + updateAvailable: true, + image: { tag: { value: "10.0.0" } }, + result: { tag: "10.1.0" }, + }, +]; + +const sampleUpdates = [ + { + id: "1", + name: "Home Assistant", + currentVersion: "2023.12.0", + newVersion: "2024.1.0", + link: "https://github.com/home-assistant/core/releases/tag/2024.1.0", + }, + { + id: "3", + name: "grafana", + currentVersion: "10.0.0", + newVersion: "10.1.0", + link: null, + }, +]; + +const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) => + new WudIntegration({ + id: "test-wud", + name: "Test WUD", + url: TEST_URL, + externalUrl: null, + decryptedSecrets, + }); + +const createIntegrationWithBasicAuth = () => + createIntegration([ + { kind: "username", value: TEST_USERNAME }, + { kind: "password", value: TEST_PASSWORD }, + ]); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("WudIntegration getStatsAsync", () => { + test("computes total and available-update counts from the containers list", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const stats = await createIntegration().getStatsAsync(); + + expect(stats).toStrictEqual({ totalContainers: 3, updatesAvailable: 2, updates: sampleUpdates }); + }); + + test("returns zero counts for an empty containers list", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const stats = await createIntegration().getStatsAsync(); + + expect(stats).toStrictEqual({ totalContainers: 0, updatesAvailable: 0, updates: [] }); + }); + + test("maps container updates using displayName, falling back to result.tag and null link", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const stats = await createIntegration().getStatsAsync(); + + expect(stats.updates).toStrictEqual(sampleUpdates); + }); + + 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>, + ); + + 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 WUD containers 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>, + ); + + 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 WUD containers 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>, + ); + + await expect(createIntegration().getStatsAsync()).rejects.toThrow(); + }); +}); + +describe("WudIntegration authentication", () => { + test("sends no Authorization header when no credentials are configured", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + await createIntegration().getStatsAsync(); + + const [, requestInit] = mockFetch.mock.calls[0] ?? []; + const headers = (requestInit?.headers ?? {}) as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + test("sends a Basic auth header when username and password are configured", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + await createIntegrationWithBasicAuth().getStatsAsync(); + + const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`; + const [url, requestInit] = mockFetch.mock.calls[0] ?? []; + expect(String(url)).toContain("/api/containers"); + expect(requestInit?.headers).toMatchObject({ Authorization: expected }); + expect(requestInit?.timeout).toBe(10_000); + }); + + test("does not send credentials over a non-HTTPS URL", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify(sampleContainersResponse), { + status: 200, + headers: { "content-type": "application/json" }, + }) as unknown as Awaited>, + ); + + const integration = new WudIntegration({ + id: "test-wud-http", + name: "Test WUD HTTP", + url: "http://wud.example.com", + externalUrl: null, + decryptedSecrets: [ + { kind: "username", value: TEST_USERNAME }, + { kind: "password", value: TEST_PASSWORD }, + ], + }); + + await integration.getStatsAsync(); + + const [, requestInit] = mockFetch.mock.calls[0] ?? []; + const headers = (requestInit?.headers ?? {}) as Record; + expect(headers.Authorization).toBeUndefined(); + }); +}); + +describe("WudIntegration testing endpoint", () => { + type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>; + + const invokeTesting = (integration: WudIntegration, input: IntegrationTestingInput) => { + const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration); + return testing(input); + }; + + const makeInput = (response: Response): IntegrationTestingInput => + ({ + fetchAsync: vi.fn().mockResolvedValue(response), + }) as unknown as IntegrationTestingInput; + + test("returns success when /api/app responds 200", async () => { + const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 })); + + const result = await invokeTesting(createIntegration(), input); + + expect(result.success).toBe(true); + }); + + test("returns a status-code failure when /api/app responds non-OK", async () => { + const input = makeInput(new Response("unauthorized", { status: 401 })); + + const result = await invokeTesting(createIntegration(), input); + + expect(result.success).toBe(false); + }); + + test("passes the Basic auth header through to fetchAsync when configured", async () => { + const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }); + const input = makeInput(response); + + await invokeTesting(createIntegrationWithBasicAuth(), input); + + const fetchAsyncMock = vi.mocked(input.fetchAsync); + const init = fetchAsyncMock.mock.calls[0]?.[1]; + const headers = (init?.headers ?? {}) as Record; + const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`; + expect(headers.Authorization).toBe(expected); + }); +}); diff --git a/packages/integrations/src/wud/wud-integration.ts b/packages/integrations/src/wud/wud-integration.ts new file mode 100644 index 000000000..d7bdb49eb --- /dev/null +++ b/packages/integrations/src/wud/wud-integration.ts @@ -0,0 +1,68 @@ +import { Buffer } from "node:buffer"; + +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 { WudStats } from "./wud-types"; +import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types"; + +const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000; + +export class WudIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/app"), { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + return TestConnectionError.StatusResult(response); + } + + try { + await response.json(); + } catch (error) { + return TestConnectionError.ParseResult( + new ParseError("Invalid WUD app response", { + cause: error instanceof Error ? error : new Error(String(error)), + }), + ); + } + + return { success: true }; + } + + public async getStatsAsync(): Promise { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/containers"), { + headers: this.getAuthHeaders(), + timeout: CONTAINERS_REQUEST_TIMEOUT_MS, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + const containers = await parseWudContainersResponseAsync(response); + + return mapWudStats(containers); + } + + private getAuthHeaders(): Record | undefined { + if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) { + return undefined; + } + + if (this.url("/api/app").protocol !== "https:") { + return undefined; + } + + const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString( + "base64", + ); + + return { Authorization: `Basic ${credentials}` }; + } +} diff --git a/packages/integrations/src/wud/wud-types.ts b/packages/integrations/src/wud/wud-types.ts new file mode 100644 index 000000000..df6a71a90 --- /dev/null +++ b/packages/integrations/src/wud/wud-types.ts @@ -0,0 +1,81 @@ +import { ParseError } from "@homarr/common/server"; +import { z } from "zod/v4"; + +const WUD_CONTAINERS_PARSE_ERROR_MESSAGE = "Invalid WUD containers response"; + +export const wudContainerSchema = z.object({ + id: z.string(), + name: z.string(), + displayName: z.string().nullable().optional(), + updateAvailable: z.boolean(), + link: z.string().nullable().optional(), + image: z + .object({ + tag: z + .object({ + value: z.string().nullable().optional(), + }) + .optional(), + }) + .optional(), + updateKind: z + .object({ + remoteValue: z.string().nullable().optional(), + }) + .optional(), + result: z + .object({ + tag: z.string().nullable().optional(), + }) + .optional(), +}); + +export const wudContainersResponseSchema = z.array(wudContainerSchema); + +export interface WudContainerUpdate { + id: string; + name: string; + currentVersion: string | null; + newVersion: string | null; + link: string | null; +} + +export interface WudStats { + totalContainers: number; + updatesAvailable: number; + updates: WudContainerUpdate[]; +} + +export const parseWudContainersResponseAsync = async (response: { + json: () => Promise; +}): Promise> => { + let json: unknown; + try { + json = await response.json(); + } catch (error) { + throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + + const parseResult = await wudContainersResponseSchema.safeParseAsync(json); + if (!parseResult.success) { + throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { cause: parseResult.error }); + } + + return parseResult.data; +}; + +const mapContainerUpdate = (container: z.infer): WudContainerUpdate => ({ + id: container.id, + name: container.displayName ?? container.name, + currentVersion: container.image?.tag?.value ?? null, + newVersion: container.updateKind?.remoteValue ?? container.result?.tag ?? null, + link: container.link ?? null, +}); + +export const mapWudStats = (containers: z.infer): WudStats => ({ + totalContainers: containers.length, + updatesAvailable: containers.filter((container) => container.updateAvailable).length, + updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate), +}); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 959d3e0ed..463a0b217 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl export const systemInfoRequestHandler = createIntegrationRequestHandler< SystemHealthMonitoring, - Exclude, "proxmox" | "coolify" | "beszel" | "patchmon">, + Exclude, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">, Record >({ async requestAsync(integration, _input) { diff --git a/packages/request-handler/src/wud.ts b/packages/request-handler/src/wud.ts new file mode 100644 index 000000000..03ab11f07 --- /dev/null +++ b/packages/request-handler/src/wud.ts @@ -0,0 +1,11 @@ +import { createIntegrationAsync } from "@homarr/integrations"; +import type { WudStats } from "@homarr/integrations"; + +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; + +export const wudStatsRequestHandler = createIntegrationRequestHandler>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getStatsAsync(); + }, +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0c7b91dd9..84cb28036 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1344,6 +1344,10 @@ "name": "Traefik", "description": "Cloud-native reverse proxy and load balancer" }, + "wud": { + "name": "What's Up Docker", + "description": "Monitor which of your Docker containers have image updates available" + }, "paperlessNgx": { "name": "Paperless-ngx", "description": "Document management system for archiving and organizing scanned documents" @@ -1793,6 +1797,38 @@ } } }, + "wud": { + "name": "What's Up Docker", + "description": "Show how many watched containers have updates available.", + "title": "What's Up Docker", + "monitored": "Monitored", + "updatesAvailable": "{count, plural, one {# update available} other {# updates available}}", + "updateAvailable": "Update available", + "openReleaseNotes": "Open release notes", + "option": { + "showTitle": { + "label": "Show title" + }, + "showRing": { + "label": "Show update ring" + }, + "layout": { + "label": "Layout", + "option": { + "horizontal": { + "label": "Horizontal" + }, + "vertical": { + "label": "Vertical" + } + } + }, + "showUpdateList": { + "label": "Show container update list", + "description": "Lists each monitored container that has an image update available" + } + } + }, "dnsHoleSummary": { "name": "DNS Hole Summary", "description": "Displays the summary of your DNS Hole", diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index 7951dbf7d..a8a185310 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -107,4 +107,5 @@ export const widgetCatalogIcons: Record = { beszelSystemStats: IconChartAreaLine, traefik: IconRoute, customApi: IconApi, + wud: IconBrandDocker, }; diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index a8dedc5c3..022afa60d 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -113,7 +113,9 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon }, ); }, - supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"), + supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter( + (kind) => kind !== "patchmon" && kind !== "wud", + ), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 061fc4b2d..36a9cbbfc 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -64,6 +64,7 @@ import * as beszelSystemStats from "./beszel-system-stats"; import * as video from "./video"; import * as weather from "./weather"; import * as customApi from "./custom-api"; +import * as wud from "./wud"; export type { WidgetDefinition, @@ -129,6 +130,7 @@ export const widgetImports = { beszelSystemStats, traefik, customApi, + wud, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 4bb2cdf2d..3fd61a0df 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -76,6 +76,7 @@ const moduleLoaders: Record Promise> = { beszelSystemStats: () => import("./beszel-system-stats") as Promise, traefik: () => import("./traefik") as Promise, customApi: () => import("./custom-api") as Promise, + wud: () => import("./wud") as Promise, }; // Keep these imports explicit so Turbopack can create one discoverable chunk per @@ -137,6 +138,7 @@ const componentLoaders: Record Promise> beszelSystemStats: () => import("./beszel-system-stats/component") as Promise, traefik: () => import("./traefik/component") as Promise, customApi: () => import("./custom-api/component") as Promise, + wud: () => import("./wud/component") as Promise, }; const definitionPromises = new Map>(); diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index fcb1ffcd4..b1bf0e410 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -26,4 +26,5 @@ export const widgetQueryRefetchIntervals = [ { queryKey: [["widget", "stockPrice"]], intervalSeconds: null }, { queryKey: [["widget", "umami"]], intervalSeconds: null }, { queryKey: [["widget", "vpn"]], intervalSeconds: null }, + { queryKey: [["widget", "wud"]], intervalSeconds: null }, ] as const; diff --git a/packages/widgets/src/wud/component.module.css b/packages/widgets/src/wud/component.module.css new file mode 100644 index 000000000..1ab18a656 --- /dev/null +++ b/packages/widgets/src/wud/component.module.css @@ -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); +} diff --git a/packages/widgets/src/wud/component.tsx b/packages/widgets/src/wud/component.tsx new file mode 100644 index 000000000..0d2f6744a --- /dev/null +++ b/packages/widgets/src/wud/component.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { ActionIcon, Avatar, Badge, Card, Center, Group, RingProgress, Stack, Text, Tooltip } from "@mantine/core"; +import { IconBrandDocker, IconExternalLink, IconServer } from "@tabler/icons-react"; +import combineClasses from "clsx"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { getIconUrl } from "@homarr/definitions"; +import type { WudContainerUpdate } from "@homarr/integrations"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.css"; + +export default function WudWidget({ integrationIds, options, width }: WidgetComponentProps<"wud">) { + const integrationId = integrationIds[0]; + if (!integrationId) return null; + return ; +} + +const WudWidgetContent = ({ + integrationId, + options, + width, +}: { + integrationId: string; + options: WidgetComponentProps<"wud">["options"]; + width: number; +}) => { + const t = useScopedI18n("widget.wud"); + const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId }); + const board = useRequiredBoard(); + + const isTiny = width < 256; + const stats = data.stats; + + if (stats.totalContainers === 0) return ; + + const updatePercentage = Math.round((stats.updatesAvailable / stats.totalContainers) * 100); + const badgeColor = stats.updatesAvailable === 0 ? "green" : progressColor(updatePercentage); + const showUpdateList = options.showUpdateList && stats.updates.length > 0; + + const ring = ( + + + {updatePercentage}% + + + + } + sections={[{ value: updatePercentage, color: progressColor(updatePercentage) }]} + /> + ); + + const summary = ( + + + + + {stats.totalContainers} {t("monitored")} + + + + {t("updatesAvailable", { count: stats.updatesAvailable })} + + + ); + + const tinyContent = options.showRing ? ( + + + {stats.totalContainers} {t("monitored")} + + {t("updatesAvailable", { count: stats.updatesAvailable })} + + } + events={{ hover: true, focus: true, touch: false }} + > +
+ {ring} +
+
+ ) : ( +
+ + {t("updatesAvailable", { count: stats.updatesAvailable })} + +
+ ); + + return ( + + {options.showTitle && !isTiny && ( + + + + + {t("title")} + + + {stats.updatesAvailable > 0 && ( + + {stats.updatesAvailable} + + )} + + )} + + {isTiny ? ( + tinyContent + ) : options.layout === "horizontal" ? ( + + {options.showRing && ring} + {summary} + + ) : ( + + {options.showRing && ring} + {summary} + + )} + + {showUpdateList && ( + + {stats.updates.map((update) => ( + + ))} + + )} + + ); +}; + +const progressColor = (percentage: number) => { + if (percentage < 40) return "green"; + else if (percentage < 60) return "yellow"; + else if (percentage < 90) return "orange"; + else return "red"; +}; + +const SHA256_DIGEST_PREFIX = "sha256:"; +const SHA256_DIGEST_LENGTH = SHA256_DIGEST_PREFIX.length + 64; +const GENERIC_MAX_LENGTH = 20; +const GENERIC_DISPLAY_LENGTH = 17; + +const isDigestVersion = (value: string | null): boolean => + value !== null && value.startsWith(SHA256_DIGEST_PREFIX) && value.length === SHA256_DIGEST_LENGTH; + +const truncateVersion = (value: string | null): string | null => { + if (!value) return value; + + if (value.length > GENERIC_MAX_LENGTH) { + return `${value.slice(0, GENERIC_DISPLAY_LENGTH)}…`; + } + + return value; +}; + +const buildVersionText = (currentVersion: string | null, newVersion: string | null) => + currentVersion && newVersion ? `${currentVersion} → ${newVersion}` : newVersion; + +const UpdateCard = ({ + update, + radius, + className, +}: { + update: WudContainerUpdate; + radius: string | undefined; + className: string | undefined; +}) => { + const t = useScopedI18n("widget.wud"); + const isDigestUpdate = isDigestVersion(update.newVersion); + const fullVersionText = buildVersionText(update.currentVersion, update.newVersion); + const versionText = isDigestUpdate + ? t("updateAvailable") + : buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion)); + + return ( + + + + {update.name} + + + {versionText && ( + + + {versionText} + + + )} + {update.link && ( + + + + )} + + + + ); +}; diff --git a/packages/widgets/src/wud/index.ts b/packages/widgets/src/wud/index.ts new file mode 100644 index 000000000..f7052adba --- /dev/null +++ b/packages/widgets/src/wud/index.ts @@ -0,0 +1,24 @@ +import { IconBrandDocker } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("wud", { + icon: IconBrandDocker, + refetchInterval: null, + supportedIntegrations: ["wud"], + createOptions() { + return optionsBuilder.from((factory) => ({ + showTitle: factory.switch({ defaultValue: true }), + showRing: factory.switch({ defaultValue: true }), + layout: factory.select({ + options: (["horizontal", "vertical"] as const).map((value) => ({ + value, + label: (t) => t(`widget.wud.option.layout.option.${value}.label`), + })), + defaultValue: "horizontal", + }), + showUpdateList: factory.switch({ defaultValue: false, withDescription: true }), + })); + }, +}).withDynamicImport(() => import("./component"));