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 ede750aa7..863ff66ec 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -61,8 +61,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 25e449fea..cc8fef316 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" @@ -383,4 +384,5 @@ export type HomarrDocumentationPath = | "/docs/widgets/beszel-system-stats" | "/docs/widgets/traefik" | "/docs/widgets/custom-api" - | "/docs/widgets/assistant"; + | "/docs/widgets/assistant" + | "/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 e4e596aa7..dd3f02e37 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -56,4 +56,5 @@ export const widgetDocSlugs: Record = { traefik: "traefik", customApi: "custom-api", assistant: "assistant", + 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 f0e659fd0..250cb856f 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( @@ -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 71b0652fc..313ba23fd 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"; @@ -92,6 +93,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 d8b69f4e4..c2b915fab 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1797,6 +1797,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" @@ -2648,6 +2652,38 @@ "unknown": "Unknown" } }, + "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 86720020d..b40a3117c 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -109,4 +109,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 1b2e84982..aee05d02e 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -114,7 +114,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 8f56ee80c..d6565a702 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -65,6 +65,7 @@ import * as video from "./video"; import * as weather from "./weather"; import * as customApi from "./custom-api"; import * as assistant from "./assistant"; +import * as wud from "./wud"; export type { NormalizedWidgetQuery, @@ -146,6 +147,7 @@ export const widgetImports = { traefik, customApi, assistant, + wud, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 6ea3d14ea..3e12ea75b 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -77,6 +77,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 @@ -139,6 +140,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 b571180c7..8f82fd265 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -32,4 +32,5 @@ export const widgetQueryRefetchIntervals = [ { queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null }, { queryKey: [["widget", "umami", "getTopReferrers"]], 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"));