diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx new file mode 100644 index 000000000..5a2ba1d58 --- /dev/null +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -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) => ( + + + {title} + {subtitle && {subtitle}} + + {children} + +); + +type BeszelAreaChartProps = Omit & { + yAxisFormatter: (value: number) => string; + yAxisDomain?: [number, string]; +}; + +export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, ...props }: BeszelAreaChartProps) => ( + +); + +export const useSystemChartData = ( + systemStats: BeszelSystemStatsRecord[] | undefined, + mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record, +) => + 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(); + 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 = { + 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 = { time: formatTime(record.created) }; + for (const name of containerNames) { + point[name] = extract(record.stats.find((c) => c.n === name)); + } + return point; + }); + }, [containerStats, containerNames, metric]); diff --git a/packages/widgets/src/beszel/_shared/hooks.ts b/packages/widgets/src/beszel/_shared/hooks.ts new file mode 100644 index 000000000..9d48256af --- /dev/null +++ b/packages/widgets/src/beszel/_shared/hooks.ts @@ -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]); +}; diff --git a/packages/widgets/src/beszel/_shared/tooltip.tsx b/packages/widgets/src/beszel/_shared/tooltip.tsx new file mode 100644 index 000000000..83d3984e1 --- /dev/null +++ b/packages/widgets/src/beszel/_shared/tooltip.tsx @@ -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 = { + 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( + + + {label} + {sorted.map((item) => ( + + + {item.name} + {formatter(item.value)} + + ))} + {showTotal && sorted.length > 1 && ( + + Total + {formatter(total)} + + )} + + , + document.body, + ); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({ + content: (props: any) => ( + + ), + wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 }, + isAnimationActive: false, +});