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

@@ -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";