feat: Add Traefik Integration with dedicated widget and Add optional Icons for Gotify/Notifcation Widget (ONLY GOTIFY) (#6206)

Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
Nerdy TechnicianandThomas authored and GitHub committed 2026-07-10 16:49:24 +02:00
1 parent bcd36b1b27
commit 9864fed775
33 files changed
+998 -46

No files matched your search

+8
View File
@@ -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
@@ -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";
<IntegrationHeader integration={traefikIntegration} categories={["Reverse proxy", "Monitoring"]} />
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: traefikWidget }]} />
### Adding the integration
<AddingIntegration />
:::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
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [
"Choose this option when the Traefik API is only reachable from a trusted internal network and does not require authentication.",
],
},
{
credentials: ["username", "password"],
steps: [
"Use this option when your Traefik dashboard route is protected by Basic Auth.",
"Enter the same username and password required to open the Traefik dashboard.",
],
},
{
credentials: ["apiKey"],
steps: [
"Use this option when your Traefik dashboard route accepts a bearer token.",
"Paste the token into the API Key field in Homarr.",
],
},
]}
/>
@@ -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",
};
+35
View File
@@ -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";
<WidgetHeader widget={traefikWidget} categories={["Monitoring", "Reverse proxy"]} />
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
<WidgetIntegrations
items={[
{
integration: traefikIntegration,
note: "Reads data from Traefik's dashboard/API endpoints",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={traefikWidget.configuration} />
+31
View File
@@ -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",
},
],
},
};
+1 -1
View File
@@ -142,7 +142,7 @@ export default async function Layout(props: {
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
+1
View File
@@ -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)),
});
@@ -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,
};
});
}),
});
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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";
@@ -50,6 +50,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
mock: null,
};
@@ -50,5 +50,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemGrid: "beszel-system-grid",
beszelAlerts: "beszel-alerts",
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
};
+9
View File
@@ -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];
@@ -40,6 +40,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
traefik: ["traefik"],
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+1
View File
@@ -48,6 +48,7 @@ export const widgetKinds = [
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
"traefik",
"customApi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -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<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -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<TestingResult> {
@@ -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<Map<number, { name: string; image?: string | null }>> {
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<string | undefined> {
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<Notification[]> {
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<number, string | undefined>();
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,
};
});
}
}
@@ -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);
+2
View File
@@ -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,
@@ -3,4 +3,9 @@ export interface Notification {
time: Date;
title: string;
body: string;
href?: string;
source?: {
name: string;
iconUrl?: string;
};
}
@@ -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<TestingResult> {
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<TraefikDashboardData> {
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<TraefikResourceSummary>(
(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<string, string> {
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 {};
}
}
@@ -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<TraefikProtocolSummary, "middlewares">;
}
export type TraefikResource = z.infer<typeof traefikResourcesSchema>[number];
export type TraefikResourceStatus = z.infer<typeof traefikResourceStatusSchema>;
+1
View File
@@ -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";
+15
View File
@@ -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<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
});
+43 -1
View File
@@ -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",
+2
View File
@@ -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;
@@ -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
<Stack w={"100%"} gap="sm">
{sortedNotifications.length > 0 ? (
sortedNotifications.map((notification) => (
<Card key={notification.id} radius={board.itemRadius} w="100%" p="sm">
<Flex gap="sm" direction="column" w="100%">
{notification.title && (
<Text fz="sm" lh="sm" lineClamp={2}>
{notification.title}
</Text>
<Card
key={notification.id}
component={notification.href ? "a" : "div"}
href={notification.href}
target={notification.href ? "_blank" : undefined}
rel={notification.href ? "noopener noreferrer" : undefined}
radius={board.itemRadius}
w="100%"
p="sm"
style={{ color: "inherit", textDecoration: "none" }}
>
<Flex gap="sm" align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size="sm"
radius={board.itemRadius}
/>
)}
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
{notification.body}
</Text>
<InfoDisplay date={notification.time} />
<Flex gap="sm" direction="column" w="100%">
{notification.title && (
<Text fz="sm" lh="sm" lineClamp={2}>
{notification.title}
</Text>
)}
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
{notification.body}
</Text>
<InfoDisplay date={notification.time} />
</Flex>
</Flex>
</Card>
))
+3 -1
View File
@@ -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"));
@@ -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;
}
+259
View File
@@ -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 <WidgetEmptyState />;
const combined = data.reduce<TraefikDashboardData>(
(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 (
<ScrollArea h="100%">
<div className={classes.root}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<div className={classes.titleBlock}>
<Text className={classes.title}>{data.length === 1 ? data[0]?.integrationName : t("instances")}</Text>
<Text className={classes.subtitle}>{combined.version ? `v${combined.version}` : t("versionUnknown")}</Text>
</div>
<HealthBadge errors={totalErrors} warnings={totalWarnings} />
</Group>
<div className={classes.hero}>
<SummaryMetric
icon={<IconRoute size={getHeroIconSize(width)} />}
label={t("summary.routers")}
value={totalRouters}
/>
<SummaryMetric
icon={<IconDoorEnter size={getHeroIconSize(width)} />}
label={t("summary.entryPoints")}
value={dedupe(combined.entryPoints).length}
/>
</div>
<div className={classes.protocolGrid}>
{protocolKeys.map((protocol) => (
<ProtocolCard key={protocol} protocol={protocol} data={combined[protocol]} />
))}
</div>
{options.showEntryPoints && combined.entryPoints.length > 0 && (
<div className={classes.entryPoints}>
{dedupe(combined.entryPoints)
.slice(0, getEntryPointLimit(width))
.map((entryPoint) => (
<Badge key={entryPoint} variant="light" size="sm" radius="sm">
{entryPoint}
</Badge>
))}
</div>
)}
</div>
</ScrollArea>
);
}
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 (
<div className={classes.protocolCard}>
<Text className={classes.protocolTitle}>{t(`protocol.${protocol}`)}</Text>
<div className={classes.protocolRows}>
{rows.map(({ key, icon: Icon, summary }) => (
<div key={key} className={classes.protocolRow}>
<Group gap={4} wrap="nowrap" className={classes.protocolLabel}>
<Icon size={14} />
<Text>{t(`resource.${key}`)}</Text>
</Group>
<StatusCount summary={summary} />
</div>
))}
</div>
</div>
);
}
function StatusCount({ summary }: { summary: TraefikResourceSummary }) {
return (
<Group gap={4} wrap="nowrap" className={classes.counts}>
<Text fw={700}>{summary.total}</Text>
{summary.errors > 0 && (
<Text c="red" fw={700}>
{summary.errors}
</Text>
)}
{summary.warnings > 0 && (
<Text c="yellow" fw={700}>
{summary.warnings}
</Text>
)}
</Group>
);
}
function SummaryMetric({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
return (
<div className={classes.metric}>
<div className={classes.metricIcon}>{icon}</div>
<div className={classes.metricText}>
<Text className={classes.metricValue}>{value}</Text>
<Text className={classes.metricLabel}>{label}</Text>
</div>
</div>
);
}
function HealthBadge({ errors, warnings }: { errors: number; warnings: number }) {
const t = useScopedI18n("widget.traefik");
if (errors > 0) {
return (
<Badge color="red" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
{t("status.errors", { count: errors })}
</Badge>
);
}
if (warnings > 0) {
return (
<Badge color="yellow" variant="light" leftSection={<IconAlertTriangle size={12} />} radius="sm">
{t("status.warnings", { count: warnings })}
</Badge>
);
}
return (
<Badge color="green" variant="light" leftSection={<IconCircleCheck size={12} />} radius="sm">
{t("status.healthy")}
</Badge>
);
}
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;
}
+28
View File
@@ -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"));