diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index a59309201..2f6d0b7ef 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -95,8 +95,8 @@ const timePeriodConfig: Record void; } const metricRenderers = [ @@ -310,7 +315,7 @@ const metricRenderers = [ }, ] as const; -const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: SystemCardProps) => { +const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => { const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); return ( @@ -319,11 +324,14 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: System radius={itemRadius} bg="transparent" h="100%" + onClick={onClick} + className={onClick ? classes.clickableCard : undefined} style={{ overflow: "hidden", display: "flex", flexDirection: "column" as const, border: "0.0625rem solid var(--border-color)", + cursor: onClick ? "pointer" : undefined, }} > @@ -353,6 +361,7 @@ export default function BeszelSystemGridWidget({ }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); + const { openModal } = useModalAction(BeszelSystemStatsModal); const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, @@ -385,17 +394,24 @@ export default function BeszelSystemGridWidget({ overflow: scrollEnabled ? "auto" : "hidden", }} > - {filteredSystems.map((system) => ( - - ))} + {filteredSystems.map((system) => { + const integrationId = system._key.split(":")[0] ?? ""; + const handleClick = isEditMode + ? undefined + : () => openModal({ integrationId, systemId: system.id }, { title: system.name }); + return ( + + ); + })} ); } diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index a0e1842a8..dca533848 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -15,6 +15,7 @@ import classes from "./component.module.css"; import type { WidgetComponentProps } from "../definition"; import { containerColors } from "../beszel/_shared/colors"; +import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, @@ -153,30 +154,31 @@ export default function BeszelSystemStatsWidget({ const systemStats = activeStats?.systemStats; const containerStatsRaw = activeStats?.containerStats; - const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive); - const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive); - const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive); - const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive); - const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive); + const timePeriod = options.timePeriod as BeszelTimePeriod; + const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod); + const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod); + const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod); + const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod); + const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod); const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw)); const dockerCpuData = useDockerChartData( statsWhenShown(options.showDockerCpu, containerStatsRaw), containerNames, "cpu", - isLive, + timePeriod, ); const dockerMemData = useDockerChartData( statsWhenShown(options.showDockerMemory, containerStatsRaw), containerNames, "memory", - isLive, + timePeriod, ); const dockerNetData = useDockerChartData( statsWhenShown(options.showDockerNetwork, containerStatsRaw), containerNames, "network", - isLive, + timePeriod, ); const containerSeries = useMemo( diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 02ae5d822..a540fbee4 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { clientApi } from "@homarr/api/client"; +import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; @@ -27,6 +28,7 @@ import type { BeszelSystemRow } from "../beszel/_shared/types"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; +import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; const directionMultiplier: Record = { asc: 1, desc: -1 }; @@ -66,6 +68,7 @@ export default function BeszelSystemTableWidget({ width, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); + const { openModal } = useModalAction(BeszelSystemStatsModal); const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( { integrationIds }, { staleTime: 5_000, refetchInterval: 5_000, retry: false }, @@ -255,6 +258,11 @@ export default function BeszelSystemTableWidget({ return cols.filter(Boolean) as DataTableColumn[]; }, [options, t, size]); + const handleRowClick = ({ record }: { record: SystemRowWithKey }) => { + const integrationId = record._key.split(":")[0] ?? ""; + openModal({ integrationId, systemId: record.id }, { title: record.name }); + }; + if (systemsError) throw systemsError; return ( @@ -272,6 +280,7 @@ export default function BeszelSystemTableWidget({ idAccessor="_key" height="100%" className="beszel-table" + onRowClick={isEditMode ? undefined : handleRowClick} /> ); } diff --git a/packages/widgets/src/beszel-system-table/styles.css b/packages/widgets/src/beszel-system-table/styles.css index 0ec492d76..cdfd682f9 100644 --- a/packages/widgets/src/beszel-system-table/styles.css +++ b/packages/widgets/src/beszel-system-table/styles.css @@ -6,14 +6,23 @@ .beszel-table .mantine-datatable-table thead, .beszel-table .mantine-datatable-table tbody, .beszel-table .mantine-datatable-table tfoot, -.beszel-table .mantine-datatable-table tr, .beszel-table th, .beszel-table td { background-color: transparent !important; } +.beszel-table .mantine-datatable-table tr { + background-color: transparent; +} + +.beszel-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; +} + .beszel-table th { white-space: nowrap; + background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + backdrop-filter: blur(8px); } .beszel-table td { diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 8a5c41b18..8d8a5e319 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -8,14 +8,46 @@ import dayjs from "dayjs"; import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; -const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); -const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss"); +export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"; -function prepareRecords(records: T[], live: boolean) { - if (live) { - return { fmt: formatTimeLive, ordered: records }; +const timeFormats: Record = { + "1m": "HH:mm:ss", + "1h": "HH:mm", + "12h": "HH:mm", + "24h": "HH:mm", + "1w": "MMM D", + "30d": "MMM D", +}; + +const periodDays: Partial> = { "1w": 7, "30d": 30 }; + +function prepareRecords(records: T[], timePeriod: BeszelTimePeriod) { + const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]); + if (timePeriod === "1m") { + return { fmt, ordered: records }; } - return { fmt: formatTime, ordered: [...records].toReversed() }; + return { fmt, ordered: [...records].toReversed() }; +} + +function padTimeGrid(data: Record[], timePeriod: BeszelTimePeriod) { + const days = periodDays[timePeriod]; + if (!days) return data; + + const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]); + 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()); } const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; @@ -77,7 +109,7 @@ const BeszelAreaChart = memo( dataKey="time" curveType="monotone" withGradient={false} - connectNulls + connectNulls={false} withDots={false} type={type} strokeWidth={1} @@ -96,16 +128,18 @@ const BeszelAreaChart = memo( export const useSystemChartData = ( systemStats: BeszelSystemStatsRecord[] | undefined, mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record, - live = false, + timePeriod: BeszelTimePeriod = "1h", ) => useMemo(() => { if (!systemStats) return []; - const { fmt, ordered } = prepareRecords(systemStats, live); - return ordered.map((r) => ({ + const { fmt, ordered } = prepareRecords(systemStats, timePeriod); + const mapped = ordered.map((r) => ({ time: fmt(r.created), + rawTime: r.created, ...mapFn(r.stats), })); - }, [systemStats, mapFn, live]); + return padTimeGrid(mapped, timePeriod); + }, [systemStats, mapFn, timePeriod]); export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => { const prevRef = useRef([]); @@ -144,19 +178,20 @@ export const useDockerChartData = ( containerStats: BeszelContainerStatsRecord[] | undefined, containerNames: string[], metric: "cpu" | "memory" | "network", - live = false, + timePeriod: BeszelTimePeriod = "1h", ) => useMemo(() => { if (!containerStats?.length) return []; const extract = defaultContainerExtractors[metric]; if (!extract) return []; - const { fmt, ordered } = prepareRecords(containerStats, live); - return ordered.map((record) => { - const point: Record = { time: fmt(record.created) }; + const { fmt, ordered } = prepareRecords(containerStats, timePeriod); + 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; }); - }, [containerStats, containerNames, metric, live]); + return padTimeGrid(mapped, timePeriod); + }, [containerStats, containerNames, metric, timePeriod]); diff --git a/packages/widgets/src/beszel/_shared/hooks.ts b/packages/widgets/src/beszel/_shared/hooks.ts index 9d48256af..ebb84b4eb 100644 --- a/packages/widgets/src/beszel/_shared/hooks.ts +++ b/packages/widgets/src/beszel/_shared/hooks.ts @@ -31,7 +31,10 @@ export const useBeszelFilteredSystems = ( statusFilter: string, ): SystemRowWithKey[] => { const allSystems = useMemo( - () => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))), + () => + results + .flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))) + .toSorted((a, b) => a.name.localeCompare(b.name)), [results], ); diff --git a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx new file mode 100644 index 000000000..36d26ad1a --- /dev/null +++ b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { createModal } from "@homarr/modals"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { BeszelTimePeriod } from "./chart"; +import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart"; +import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format"; +import { makeTooltipProps } from "./tooltip"; + +const tooltipPercent = makeTooltipProps(formatPercent); +const tooltipGB = makeTooltipProps(formatGB, true); +const tooltipRate = makeTooltipProps(formatByteRate, true); + +const CHART_HEIGHT = 180; +const MB = 1024 * 1024; + +const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [ + { value: "1h", label: "1H" }, + { value: "12h", label: "12H" }, + { value: "24h", label: "24H" }, + { value: "1w", label: "1W" }, + { value: "30d", label: "30D" }, +]; + +interface BeszelSystemStatsModalProps { + integrationId: string; + systemId: string; +} + +export const BeszelSystemStatsModal = createModal(({ innerProps }) => { + const t = useScopedI18n("widget.beszelSystemStats"); + const [timePeriod, setTimePeriod] = useState("1h"); + + const { data } = clientApi.widget.beszel.getSystemStats.useQuery( + { + integrationIds: [innerProps.integrationId], + systemId: innerProps.systemId, + timePeriod, + includeDocker: false, + }, + { staleTime: 30_000 }, + ); + + const mappers = useMemo( + () => ({ + cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), + memory: (s: { mu: number; mb: number }) => ({ + [t("chart.memory.series")]: s.mu, + [t("chart.memory.cache")]: s.mb ?? 0, + }), + disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }), + diskIO: (s: { dr?: number; dw?: number }) => ({ + [t("chart.diskIO.read")]: (s.dr ?? 0) * MB, + [t("chart.diskIO.write")]: (s.dw ?? 0) * MB, + }), + network: (s: { ns: number; nr: number; b?: [number, number] }) => ({ + [t("chart.network.sent")]: s.b?.[0] ?? s.ns, + [t("chart.network.recv")]: s.b?.[1] ?? s.nr, + }), + }), + [t], + ); + + const series = useMemo( + () => ({ + cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }], + memory: [ + { name: t("chart.memory.series"), color: "teal.5" }, + { name: t("chart.memory.cache"), color: "teal.8" }, + ], + disk: [{ name: t("chart.disk.series"), color: "grape.6" }], + diskIO: [ + { name: t("chart.diskIO.write"), color: "orange.6" }, + { name: t("chart.diskIO.read"), color: "blue.6" }, + ], + network: [ + { name: t("chart.network.sent"), color: "blue.6" }, + { name: t("chart.network.recv"), color: "teal.6" }, + ], + }), + [t], + ); + + const systemStats = data?.systemStats; + const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod); + const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod); + const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod); + const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod); + const networkData = useSystemChartData(systemStats, mappers.network, timePeriod); + + return ( + + setTimePeriod(v as BeszelTimePeriod)} + data={timePeriodOptions} + /> + + {cpuData.length > 0 && ( + + )} + {memoryData.length > 0 && ( + + )} + {diskData.length > 0 && ( + + )} + {diskIOData.length > 0 && ( + + )} + {networkData.length > 0 && ( + + )} + + + ); +}).withOptions({ + defaultTitle: (t) => t("widget.beszelSystemStats.name"), + size: 1200, +}); diff --git a/packages/widgets/src/beszel/_shared/tooltip.tsx b/packages/widgets/src/beszel/_shared/tooltip.tsx index a670af67d..b43ea1765 100644 --- a/packages/widgets/src/beszel/_shared/tooltip.tsx +++ b/packages/widgets/src/beszel/_shared/tooltip.tsx @@ -3,6 +3,10 @@ import type { CSSProperties } from "react"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useRef, useState } from "react"; +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; + +dayjs.extend(localizedFormat); const styles: Record = { wrapper: { @@ -40,6 +44,7 @@ export interface TooltipPayloadItem { value: number; color: string; dataKey: string; + payload?: Record; } interface PortalTooltipProps { @@ -50,16 +55,39 @@ interface PortalTooltipProps { showTotal?: boolean; } +const MARGIN = 14; + const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => { const mouseRef = useRef({ x: 0, y: 0 }); - const [mouse, setMouse] = useState({ x: 0, y: 0 }); + const [pos, setPos] = useState({ x: 0, y: 0 }); const rafRef = useRef(0); + const tooltipRef = useRef(null); const handleMouse = useCallback((e: MouseEvent) => { mouseRef.current = { x: e.clientX, y: e.clientY }; cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { - setMouse(mouseRef.current); + const el = tooltipRef.current; + const mx = mouseRef.current.x; + const my = mouseRef.current.y; + + if (!el) { + setPos({ x: mx + MARGIN, y: my }); + return; + } + + const { width, height } = el.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let x = mx + MARGIN; + let y = my - height / 2; + + if (x + width > vw - MARGIN) x = mx - width - MARGIN; + if (y < MARGIN) y = MARGIN; + if (y + height > vh - MARGIN) y = vh - MARGIN - height; + + setPos({ x, y }); }); }, []); @@ -84,21 +112,22 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: if (!sorted.length) return null; + const rawTime = sorted[0]?.payload?.rawTime as string | undefined; + const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label; 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%)", + left: pos.x, + top: pos.y, zIndex: 10000, pointerEvents: "none", }; return createPortal( -
+
-
{label}
+
{tooltipLabel}
{sorted.map((item) => (