feat: OPNsense integration and widget (#3424)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: deepsource-io[bot] <42547082+deepsource-io[bot]@users.noreply.github.com>
This commit is contained in:
Benoit SERRA
2025-08-01 18:34:06 +02:00
committed by GitHub
parent 511551aee7
commit 1dc1854cbf
24 changed files with 1151 additions and 2 deletions

View File

@@ -0,0 +1,215 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
firewallMemoryRequestHandler,
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallCpuRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallCpuStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallCpuSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallInterfacesStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallInterfacesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallInterfacesStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallInterfacesSummary[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallVersionStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallVersionRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallVersionStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallVersionSummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
getFirewallMemoryStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = firewallMemoryRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
return results;
}),
subscribeFirewallMemoryStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
summary: FirewallMemorySummary;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -3,6 +3,7 @@ import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { downloadsRouter } from "./downloads";
import { firewallRouter } from "./firewall";
import { healthMonitoringRouter } from "./health-monitoring";
import { indexerManagerRouter } from "./indexer-manager";
import { mediaReleaseRouter } from "./media-release";
@@ -40,5 +41,6 @@ export const widgetRouter = createTRPCRouter({
options: optionsRouter,
releases: releasesRouter,
networkController: networkControllerRouter,
firewall: firewallRouter,
notifications: notificationsRouter,
});

View File

@@ -1,6 +1,7 @@
import { checkCron } from "./validation";
export const EVERY_5_SECONDS = checkCron("*/5 * * * * *") satisfies string;
export const EVERY_30_SECONDS = checkCron("*/30 * * * * *") satisfies string;
export const EVERY_MINUTE = checkCron("* * * * *") satisfies string;
export const EVERY_5_MINUTES = checkCron("*/5 * * * *") satisfies string;
export const EVERY_10_MINUTES = checkCron("*/10 * * * *") satisfies string;

View File

@@ -3,6 +3,12 @@ import { dockerContainersJob } from "./jobs/docker";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
import { downloadsJob } from "./jobs/integrations/downloads";
import {
firewallCpuJob,
firewallInterfacesJob,
firewallMemoryJob,
firewallVersionJob,
} from "./jobs/integrations/firewall";
import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
@@ -39,6 +45,10 @@ export const jobGroup = createCronJobGroup({
minecraftServerStatus: minecraftServerStatusJob,
dockerContainers: dockerContainersJob,
networkController: networkControllerJob,
firewallCpu: firewallCpuJob,
firewallMemory: firewallMemoryJob,
firewallVersion: firewallVersionJob,
firewallInterfaces: firewallInterfacesJob,
refreshNotifications: refreshNotificationsJob,
});

View File

@@ -0,0 +1,46 @@
import { EVERY_5_SECONDS, EVERY_30_SECONDS, EVERY_HOUR, EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
firewallMemoryRequestHandler,
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const firewallCpuJob = createCronJob("firewallCpu", EVERY_5_SECONDS).withCallback(
createRequestIntegrationJobHandler(firewallCpuRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);
export const firewallMemoryJob = createCronJob("firewallMemory", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(firewallMemoryRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);
export const firewallInterfacesJob = createCronJob("firewallInterfaces", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(firewallInterfacesRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);
export const firewallVersionJob = createCronJob("firewallVersion", EVERY_HOUR).withCallback(
createRequestIntegrationJobHandler(firewallVersionRequestHandler.handler, {
widgetKinds: ["firewall"],
getInput: {
firewall: () => ({}),
},
}),
);

View File

@@ -172,6 +172,12 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
},
opnsense: {
name: "OPNsense",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
category: ["firewall"],
},
github: {
name: "Github",
secretKinds: [[], ["personalAccessToken"]],
@@ -318,6 +324,7 @@ export const integrationCategories = [
"networkController",
"releasesProvider",
"notifications",
"firewall",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];

View File

@@ -26,6 +26,7 @@ export const widgetKinds = [
"releases",
"mediaReleases",
"dockerContainers",
"firewall",
"notifications",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -31,6 +31,7 @@ import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
@@ -102,6 +103,7 @@ export const integrationCreators = {
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
github: GithubIntegration,
dockerHub: DockerHubIntegration,
gitlab: GitlabIntegration,

View File

@@ -21,13 +21,20 @@ export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
// Types
export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type {
FirewallInterface,
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallVersionSummary,
FirewallMemorySummary,
} from "./interfaces/firewall-summary/firewall-summary-types";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";

View File

@@ -0,0 +1,13 @@
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "./firewall-summary-types";
export interface FirewallSummaryIntegration {
getFirewallCpuAsync(): Promise<FirewallCpuSummary>;
getFirewallMemoryAsync(): Promise<FirewallMemorySummary>;
getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]>;
getFirewallVersionAsync(): Promise<FirewallVersionSummary>;
}

View File

@@ -0,0 +1,24 @@
export interface FirewallInterfacesSummary {
data: FirewallInterface[];
timestamp: Date;
}
export interface FirewallInterface {
name: string;
receive: number;
transmit: number;
}
export interface FirewallVersionSummary {
version: string;
}
export interface FirewallCpuSummary {
total: number;
}
export interface FirewallMemorySummary {
used: number;
total: number;
percent: number;
}

View File

@@ -0,0 +1,189 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError, ResponseError } from "@homarr/common/server";
import { createChannelEventHistory } from "@homarr/redis";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { FirewallSummaryIntegration } from "../interfaces/firewall-summary/firewall-summary-integration";
import type {
FirewallCpuSummary,
FirewallInterface,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "../interfaces/firewall-summary/firewall-summary-types";
import {
opnsenseCPUSchema,
opnsenseInterfacesSchema,
opnsenseMemorySchema,
opnsenseSystemSummarySchema,
} from "./opnsense-types";
@HandleIntegrationErrors([])
export class OPNsenseIntegration extends Integration implements FirewallSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/diagnostics/system/system_information"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
const result = await response.json();
if (typeof result === "object" && result !== null) return { success: true };
return TestConnectionError.ParseResult(new ParseError("Expected object data"));
}
private getAuthHeaders() {
const username = super.getSecretValue("username");
const password = super.getSecretValue("password");
return `Basic ${btoa(`${username}:${password}`)}`;
}
public async getFirewallVersionAsync(): Promise<FirewallVersionSummary> {
const responseVersion = await fetchWithTrustedCertificatesAsync(
this.url("/api/diagnostics/system/system_information"),
{
headers: {
Authorization: this.getAuthHeaders(),
},
},
);
if (!responseVersion.ok) {
throw new ResponseError(responseVersion);
}
const summary = opnsenseSystemSummarySchema.parse(await responseVersion.json());
return {
version: summary.versions.at(0) ?? "Unknown",
};
}
private getInterfacesChannel() {
return createChannelEventHistory<FirewallInterface[]>(`integration:${this.integration.id}:interfaces`, 15);
}
public async getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]> {
const channel = this.getInterfacesChannel();
const responseInterfaces = await fetchWithTrustedCertificatesAsync(this.url("/api/diagnostics/traffic/interface"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!responseInterfaces.ok) {
throw new ResponseError(responseInterfaces);
}
const interfaces = opnsenseInterfacesSchema.parse(await responseInterfaces.json());
const returnValue: FirewallInterface[] = [];
const interfaceKeys = Object.keys(interfaces.interfaces);
for (const key of interfaceKeys) {
const inter = interfaces.interfaces[key];
if (!inter) continue;
const bytesTransmitted = inter["bytes transmitted"];
const bytesReceived = inter["bytes received"];
const receiveValue = parseInt(bytesReceived, 10);
const transmitValue = parseInt(bytesTransmitted, 10);
returnValue.push({
name: inter.name,
receive: receiveValue,
transmit: transmitValue,
});
}
await channel.pushAsync(returnValue);
return await channel.getSliceAsync(0, 1);
}
public async getFirewallMemoryAsync(): Promise<FirewallMemorySummary> {
const responseMemory = await fetchWithTrustedCertificatesAsync(
this.url("/api/diagnostics/system/systemResources"),
{
headers: {
Authorization: this.getAuthHeaders(),
},
},
);
if (!responseMemory.ok) {
throw new ResponseError(responseMemory);
}
const memory = opnsenseMemorySchema.parse(await responseMemory.json());
// Using parseInt for memoryTotal is normal, the api sends the total memory as a string
const memoryTotal = parseInt(memory.memory.total);
const memoryUsed = memory.memory.used;
const memoryPercent = (100 * memoryUsed) / memoryTotal;
return {
total: memoryTotal,
used: memoryUsed,
percent: memoryPercent,
};
}
public async getFirewallCpuAsync(): Promise<FirewallCpuSummary> {
const responseCpu = await fetchWithTrustedCertificatesAsync(this.url("/api/diagnostics/cpu_usage/stream"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!responseCpu.ok) {
throw new ResponseError(responseCpu);
}
if (!responseCpu.body) {
throw new Error("ReadableStream not supported in this environment.");
}
const reader = responseCpu.body.getReader();
const decoder = new TextDecoder();
let loopCounter = 0;
try {
while (loopCounter < 10) {
loopCounter++;
const result = await reader.read();
if (result.done) {
break;
}
if (!(result.value instanceof Uint8Array)) {
throw new Error("Received value is not an Uint8Array.");
}
const value: AllowSharedBufferSource = result.value;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (!line.startsWith("data:")) {
continue;
}
if (loopCounter < 2) {
continue;
}
const data = line.substring(5).trim();
const cpuValues = opnsenseCPUSchema.parse(JSON.parse(data));
return {
...cpuValues,
};
}
}
throw new Error("No valid CPU data found.");
} finally {
await reader.cancel();
}
}
}

View File

@@ -0,0 +1,30 @@
import { z } from "zod";
// API documentation : https://docs.opnsense.org/development/api.html#core-api
export const opnsenseSystemSummarySchema = z.object({
name: z.string(),
versions: z.array(z.string()),
});
export const opnsenseMemorySchema = z.object({
memory: z.object({
total: z.string(),
used: z.number(),
}),
});
const interfaceSchema = z.object({
"bytes received": z.string(),
"bytes transmitted": z.string(),
name: z.string(),
});
export const opnsenseInterfacesSchema = z.object({
interfaces: z.record(interfaceSchema),
time: z.number(),
});
export const opnsenseCPUSchema = z.object({
total: z.number(),
});

View File

@@ -1,6 +1,7 @@
export * from "./interfaces/calendar/calendar-types";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
@@ -8,4 +9,5 @@ export * from "./base/searchable-integration";
export * from "./homeassistant/homeassistant-types";
export * from "./proxmox/proxmox-types";
export * from "./unifi-controller/unifi-controller-types";
export * from "./opnsense/opnsense-types";
export * from "./interfaces/media-releases";

View File

@@ -9,6 +9,7 @@ export {
createIntegrationOptionsChannel,
createWidgetOptionsChannel,
createChannelWithLatestAndEvents,
createChannelEventHistory,
handshakeAsync,
createSubPubChannel,
createGetSetChannel,

View File

@@ -232,7 +232,7 @@ export const createChannelEventHistory = <TData>(channelName: string, maxElement
if (length <= maxElements) {
return;
}
await getSetClient.ltrim(channelName, length - maxElements, length);
await getSetClient.ltrim(channelName, 0, maxElements - 1);
};
return {

View File

@@ -0,0 +1,64 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler<
FirewallCpuSummary,
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return integrationInstance.getFirewallCpuAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "firewallCpuSummary",
});
export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandler<
FirewallMemorySummary,
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallMemoryAsync();
},
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "firewallMemorySummary",
});
export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHandler<
FirewallInterfacesSummary[],
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallInterfacesAsync();
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "firewallInterfacesSummary",
});
export const firewallVersionRequestHandler = createCachedIntegrationRequestHandler<
FirewallVersionSummary,
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallVersionAsync();
},
cacheDuration: dayjs.duration(1, "hour"),
queryKey: "firewallVersionSummary",
});

View File

@@ -2413,6 +2413,35 @@
"internalServerError": "Failed to fetch Network Controller Summary"
}
},
"firewall": {
"name": "Firewall Monitoring",
"description": "Displays a summary of firewalls",
"tab": {
"system": "System",
"interfaces": "Interfaces"
},
"error": {
"internalServerError": "Unable to get data from firewall"
},
"option": {
"interfaces": "Network interfaces to display"
},
"widget": {
"fwname": "Name",
"version": "Version",
"versiontitle": "Versions",
"cputitle": "CPU usage",
"memorytitle": "Memory usage",
"cpu": "CPU",
"memory": "Memory",
"interfaces": {
"name": "name",
"trans": "Transmited",
"recv": "Received",
"title": "Network Interfaces"
}
}
},
"notifications": {
"name": "Notifications",
"description": "Display notification history from an integration",
@@ -3192,6 +3221,18 @@
},
"dockerContainers": {
"label": "Docker containers"
},
"firewallCpu": {
"label": "Firewall CPU"
},
"firewallMemory": {
"label": "Firewall Memory"
},
"firewallVersion": {
"label": "Firewall Version"
},
"firewallInterfaces": {
"label": "Firewall Interfaces"
}
},
"interval": {

View File

@@ -0,0 +1,397 @@
"use client";
import { useCallback, useState } from "react";
import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { FirewallMenu } from "./firewall-menu";
import { FirewallVersion } from "./firewall-version";
export interface Firewall {
label: string;
value: string;
}
export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState<string>("");
const handleSelect = useCallback((value: string | null) => {
if (value !== null) {
setSelectedFirewall(value);
} else {
setSelectedFirewall("default_value");
}
}, []);
const firewallsCpuData = useUpdatingCpuStatus(integrationIds);
const firewallsMemoryData = useUpdatingMemoryStatus(integrationIds);
const firewallsVersionData = useUpdatingVersionStatus(integrationIds);
const firewallsInterfacesData = useUpdatingInterfacesStatus(integrationIds);
const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
const isTiny = width < 256;
const [accordionValue, setAccordionValue] = useLocalStorage<string | null>({
key: `homarr-${itemId}-firewall`,
defaultValue: "interfaces",
});
const dropdownItems = firewallsVersionData.map((firewall) => ({
label: firewall.integration.name,
value: firewall.integration.id,
}));
const t = useI18n();
return (
<ScrollArea h="100%">
<Group justify="space-between" w="100%" style={{ padding: "8px" }}>
<FirewallMenu
onChange={handleSelect}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
<FirewallVersion
firewallsVersionData={firewallsVersionData}
selectedFirewall={selectedFirewall || initialSelectedFirewall}
isTiny={isTiny}
/>
</Group>
<Flex justify="center" align="center" wrap="wrap">
{/* Render CPU and Memory data */}
{firewallsCpuData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-cpu`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.total.toFixed(2)}%`}</Text>
<IconCpu size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.total.toFixed(1)),
color: summary.total > 50 ? (summary.total < 75 ? "yellow" : "red") : "green",
},
]}
/>
))}
{firewallsMemoryData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary, integration }) => (
<RingProgress
key={`${integration.name}-memory`}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{`${summary.percent.toFixed(1)}%`}</Text>
<IconBrain size={isTiny ? 8 : 16} />
</Center>
}
sections={[
{
value: Number(summary.percent.toFixed(1)),
color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green",
},
]}
/>
))}
</Flex>
{firewallsInterfacesData
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
.map(({ summary }) => (
<Accordion key="interfaces" value={accordionValue} onChange={setAccordionValue}>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}> {t("widget.firewall.widget.interfaces.title")} </Text>
</Accordion.Control>
<Accordion.Panel>
<Flex direction="column" key="interfaces">
{Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? (
calculateBandwidth(summary).data.map(({ name, receive, transmit }) => (
<Flex
key={name}
direction={isTiny ? "column" : "row"}
style={{
width: "100%",
padding: isTiny ? "2px" : "0px",
}}
>
<Flex w={isTiny ? "100%" : "33%"} style={{ justifyContent: "flex-start" }}>
<Text
size={isTiny ? "8px" : "xs"}
color="lightblue"
style={{
maxWidth: "100px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textAlign: "left",
}}
>
{name}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarUp size={isTiny ? "8" : "12"} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} color="lightgreen" style={{ textAlign: "left" }}>
{formatBitsPerSec(transmit, 2)}
</Text>
</Flex>
<Flex
align="center"
gap="4"
w={isTiny ? "100%" : "33%"}
style={{ justifyContent: "flex-start" }}
>
<IconArrowBarDown size={isTiny ? "8" : "12"} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} color="yellow" style={{ textAlign: "left" }}>
{formatBitsPerSec(receive, 2)}
</Text>
</Flex>
</Flex>
))
) : (
<Box>No data available</Box>
)}
</Flex>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
))}
</ScrollArea>
);
}
export const useUpdatingCpuStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsCpuData] = clientApi.widget.firewall.getFirewallCpuStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallCpuStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallCpuStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsCpuData;
};
export const useUpdatingMemoryStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsMemoryData] = clientApi.widget.firewall.getFirewallMemoryStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallMemoryStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallMemoryStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsMemoryData;
};
export const useUpdatingVersionStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsVersionData] = clientApi.widget.firewall.getFirewallVersionStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallVersionStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallVersionStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsVersionData;
};
export const useUpdatingInterfacesStatus = (integrationIds: string[]) => {
const utils = clientApi.useUtils();
const [firewallsInterfacesData] = clientApi.widget.firewall.getFirewallInterfacesStatus.useSuspenseQuery(
{
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
clientApi.widget.firewall.subscribeFirewallInterfacesStatus.useSubscription(
{
integrationIds,
},
{
onData: (data) => {
utils.widget.firewall.getFirewallInterfacesStatus.setData(
{
integrationIds,
},
(prevData) => {
if (!prevData) {
return undefined;
}
return prevData.map((item) =>
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
);
},
);
},
},
);
return firewallsInterfacesData;
};
export function formatBitsPerSec(bytes: number, decimals: number): string {
if (bytes === 0) return "0 b/s";
const kilobyte = 1024;
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
const i = Math.floor(Math.log(bytes) / Math.log(kilobyte));
return `${parseFloat((bytes / Math.pow(kilobyte, i)).toFixed(decimals))} ${sizes[i]}`;
}
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
const result = {
data: [] as FirewallInterface[],
timestamp: new Date().toISOString(),
};
if (data.length > 1) {
const firstData = data[0];
const secondData = data[1];
if (firstData && secondData) {
const time1 = new Date(firstData.timestamp);
const time2 = new Date(secondData.timestamp);
const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000;
firstData.data.forEach((iface) => {
const ifaceName = iface.name;
const recv1 = iface.receive;
const trans1 = iface.transmit;
const iface2 = secondData.data.find((i) => i.name === ifaceName);
if (iface2) {
const recv2 = iface2.receive;
const trans2 = iface2.transmit;
const recvDiff = recv1 - recv2;
const transDiff = trans1 - trans2;
result.data.push({
name: ifaceName,
receive: (8 * recvDiff) / timeDiffInSeconds,
transmit: (8 * transDiff) / timeDiffInSeconds,
});
}
});
}
}
return result;
}

View File

@@ -0,0 +1,27 @@
import { Box, Select } from "@mantine/core";
import type { Firewall } from "./component";
interface FirewallMenuProps {
onChange: (value: string | null) => void;
dropdownItems: Firewall[];
selectedFirewall: string;
isTiny: boolean;
}
export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall }: FirewallMenuProps) => (
<Box>
<Select
value={selectedFirewall}
onChange={onChange}
size={isTiny ? "8px" : "xs"}
color="lightgray"
data={dropdownItems}
styles={{
input: {
minHeight: "24px",
},
}}
/>
</Box>
);

