"use client"; import { memo, useMemo, useRef } 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"; import { formatLocalizedDate } from "../../common/locale"; export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"; const timeFormatOptions: Record = { "1m": { hour: "numeric", minute: "2-digit", second: "2-digit" }, "1h": { hour: "numeric", minute: "2-digit" }, "12h": { hour: "numeric", minute: "2-digit" }, "24h": { hour: "numeric", minute: "2-digit" }, "1w": { month: "short", day: "numeric" }, "30d": { month: "short", day: "numeric" }, }; const periodDays: Partial> = { "1w": 7, "30d": 30 }; export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string => formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]); function prepareRecords(records: T[], timePeriod: BeszelTimePeriod, locale: string) { const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale); if (timePeriod === "1m") { return { fmt, ordered: records }; } return { fmt, ordered: records.toReversed() }; } function padTimeGrid(data: Record[], timePeriod: BeszelTimePeriod, locale: string) { if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale); const days = periodDays[timePeriod]; if (!days) return data; const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale); const now = dayjs(); const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD"))); const result = [...data]; for (let i = days; i >= 0; i--) { const d = now.subtract(i, "day").startOf("day"); const key = d.format("YYYY-MM-DD"); if (!existingDays.has(key)) { const iso = d.toISOString(); result.push({ time: fmt(iso), rawTime: iso }); } } return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime()); } export function padLiveTimeGrid(data: Record[], pointCount = 60, locale = "en-US") { if (data.length === 0) return data; const end = dayjs( data.reduce((latest, point) => { const timestamp = new Date(point.rawTime as string).getTime(); return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest; }, 0), ).startOf("second"); const pointsBySecond = new Map( data.map( (point) => [ dayjs(point.rawTime as string) .startOf("second") .valueOf(), point, ] as const, ), ); return Array.from({ length: pointCount }, (_, index) => { const timestamp = end.subtract(pointCount - index - 1, "second"); return ( pointsBySecond.get(timestamp.valueOf()) ?? { time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale), rawTime: timestamp.toISOString(), } ); }); } const yAxisBase = { tickMargin: 0, tick: { fontSize: "var(--mantine-font-size-xs)" } } as const; const chartStyle = { minWidth: 0, minHeight: 1 } as const; const panelStyle = { minWidth: 0, overflow: "hidden" } as const; export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"]; interface BeszelChartPanelProps { title: string; subtitle?: string; chartProps: BeszelAreaChartProps; } export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => ( {title} {subtitle && ( {subtitle} )} )); type BeszelAreaChartProps = Omit & { yAxisFormatter: (value: number) => string; yAxisDomain?: [number, string]; }; const BeszelAreaChart = memo( ({ yAxisFormatter, yAxisDomain, yAxisProps: yAxisPropsOverride, type = "default", ...props }: BeszelAreaChartProps) => { const mergedYAxis = useMemo(() => { const base = { ...yAxisBase, width: 48, tickMargin: 2, tickFormatter: yAxisFormatter, ...yAxisPropsOverride, }; if (yAxisDomain) { return { ...base, domain: yAxisDomain }; } return base; }, [yAxisFormatter, yAxisDomain, yAxisPropsOverride]); return ( ); }, ); export const useSystemChartData = ( systemStats: BeszelSystemStatsRecord[] | undefined, mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record, timePeriod: BeszelTimePeriod = "1h", locale = "en-US", ) => useMemo(() => { if (!systemStats) return []; const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale); const mapped = ordered.map((r) => ({ time: fmt(r.created), rawTime: r.created, ...mapFn(r.stats), })); return padTimeGrid(mapped, timePeriod, locale); }, [systemStats, mapFn, timePeriod, locale]); export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => { const prevRef = useRef([]); return useMemo(() => { if (!containerStats?.length) { if (prevRef.current.length === 0) return prevRef.current; prevRef.current = []; return prevRef.current; } const names = new Set(); for (const record of containerStats) { for (const c of record.stats) { names.add(c.n); } } const next = [...names].slice(0, max); const prev = prevRef.current; if (prev.length === next.length && prev.every((n, i) => n === next[i])) return prev; prevRef.current = next; return next; }, [containerStats, max]); }; type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number; // c = CPU (%), m = memory (MB), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s) const MB = 1024 * 1024; const defaultContainerExtractors: Record = { cpu: (c) => c?.c ?? 0, memory: (c) => (c?.m ?? 0) * MB, network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)), }; export const useDiskChartData = ( systemStats: BeszelSystemStatsRecord[] | undefined, efsPaths: string[], rootSeriesName: string, timePeriod: BeszelTimePeriod = "1h", locale = "en-US", ) => useMemo( () => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale), [systemStats, efsPaths, rootSeriesName, timePeriod, locale], ); export const buildDiskChartData = ( systemStats: BeszelSystemStatsRecord[] | undefined, efsPaths: string[], rootSeriesName: string, timePeriod: BeszelTimePeriod = "1h", locale = "en-US", ) => { if (!systemStats?.length) return []; const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale); const mapped = ordered.map((record) => { const point: Record = { time: fmt(record.created), rawTime: record.created, [rootSeriesName]: record.stats.du, }; const efs = record.stats.efs ?? {}; for (const path of efsPaths) { point[path] = efs[path]?.du ?? 0; } return point; }); return padTimeGrid(mapped, timePeriod, locale); }; export const useDockerChartData = ( containerStats: BeszelContainerStatsRecord[] | undefined, containerNames: string[], metric: "cpu" | "memory" | "network", timePeriod: BeszelTimePeriod = "1h", locale = "en-US", ) => useMemo(() => { if (!containerStats?.length) return []; const extract = defaultContainerExtractors[metric]; if (!extract) return []; const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale); const mapped = ordered.map((record) => { const point: Record = { time: fmt(record.created), rawTime: record.created }; const byName = new Map(record.stats.map((c) => [c.n, c])); for (const name of containerNames) { point[name] = extract(byName.get(name)); } return point; }); return padTimeGrid(mapped, timePeriod, locale); }, [containerStats, containerNames, metric, timePeriod, locale]);