diff --git a/CODEOWNERS b/CODEOWNERS index fc790a889..addd37e8c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -191,6 +191,14 @@ packages/integrations/src/unraid @manuel-rw apps/docs/docs/integrations/uptime-kuma @ajnart packages/integrations/src/uptime-kuma @ajnart +# Traefik - by @Nerdy-Technician +apps/docs/docs/integrations/traefik @Nerdy-Technician +apps/docs/docs/widgets/traefik @Nerdy-Technician +packages/api/src/router/widgets/traefik.ts @Nerdy-Technician +packages/integrations/src/traefik @Nerdy-Technician +packages/request-handler/src/traefik.ts @Nerdy-Technician +packages/widgets/src/traefik @Nerdy-Technician + # Exempt Renovate-managed files (no owners) package.json **/package.json diff --git a/apps/docs/docs/integrations/traefik/index.mdx b/apps/docs/docs/integrations/traefik/index.mdx new file mode 100644 index 000000000..819ede6c6 --- /dev/null +++ b/apps/docs/docs/integrations/traefik/index.mdx @@ -0,0 +1,57 @@ +--- +title: "Traefik" +description: "Cloud-native reverse proxy and load balancer" +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 { traefikIntegration } from "."; +import { traefikWidget } from "../../widgets/traefik"; + + + +### Widgets & Capabilities + + + +### Adding the integration + + + +:::info URL configuration +Use the URL where Traefik exposes its dashboard/API, for example `http://traefik:8080` or `https://traefik.example.com`. Homarr calls Traefik endpoints under `/api` automatically. +::: + +:::warning API requirement +Traefik must have the API enabled. For local or internal setups this is commonly exposed through `api.insecure=true`; for production setups, expose the `api@internal` service through an authenticated router. +::: + +### Secrets + + diff --git a/apps/docs/docs/integrations/traefik/index.ts b/apps/docs/docs/integrations/traefik/index.ts new file mode 100644 index 000000000..6e7db80a5 --- /dev/null +++ b/apps/docs/docs/integrations/traefik/index.ts @@ -0,0 +1,8 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const traefikIntegration: IntegrationDefinition = { + name: "Traefik", + description: "Cloud-native reverse proxy and load balancer", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg", + path: "../../integrations/traefik", +}; diff --git a/apps/docs/docs/widgets/traefik/index.mdx b/apps/docs/docs/widgets/traefik/index.mdx new file mode 100644 index 000000000..dc9f6a6c4 --- /dev/null +++ b/apps/docs/docs/widgets/traefik/index.mdx @@ -0,0 +1,35 @@ +--- +title: "Traefik" +description: "Displays Traefik router, service, middleware, and entry point counts." +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 { traefikWidget } from "."; +import { traefikIntegration } from "@site/docs/integrations/traefik"; + + + +This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them. + +### Supported Integrations + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/traefik/index.ts b/apps/docs/docs/widgets/traefik/index.ts new file mode 100644 index 000000000..d55216eef --- /dev/null +++ b/apps/docs/docs/widgets/traefik/index.ts @@ -0,0 +1,31 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconRoute } from "@tabler/icons-react"; + +export const traefikWidget: WidgetDefinition = { + icon: IconRoute, + name: "Traefik", + description: "Overview of Traefik routers, services, middlewares, and entry points.", + path: "../../widgets/traefik", + configuration: { + items: [ + { + name: "Show TCP", + description: "Display TCP routers, services, and middlewares.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show UDP", + description: "Display UDP routers and services.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show entry points", + description: "Display the configured Traefik entry point names.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + ], + }, +}; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index dbb81dc49..2388e2812 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -142,7 +142,7 @@ export default async function Layout(props: { - + diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 046f422fe..88aa78d8e 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -38,6 +38,7 @@ export const widgetRouter = createTRPCRouter({ umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), + 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)), }); diff --git a/packages/api/src/router/widgets/traefik.ts b/packages/api/src/router/widgets/traefik.ts new file mode 100644 index 000000000..8f9c775e2 --- /dev/null +++ b/packages/api/src/router/widgets/traefik.ts @@ -0,0 +1,31 @@ +import { traefikRequestHandler } from "@homarr/request-handler/traefik"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const traefikRouter = createTRPCRouter({ + getDashboard: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.", + }, + }) + .concat(createManyIntegrationMiddleware("query", "traefik")) + .query(async ({ ctx }) => { + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = traefikRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }); + }), +}); diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 4ce2bf038..b2665bb91 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -295,13 +295,6 @@ "when": 1780764500000, "tag": "0041_merge_custom_widget_url", "breakpoints": true - }, - { - "idx": 42, - "version": "5", - "when": 1783176100870, - "tag": "0042_add_widget_secret", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json index 829168b9e..495b5e2fb 100644 --- a/packages/db/migrations/postgresql/meta/_journal.json +++ b/packages/db/migrations/postgresql/meta/_journal.json @@ -57,13 +57,6 @@ "when": 1780764500000, "tag": "0007_merge_custom_widget_url", "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1783176100870, - "tag": "0008_add_widget_secret", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index a99a995ce..85ee5fc47 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -281,13 +281,6 @@ "when": 1780764500000, "tag": "0039_merge_custom_widget_url", "breakpoints": true - }, - { - "idx": 40, - "version": "6", - "when": 1783176100870, - "tag": "0040_add_widget_secret", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 344e791fe..bd813adc0 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -319,6 +319,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/gluetun" | "/docs/integrations/peanut" | "/docs/integrations/beszel" + | "/docs/integrations/traefik" | "/docs/widgets/clock" | "/docs/widgets/weather" | "/docs/widgets/app" @@ -367,4 +368,5 @@ export type HomarrDocumentationPath = | "/docs/widgets/beszel-system-grid" | "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-system-stats" + | "/docs/widgets/traefik" | "/docs/widgets/custom-api"; diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 3d8287383..7d7b19e3b 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -50,6 +50,7 @@ export const integrationDocSlugs: Record = { gluetun: "gluetun", peaNut: "peanut", beszel: "beszel", + traefik: "traefik", archiveTeamWarrior: null, mock: null, }; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 58079787a..7863aadeb 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -50,5 +50,6 @@ export const widgetDocSlugs: Record = { beszelSystemGrid: "beszel-system-grid", beszelAlerts: "beszel-alerts", beszelSystemStats: "beszel-system-stats", + traefik: "traefik", customApi: "custom-api", }; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index c15644c2c..0d548f81a 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -433,6 +433,14 @@ export const integrationDefs = { category: ["vpn"], documentationUrl: createDocumentationLink("/docs/integrations/gluetun"), }, + traefik: { + name: "Traefik", + secretKinds: [[], ["username", "password"], ["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg", + category: ["reverseProxy"], + documentationUrl: createDocumentationLink("/docs/integrations/traefik"), + defaultPort: 8080, + }, archiveTeamWarrior: { name: "ArchiveTeam Warrior", secretKinds: [[], ["username", "password"]], @@ -553,6 +561,7 @@ export const integrationCategories = [ "documents", "mediaLibrary", "uptimeMonitoring", + "reverseProxy", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index a69473393..781c37ecd 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -40,6 +40,7 @@ export const widgetIntegrationSupport: Partial { diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index db2028082..f57251971 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -48,6 +48,7 @@ export const widgetKinds = [ "beszelSystemGrid", "beszelAlerts", "beszelSystemStats", + "traefik", "customApi", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 82c722d50..497122572 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -45,6 +45,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; import { SeerrIntegration } from "../seerr/seerr-integration"; import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration"; import { TracearrIntegration } from "../tracearr/tracearr-integration"; +import { TraefikIntegration } from "../traefik/traefik-integration"; import { SynologyIntegration } from "../synology/synology-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration"; import { UmamiIntegration } from "../umami/umami-integration"; @@ -127,6 +128,7 @@ export const integrationCreators = { uptimeKuma: UptimeKumaIntegration, peaNut: PeaNutIntegration, beszel: BeszelIntegration, + traefik: TraefikIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/gotify/gotify-integration.ts b/packages/integrations/src/gotify/gotify-integration.ts index 1d0f5eb8c..829e7abcd 100644 --- a/packages/integrations/src/gotify/gotify-integration.ts +++ b/packages/integrations/src/gotify/gotify-integration.ts @@ -1,12 +1,13 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { ImageProxy } from "@homarr/image-proxy"; import { Integration } from "../base/integration"; import type { IntegrationTestingInput } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { Notification } from "../interfaces/notifications/notification-types"; import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration"; -import { gotifyMessagesResponseSchema } from "./gotify-schema"; +import { gotifyApplicationsResponseSchema, gotifyMessagesResponseSchema } from "./gotify-schema"; export class GotifyIntegration extends Integration implements INotificationsIntegration { public async testingAsync(input: IntegrationTestingInput): Promise { @@ -21,23 +22,90 @@ export class GotifyIntegration extends Integration implements INotificationsInte return { Authorization: `Basic ${credentials}` }; } + private getMessagesUrl(applicationId: number) { + const url = this.externalUrl("/"); + url.hash = `/messages/${applicationId}`; + return url.toString(); + } + + private async getApplicationsByIdAsync(): Promise> { + try { + const response = await fetchWithTrustedCertificatesAsync(this.url("/application"), { + headers: this.getHeaders(), + }); + if (!response.ok) throw new ResponseError(response); + + const result = await gotifyApplicationsResponseSchema.safeParseAsync(await response.json()); + if (!result.success) return new Map(); + + return new Map(result.data.map((application) => [application.id, application])); + } catch { + return new Map(); + } + } + + private async getApplicationIconUrlAsync( + imageProxy: ImageProxy, + imagePath: string | null | undefined, + ): Promise { + const trimmedImagePath = imagePath?.trim(); + if (!trimmedImagePath) return undefined; + + try { + const imageUrl = this.getApplicationImageUrl(trimmedImagePath); + return await imageProxy.createImageAsync(imageUrl, this.getHeaders()); + } catch { + return undefined; + } + } + + private getApplicationImageUrl(imagePath: string) { + try { + return new URL(imagePath).toString(); + } catch { + const path = imagePath.startsWith("/") ? imagePath : `/${imagePath}`; + return this.url(path as `/${string}`).toString(); + } + } + public async getNotificationsAsync(): Promise { - const url = this.url("/message", { limit: 100 }); - const response = await fetchWithTrustedCertificatesAsync(url, { headers: this.getHeaders() }); + const [messagesResponse, applicationsById] = await Promise.all([ + fetchWithTrustedCertificatesAsync(this.url("/message", { limit: 100 }), { headers: this.getHeaders() }), + this.getApplicationsByIdAsync(), + ]); - if (!response.ok) throw new ResponseError(response); + if (!messagesResponse.ok) throw new ResponseError(messagesResponse); - const result = await gotifyMessagesResponseSchema.safeParseAsync(await response.json()); - if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`); - const parsed = result.data; + const messagesResult = await gotifyMessagesResponseSchema.safeParseAsync(await messagesResponse.json()); + if (!messagesResult.success) throw new Error(`Failed to parse Gotify response: ${messagesResult.error.message}`); - return parsed.messages.map( - (message): Notification => ({ + const imageProxy = new ImageProxy(); + const iconUrlsByApplicationId = new Map(); + await Promise.all( + [...applicationsById.entries()].map(async ([applicationId, application]) => { + iconUrlsByApplicationId.set( + applicationId, + await this.getApplicationIconUrlAsync(imageProxy, application.image), + ); + }), + ); + + return messagesResult.data.messages.map((message): Notification => { + const application = applicationsById.get(message.appid); + + return { id: String(message.id), time: new Date(message.date), title: message.title, body: message.message, - }), - ); + href: this.getMessagesUrl(message.appid), + source: application + ? { + name: application.name, + iconUrl: iconUrlsByApplicationId.get(message.appid), + } + : undefined, + }; + }); } } diff --git a/packages/integrations/src/gotify/gotify-schema.ts b/packages/integrations/src/gotify/gotify-schema.ts index 5c89774ec..ca92535b0 100644 --- a/packages/integrations/src/gotify/gotify-schema.ts +++ b/packages/integrations/src/gotify/gotify-schema.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; // See: https://gotify.net/api-docs#/message/getMessages export const gotifyMessageSchema = z.object({ id: z.number(), + appid: z.number(), date: z.string(), title: z.string(), message: z.string(), @@ -12,3 +13,11 @@ export const gotifyMessageSchema = z.object({ export const gotifyMessagesResponseSchema = z.object({ messages: z.array(gotifyMessageSchema), }); + +export const gotifyApplicationSchema = z.object({ + id: z.number(), + name: z.string(), + image: z.string().nullable().optional(), +}); + +export const gotifyApplicationsResponseSchema = z.array(gotifyApplicationSchema); diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 3ad8bd4ec..fe79f2159 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -42,6 +42,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration"; export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration"; export { UmamiIntegration } from "./umami/umami-integration"; export { PeaNutIntegration } from "./peanut/peanut-integration"; +export { TraefikIntegration } from "./traefik/traefik-integration"; // Types export type { IntegrationInput } from "./base/integration"; @@ -81,6 +82,7 @@ export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshel export type { NavidromeDashboardData } from "./navidrome/navidrome-types"; export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types"; export type { UmamiVisitorStats } from "./umami/umami-types"; +export type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "./traefik/traefik-types"; export type { ArchiveTeamWarriorBandwidth, ArchiveTeamWarriorItem, diff --git a/packages/integrations/src/interfaces/notifications/notification-types.ts b/packages/integrations/src/interfaces/notifications/notification-types.ts index 8f6944944..252633e9e 100644 --- a/packages/integrations/src/interfaces/notifications/notification-types.ts +++ b/packages/integrations/src/interfaces/notifications/notification-types.ts @@ -3,4 +3,9 @@ export interface Notification { time: Date; title: string; body: string; + href?: string; + source?: { + name: string; + iconUrl?: string; + }; } diff --git a/packages/integrations/src/traefik/traefik-integration.ts b/packages/integrations/src/traefik/traefik-integration.ts new file mode 100644 index 000000000..0dc8cf57f --- /dev/null +++ b/packages/integrations/src/traefik/traefik-integration.ts @@ -0,0 +1,163 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { + TraefikDashboardData, + TraefikProtocolSummary, + TraefikResource, + TraefikResourceStatus, + TraefikResourceSummary, +} from "./traefik-types"; +import { traefikResourcesSchema, traefikVersionSchema } from "./traefik-types"; + +type ResourcePath = + | "/api/http/routers" + | "/api/http/services" + | "/api/http/middlewares" + | "/api/tcp/routers" + | "/api/tcp/services" + | "/api/tcp/middlewares" + | "/api/udp/routers" + | "/api/udp/services" + | "/api/entrypoints"; + +export class TraefikIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() }); + + if (!response.ok) { + throw new ResponseError(response); + } + + traefikVersionSchema.parse(await response.json()); + return { success: true }; + } + + public async getDashboardDataAsync(): Promise { + const [ + versionResult, + entryPointsResult, + httpRoutersResult, + httpServicesResult, + httpMiddlewaresResult, + tcpRoutersResult, + tcpServicesResult, + tcpMiddlewaresResult, + udpRoutersResult, + udpServicesResult, + ] = await Promise.allSettled([ + this.getVersionAsync(), + this.getResourcesAsync("/api/entrypoints"), + this.getResourcesAsync("/api/http/routers"), + this.getResourcesAsync("/api/http/services"), + this.getResourcesAsync("/api/http/middlewares"), + this.getResourcesAsync("/api/tcp/routers"), + this.getResourcesAsync("/api/tcp/services"), + this.getResourcesAsync("/api/tcp/middlewares"), + this.getResourcesAsync("/api/udp/routers"), + this.getResourcesAsync("/api/udp/services"), + ]); + const version = versionResult.status === "fulfilled" ? versionResult.value : null; + const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : []; + const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : []; + const httpServices = httpServicesResult.status === "fulfilled" ? httpServicesResult.value : []; + const httpMiddlewares = httpMiddlewaresResult.status === "fulfilled" ? httpMiddlewaresResult.value : []; + const tcpRouters = tcpRoutersResult.status === "fulfilled" ? tcpRoutersResult.value : []; + const tcpServices = tcpServicesResult.status === "fulfilled" ? tcpServicesResult.value : []; + const tcpMiddlewares = tcpMiddlewaresResult.status === "fulfilled" ? tcpMiddlewaresResult.value : []; + const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : []; + const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : []; + + return { + version, + entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`), + http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares), + tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares), + udp: { + routers: this.summarizeResources(udpRouters), + services: this.summarizeResources(udpServices), + }, + }; + } + + private async getVersionAsync() { + const response = await this.fetchOkAsync("/api/version"); + const version = traefikVersionSchema.parse(await response.json()); + return version.Version ?? null; + } + + private async getResourcesAsync(path: ResourcePath) { + const response = await this.fetchOkAsync(path); + return traefikResourcesSchema.parse(await response.json()); + } + + private createProtocolSummary( + routers: TraefikResource[], + services: TraefikResource[], + middlewares: TraefikResource[], + ): TraefikProtocolSummary { + return { + routers: this.summarizeResources(routers), + services: this.summarizeResources(services), + middlewares: this.summarizeResources(middlewares), + }; + } + + private summarizeResources(resources: TraefikResource[]): TraefikResourceSummary { + return resources.reduce( + (summary, resource) => { + const status = this.normalizeStatus(resource.status); + summary.total += 1; + + if (status === "enabled") summary.enabled += 1; + if (status === "warning") summary.warnings += 1; + if (status === "error") summary.errors += 1; + + return summary; + }, + { total: 0, enabled: 0, warnings: 0, errors: 0 }, + ); + } + + private normalizeStatus(status: string | undefined): TraefikResourceStatus { + if (!status) return "unknown"; + const normalized = status.toLowerCase(); + + if (normalized === "enabled" || normalized === "disabled" || normalized === "warning" || normalized === "error") { + return normalized; + } + + return "unknown"; + } + + private async fetchOkAsync(path: ResourcePath | "/api/version") { + const response = await fetchWithTrustedCertificatesAsync(this.url(path), { + headers: this.getAuthHeaders(), + timeout: 10_000, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return response; + } + + private getAuthHeaders(): Record { + if (this.hasSecretValue("username") && this.hasSecretValue("password")) { + const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString( + "base64", + ); + return { Authorization: `Basic ${credentials}` }; + } + + if (this.hasSecretValue("apiKey")) { + return { Authorization: `Bearer ${this.getSecretValue("apiKey")}` }; + } + + return {}; + } +} diff --git a/packages/integrations/src/traefik/traefik-types.ts b/packages/integrations/src/traefik/traefik-types.ts new file mode 100644 index 000000000..5874ef41b --- /dev/null +++ b/packages/integrations/src/traefik/traefik-types.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export const traefikResourceStatusSchema = z.enum(["enabled", "disabled", "warning", "error", "unknown"]); + +const traefikResourceSchema = z + .object({ + name: z.string().optional(), + provider: z.string().optional(), + status: z.string().optional(), + }) + .passthrough(); + +export const traefikVersionSchema = z + .object({ + Version: z.string().optional(), + }) + .passthrough(); + +export const traefikResourcesSchema = z.array(traefikResourceSchema); + +export interface TraefikResourceSummary { + total: number; + enabled: number; + warnings: number; + errors: number; +} + +export interface TraefikProtocolSummary { + routers: TraefikResourceSummary; + services: TraefikResourceSummary; + middlewares: TraefikResourceSummary; +} + +export interface TraefikDashboardData { + version: string | null; + entryPoints: string[]; + http: TraefikProtocolSummary; + tcp: TraefikProtocolSummary; + udp: Omit; +} + +export type TraefikResource = z.infer[number]; +export type TraefikResourceStatus = z.infer; diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 273b5b2d2..b8de50802 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -24,3 +24,4 @@ export * from "./paperless-ngx/paperless-ngx-types"; export * from "./umami/umami-types"; export * from "./gluetun/gluetun-types"; export * from "./beszel/beszel-types"; +export * from "./traefik/traefik-types"; diff --git a/packages/request-handler/src/traefik.ts b/packages/request-handler/src/traefik.ts new file mode 100644 index 000000000..1b87e2bb1 --- /dev/null +++ b/packages/request-handler/src/traefik.ts @@ -0,0 +1,15 @@ +import { createIntegrationAsync } from "@homarr/integrations"; +import type { TraefikDashboardData } from "@homarr/integrations/types"; + +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; + +export const traefikRequestHandler = createIntegrationRequestHandler< + TraefikDashboardData, + "traefik", + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1d910c41a..54ce4c7f9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1328,6 +1328,10 @@ } } }, + "traefik": { + "name": "Traefik", + "description": "Cloud-native reverse proxy and load balancer" + }, "paperlessNgx": { "name": "Paperless-ngx", "description": "Document management system for archiving and organizing scanned documents" @@ -3336,7 +3340,11 @@ "name": "Notifications", "description": "Display notification history from an integration", "noItems": "No notifications to display.", - "option": {} + "option": { + "hideLogos": { + "label": "Hide logos" + } + } }, "speedtestTracker": { "name": "Speedtest Tracker", @@ -3417,6 +3425,40 @@ "internalServerError": "Failed to fetch uptime data" } }, + "traefik": { + "name": "Traefik", + "description": "Overview of Traefik routers, services, middlewares, and entry points", + "option": { + "showTcp": { + "label": "Show TCP" + }, + "showUdp": { + "label": "Show UDP" + }, + "showEntryPoints": { + "label": "Show entry points" + } + }, + "instances": "Traefik instances", + "versionUnknown": "Version unknown", + "summary": { + "routers": "Routers", + "entryPoints": "Entry points" + }, + "resource": { + "routers": "Routers", + "services": "Services", + "middlewares": "Middlewares" + }, + "status": { + "healthy": "Healthy", + "errors": "{count} errors", + "warnings": "{count} warnings" + }, + "error": { + "internalServerError": "Failed to fetch Traefik data" + } + }, "audioStats": { "name": "Audio stats", "description": "Library and playback statistics from Navidrome or Audiobookshelf", diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index ca8a5f62f..2b843425d 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -49,6 +49,7 @@ import * as stockPrice from "./stocks"; import * as systemDisks from "./system-disks"; import * as systemResources from "./system-resources"; import * as timetable from "./timetable"; +import * as traefik from "./traefik"; import * as tracearr from "./tracearr"; import * as umami from "./umami"; import * as ups from "./ups"; @@ -120,6 +121,7 @@ export const widgetImports = { beszelSystemGrid, beszelAlerts, beszelSystemStats, + traefik, customApi, } satisfies WidgetImportRecord; diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index 3b7cc5ba7..3b04393d0 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; +import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; import { IconClock } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -34,18 +34,39 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC {sortedNotifications.length > 0 ? ( sortedNotifications.map((notification) => ( - - - {notification.title && ( - - {notification.title} - + + + {!options.hideLogos && notification.source?.iconUrl && ( + )} - - {notification.body} - - + + {notification.title && ( + + {notification.title} + + )} + + {notification.body} + + + + )) diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 577d698a0..553ef5d9f 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -8,7 +8,9 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("notifications", { icon: IconMessage, createOptions() { - return optionsBuilder.from(() => ({})); + return optionsBuilder.from((factory) => ({ + hideLogos: factory.switch({ defaultValue: false }), + })); }, supportedIntegrations: getIntegrationKindsByCategory("notifications"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/traefik/component.module.css b/packages/widgets/src/traefik/component.module.css new file mode 100644 index 000000000..59bede44b --- /dev/null +++ b/packages/widgets/src/traefik/component.module.css @@ -0,0 +1,125 @@ +.root { + container-type: inline-size; + display: flex; + flex-direction: column; + min-height: 100%; + padding: clamp(6px, 3cqi, 12px); + gap: clamp(6px, 3cqi, 12px); +} + +.titleBlock { + min-width: 0; +} + +.title { + font-size: clamp(12px, 4cqi, 15px); + font-weight: 700; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subtitle { + font-size: clamp(9px, 3cqi, 12px); + color: var(--mantine-color-dimmed); + line-height: 1.2; +} + +.hero { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: clamp(6px, 3cqi, 10px); +} + +.metric { + display: flex; + align-items: center; + min-width: 0; + gap: clamp(5px, 2cqi, 8px); + padding: clamp(6px, 3cqi, 10px); + border-radius: var(--mantine-radius-sm); + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} + +.metricIcon { + flex-shrink: 0; + color: var(--mantine-color-teal-6); +} + +.metricText { + min-width: 0; +} + +.metricValue { + font-size: clamp(16px, 7cqi, 24px); + font-weight: 800; + line-height: 1; +} + +.metricLabel { + font-size: clamp(8px, 2.8cqi, 11px); + color: var(--mantine-color-dimmed); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.protocolGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: clamp(6px, 3cqi, 10px); +} + +.protocolCard { + min-width: 0; + padding: clamp(6px, 3cqi, 10px); + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); + border-radius: var(--mantine-radius-sm); +} + +.protocolTitle { + font-size: clamp(10px, 3.5cqi, 13px); + font-weight: 700; + line-height: 1.2; + text-transform: uppercase; +} + +.protocolRows { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.protocolRow { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + gap: 6px; +} + +.protocolLabel { + min-width: 0; + color: var(--mantine-color-dimmed); + font-size: clamp(9px, 3cqi, 12px); +} + +.protocolLabel p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.counts { + flex-shrink: 0; + font-size: clamp(10px, 3.5cqi, 13px); +} + +.entryPoints { + display: flex; + flex-wrap: wrap; + gap: 4px; +} diff --git a/packages/widgets/src/traefik/component.tsx b/packages/widgets/src/traefik/component.tsx new file mode 100644 index 000000000..91de88ffb --- /dev/null +++ b/packages/widgets/src/traefik/component.tsx @@ -0,0 +1,259 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Badge, Group, ScrollArea, Text } from "@mantine/core"; +import { + IconAlertTriangle, + IconCircleCheck, + IconDoorEnter, + IconRoute, + IconServer, + IconStack2, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationDataError } from "../errors/no-data-integration"; +import classes from "./component.module.css"; + +const emptySummary: TraefikResourceSummary = { + total: 0, + enabled: 0, + warnings: 0, + errors: 0, +}; + +const emptyProtocol: TraefikProtocolSummary = { + routers: emptySummary, + services: emptySummary, + middlewares: emptySummary, +}; + +const emptyDashboard: TraefikDashboardData = { + version: null, + entryPoints: [], + http: emptyProtocol, + tcp: emptyProtocol, + udp: { + routers: emptySummary, + services: emptySummary, + }, +}; + +type ProtocolKey = "http" | "tcp" | "udp"; +type ResourceKey = "routers" | "services" | "middlewares"; + +export default function TraefikWidget({ integrationIds, options, width }: WidgetComponentProps<"traefik">) { + if (integrationIds.length === 0) { + throw new NoIntegrationDataError(); + } + + const t = useScopedI18n("widget.traefik"); + const { data } = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds }); + + if (!data) return ; + + const combined = data.reduce( + (acc, item) => ({ + version: acc.version ?? item.dashboard.version, + entryPoints: [...acc.entryPoints, ...item.dashboard.entryPoints], + http: combineProtocol(acc.http, item.dashboard.http), + tcp: combineProtocol(acc.tcp, item.dashboard.tcp), + udp: { + routers: combineSummary(acc.udp.routers, item.dashboard.udp.routers), + services: combineSummary(acc.udp.services, item.dashboard.udp.services), + }, + }), + emptyDashboard, + ); + + const totalRouters = combined.http.routers.total + combined.tcp.routers.total + combined.udp.routers.total; + const totalErrors = getProtocolErrors(combined.http) + getProtocolErrors(combined.tcp) + getUdpErrors(combined.udp); + const totalWarnings = + getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp); + const protocolKeys: ProtocolKey[] = ["http"]; + if (options.showTcp) protocolKeys.push("tcp"); + if (options.showUdp) protocolKeys.push("udp"); + + return ( + +
+ +
+ {data.length === 1 ? data[0]?.integrationName : t("instances")} + {combined.version ? `v${combined.version}` : t("versionUnknown")} +
+ +
+ +
+ } + label={t("summary.routers")} + value={totalRouters} + /> + } + label={t("summary.entryPoints")} + value={dedupe(combined.entryPoints).length} + /> +
+ +
+ {protocolKeys.map((protocol) => ( + + ))} +
+ + {options.showEntryPoints && combined.entryPoints.length > 0 && ( +
+ {dedupe(combined.entryPoints) + .slice(0, getEntryPointLimit(width)) + .map((entryPoint) => ( + + {entryPoint} + + ))} +
+ )} +
+
+ ); +} + +function ProtocolCard({ protocol, data }: { protocol: ProtocolKey; data: TraefikDashboardData[ProtocolKey] }) { + const t = useScopedI18n("widget.traefik"); + const rows: { key: ResourceKey; icon: typeof IconRoute; summary: TraefikResourceSummary }[] = [ + { key: "routers", icon: IconRoute, summary: data.routers }, + { key: "services", icon: IconServer, summary: data.services }, + ]; + if ("middlewares" in data) { + rows.push({ key: "middlewares", icon: IconStack2, summary: data.middlewares }); + } + + return ( +
+ {t(`protocol.${protocol}`)} +
+ {rows.map(({ key, icon: Icon, summary }) => ( +
+ + + {t(`resource.${key}`)} + + +
+ ))} +
+
+ ); +} + +function StatusCount({ summary }: { summary: TraefikResourceSummary }) { + return ( + + {summary.total} + {summary.errors > 0 && ( + + {summary.errors} + + )} + {summary.warnings > 0 && ( + + {summary.warnings} + + )} + + ); +} + +function SummaryMetric({ icon, label, value }: { icon: ReactNode; label: string; value: number }) { + return ( +
+
{icon}
+
+ {value} + {label} +
+
+ ); +} + +function HealthBadge({ errors, warnings }: { errors: number; warnings: number }) { + const t = useScopedI18n("widget.traefik"); + + if (errors > 0) { + return ( + } radius="sm"> + {t("status.errors", { count: errors })} + + ); + } + + if (warnings > 0) { + return ( + } radius="sm"> + {t("status.warnings", { count: warnings })} + + ); + } + + return ( + } radius="sm"> + {t("status.healthy")} + + ); +} + +function combineProtocol(left: TraefikProtocolSummary, right: TraefikProtocolSummary): TraefikProtocolSummary { + return { + routers: combineSummary(left.routers, right.routers), + services: combineSummary(left.services, right.services), + middlewares: combineSummary(left.middlewares, right.middlewares), + }; +} + +function combineSummary(left: TraefikResourceSummary, right: TraefikResourceSummary): TraefikResourceSummary { + return { + total: left.total + right.total, + enabled: left.enabled + right.enabled, + warnings: left.warnings + right.warnings, + errors: left.errors + right.errors, + }; +} + +function getProtocolErrors(protocol: TraefikProtocolSummary) { + return protocol.routers.errors + protocol.services.errors + protocol.middlewares.errors; +} + +function getProtocolWarnings(protocol: TraefikProtocolSummary) { + return protocol.routers.warnings + protocol.services.warnings + protocol.middlewares.warnings; +} + +function getUdpErrors(protocol: TraefikDashboardData["udp"]) { + return protocol.routers.errors + protocol.services.errors; +} + +function getUdpWarnings(protocol: TraefikDashboardData["udp"]) { + return protocol.routers.warnings + protocol.services.warnings; +} + +function dedupe(values: string[]) { + return Array.from(new Set(values)); +} + +function getHeroIconSize(width: number) { + if (width < 220) return 16; + if (width < 340) return 18; + return 22; +} + +function getEntryPointLimit(width: number) { + if (width < 220) return 2; + if (width < 360) return 4; + return 8; +} diff --git a/packages/widgets/src/traefik/index.ts b/packages/widgets/src/traefik/index.ts new file mode 100644 index 000000000..854075121 --- /dev/null +++ b/packages/widgets/src/traefik/index.ts @@ -0,0 +1,28 @@ +import { IconRoute, IconRouteOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("traefik", { + icon: IconRoute, + createOptions() { + return optionsBuilder.from((factory) => ({ + showTcp: factory.switch({ + defaultValue: true, + }), + showUdp: factory.switch({ + defaultValue: true, + }), + showEntryPoints: factory.switch({ + defaultValue: true, + }), + })); + }, + supportedIntegrations: ["traefik"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconRouteOff, + message: (t) => t("widget.traefik.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component"));