feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:07 +02:00
1 parent f3048f4804
commit a2763d1f6f
261 files changed
+9392 -2697

No files matched your search

@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { calculateBandwidth, formatBitsPerSec } 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("formatBitsPerSec", () => {
test("handles invalid and negative rates", () => {
expect(formatBitsPerSec(Number.NaN, 2)).toBe("0 b/s");
expect(formatBitsPerSec(-1, 2)).toBe("0 b/s");
});
});
@@ -0,0 +1,39 @@
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
export function formatBitsPerSec(bitsPerSecond: number, decimals: number): string {
if (!Number.isFinite(bitsPerSecond) || bitsPerSecond <= 0) return "0 b/s";
const unitSize = 1024;
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
const unitIndex = Math.min(Math.floor(Math.log(bitsPerSecond) / Math.log(unitSize)), sizes.length - 1);
return `${parseFloat((bitsPerSecond / Math.pow(unitSize, unitIndex)).toFixed(decimals))} ${sizes[unitIndex]}`;
}
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),
},
];
}),
};
}
+277 -185
View File
@@ -1,32 +1,51 @@
"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 type { FirewallInterfacesSummary } from "@homarr/integrations";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
import { FirewallVersion } from "./firewall-version";
import { FirewallVersion, formatVersion } from "./firewall-version";
export interface Firewall {
label: string;
value: string;
}
export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState<string>("");
export default function FirewallWidget({
integrationIds,
width,
itemId,
displayMode,
}: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
const t = useI18n();
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 });
@@ -40,202 +59,275 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
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 initialSelectedFirewall = firewallsVersionData[0]?.integration.id ?? "";
const activeFirewall = firewallsVersionData.some(({ integration }) => integration.id === selectedFirewall)
? selectedFirewall
: initialSelectedFirewall;
const displayedFirewallIds = isAdvanced
? firewallsVersionData.map(({ integration }) => integration.id)
: activeFirewall
? [activeFirewall]
: [];
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",
},
]}
<Stack gap="xs" p={isAdvanced ? "xs" : 0}>
{!isAdvanced && (
<Group justify="space-between" w="100%" p="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",
},
]}
<FirewallVersion
firewallsVersionData={firewallsVersionData}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
))}
</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>
)}
<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 = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
return (
<FirewallPanel
key={firewallId}
name={version?.integration.name ?? cpu?.integration.name ?? firewallId}
kind={version?.integration.kind}
version={version?.summary.version}
cpu={cpu?.summary.total}
memory={memory?.summary.percent}
interfaces={interfaces?.summary}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={t("widget.firewall.error.internalServerError")}
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
t={t}
/>
);
})}
</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[];
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
noDataLabel: string;
interfacesLabel: string;
t: TranslationFunction;
}
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
const result = {
data: [] as FirewallInterface[],
timestamp: new Date().toISOString(),
};
const FirewallPanel = ({
name,
kind,
version,
cpu,
memory,
interfaces,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
noDataLabel,
interfacesLabel,
t,
}: FirewallPanelProps) => (
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
<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={noDataLabel}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
</Badge>
</Tooltip>
)}
<Badge variant="outline" color="gray" size="xs">
{formatVersion(version ?? "", t("widget.firewall.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} isTiny={isTiny} label={t("widget.firewall.widget.cpu")} t={t} />
)}
{memory !== undefined && (
<MetricRing
value={memory}
icon={IconBrain}
isTiny={isTiny}
label={t("widget.firewall.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 ?? []}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={noDataLabel}
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;
isTiny: boolean;
label: string;
t: TranslationFunction;
}
const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) => {
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const status = getMetricStatus(safeValue);
const statusLabel = t(`widget.firewall.status.${status}`);
return (
<RingProgress
aria-label={t("widget.firewall.metricAccessible", {
metric: label,
value: safeValue.toFixed(1),
status: statusLabel,
})}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
<Icon size={isTiny ? 8 : 16} />
{!isTiny && <Text size="8px">{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[];
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
noDataLabel: string;
label: string;
}
const InterfacesPanel = ({
summary,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
noDataLabel,
label,
}: InterfacesPanelProps) => {
const bandwidth = calculateBandwidth(summary).data;
return (
<Accordion
value={isAdvanced ? "interfaces" : accordionValue}
onChange={isAdvanced ? undefined : setAccordionValue}
variant={isAdvanced ? "contained" : "default"}
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
{bandwidth.length > 0 ? (
bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
{name}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
{formatBitsPerSec(transmit, 2)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
</Group>
))
) : (
<Text size="xs" c="dimmed">
{noDataLabel}
</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
};
@@ -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,34 @@ 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();
function formatVersion(inputString: string): string {
return (
<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, t("widget.firewall.versionUnknown"))}</span>
))}
</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
View File
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));