feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { formatBitRate } from "@homarr/common";
|
||||
|
||||
import { calculateBandwidth } from "./bandwidth";
|
||||
|
||||
describe("calculateBandwidth", () => {
|
||||
test("sorts samples before calculating a rate", () => {
|
||||
const result = calculateBandwidth([
|
||||
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 200 }] },
|
||||
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 300, transmit: 500 }] },
|
||||
]);
|
||||
|
||||
expect(result.data).toEqual([{ name: "wan", receive: 160, transmit: 240 }]);
|
||||
});
|
||||
|
||||
test("treats counter resets as zero instead of negative traffic", () => {
|
||||
const result = calculateBandwidth([
|
||||
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 10, transmit: 10 }] },
|
||||
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 100 }] },
|
||||
]);
|
||||
|
||||
expect(result.data).toEqual([{ name: "wan", receive: 0, transmit: 0 }]);
|
||||
});
|
||||
|
||||
test("rejects duplicate timestamps", () => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00Z");
|
||||
expect(
|
||||
calculateBandwidth([
|
||||
{ timestamp, data: [{ name: "wan", receive: 100, transmit: 100 }] },
|
||||
{ timestamp, data: [{ name: "wan", receive: 50, transmit: 50 }] },
|
||||
]).data,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBitRate", () => {
|
||||
test("handles invalid and negative rates", () => {
|
||||
expect(formatBitRate(Number.NaN)).toBe("0 b/s");
|
||||
expect(formatBitRate(-1)).toBe("0 b/s");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
|
||||
|
||||
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
|
||||
const samples = data
|
||||
.filter((sample) => Number.isFinite(new Date(sample.timestamp).getTime()))
|
||||
.toSorted((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
|
||||
const latest = samples[0];
|
||||
const previous = samples[1];
|
||||
|
||||
if (!latest || !previous) return { data: [] };
|
||||
|
||||
const timeDiffInSeconds = (new Date(latest.timestamp).getTime() - new Date(previous.timestamp).getTime()) / 1000;
|
||||
if (timeDiffInSeconds <= 0) return { data: [] };
|
||||
|
||||
return {
|
||||
data: latest.data.flatMap((latestInterface) => {
|
||||
const previousInterface = previous.data.find((item) => item.name === latestInterface.name);
|
||||
if (!previousInterface) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
name: latestInterface.name,
|
||||
receive: Math.max(0, (8 * (latestInterface.receive - previousInterface.receive)) / timeDiffInSeconds),
|
||||
transmit: Math.max(0, (8 * (latestInterface.transmit - previousInterface.transmit)) / timeDiffInSeconds),
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,241 +1,454 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} 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 { formatBitRate } from "@homarr/common";
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
import type { FirewallInterfacesSummary } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { calculateBandwidth } from "./bandwidth";
|
||||
import { FirewallMenu } from "./firewall-menu";
|
||||
import { FirewallVersion } from "./firewall-version";
|
||||
import { FirewallVersion, formatVersion } from "./firewall-version";
|
||||
import classes from "./firewall.module.css";
|
||||
|
||||
export interface Firewall {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
|
||||
const [selectedFirewall, setSelectedFirewall] = useState<string>("");
|
||||
interface FirewallQueryState {
|
||||
isPending: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
data?: readonly { error?: string; integration?: { id: string } }[];
|
||||
}
|
||||
|
||||
export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[]) =>
|
||||
queries.every((query) => !query.isPending && !query.isFetching) &&
|
||||
queries.every(
|
||||
(query) =>
|
||||
(query.isError && query.data === undefined) ||
|
||||
(query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
|
||||
);
|
||||
|
||||
export const hasFirewallPartialFailure = (firewallId: string, queries: readonly FirewallQueryState[]) =>
|
||||
queries.some(
|
||||
(query) =>
|
||||
query.isError ||
|
||||
query.data?.some(({ error, integration }) => integration?.id === firewallId && Boolean(error)) === true,
|
||||
);
|
||||
|
||||
const getCompactRingSize = (width: number, height: number): number => {
|
||||
if (width < 160 || height < 120) return 44;
|
||||
if (width < 256 || height < 180) return 64;
|
||||
return 100;
|
||||
};
|
||||
|
||||
export default function FirewallWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayScale = 1,
|
||||
itemId,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"firewall">) {
|
||||
const [selectedFirewall, setSelectedFirewall] = useState("");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
let responsiveWidth = width;
|
||||
let responsiveHeight = height;
|
||||
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) {
|
||||
responsiveWidth *= displayScale;
|
||||
responsiveHeight *= displayScale;
|
||||
}
|
||||
const isTiny = !isAdvanced && (responsiveWidth < 256 || responsiveHeight < 180);
|
||||
const ringSize = isAdvanced ? 100 : getCompactRingSize(responsiveWidth, responsiveHeight);
|
||||
const t = useI18n("widget.firewall");
|
||||
const tCommon = useI18n("common");
|
||||
|
||||
const handleSelect = useCallback((value: string | null) => {
|
||||
if (value !== null) {
|
||||
setSelectedFirewall(value);
|
||||
} else {
|
||||
setSelectedFirewall("default_value");
|
||||
}
|
||||
setSelectedFirewall(value ?? "");
|
||||
}, []);
|
||||
|
||||
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
|
||||
const { data: firewallsMemoryData = [] } = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery({
|
||||
const cpuQuery = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
|
||||
const memoryQuery = clientApi.widget.firewall.getFirewallMemoryStatus.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const { data: firewallsVersionData = [] } = clientApi.widget.firewall.getFirewallVersionStatus.useQuery({
|
||||
const versionQuery = clientApi.widget.firewall.getFirewallVersionStatus.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const { data: firewallsInterfacesData = [] } = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery({
|
||||
const interfacesQuery = clientApi.widget.firewall.getFirewallInterfacesStatus.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
|
||||
const isTiny = width < 256;
|
||||
const firewallsCpuData = cpuQuery.data ?? [];
|
||||
const firewallsMemoryData = memoryQuery.data ?? [];
|
||||
const firewallsVersionData = versionQuery.data ?? [];
|
||||
const firewallsInterfacesData = interfacesQuery.data ?? [];
|
||||
const queries = [cpuQuery, memoryQuery, versionQuery, interfacesQuery];
|
||||
const hasTotalFailure = hasTotalFirewallFailure(queries);
|
||||
|
||||
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,
|
||||
if (hasTotalFailure) {
|
||||
throw new Error(t("error.internalServerError"));
|
||||
}
|
||||
|
||||
const firewallMetadata = new Map(
|
||||
[...firewallsVersionData, ...firewallsCpuData, ...firewallsMemoryData, ...firewallsInterfacesData].map(
|
||||
({ integration }) => [integration.id, integration] as const,
|
||||
),
|
||||
);
|
||||
const firewallIds = [...firewallMetadata.keys()];
|
||||
const initialSelectedFirewall = firewallIds[0] ?? "";
|
||||
const activeFirewall = firewallMetadata.has(selectedFirewall) ? selectedFirewall : initialSelectedFirewall;
|
||||
const displayedFirewallIds = isAdvanced ? firewallIds : activeFirewall ? [activeFirewall] : [];
|
||||
const firewallHasError = (firewallId: string) => hasFirewallPartialFailure(firewallId, queries);
|
||||
const dropdownItems = firewallIds.map((firewallId) => ({
|
||||
label: firewallMetadata.get(firewallId)?.name ?? firewallId,
|
||||
value: firewallId,
|
||||
}));
|
||||
|
||||
const t = useI18n();
|
||||
if (firewallIds.length === 0) {
|
||||
const isLoading = queries.some((query) => query.isPending || query.isFetching);
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{isLoading ? tCommon("action.loading") : t("empty.noInterfaces")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ minHeight: 0 }}>
|
||||
<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",
|
||||
},
|
||||
]}
|
||||
<Stack gap={isTiny ? 4 : "xs"} p={isAdvanced ? "xs" : 0}>
|
||||
{!isAdvanced && (
|
||||
<Group justify="space-between" wrap="nowrap" gap={4} w="100%" p={isTiny ? 4 : "xs"}>
|
||||
<FirewallMenu
|
||||
onChange={handleSelect}
|
||||
selectedFirewall={activeFirewall}
|
||||
dropdownItems={dropdownItems}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
))}
|
||||
{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"} c="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"} c="yellow" style={{ textAlign: "left" }}>
|
||||
{formatBitsPerSec(receive, 2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))
|
||||
) : (
|
||||
<Box>No data available</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
))}
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{activeFirewall && firewallHasError(activeFirewall) && (
|
||||
<Tooltip label={t("error.internalServerError")}>
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{tCommon("error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<FirewallVersion
|
||||
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
|
||||
selectedFirewall={activeFirewall}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={isAdvanced && width >= 720 ? 2 : 1} spacing="xs">
|
||||
{displayedFirewallIds.map((firewallId) => {
|
||||
const cpu = firewallsCpuData.find(({ integration }) => integration.id === firewallId);
|
||||
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
|
||||
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
|
||||
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
|
||||
const hasError = firewallHasError(firewallId);
|
||||
const metadata = firewallMetadata.get(firewallId);
|
||||
|
||||
return (
|
||||
<FirewallPanel
|
||||
key={firewallId}
|
||||
name={metadata?.name ?? firewallId}
|
||||
kind={metadata?.kind}
|
||||
version={version?.error ? undefined : version?.summary.version}
|
||||
cpu={cpu?.error ? undefined : cpu?.summary.total}
|
||||
memory={memory?.error ? undefined : memory?.summary.percent}
|
||||
interfaces={interfaces?.summary}
|
||||
interfacesLoaded={interfaces !== undefined}
|
||||
interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
|
||||
hasError={hasError}
|
||||
isAdvanced={isAdvanced}
|
||||
ringSize={ringSize}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={t("error.internalServerError")}
|
||||
noDataLabel={t("empty.noInterfaces")}
|
||||
loadingLabel={tCommon("action.loading")}
|
||||
errorBadgeLabel={tCommon("error")}
|
||||
interfacesLabel={t("widget.interfaces.title")}
|
||||
t={t}
|
||||
tCommon={tCommon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
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]}`;
|
||||
interface FirewallPanelProps {
|
||||
name: string;
|
||||
kind?: string;
|
||||
version?: string;
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
interfaces?: FirewallInterfacesSummary[];
|
||||
interfacesLoaded: boolean;
|
||||
interfacesError: boolean;
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
ringSize: number;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
errorLabel: string;
|
||||
noDataLabel: string;
|
||||
loadingLabel: string;
|
||||
errorBadgeLabel: string;
|
||||
interfacesLabel: string;
|
||||
t: ReturnType<typeof useI18n<"widget.firewall">>;
|
||||
tCommon: ReturnType<typeof useI18n<"common">>;
|
||||
}
|
||||
|
||||
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
|
||||
const result = {
|
||||
data: [] as FirewallInterface[],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const FirewallPanel = ({
|
||||
name,
|
||||
kind,
|
||||
version,
|
||||
cpu,
|
||||
memory,
|
||||
interfaces,
|
||||
interfacesLoaded,
|
||||
interfacesError,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
ringSize,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
errorLabel,
|
||||
noDataLabel,
|
||||
loadingLabel,
|
||||
errorBadgeLabel,
|
||||
interfacesLabel,
|
||||
t,
|
||||
tCommon,
|
||||
}: FirewallPanelProps) => (
|
||||
<Paper className={classes.card} withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0} bg="transparent">
|
||||
<Stack gap="xs">
|
||||
{isAdvanced && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} truncate="end">
|
||||
{name}
|
||||
</Text>
|
||||
{kind && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{kind}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{hasError && (
|
||||
<Tooltip label={errorLabel}>
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{tCommon("error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Badge variant="outline" color="gray" size="xs">
|
||||
{formatVersion(version ?? "", t("versionUnknown"))}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
if (data.length > 1) {
|
||||
const firstData = data[0];
|
||||
const secondData = data[1];
|
||||
<Flex justify="center" align="center" wrap="wrap">
|
||||
{cpu !== undefined && (
|
||||
<MetricRing value={cpu} icon={IconCpu} size={ringSize} label={invariantTechnicalLabels.cpu} t={t} />
|
||||
)}
|
||||
{memory !== undefined && (
|
||||
<MetricRing value={memory} icon={IconBrain} size={ringSize} label={t("widget.memory")} t={t} />
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
if (firstData && secondData) {
|
||||
const time1 = new Date(firstData.timestamp);
|
||||
const time2 = new Date(secondData.timestamp);
|
||||
const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000;
|
||||
<InterfacesPanel
|
||||
summary={interfaces ?? []}
|
||||
hasResult={interfacesLoaded}
|
||||
hasError={interfacesError}
|
||||
isAdvanced={isAdvanced}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
errorLabel={errorLabel}
|
||||
errorBadgeLabel={errorBadgeLabel}
|
||||
noDataLabel={noDataLabel}
|
||||
loadingLabel={loadingLabel}
|
||||
label={interfacesLabel}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
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;
|
||||
interface MetricRingProps {
|
||||
value: number;
|
||||
icon: typeof IconCpu;
|
||||
size: number;
|
||||
label: string;
|
||||
t: ReturnType<typeof useI18n<"widget.firewall">>;
|
||||
}
|
||||
|
||||
const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
|
||||
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
const status = getMetricStatus(safeValue);
|
||||
const statusLabel = t(`status.${status}` as never);
|
||||
const showIcon = size >= 40;
|
||||
const showStatus = size >= 96;
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
aria-label={t("metricAccessible", {
|
||||
metric: label,
|
||||
value: safeValue.toFixed(1),
|
||||
status: statusLabel,
|
||||
})}
|
||||
roundCaps
|
||||
size={size}
|
||||
thickness={size < 72 ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size="xs">{safeValue.toFixed(1)}%</Text>
|
||||
{showIcon && <Icon style={zoomCompensatedSize(size < 96 ? 12 : 16)} />}
|
||||
{showStatus && <Text size="xs">{statusLabel}</Text>}
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: safeValue,
|
||||
color: status === "warning" ? "yellow" : status === "critical" ? "red" : "green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
|
||||
if (value >= 75) return "critical";
|
||||
if (value > 50) return "warning";
|
||||
return "normal";
|
||||
};
|
||||
|
||||
interface InterfacesPanelProps {
|
||||
summary: FirewallInterfacesSummary[];
|
||||
hasResult: boolean;
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
errorLabel: string;
|
||||
errorBadgeLabel: string;
|
||||
noDataLabel: string;
|
||||
loadingLabel: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const InterfacesPanel = ({
|
||||
summary,
|
||||
hasResult,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
errorLabel,
|
||||
errorBadgeLabel,
|
||||
noDataLabel,
|
||||
loadingLabel,
|
||||
label,
|
||||
}: InterfacesPanelProps) => {
|
||||
const bandwidth = calculateBandwidth(summary).data;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
value={isAdvanced ? "interfaces" : accordionValue}
|
||||
onChange={isAdvanced ? undefined : setAccordionValue}
|
||||
variant={isAdvanced ? "contained" : "default"}
|
||||
classNames={{ item: classes.accordionItem, control: classes.accordionControl }}
|
||||
>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text size="xs">{label}</Text>
|
||||
{hasError && (
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{errorBadgeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap={4}>
|
||||
{bandwidth.map(({ name, receive, transmit }) => (
|
||||
<Group key={name} gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Text
|
||||
size="xs"
|
||||
c="light-dark(var(--mantine-color-blue-8), var(--mantine-color-blue-3))"
|
||||
truncate="end"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarUp
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
|
||||
{formatBitRate(transmit)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarDown
|
||||
size="var(--mantine-font-size-xs)"
|
||||
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
|
||||
/>
|
||||
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
|
||||
{formatBitRate(receive)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
{hasError ? (
|
||||
<Text size="xs" c="red">
|
||||
{errorLabel}
|
||||
</Text>
|
||||
) : bandwidth.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{hasResult ? noDataLabel : loadingLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasFirewallPartialFailure, hasTotalFirewallFailure } from "./component";
|
||||
|
||||
const failedQuery = {
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: [{ error: "offline" }],
|
||||
};
|
||||
|
||||
describe("firewall failure state", () => {
|
||||
it("rejects only after every dimension fails", () => {
|
||||
expect(hasTotalFirewallFailure([failedQuery, failedQuery, failedQuery, failedQuery])).toBe(true);
|
||||
expect(
|
||||
hasTotalFirewallFailure([
|
||||
failedQuery,
|
||||
failedQuery,
|
||||
failedQuery,
|
||||
{ ...failedQuery, data: [{ error: undefined }] },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lets an error-only persisted cache recover while it refetches", () => {
|
||||
expect(hasTotalFirewallFailure([failedQuery, failedQuery, { ...failedQuery, isFetching: true }, failedQuery])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves stale cached data when a query-wide refetch fails", () => {
|
||||
const staleQuery = {
|
||||
...failedQuery,
|
||||
isError: true,
|
||||
data: [{ integration: { id: "firewall-1" }, error: undefined }],
|
||||
};
|
||||
|
||||
expect(hasTotalFirewallFailure([staleQuery, failedQuery, failedQuery, failedQuery])).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces query-wide and integration-specific partial failures", () => {
|
||||
const successfulQuery = {
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
data: [{ integration: { id: "firewall-1" }, error: undefined }],
|
||||
};
|
||||
|
||||
expect(hasFirewallPartialFailure("firewall-1", [successfulQuery])).toBe(false);
|
||||
expect(hasFirewallPartialFailure("firewall-1", [{ ...successfulQuery, isError: true }])).toBe(true);
|
||||
expect(
|
||||
hasFirewallPartialFailure("firewall-1", [
|
||||
{ ...successfulQuery, data: [{ integration: { id: "firewall-1" }, error: "offline" }] },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Select } from "@mantine/core";
|
||||
|
||||
import type { Firewall } from "./component";
|
||||
import classes from "./firewall.module.css";
|
||||
|
||||
interface FirewallMenuProps {
|
||||
onChange: (value: string | null) => void;
|
||||
@@ -10,16 +11,19 @@ interface FirewallMenuProps {
|
||||
}
|
||||
|
||||
export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall }: FirewallMenuProps) => (
|
||||
<Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Select
|
||||
value={selectedFirewall}
|
||||
onChange={onChange}
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
size="xs"
|
||||
w="100%"
|
||||
color="lightgray"
|
||||
data={dropdownItems}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: "24px",
|
||||
paddingInline: isTiny ? 6 : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge, Box } from "@mantine/core";
|
||||
|
||||
import type { FirewallVersionSummary } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FirewallVersionProps {
|
||||
firewallsVersionData: {
|
||||
@@ -18,24 +19,32 @@ export interface FirewallIntegration {
|
||||
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>
|
||||
);
|
||||
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => {
|
||||
const t = useI18n("widget.firewall");
|
||||
const version = firewallsVersionData.find(({ integration }) => integration.id === selectedFirewall)?.summary.version;
|
||||
|
||||
function formatVersion(inputString: string): string {
|
||||
return (
|
||||
<Box>
|
||||
<Badge
|
||||
autoContrast
|
||||
variant="outline"
|
||||
color="lightgray"
|
||||
size="xs"
|
||||
style={{ minHeight: "24px", maxWidth: isTiny ? 72 : undefined }}
|
||||
styles={{ label: { overflow: "hidden", textOverflow: "ellipsis" } }}
|
||||
>
|
||||
{formatVersion(version ?? "", t("versionUnknown"))}
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export function formatVersion(inputString: string, fallback: string): string {
|
||||
const regex = /([\d._]+)/;
|
||||
const match = regex.exec(inputString);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
} else {
|
||||
return "Unknown Version";
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,33 @@
|
||||
[data-mantine-color-scheme="light"] .card {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
.card {
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .card {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
.accordionItem {
|
||||
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.accordionItem[data-active] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.accordionControl {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.accordionControl:hover,
|
||||
.accordionControl:focus-visible,
|
||||
.accordionControl:active {
|
||||
background-color: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.4));
|
||||
}
|
||||
|
||||
.selectInput {
|
||||
background-color: transparent;
|
||||
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
|
||||
}
|
||||
|
||||
.selectInput:focus,
|
||||
.selectInput:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { IconWall, IconWallOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { getWidgetIntegrationConfig } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
|
||||
icon: IconWall,
|
||||
refetchInterval: 5,
|
||||
supportsAdvancedFocus: true,
|
||||
refetchInterval: 10,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("firewall"),
|
||||
...getWidgetIntegrationConfig("firewall"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconWallOff,
|
||||
|
||||
Reference in new issue
Block a user