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