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:
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);
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user