Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ef1b313d | ||
|
|
98bb1aecf8 |
No files matched your search
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
```
|
||||
|
||||
@@ -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";
|
||||
|
||||
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
|
||||
|
||||
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
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::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.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
|
||||
"Enter that username and password in Homarr — Basic authentication is handled automatically",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
|
||||
|
||||
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
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: wudIntegration,
|
||||
note: "Displays monitored container and available-update counts",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={wudWidget.configuration} />
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -429,6 +429,13 @@
|
||||
<br/>
|
||||
<p align="center">Uptime<br/>Kuma</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">What's<br/>Up<br/>Docker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -210,6 +210,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/unifi-controller"
|
||||
| "/docs/integrations/unraid"
|
||||
| "/docs/integrations/uptime-kuma"
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/management/api"
|
||||
| "/docs/management/apps"
|
||||
| "/docs/management/backup"
|
||||
@@ -276,6 +277,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/whats-up-docker"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
| "/docs/integrations/sabnzbd"
|
||||
@@ -330,6 +332,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 +385,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";
|
||||
@@ -54,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
wud: "whats-up-docker",
|
||||
mock: null,
|
||||
};
|
||||
@@ -55,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -24,7 +24,9 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => 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<Record<WidgetKind, readonly Integ
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
traefik: ["traefik"],
|
||||
wud: ["wud"],
|
||||
};
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
|
||||
@@ -53,6 +53,7 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"wud",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -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 <TKind extends keyof typeof integrationCreators>(
|
||||
@@ -133,6 +134,7 @@ export const integrationCreators = {
|
||||
beszel: BeszelIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
wud: WudIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
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<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 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<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 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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
await createIntegration().getStatsAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||
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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
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<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
|
||||
);
|
||||
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -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<TestingResult> {
|
||||
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<WudStats> {
|
||||
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<string, string> | 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}` };
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
}): Promise<z.infer<typeof wudContainersResponseSchema>> => {
|
||||
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<typeof wudContainerSchema>): 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<typeof wudContainersResponseSchema>): WudStats => ({
|
||||
totalContainers: containers.length,
|
||||
updatesAvailable: containers.filter((container) => container.updateAvailable).length,
|
||||
updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate),
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
|
||||
|
||||
export const systemInfoRequestHandler = createIntegrationRequestHandler<
|
||||
SystemHealthMonitoring,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">,
|
||||
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
|
||||
@@ -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<WudStats, "wud", Record<string, never>>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatsAsync();
|
||||
},
|
||||
});
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "点击时触发请求的按钮",
|
||||
"customJsx": "使用白名单 Mantine 组件的自定义 JSX 布局,支持完整 API 数据绑定"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "删除",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "空",
|
||||
"bearer": "Bearer 令牌",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "crwdns5493:0crwdne5493:0",
|
||||
"customJsx": "crwdns5495:0crwdne5495:0"
|
||||
},
|
||||
"method": {
|
||||
"GET": "crwdns5497:0crwdne5497:0",
|
||||
"POST": "crwdns5499:0crwdne5499:0",
|
||||
"PUT": "crwdns5501:0crwdne5501:0",
|
||||
"DELETE": "crwdns5503:0crwdne5503:0",
|
||||
"PATCH": "crwdns5505:0crwdne5505:0"
|
||||
},
|
||||
"authType": {
|
||||
"none": "crwdns5507:0crwdne5507:0",
|
||||
"bearer": "crwdns5509:0crwdne5509:0",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Eine Schaltfläche welche eine Anfrage auslöst wenn sie angeklickt wird",
|
||||
"customJsx": "Benutzerdefiniertes JSX-Layout mit Whitelist Mantine Komponenten und vollständiger API-Datenbindung"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Keine",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "A button that triggers a request when clicked",
|
||||
"customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "None",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "A button that triggers a request when clicked",
|
||||
"customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "None",
|
||||
"bearer": "Bearer Token",
|
||||
@@ -1337,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"
|
||||
@@ -1786,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",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Painike, joka laukaisee pyynnön napsautettaessa",
|
||||
"customJsx": "Mukautettu JSX-asettelu käyttäen sallittuja Mantine-komponentteja täydellä API-tietojen sidonnalla"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Ei mitään",
|
||||
"bearer": "Bearer-tunniste",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Un bouton qui déclenche une requête lorsqu'elle est cliquée",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Aucun",
|
||||
"bearer": "Jeton Bearer",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Egy gomb, ami kérés végrehajtását váltja ki kattintásra",
|
||||
"customJsx": "Egyéni JSX elrendezés engedélyezett Mantine komponensekkel és teljes API adatkötéssel"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Nincs",
|
||||
"bearer": "Bearer token",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Un pulsante che attiva una richiesta al clic",
|
||||
"customJsx": "Layout JSX personalizzato che usa componenti Mantine consentiti con binding completo dei dati API"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Nessuno",
|
||||
"bearer": "Token Bearer",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "클릭 시 요청을 트리거하는 버튼",
|
||||
"customJsx": "전체 API 데이터 바인딩과 함께 허용된 Mantine 구성 요소를 사용하는 사용자 정의 JSX 레이아웃"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "없음",
|
||||
"bearer": "Bearer 토큰",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Przycisk, który wywołuje zapytanie po kliknięciu",
|
||||
"customJsx": "Własny układ JSX za pomocą komponentów Mantine z białej listy z pełnym powiązaniem danych API"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "None",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Um botão que aciona uma solicitação quando clicado",
|
||||
"customJsx": "Layout JSX personalizado usando componentes Mantine na lista branca com ligação completa de dados de API"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Nenhum",
|
||||
"bearer": "Token Bearer",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Tlačidlo, ktoré po kliknutí spustí požiadavku",
|
||||
"customJsx": "Vlastné rozloženie JSX pomocou whitelistovaných komponentov Mantine s plnou väzbou API dát"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Žiadna",
|
||||
"bearer": "Bearer token",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Tıklandığında istek başlatan bir düğme",
|
||||
"customJsx": "Tam API veri bağlamasıyla izin verilen Mantine bileşenlerini kullanan özel JSX düzeni"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Yok",
|
||||
"bearer": "Bearer Token",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "Кнопка яка виконує запит при натисканні",
|
||||
"customJsx": "Користувацький макет JSX з пов'язування повним API даних з білого списку"
|
||||
},
|
||||
"method": {
|
||||
"GET": "ОТРИМАТИ",
|
||||
"POST": "СТВОРИТИ",
|
||||
"PUT": "Розміщення",
|
||||
"DELETE": "ВИДАЛИТИ",
|
||||
"PATCH": "ПАТЧ"
|
||||
},
|
||||
"authType": {
|
||||
"none": "Немає",
|
||||
"bearer": "Пред'явити токен",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "",
|
||||
"customJsx": ""
|
||||
},
|
||||
"method": {
|
||||
"GET": "",
|
||||
"POST": "",
|
||||
"PUT": "",
|
||||
"DELETE": "",
|
||||
"PATCH": ""
|
||||
},
|
||||
"authType": {
|
||||
"none": "",
|
||||
"bearer": "",
|
||||
|
||||
@@ -824,6 +824,13 @@
|
||||
"actionButton": "點擊時觸發請求的按鈕",
|
||||
"customJsx": "使用白名單 Mantine 組件的自訂 JSX 佈局,具有完整的 API 資料綁定"
|
||||
},
|
||||
"method": {
|
||||
"GET": "GET",
|
||||
"POST": "POST",
|
||||
"PUT": "PUT",
|
||||
"DELETE": "DELETE",
|
||||
"PATCH": "PATCH"
|
||||
},
|
||||
"authType": {
|
||||
"none": "無",
|
||||
"bearer": "Bearer 令牌",
|
||||
|
||||
@@ -107,4 +107,5 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
beszelSystemStats: IconChartAreaLine,
|
||||
traefik: IconRoute,
|
||||
customApi: IconApi,
|
||||
wud: IconBrandDocker,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -76,6 +76,7 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
beszelSystemStats: () => import("./beszel-system-stats") as Promise<WidgetModule>,
|
||||
traefik: () => import("./traefik") as Promise<WidgetModule>,
|
||||
customApi: () => import("./custom-api") as Promise<WidgetModule>,
|
||||
wud: () => import("./wud") as Promise<WidgetModule>,
|
||||
};
|
||||
|
||||
// Keep these imports explicit so Turbopack can create one discoverable chunk per
|
||||
@@ -137,6 +138,7 @@ const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>>
|
||||
beszelSystemStats: () => import("./beszel-system-stats/component") as Promise<WidgetComponentModule>,
|
||||
traefik: () => import("./traefik/component") as Promise<WidgetComponentModule>,
|
||||
customApi: () => import("./custom-api/component") as Promise<WidgetComponentModule>,
|
||||
wud: () => import("./wud/component") as Promise<WidgetComponentModule>,
|
||||
};
|
||||
|
||||
const definitionPromises = new Map<WidgetKind, Promise<WidgetDefinition>>();
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <WudWidgetContent integrationId={integrationId} options={options} width={width} />;
|
||||
}
|
||||
|
||||
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 <WidgetEmptyState />;
|
||||
|
||||
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 = (
|
||||
<RingProgress
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={isTiny ? "8px" : "xs"} fw={700}>
|
||||
{updatePercentage}%
|
||||
</Text>
|
||||
<IconBrandDocker size={isTiny ? 8 : 16} />
|
||||
</Center>
|
||||
}
|
||||
sections={[{ value: updatePercentage, color: progressColor(updatePercentage) }]}
|
||||
/>
|
||||
);
|
||||
|
||||
const summary = (
|
||||
<Stack gap={4} align={options.layout === "vertical" ? "center" : "flex-start"}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconServer size="1rem" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.totalContainers} {t("monitored")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color={badgeColor} variant="light">
|
||||
{t("updatesAvailable", { count: stats.updatesAvailable })}
|
||||
</Badge>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const tinyContent = options.showRing ? (
|
||||
<Tooltip
|
||||
label={
|
||||
<Stack gap={2}>
|
||||
<Text size="xs">
|
||||
{stats.totalContainers} {t("monitored")}
|
||||
</Text>
|
||||
<Text size="xs">{t("updatesAvailable", { count: stats.updatesAvailable })}</Text>
|
||||
</Stack>
|
||||
}
|
||||
events={{ hover: true, focus: true, touch: false }}
|
||||
>
|
||||
<Center
|
||||
tabIndex={0}
|
||||
aria-label={`${stats.totalContainers} ${t("monitored")}, ${t("updatesAvailable", { count: stats.updatesAvailable })}`}
|
||||
>
|
||||
{ring}
|
||||
</Center>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Center>
|
||||
<Badge color={badgeColor} variant="light">
|
||||
{t("updatesAvailable", { count: stats.updatesAvailable })}
|
||||
</Badge>
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%">
|
||||
{options.showTitle && !isTiny && (
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Avatar size={20} radius="sm" src={getIconUrl("wud")} />
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
</Group>
|
||||
{stats.updatesAvailable > 0 && (
|
||||
<Badge color={badgeColor} variant="filled" circle={stats.updatesAvailable < 10}>
|
||||
{stats.updatesAvailable}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{isTiny ? (
|
||||
tinyContent
|
||||
) : options.layout === "horizontal" ? (
|
||||
<Group justify="center" wrap="nowrap" gap="md">
|
||||
{options.showRing && ring}
|
||||
{summary}
|
||||
</Group>
|
||||
) : (
|
||||
<Stack align="center" gap="xs">
|
||||
{options.showRing && ring}
|
||||
{summary}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{showUpdateList && (
|
||||
<Stack gap={4} style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
{stats.updates.map((update) => (
|
||||
<UpdateCard key={update.id} update={update} radius={board.itemRadius} className={classes.card} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card className={combineClasses(className)} radius={radius} p="xs" style={{ overflow: "visible" }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs" miw={0}>
|
||||
<Text size="xs" fw={500} lineClamp={1} miw={0}>
|
||||
{update.name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{versionText && (
|
||||
<Tooltip label={fullVersionText} disabled={isDigestUpdate || versionText === fullVersionText}>
|
||||
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
|
||||
{versionText}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{update.link && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={update.link}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("openReleaseNotes")}
|
||||
>
|
||||
<IconExternalLink size="0.9rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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"));
|
||||
Reference in new issue
Block a user