View File

@@ -0,0 +1,41 @@
import { Badge, Box } from "@mantine/core";
import type { FirewallVersionSummary } from "@homarr/integrations";
interface FirewallVersionProps {
firewallsVersionData: {
integration: FirewallIntegration;
summary: FirewallVersionSummary;
}[];
selectedFirewall: string;
isTiny: boolean;
}
export interface FirewallIntegration {
id: string;
name: string;
kind: string;
updatedAt: Date;
}
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => (
<Box>
<Badge autoContrast variant="outline" color="lightgray" size={isTiny ? "8px" : "xs"} style={{ minHeight: "24px" }}>
{firewallsVersionData
.filter(({ integration }) => integration.id === selectedFirewall)
.map(({ summary, integration }) => (
<span key={integration.id}>{formatVersion(summary.version)}</span>
))}
</Badge>
</Box>
);
function formatVersion(inputString: string): string {
const regex = /([\d._]+)/;
const match = regex.exec(inputString);
if (match?.[1]) {
return match[1];
} else {
return "Unknown Version";
}
}

View File

@@ -0,0 +1,7 @@
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}

View File

@@ -0,0 +1,20 @@
import { IconWall, IconWallOff } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: getIntegrationKindsByCategory("firewall"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconWallOff,
message: (t) => t("widget.firewall.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -16,6 +16,7 @@ import * as dnsHoleControls from "./dns-hole/controls";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as dockerContainers from "./docker";
import * as downloads from "./downloads";
import * as firewall from "./firewall";
import * as healthMonitoring from "./health-monitoring";
import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
@@ -69,6 +70,7 @@ export const widgetImports = {
minecraftServerStatus,
dockerContainers,
releases,
firewall,
notifications,
mediaReleases,
} satisfies WidgetImportRecord;