refactor(beszel): extract shared hooks, chart helpers, and tooltip
Create useBeszelSystemsSubscription and useBeszelFilteredSystems hooks to eliminate duplicated subscription + cache-update + filtering logic across grid/table/alerts. Add BeszelAreaChart wrapper and chart data hooks (useSystemChartData, useDockerChartData) to reduce ~90 lines of repetitive useMemo transforms in system-stats. Add portal tooltip with mousemove listener gated on active state to prevent unnecessary re-renders during drag-and-drop.
This commit is contained in:
1 parent
59d44933cf
commit
3131687229
3 files changed
+272
No files matched your search
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import type { AreaChartProps } from "@mantine/charts";
|
||||
import { AreaChart } from "@mantine/charts";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
|
||||
|
||||
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
|
||||
|
||||
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
|
||||
|
||||
interface ChartPanelProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>{title}</Text>
|
||||
{subtitle && <Text size="xs" c="dimmed">{subtitle}</Text>}
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
|
||||
yAxisFormatter: (value: number) => string;
|
||||
yAxisDomain?: [number, string];
|
||||
};
|
||||
|
||||
export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, ...props }: BeszelAreaChartProps) => (
|
||||
<AreaChart
|
||||
dataKey="time"
|
||||
curveType="monotone"
|
||||
withDots={false}
|
||||
withXAxis
|
||||
withYAxis
|
||||
yAxisProps={{ ...yAxisBase, ...(yAxisDomain ? { domain: yAxisDomain } : {}), tickFormatter: yAxisFormatter, ...yAxisProps }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const useSystemChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!systemStats) return [];
|
||||
return [...systemStats].reverse().map((r) => ({
|
||||
time: formatTime(r.created),
|
||||
...mapFn(r.stats),
|
||||
}));
|
||||
}, [systemStats, mapFn]);
|
||||
|
||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const names = new Set<string>();
|
||||
for (const record of containerStats) {
|
||||
for (const c of record.stats) {
|
||||
names.add(c.n);
|
||||
}
|
||||
}
|
||||
return [...names].slice(0, max);
|
||||
}, [containerStats, max]);
|
||||
|
||||
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
|
||||
|
||||
const defaultContainerExtractors: Record<string, ContainerExtractor> = {
|
||||
cpu: (c) => c?.c ?? 0,
|
||||
memory: (c) => c?.m ?? 0,
|
||||
network: (c) => (c?.ns ?? 0) + (c?.nr ?? 0),
|
||||
};
|
||||
|
||||
export const useDockerChartData = (
|
||||
containerStats: BeszelContainerStatsRecord[] | undefined,
|
||||
containerNames: string[],
|
||||
metric: "cpu" | "memory" | "network",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const extract = defaultContainerExtractors[metric]!;
|
||||
return [...containerStats].reverse().map((record) => {
|
||||
const point: Record<string, unknown> = { time: formatTime(record.created) };
|
||||
for (const name of containerNames) {
|
||||
point[name] = extract(record.stats.find((c) => c.n === name));
|
||||
}
|
||||
return point;
|
||||
});
|
||||
}, [containerStats, containerNames, metric]);
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { BeszelSystemRow } from "./types";
|
||||
|
||||
type SystemRowWithKey = BeszelSystemRow & { _key: string };
|
||||
|
||||
export const useBeszelSystemsSubscription = (integrationIds: string[], enabled = true) => {
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.beszel.subscribeSystems.useSubscription(
|
||||
{ integrationIds },
|
||||
{
|
||||
enabled,
|
||||
onData(data) {
|
||||
utils.widget.beszel.getSystems.setData({ integrationIds }, (prev) => {
|
||||
if (!prev) return prev;
|
||||
return prev.map((r) =>
|
||||
r.integrationId === data.integrationId ? { ...r, systems: data.systems, updatedAt: data.timestamp } : r,
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useBeszelFilteredSystems = (
|
||||
results: { integrationId: string; systems: BeszelSystemRow[] }[],
|
||||
statusFilter: string,
|
||||
): SystemRowWithKey[] => {
|
||||
const allSystems = useMemo(
|
||||
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
|
||||
[results],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (statusFilter === "all") return allSystems;
|
||||
return allSystems.filter((s) => s.status === statusFilter);
|
||||
}, [allSystems, statusFilter]);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
wrapper: {
|
||||
background: "var(--mantine-color-dark-7)",
|
||||
border: "1px solid var(--mantine-color-dark-4)",
|
||||
borderRadius: 6,
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
pointerEvents: "none",
|
||||
maxHeight: 320,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
},
|
||||
header: { marginBottom: 4, fontWeight: 500, color: "var(--mantine-color-gray-2)" },
|
||||
row: { display: "flex", alignItems: "center", gap: 6, padding: "1px 0" },
|
||||
indicator: { width: 3, height: 14, borderRadius: 1, flexShrink: 0 },
|
||||
name: {
|
||||
flex: 1,
|
||||
color: "var(--mantine-color-gray-3)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 140,
|
||||
},
|
||||
value: {
|
||||
fontWeight: 700,
|
||||
color: "var(--mantine-color-gray-1)",
|
||||
whiteSpace: "nowrap",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
},
|
||||
separator: { borderTop: "1px solid var(--mantine-color-dark-4)", marginTop: 3, paddingTop: 3 },
|
||||
};
|
||||
|
||||
export interface TooltipPayloadItem {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
dataKey: string;
|
||||
}
|
||||
|
||||
interface PortalTooltipProps {
|
||||
active?: boolean;
|
||||
label?: string;
|
||||
payload?: TooltipPayloadItem[];
|
||||
formatter: (value: number) => string;
|
||||
showTotal?: boolean;
|
||||
}
|
||||
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
const [mouse, setMouse] = useState({ x: 0, y: 0 });
|
||||
const rafRef = useRef(0);
|
||||
|
||||
const handleMouse = useCallback((e: MouseEvent) => {
|
||||
mouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setMouse(mouseRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isActive = active && (payload?.length ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
document.addEventListener("mousemove", handleMouse);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouse);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [isActive, handleMouse]);
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
const sorted = [...(payload ?? [])]
|
||||
.filter((p) => p.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (!sorted.length) return null;
|
||||
|
||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
const portalStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
left: mouse.x + 14,
|
||||
top: mouse.y,
|
||||
transform: "translateY(-50%)",
|
||||
zIndex: 10000,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div style={portalStyle}>
|
||||
<div style={styles.wrapper}>
|
||||
<div style={styles.header}>{label}</div>
|
||||
{sorted.map((item) => (
|
||||
<div key={item.dataKey} style={styles.row}>
|
||||
<div style={{ ...styles.indicator, background: item.color }} />
|
||||
<span style={styles.name}>{item.name}</span>
|
||||
<span style={styles.value}>{formatter(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{showTotal && sorted.length > 1 && (
|
||||
<div style={{ ...styles.row, ...styles.separator }}>
|
||||
<span style={styles.name}>Total</span>
|
||||
<span style={styles.value}>{formatter(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
|
||||
content: (props: any) => (
|
||||
<PortalTooltipContent
|
||||
active={props.active}
|
||||
label={props.label != null ? String(props.label) : undefined}
|
||||
payload={props.payload}
|
||||
formatter={formatter}
|
||||
showTotal={showTotal}
|
||||
/>
|
||||
),
|
||||
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
|
||||
isAnimationActive: false,
|
||||
});
|
||||
Reference in new issue
Block a user