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
|
||||
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",
|
||||
};
|
||||
@@ -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 />
|
||||
<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" />
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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": {
|
||||
"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",
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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