Compare commits

...
Author SHA1 Message Date
ajnart e29ecf748a fix(icon-picker): keep URL visible in edit mode, add SVG-first ordering
Three UX bugs in the icon picker:

1. Skeleton-forever on edit screens. The input was pre-filled with the
   full CDN URL, the search query fired with that URL, and the DB only
   stores bare filenames, so the response was always empty -> 15
   skeletons, forever. Decouple the input display from the query:
   initialize search to the committed value and only fire the query
   when the user has actually typed something different.

2. Loading / error / no-results all looked identical (15 skeletons).
   Split the render into four explicit branches: isError (with retry),
   isLoading (skeletons, only on cold first load), results, no-results.

3. No relevance ranking and no SVG-first ordering. Add orderBy in the
   icons router: relevance tier (exact > prefix > substring) -> SVG
   first -> alphabetical. Patterns are bound as JS strings so the SQL
   works across sqlite / postgresql / mysql without concat operator
   differences.

Also add placeholderData: keepPreviousData so refetching during typing
doesn't flash the dropdown to skeletons, fix the upload pre-fill that
was leaking a local media URL into the search, drop the no-op
updateSelectedOptionIndex, and mark the file as a client component so
Next.js can render it from server-component routes.

Add common.iconPicker headerLoading / noResults / error keys to
en.json (other locales fall back to en until the next sync).

No ternaries in the changed files. Intended simplifications are marked
with ponytail: comments.
2026-06-22 12:41:04 +02:00
Thomas 55fb227305 fix(beszel): improve stats date display, add click-to-stats modal, fix grid ordering (#6026) 2026-06-20 15:38:04 +02:00
13 changed files with 437 additions and 119 deletions

No files matched your search

+25 -9
View File
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? "";
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
let whereCondition = undefined;
if (term.length > 0) {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
columns: { id: true, name: true, url: true },
where: whereCondition,
orderBy: (table, { asc, sql }) => {
// ponytail: SVG first, then alphabetical; with a search term,
// prepend a relevance tier (exact > prefix > substring).
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
return [svgFirst, nameAsc];
}
const tier = sql`CASE
WHEN LOWER(${table.name}) = ${term} THEN 0
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
ELSE 3
END`;
return [tier, svgFirst, nameAsc];
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
},
@@ -1,8 +1,11 @@
"use client";
import type { FocusEventHandler } from "react";
import { startTransition } from "react";
import { startTransition, useEffect, useState } from "react";
import {
ActionIcon,
Box,
Button,
Card,
Combobox,
Flex,
@@ -18,7 +21,7 @@ import {
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -34,7 +37,6 @@ interface IconPickerProps {
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
withAsterisk?: boolean;
@@ -50,29 +52,30 @@ export const IconPicker = ({
label,
placeholder,
}: IconPickerProps) => {
const [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
const [query, setQuery] = useState("");
// ponytail: reset the typed query when the committed value changes
// externally (form reset, parent re-render) so the input reverts to the URL.
useEffect(() => {
setQuery("");
}, [value]);
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
const [debouncedSearch] = useDebouncedValue(search, 100);
// ponytail: only search when the typed query differs from the committed
// value. On mount the input shows the existing URL (edit mode), which
// would never match the bare filenames in the icon table.
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
const { data } = clientApi.icon.findIcons.useQuery({
searchText: debouncedSearch,
});
// ponytail: not useSuspenseQuery — an above Suspense boundary would
// trigger on every keystroke and the dropdown flash is bad UX.
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText },
{ placeholderData: (prev) => prev },
);
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
@@ -90,25 +93,15 @@ export const IconPicker = ({
>
<UnstyledButton
onClick={() => {
const value = item.url;
startTransition(() => {
setPreviewUrl(value);
setSearch(value);
setValue(value);
setValue(item.url);
setQuery("");
combobox.closeDropdown();
});
}}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card
p="sm"
pos="relative"
style={{
overflow: "visible",
cursor: "pointer",
}}
className={classes.iconCard}
>
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} />
</Box>
@@ -130,6 +123,48 @@ export const IconPicker = ({
);
});
// ponytail: show the typed query if non-empty, else fall back to the URL.
const inputValue = query || value || "";
// ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
// ponytail: prop override -> real count -> loading text.
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const renderDropdown = () => {
if (isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
</Stack>
);
};
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
@@ -137,16 +172,11 @@ export const IconPicker = ({
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
}
value={search}
leftSection={leftSection}
value={inputValue}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
setQuery(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -156,25 +186,21 @@ export const IconPicker = ({
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
setQuery("");
}}
rightSectionPointerEvents="none"
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
placeholder={placeholderText}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
setValue(first.url);
setPreviewUrl(first.url);
setSearch(first.url);
});
}}
>
@@ -190,17 +216,7 @@ export const IconPicker = ({
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? (
<Stack gap={4}>{groups}</Stack>
) : (
Array(15)
.fill(0)
.map((_, index: number) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
))
)}
{renderDropdown()}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
@@ -209,8 +225,8 @@ export const IconPicker = ({
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
// See https://github.com/homarr-labs/homarr/issues/3070
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
const shouldShowPreview = (value: string | null): value is string => {
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false;
return !localizationPathRegex.test(value);
};
+2 -2
View File
@@ -95,8 +95,8 @@ const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSec
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
"1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
};
export interface BeszelStatsData {
+4 -1
View File
@@ -1412,7 +1412,10 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
"headerLoading": "Type to search icons…",
"noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons."
},
"colorScheme": {
"options": {
@@ -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 { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { 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";
interface SizeConfig {
iconSize: number;
@@ -154,6 +158,7 @@ interface SystemCardProps {
size: SizeConfig;
maxMetrics: number;
itemRadius: MantineSize;
onClick?: () => 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,
}}
>
<Group gap="xs" mb={2}>
@@ -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) => (
<SystemCard
key={system._key}
system={system}
options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
/>
))}
{filteredSystems.map((system) => {
const integrationId = system._key.split(":")[0] ?? "";
const handleClick = isEditMode
? undefined
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
return (
<SystemCard
key={system._key}
system={system}
options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
onClick={handleClick}
/>
);
})}
</Box>
);
}
@@ -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(
@@ -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<string, number> = { 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<SystemRowWithKey>[];
}, [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}
/>
);
}
@@ -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 {
+51 -16
View File
@@ -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<T>(records: T[], live: boolean) {
if (live) {
return { fmt: formatTimeLive, ordered: records };
const timeFormats: Record<BeszelTimePeriod, string> = {
"1m": "HH:mm:ss",
"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;
@@ -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<string, unknown>,
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<string[]>([]);
@@ -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<string, unknown> = { time: fmt(record.created) };
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { 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]);
+4 -1
View File
@@ -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],
);
@@ -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 { 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<string, CSSProperties> = {
wrapper: {
@@ -40,6 +44,7 @@ export interface TooltipPayloadItem {
value: number;
color: string;
dataKey: string;
payload?: Record<string, unknown>;
}
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<HTMLDivElement>(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(
<div style={portalStyle}>
<div ref={tooltipRef} style={portalStyle}>
<div style={styles.wrapper}>
<div style={styles.header}>{label}</div>
<div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />