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
@@ -191,6 +191,14 @@ packages/integrations/src/unraid @manuel-rw
|
|||||||
apps/docs/docs/integrations/uptime-kuma @ajnart
|
apps/docs/docs/integrations/uptime-kuma @ajnart
|
||||||
packages/integrations/src/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)
|
# Exempt Renovate-managed files (no owners)
|
||||||
package.json
|
package.json
|
||||||
**/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",
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -142,7 +142,7 @@ export default async function Layout(props: {
|
|||||||
<SearchEngineOptimization />
|
<SearchEngineOptimization />
|
||||||
<CrowdinLiveTranslation locale={locale} />
|
<CrowdinLiveTranslation locale={locale} />
|
||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
<Notifications pauseResetOnHover="notification" />
|
<Notifications pauseResetOnHover="notification" />
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
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)),
|
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
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,
|
"when": 1780764500000,
|
||||||
"tag": "0041_merge_custom_widget_url",
|
"tag": "0041_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 42,
|
|
||||||
"version": "5",
|
|
||||||
"when": 1783176100870,
|
|
||||||
"tag": "0042_add_widget_secret",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -57,13 +57,6 @@
|
|||||||
"when": 1780764500000,
|
"when": 1780764500000,
|
||||||
"tag": "0007_merge_custom_widget_url",
|
"tag": "0007_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1783176100870,
|
|
||||||
"tag": "0008_add_widget_secret",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -281,13 +281,6 @@
|
|||||||
"when": 1780764500000,
|
"when": 1780764500000,
|
||||||
"tag": "0039_merge_custom_widget_url",
|
"tag": "0039_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"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/gluetun"
|
||||||
| "/docs/integrations/peanut"
|
| "/docs/integrations/peanut"
|
||||||
| "/docs/integrations/beszel"
|
| "/docs/integrations/beszel"
|
||||||
|
| "/docs/integrations/traefik"
|
||||||
| "/docs/widgets/clock"
|
| "/docs/widgets/clock"
|
||||||
| "/docs/widgets/weather"
|
| "/docs/widgets/weather"
|
||||||
| "/docs/widgets/app"
|
| "/docs/widgets/app"
|
||||||
@@ -367,4 +368,5 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/beszel-system-grid"
|
| "/docs/widgets/beszel-system-grid"
|
||||||
| "/docs/widgets/beszel-alerts"
|
| "/docs/widgets/beszel-alerts"
|
||||||
| "/docs/widgets/beszel-system-stats"
|
| "/docs/widgets/beszel-system-stats"
|
||||||
|
| "/docs/widgets/traefik"
|
||||||
| "/docs/widgets/custom-api";
|
| "/docs/widgets/custom-api";
|
||||||
@@ -50,6 +50,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
|||||||
gluetun: "gluetun",
|
gluetun: "gluetun",
|
||||||
peaNut: "peanut",
|
peaNut: "peanut",
|
||||||
beszel: "beszel",
|
beszel: "beszel",
|
||||||
|
traefik: "traefik",
|
||||||
archiveTeamWarrior: null,
|
archiveTeamWarrior: null,
|
||||||
mock: null,
|
mock: null,
|
||||||
};
|
};
|
||||||
@@ -50,5 +50,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
|||||||
beszelSystemGrid: "beszel-system-grid",
|
beszelSystemGrid: "beszel-system-grid",
|
||||||
beszelAlerts: "beszel-alerts",
|
beszelAlerts: "beszel-alerts",
|
||||||
beszelSystemStats: "beszel-system-stats",
|
beszelSystemStats: "beszel-system-stats",
|
||||||
|
traefik: "traefik",
|
||||||
customApi: "custom-api",
|
customApi: "custom-api",
|
||||||
};
|
};
|
||||||
@@ -433,6 +433,14 @@ export const integrationDefs = {
|
|||||||
category: ["vpn"],
|
category: ["vpn"],
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
|
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: {
|
archiveTeamWarrior: {
|
||||||
name: "ArchiveTeam Warrior",
|
name: "ArchiveTeam Warrior",
|
||||||
secretKinds: [[], ["username", "password"]],
|
secretKinds: [[], ["username", "password"]],
|
||||||
@@ -553,6 +561,7 @@ export const integrationCategories = [
|
|||||||
"documents",
|
"documents",
|
||||||
"mediaLibrary",
|
"mediaLibrary",
|
||||||
"uptimeMonitoring",
|
"uptimeMonitoring",
|
||||||
|
"reverseProxy",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||||
@@ -40,6 +40,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
|||||||
vpn: getIntegrationKindsByCategory("vpn"),
|
vpn: getIntegrationKindsByCategory("vpn"),
|
||||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||||
anchorNote: ["anchor"],
|
anchorNote: ["anchor"],
|
||||||
|
traefik: ["traefik"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const widgetKinds = [
|
|||||||
"beszelSystemGrid",
|
"beszelSystemGrid",
|
||||||
"beszelAlerts",
|
"beszelAlerts",
|
||||||
"beszelSystemStats",
|
"beszelSystemStats",
|
||||||
|
"traefik",
|
||||||
"customApi",
|
"customApi",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
|||||||
import { SeerrIntegration } from "../seerr/seerr-integration";
|
import { SeerrIntegration } from "../seerr/seerr-integration";
|
||||||
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
|
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
|
||||||
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
||||||
|
import { TraefikIntegration } from "../traefik/traefik-integration";
|
||||||
import { SynologyIntegration } from "../synology/synology-integration";
|
import { SynologyIntegration } from "../synology/synology-integration";
|
||||||
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||||
import { UmamiIntegration } from "../umami/umami-integration";
|
import { UmamiIntegration } from "../umami/umami-integration";
|
||||||
@@ -127,6 +128,7 @@ export const integrationCreators = {
|
|||||||
uptimeKuma: UptimeKumaIntegration,
|
uptimeKuma: UptimeKumaIntegration,
|
||||||
peaNut: PeaNutIntegration,
|
peaNut: PeaNutIntegration,
|
||||||
beszel: BeszelIntegration,
|
beszel: BeszelIntegration,
|
||||||
|
traefik: TraefikIntegration,
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
|
|
||||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ResponseError } from "@homarr/common/server";
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
import { ImageProxy } from "@homarr/image-proxy";
|
||||||
|
|
||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import type { IntegrationTestingInput } from "../base/integration";
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
import type { Notification } from "../interfaces/notifications/notification-types";
|
import type { Notification } from "../interfaces/notifications/notification-types";
|
||||||
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
|
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 {
|
export class GotifyIntegration extends Integration implements INotificationsIntegration {
|
||||||
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
@@ -21,23 +22,90 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
|||||||
return { Authorization: `Basic ${credentials}` };
|
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[]> {
|
public async getNotificationsAsync(): Promise<Notification[]> {
|
||||||
const url = this.url("/message", { limit: 100 });
|
const [messagesResponse, applicationsById] = await Promise.all([
|
||||||
const response = await fetchWithTrustedCertificatesAsync(url, { headers: this.getHeaders() });
|
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());
|
const messagesResult = await gotifyMessagesResponseSchema.safeParseAsync(await messagesResponse.json());
|
||||||
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
|
if (!messagesResult.success) throw new Error(`Failed to parse Gotify response: ${messagesResult.error.message}`);
|
||||||
const parsed = result.data;
|
|
||||||
|
|
||||||
return parsed.messages.map(
|
const imageProxy = new ImageProxy();
|
||||||
(message): Notification => ({
|
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),
|
id: String(message.id),
|
||||||
time: new Date(message.date),
|
time: new Date(message.date),
|
||||||
title: message.title,
|
title: message.title,
|
||||||
body: message.message,
|
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
|
// See: https://gotify.net/api-docs#/message/getMessages
|
||||||
export const gotifyMessageSchema = z.object({
|
export const gotifyMessageSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
appid: z.number(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
@@ -12,3 +13,11 @@ export const gotifyMessageSchema = z.object({
|
|||||||
export const gotifyMessagesResponseSchema = z.object({
|
export const gotifyMessagesResponseSchema = z.object({
|
||||||
messages: z.array(gotifyMessageSchema),
|
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 { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
|
||||||
export { UmamiIntegration } from "./umami/umami-integration";
|
export { UmamiIntegration } from "./umami/umami-integration";
|
||||||
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||||
|
export { TraefikIntegration } from "./traefik/traefik-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { IntegrationInput } from "./base/integration";
|
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 { NavidromeDashboardData } from "./navidrome/navidrome-types";
|
||||||
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
|
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
|
||||||
export type { UmamiVisitorStats } from "./umami/umami-types";
|
export type { UmamiVisitorStats } from "./umami/umami-types";
|
||||||
|
export type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "./traefik/traefik-types";
|
||||||
export type {
|
export type {
|
||||||
ArchiveTeamWarriorBandwidth,
|
ArchiveTeamWarriorBandwidth,
|
||||||
ArchiveTeamWarriorItem,
|
ArchiveTeamWarriorItem,
|
||||||
|
|||||||
@@ -3,4 +3,9 @@ export interface Notification {
|
|||||||
time: Date;
|
time: Date;
|
||||||
title: string;
|
title: string;
|
||||||
body: 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 "./umami/umami-types";
|
||||||
export * from "./gluetun/gluetun-types";
|
export * from "./gluetun/gluetun-types";
|
||||||
export * from "./beszel/beszel-types";
|
export * from "./beszel/beszel-types";
|
||||||
|
export * from "./traefik/traefik-types";
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1328,6 +1328,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"traefik": {
|
||||||
|
"name": "Traefik",
|
||||||
|
"description": "Cloud-native reverse proxy and load balancer"
|
||||||
|
},
|
||||||
"paperlessNgx": {
|
"paperlessNgx": {
|
||||||
"name": "Paperless-ngx",
|
"name": "Paperless-ngx",
|
||||||
"description": "Document management system for archiving and organizing scanned documents"
|
"description": "Document management system for archiving and organizing scanned documents"
|
||||||
@@ -3336,7 +3340,11 @@
|
|||||||
"name": "Notifications",
|
"name": "Notifications",
|
||||||
"description": "Display notification history from an integration",
|
"description": "Display notification history from an integration",
|
||||||
"noItems": "No notifications to display.",
|
"noItems": "No notifications to display.",
|
||||||
"option": {}
|
"option": {
|
||||||
|
"hideLogos": {
|
||||||
|
"label": "Hide logos"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"speedtestTracker": {
|
"speedtestTracker": {
|
||||||
"name": "Speedtest Tracker",
|
"name": "Speedtest Tracker",
|
||||||
@@ -3417,6 +3425,40 @@
|
|||||||
"internalServerError": "Failed to fetch uptime data"
|
"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": {
|
"audioStats": {
|
||||||
"name": "Audio stats",
|
"name": "Audio stats",
|
||||||
"description": "Library and playback statistics from Navidrome or Audiobookshelf",
|
"description": "Library and playback statistics from Navidrome or Audiobookshelf",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import * as stockPrice from "./stocks";
|
|||||||
import * as systemDisks from "./system-disks";
|
import * as systemDisks from "./system-disks";
|
||||||
import * as systemResources from "./system-resources";
|
import * as systemResources from "./system-resources";
|
||||||
import * as timetable from "./timetable";
|
import * as timetable from "./timetable";
|
||||||
|
import * as traefik from "./traefik";
|
||||||
import * as tracearr from "./tracearr";
|
import * as tracearr from "./tracearr";
|
||||||
import * as umami from "./umami";
|
import * as umami from "./umami";
|
||||||
import * as ups from "./ups";
|
import * as ups from "./ups";
|
||||||
@@ -120,6 +121,7 @@ export const widgetImports = {
|
|||||||
beszelSystemGrid,
|
beszelSystemGrid,
|
||||||
beszelAlerts,
|
beszelAlerts,
|
||||||
beszelSystemStats,
|
beszelSystemStats,
|
||||||
|
traefik,
|
||||||
customApi,
|
customApi,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
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 { IconClock } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -34,18 +34,39 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
|
|||||||
<Stack w={"100%"} gap="sm">
|
<Stack w={"100%"} gap="sm">
|
||||||
{sortedNotifications.length > 0 ? (
|
{sortedNotifications.length > 0 ? (
|
||||||
sortedNotifications.map((notification) => (
|
sortedNotifications.map((notification) => (
|
||||||
<Card key={notification.id} radius={board.itemRadius} w="100%" p="sm">
|
<Card
|
||||||
<Flex gap="sm" direction="column" w="100%">
|
key={notification.id}
|
||||||
{notification.title && (
|
component={notification.href ? "a" : "div"}
|
||||||
<Text fz="sm" lh="sm" lineClamp={2}>
|
href={notification.href}
|
||||||
{notification.title}
|
target={notification.href ? "_blank" : undefined}
|
||||||
</Text>
|
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>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { optionsBuilder } from "../options";
|
|||||||
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
||||||
icon: IconMessage,
|
icon: IconMessage,
|
||||||
createOptions() {
|
createOptions() {
|
||||||
return optionsBuilder.from(() => ({}));
|
return optionsBuilder.from((factory) => ({
|
||||||
|
hideLogos: factory.switch({ defaultValue: false }),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
supportedIntegrations: getIntegrationKindsByCategory("notifications"),
|
supportedIntegrations: getIntegrationKindsByCategory("notifications"),
|
||||||
}).withDynamicImport(() => import("./component"));
|
}).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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
Reference in new issue
Block a user