Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35cac9b46b |
No files matched your search
@@ -95,8 +95,8 @@ const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSec
|
|||||||
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
||||||
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
||||||
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
||||||
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
|
"1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
|
||||||
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
|
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BeszelStatsData {
|
export interface BeszelStatsData {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.clickableCard {
|
||||||
|
transition: background-color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickableCard:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||||
|
}
|
||||||
@@ -18,13 +18,17 @@ import {
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||||
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||||
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
|
|
||||||
interface SizeConfig {
|
interface SizeConfig {
|
||||||
iconSize: number;
|
iconSize: number;
|
||||||
@@ -154,6 +158,7 @@ interface SystemCardProps {
|
|||||||
size: SizeConfig;
|
size: SizeConfig;
|
||||||
maxMetrics: number;
|
maxMetrics: number;
|
||||||
itemRadius: MantineSize;
|
itemRadius: MantineSize;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metricRenderers = [
|
const metricRenderers = [
|
||||||
@@ -310,7 +315,7 @@ const metricRenderers = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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);
|
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -319,11 +324,14 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: System
|
|||||||
radius={itemRadius}
|
radius={itemRadius}
|
||||||
bg="transparent"
|
bg="transparent"
|
||||||
h="100%"
|
h="100%"
|
||||||
|
onClick={onClick}
|
||||||
|
className={onClick ? classes.clickableCard : undefined}
|
||||||
style={{
|
style={{
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column" as const,
|
flexDirection: "column" as const,
|
||||||
border: "0.0625rem solid var(--border-color)",
|
border: "0.0625rem solid var(--border-color)",
|
||||||
|
cursor: onClick ? "pointer" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="xs" mb={2}>
|
<Group gap="xs" mb={2}>
|
||||||
@@ -353,6 +361,7 @@ export default function BeszelSystemGridWidget({
|
|||||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||||
|
|
||||||
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
@@ -385,17 +394,24 @@ export default function BeszelSystemGridWidget({
|
|||||||
overflow: scrollEnabled ? "auto" : "hidden",
|
overflow: scrollEnabled ? "auto" : "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredSystems.map((system) => (
|
{filteredSystems.map((system) => {
|
||||||
<SystemCard
|
const integrationId = system._key.split(":")[0] ?? "";
|
||||||
key={system._key}
|
const handleClick = isEditMode
|
||||||
system={system}
|
? undefined
|
||||||
options={options}
|
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
|
||||||
t={t}
|
return (
|
||||||
size={size}
|
<SystemCard
|
||||||
maxMetrics={maxMetrics}
|
key={system._key}
|
||||||
itemRadius={board.itemRadius}
|
system={system}
|
||||||
/>
|
options={options}
|
||||||
))}
|
t={t}
|
||||||
|
size={size}
|
||||||
|
maxMetrics={maxMetrics}
|
||||||
|
itemRadius={board.itemRadius}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import classes from "./component.module.css";
|
|||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { containerColors } from "../beszel/_shared/colors";
|
import { containerColors } from "../beszel/_shared/colors";
|
||||||
|
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||||
import {
|
import {
|
||||||
BeszelChartPanel,
|
BeszelChartPanel,
|
||||||
CPU_Y_AXIS_DOMAIN,
|
CPU_Y_AXIS_DOMAIN,
|
||||||
@@ -153,30 +154,31 @@ export default function BeszelSystemStatsWidget({
|
|||||||
const systemStats = activeStats?.systemStats;
|
const systemStats = activeStats?.systemStats;
|
||||||
const containerStatsRaw = activeStats?.containerStats;
|
const containerStatsRaw = activeStats?.containerStats;
|
||||||
|
|
||||||
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive);
|
const timePeriod = options.timePeriod as BeszelTimePeriod;
|
||||||
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive);
|
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
|
||||||
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive);
|
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
|
||||||
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive);
|
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
|
||||||
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive);
|
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 containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
|
||||||
const dockerCpuData = useDockerChartData(
|
const dockerCpuData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerCpu, containerStatsRaw),
|
statsWhenShown(options.showDockerCpu, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"cpu",
|
"cpu",
|
||||||
isLive,
|
timePeriod,
|
||||||
);
|
);
|
||||||
const dockerMemData = useDockerChartData(
|
const dockerMemData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"memory",
|
"memory",
|
||||||
isLive,
|
timePeriod,
|
||||||
);
|
);
|
||||||
const dockerNetData = useDockerChartData(
|
const dockerNetData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"network",
|
"network",
|
||||||
isLive,
|
timePeriod,
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerSeries = useMemo(
|
const containerSeries = useMemo(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
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 { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||||
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
|
|
||||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ export default function BeszelSystemTableWidget({
|
|||||||
width,
|
width,
|
||||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||||
const t = useScopedI18n("widget.beszelSystemTable");
|
const t = useScopedI18n("widget.beszelSystemTable");
|
||||||
|
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||||
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
||||||
@@ -255,6 +258,11 @@ export default function BeszelSystemTableWidget({
|
|||||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||||
}, [options, t, size]);
|
}, [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;
|
if (systemsError) throw systemsError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -272,6 +280,7 @@ export default function BeszelSystemTableWidget({
|
|||||||
idAccessor="_key"
|
idAccessor="_key"
|
||||||
height="100%"
|
height="100%"
|
||||||
className="beszel-table"
|
className="beszel-table"
|
||||||
|
onRowClick={isEditMode ? undefined : handleRowClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,23 @@
|
|||||||
.beszel-table .mantine-datatable-table thead,
|
.beszel-table .mantine-datatable-table thead,
|
||||||
.beszel-table .mantine-datatable-table tbody,
|
.beszel-table .mantine-datatable-table tbody,
|
||||||
.beszel-table .mantine-datatable-table tfoot,
|
.beszel-table .mantine-datatable-table tfoot,
|
||||||
.beszel-table .mantine-datatable-table tr,
|
|
||||||
.beszel-table th,
|
.beszel-table th,
|
||||||
.beszel-table td {
|
.beszel-table td {
|
||||||
background-color: transparent !important;
|
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 {
|
.beszel-table th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.beszel-table td {
|
.beszel-table td {
|
||||||
|
|||||||
@@ -8,14 +8,46 @@ import dayjs from "dayjs";
|
|||||||
|
|
||||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||||
|
|
||||||
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
|
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
|
||||||
const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss");
|
|
||||||
|
|
||||||
function prepareRecords<T>(records: T[], live: boolean) {
|
const timeFormats: Record<BeszelTimePeriod, string> = {
|
||||||
if (live) {
|
"1m": "HH:mm:ss",
|
||||||
return { fmt: formatTimeLive, ordered: records };
|
"1h": "HH:mm",
|
||||||
|
"12h": "HH:mm",
|
||||||
|
"24h": "HH:mm",
|
||||||
|
"1w": "MMM D",
|
||||||
|
"30d": "MMM D",
|
||||||
|
};
|
||||||
|
|
||||||
|
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
|
||||||
|
|
||||||
|
function prepareRecords<T>(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<string, unknown>[], 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;
|
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
|
||||||
@@ -77,7 +109,7 @@ const BeszelAreaChart = memo(
|
|||||||
dataKey="time"
|
dataKey="time"
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
withGradient={false}
|
withGradient={false}
|
||||||
connectNulls
|
connectNulls={false}
|
||||||
withDots={false}
|
withDots={false}
|
||||||
type={type}
|
type={type}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
@@ -96,16 +128,18 @@ const BeszelAreaChart = memo(
|
|||||||
export const useSystemChartData = (
|
export const useSystemChartData = (
|
||||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||||
live = false,
|
timePeriod: BeszelTimePeriod = "1h",
|
||||||
) =>
|
) =>
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!systemStats) return [];
|
if (!systemStats) return [];
|
||||||
const { fmt, ordered } = prepareRecords(systemStats, live);
|
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||||
return ordered.map((r) => ({
|
const mapped = ordered.map((r) => ({
|
||||||
time: fmt(r.created),
|
time: fmt(r.created),
|
||||||
|
rawTime: r.created,
|
||||||
...mapFn(r.stats),
|
...mapFn(r.stats),
|
||||||
}));
|
}));
|
||||||
}, [systemStats, mapFn, live]);
|
return padTimeGrid(mapped, timePeriod);
|
||||||
|
}, [systemStats, mapFn, timePeriod]);
|
||||||
|
|
||||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
||||||
const prevRef = useRef<string[]>([]);
|
const prevRef = useRef<string[]>([]);
|
||||||
@@ -144,19 +178,20 @@ export const useDockerChartData = (
|
|||||||
containerStats: BeszelContainerStatsRecord[] | undefined,
|
containerStats: BeszelContainerStatsRecord[] | undefined,
|
||||||
containerNames: string[],
|
containerNames: string[],
|
||||||
metric: "cpu" | "memory" | "network",
|
metric: "cpu" | "memory" | "network",
|
||||||
live = false,
|
timePeriod: BeszelTimePeriod = "1h",
|
||||||
) =>
|
) =>
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!containerStats?.length) return [];
|
if (!containerStats?.length) return [];
|
||||||
const extract = defaultContainerExtractors[metric];
|
const extract = defaultContainerExtractors[metric];
|
||||||
if (!extract) return [];
|
if (!extract) return [];
|
||||||
const { fmt, ordered } = prepareRecords(containerStats, live);
|
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
|
||||||
return ordered.map((record) => {
|
const mapped = ordered.map((record) => {
|
||||||
const point: Record<string, unknown> = { time: fmt(record.created) };
|
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
||||||
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
||||||
for (const name of containerNames) {
|
for (const name of containerNames) {
|
||||||
point[name] = extract(byName.get(name));
|
point[name] = extract(byName.get(name));
|
||||||
}
|
}
|
||||||
return point;
|
return point;
|
||||||
});
|
});
|
||||||
}, [containerStats, containerNames, metric, live]);
|
return padTimeGrid(mapped, timePeriod);
|
||||||
|
}, [containerStats, containerNames, metric, timePeriod]);
|
||||||
@@ -31,7 +31,10 @@ export const useBeszelFilteredSystems = (
|
|||||||
statusFilter: string,
|
statusFilter: string,
|
||||||
): SystemRowWithKey[] => {
|
): SystemRowWithKey[] => {
|
||||||
const allSystems = useMemo(
|
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],
|
[results],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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<BeszelSystemStatsModalProps>(({ innerProps }) => {
|
||||||
|
const t = useScopedI18n("widget.beszelSystemStats");
|
||||||
|
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("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 (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={timePeriod}
|
||||||
|
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
|
||||||
|
data={timePeriodOptions}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
{cpuData.length > 0 && (
|
||||||
|
<BeszelChartPanel
|
||||||
|
title={t("chart.cpu.title")}
|
||||||
|
chartProps={{
|
||||||
|
h: CHART_HEIGHT,
|
||||||
|
data: cpuData,
|
||||||
|
series: series.cpu,
|
||||||
|
yAxisFormatter: chartAxisFormatters.percent,
|
||||||
|
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||||
|
tooltipProps: tooltipPercent,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{memoryData.length > 0 && (
|
||||||
|
<BeszelChartPanel
|
||||||
|
title={t("chart.memory.title")}
|
||||||
|
chartProps={{
|
||||||
|
h: CHART_HEIGHT,
|
||||||
|
data: memoryData,
|
||||||
|
type: "stacked",
|
||||||
|
series: series.memory,
|
||||||
|
yAxisFormatter: chartAxisFormatters.gb,
|
||||||
|
tooltipProps: tooltipGB,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{diskData.length > 0 && (
|
||||||
|
<BeszelChartPanel
|
||||||
|
title={t("chart.disk.title")}
|
||||||
|
chartProps={{
|
||||||
|
h: CHART_HEIGHT,
|
||||||
|
data: diskData,
|
||||||
|
series: series.disk,
|
||||||
|
yAxisFormatter: chartAxisFormatters.gb,
|
||||||
|
tooltipProps: tooltipGB,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{diskIOData.length > 0 && (
|
||||||
|
<BeszelChartPanel
|
||||||
|
title={t("chart.diskIO.title")}
|
||||||
|
chartProps={{
|
||||||
|
h: CHART_HEIGHT,
|
||||||
|
data: diskIOData,
|
||||||
|
series: series.diskIO,
|
||||||
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
|
tooltipProps: tooltipRate,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{networkData.length > 0 && (
|
||||||
|
<BeszelChartPanel
|
||||||
|
title={t("chart.network.title")}
|
||||||
|
chartProps={{
|
||||||
|
h: CHART_HEIGHT,
|
||||||
|
data: networkData,
|
||||||
|
series: series.network,
|
||||||
|
yAxisFormatter: chartAxisFormatters.rate,
|
||||||
|
tooltipProps: tooltipRate,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle: (t) => t("widget.beszelSystemStats.name"),
|
||||||
|
size: 1200,
|
||||||
|
});
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
const styles: Record<string, CSSProperties> = {
|
const styles: Record<string, CSSProperties> = {
|
||||||
wrapper: {
|
wrapper: {
|
||||||
@@ -40,6 +44,7 @@ export interface TooltipPayloadItem {
|
|||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
dataKey: string;
|
dataKey: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortalTooltipProps {
|
interface PortalTooltipProps {
|
||||||
@@ -50,16 +55,39 @@ interface PortalTooltipProps {
|
|||||||
showTotal?: boolean;
|
showTotal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MARGIN = 14;
|
||||||
|
|
||||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||||
const mouseRef = useRef({ x: 0, y: 0 });
|
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 rafRef = useRef(0);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleMouse = useCallback((e: MouseEvent) => {
|
const handleMouse = useCallback((e: MouseEvent) => {
|
||||||
mouseRef.current = { x: e.clientX, y: e.clientY };
|
mouseRef.current = { x: e.clientX, y: e.clientY };
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
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;
|
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 total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||||
|
|
||||||
const portalStyle: CSSProperties = {
|
const portalStyle: CSSProperties = {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: mouse.x + 14,
|
left: pos.x,
|
||||||
top: mouse.y,
|
top: pos.y,
|
||||||
transform: "translateY(-50%)",
|
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div style={portalStyle}>
|
<div ref={tooltipRef} style={portalStyle}>
|
||||||
<div style={styles.wrapper}>
|
<div style={styles.wrapper}>
|
||||||
<div style={styles.header}>{label}</div>
|
<div style={styles.header}>{tooltipLabel}</div>
|
||||||
{sorted.map((item) => (
|
{sorted.map((item) => (
|
||||||
<div key={item.dataKey} style={styles.row}>
|
<div key={item.dataKey} style={styles.row}>
|
||||||
<div style={{ ...styles.indicator, background: item.color }} />
|
<div style={{ ...styles.indicator, background: item.color }} />
|
||||||
|
|||||||
Reference in new issue
Block a user