Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a3ef02f0 | ||
|
|
aa7d4e69b5 |
No files matched your search
@@ -15,36 +15,20 @@ export const iconsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.input(iconsFindSchema)
|
.input(iconsFindSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.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 {
|
return {
|
||||||
icons: await ctx.db.query.iconRepositories.findMany({
|
icons: await ctx.db.query.iconRepositories.findMany({
|
||||||
with: {
|
with: {
|
||||||
icons: {
|
icons: {
|
||||||
columns: { id: true, name: true, url: true },
|
columns: {
|
||||||
where: whereCondition,
|
id: true,
|
||||||
orderBy: (table, { asc, sql }) => {
|
name: true,
|
||||||
// ponytail: SVG first, then alphabetical; with a search term,
|
url: true,
|
||||||
// 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,
|
limit: input.limitPerGroup,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { FocusEventHandler } from "react";
|
import type { FocusEventHandler } from "react";
|
||||||
import { startTransition, useEffect, useState } from "react";
|
import { startTransition } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
Combobox,
|
Combobox,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -21,7 +18,7 @@ import {
|
|||||||
useCombobox,
|
useCombobox,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
import { IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -37,6 +34,7 @@ interface IconPickerProps {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
onFocus?: FocusEventHandler;
|
onFocus?: FocusEventHandler;
|
||||||
onBlur?: FocusEventHandler;
|
onBlur?: FocusEventHandler;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -52,30 +50,29 @@ export const IconPicker = ({
|
|||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: IconPickerProps) => {
|
}: IconPickerProps) => {
|
||||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
const [value, setValue] = useUncontrolled({
|
||||||
const [query, setQuery] = useState("");
|
value: propsValue,
|
||||||
|
onChange,
|
||||||
// ponytail: reset the typed query when the committed value changes
|
});
|
||||||
// externally (form reset, parent re-render) so the input reverts to the URL.
|
const [search, setSearch] = useUncontrolled({
|
||||||
useEffect(() => {
|
value,
|
||||||
setQuery("");
|
onChange: (value) => {
|
||||||
}, [value]);
|
setValue(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
||||||
|
value: propsValue ?? null,
|
||||||
|
});
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
|
||||||
|
|
||||||
// ponytail: only search when the typed query differs from the committed
|
const [debouncedSearch] = useDebouncedValue(search, 100);
|
||||||
// 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) || "";
|
|
||||||
|
|
||||||
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
||||||
// trigger on every keystroke and the dropdown flash is bad UX.
|
const { data } = clientApi.icon.findIcons.useQuery({
|
||||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
searchText: debouncedSearch,
|
||||||
{ searchText },
|
});
|
||||||
{ placeholderData: (prev) => prev },
|
|
||||||
);
|
|
||||||
|
|
||||||
const combobox = useCombobox({
|
const combobox = useCombobox({
|
||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
@@ -93,15 +90,25 @@ export const IconPicker = ({
|
|||||||
>
|
>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const value = item.url;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(item.url);
|
setPreviewUrl(value);
|
||||||
setQuery("");
|
setSearch(value);
|
||||||
|
setValue(value);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
<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}>
|
<Box w={25} h={25}>
|
||||||
<Image src={item.url} w={25} h={25} />
|
<Image src={item.url} w={25} h={25} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -123,48 +130,6 @@ 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 (
|
return (
|
||||||
<Combobox store={combobox} withinPortal>
|
<Combobox store={combobox} withinPortal>
|
||||||
<Combobox.Target>
|
<Combobox.Target>
|
||||||
@@ -172,11 +137,16 @@ export const IconPicker = ({
|
|||||||
<InputBase
|
<InputBase
|
||||||
flex={1}
|
flex={1}
|
||||||
rightSection={<Combobox.Chevron />}
|
rightSection={<Combobox.Chevron />}
|
||||||
leftSection={leftSection}
|
leftSection={
|
||||||
value={inputValue}
|
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
||||||
|
}
|
||||||
|
value={search}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
combobox.openDropdown();
|
combobox.openDropdown();
|
||||||
setQuery(event.currentTarget.value);
|
combobox.updateSelectedOptionIndex();
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
setValue(event.currentTarget.value);
|
||||||
|
setPreviewUrl(null);
|
||||||
}}
|
}}
|
||||||
onClick={() => combobox.openDropdown()}
|
onClick={() => combobox.openDropdown()}
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
@@ -186,21 +156,25 @@ export const IconPicker = ({
|
|||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
onBlur?.(event);
|
onBlur?.(event);
|
||||||
combobox.closeDropdown();
|
combobox.closeDropdown();
|
||||||
setQuery("");
|
setPreviewUrl(value);
|
||||||
|
setSearch(value || "");
|
||||||
}}
|
}}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
withAsterisk={withAsterisk}
|
withAsterisk={withAsterisk}
|
||||||
error={error}
|
error={error}
|
||||||
label={label ?? tCommon("iconPicker.label")}
|
label={label ?? tCommon("iconPicker.label")}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<UploadMedia
|
<UploadMedia
|
||||||
onSuccess={(medias) => {
|
onSuccess={(medias) => {
|
||||||
const first = medias.at(0);
|
const first = medias.at(0);
|
||||||
if (!first) return;
|
if (!first) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(first.url);
|
setValue(first.url);
|
||||||
|
setPreviewUrl(first.url);
|
||||||
|
setSearch(first.url);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -216,7 +190,17 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||||
{renderDropdown()}
|
{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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
@@ -225,8 +209,8 @@ export const IconPicker = ({
|
|||||||
|
|
||||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
// 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
|
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
||||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
const shouldShowPreview = (value: string | null): value is string => {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
return !localizationPathRegex.test(value);
|
return !localizationPathRegex.test(value);
|
||||||
};
|
};
|
||||||
@@ -55,7 +55,7 @@ export abstract class Integration {
|
|||||||
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
|
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createUrl(
|
protected createUrl(
|
||||||
inputUrl: string,
|
inputUrl: string,
|
||||||
path: `/${string}`,
|
path: `/${string}`,
|
||||||
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
|
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo
|
|||||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
||||||
|
|
||||||
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
|
/**
|
||||||
|
* Dashdot v5 had endpoints at /api/info, /api/load/* etc.
|
||||||
|
* Dashdot v6 removed the /api prefix. Users who previously configured
|
||||||
|
* their integration URL as http://host:port/api need it normalized.
|
||||||
|
*/
|
||||||
|
protected override url(
|
||||||
|
path: `/${string}`,
|
||||||
|
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
|
||||||
|
) {
|
||||||
|
const normalizedUrl = this.integration.url.replace(/\/api\/?$/, "");
|
||||||
|
return this.createUrl(normalizedUrl, path, queryParams);
|
||||||
|
}
|
||||||
|
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const response = await input.fetchAsync(this.url("/info"));
|
const response = await input.fetchAsync(this.url("/info"));
|
||||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
|
import type { IntegrationTestingInput } from "../src/base/integration";
|
||||||
|
import { DashDotIntegration } from "../src/dashdot/dashdot-integration";
|
||||||
|
|
||||||
|
vi.mock("@homarr/db", async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import("@homarr/db")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
db: createDb(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@homarr/redis", () => ({}));
|
||||||
|
|
||||||
|
const createIntegration = (url: string) =>
|
||||||
|
new DashDotIntegration({
|
||||||
|
id: "test",
|
||||||
|
name: "Dashdot",
|
||||||
|
url,
|
||||||
|
decryptedSecrets: [],
|
||||||
|
externalUrl: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DashDot URL normalization", () => {
|
||||||
|
test("url with /api suffix is stripped", async () => {
|
||||||
|
const integration = createIntegration("http://dashdot:3001/api");
|
||||||
|
// Access protected url() via testingAsync which calls this.url("/info")
|
||||||
|
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||||
|
expect(url.toString()).toBe("http://dashdot:3001/info");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url with /api/ trailing slash is stripped", async () => {
|
||||||
|
const integration = createIntegration("http://dashdot:3001/api/");
|
||||||
|
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||||
|
expect(url.toString()).toBe("http://dashdot:3001/info");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url without /api suffix is unchanged", async () => {
|
||||||
|
const integration = createIntegration("http://dashdot:3001");
|
||||||
|
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||||
|
expect(url.toString()).toBe("http://dashdot:3001/info");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url with /api only at end is stripped (not mid-path)", async () => {
|
||||||
|
const integration = createIntegration("http://dashdot:3001/somepath/api");
|
||||||
|
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||||
|
expect(url.toString()).toBe("http://dashdot:3001/somepath/info");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url with path prefix other than /api is preserved", async () => {
|
||||||
|
const integration = createIntegration("http://dashdot:3001/dashdot");
|
||||||
|
const url = (integration as unknown as { url: (path: string) => URL }).url("/load/cpu");
|
||||||
|
expect(url.toString()).toBe("http://dashdot:3001/dashdot/load/cpu");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: "1440m", perPage: 9, cacheSeconds: 1800 },
|
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
|
||||||
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
|
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BeszelStatsData {
|
export interface BeszelStatsData {
|
||||||
|
|||||||
@@ -1412,10 +1412,7 @@
|
|||||||
"here": "here",
|
"here": "here",
|
||||||
"iconPicker": {
|
"iconPicker": {
|
||||||
"label": "Icon URL",
|
"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": {
|
"colorScheme": {
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
.clickableCard {
|
|
||||||
transition: background-color 150ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickableCard:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
|
||||||
}
|
|
||||||
@@ -18,17 +18,13 @@ 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;
|
||||||
@@ -158,7 +154,6 @@ interface SystemCardProps {
|
|||||||
size: SizeConfig;
|
size: SizeConfig;
|
||||||
maxMetrics: number;
|
maxMetrics: number;
|
||||||
itemRadius: MantineSize;
|
itemRadius: MantineSize;
|
||||||
onClick?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metricRenderers = [
|
const metricRenderers = [
|
||||||
@@ -315,7 +310,7 @@ const metricRenderers = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
|
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: 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 (
|
||||||
@@ -324,14 +319,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
|||||||
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}>
|
||||||
@@ -361,7 +353,6 @@ 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 },
|
||||||
@@ -394,24 +385,17 @@ export default function BeszelSystemGridWidget({
|
|||||||
overflow: scrollEnabled ? "auto" : "hidden",
|
overflow: scrollEnabled ? "auto" : "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredSystems.map((system) => {
|
{filteredSystems.map((system) => (
|
||||||
const integrationId = system._key.split(":")[0] ?? "";
|
<SystemCard
|
||||||
const handleClick = isEditMode
|
key={system._key}
|
||||||
? undefined
|
system={system}
|
||||||
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
|
options={options}
|
||||||
return (
|
t={t}
|
||||||
<SystemCard
|
size={size}
|
||||||
key={system._key}
|
maxMetrics={maxMetrics}
|
||||||
system={system}
|
itemRadius={board.itemRadius}
|
||||||
options={options}
|
/>
|
||||||
t={t}
|
))}
|
||||||
size={size}
|
|
||||||
maxMetrics={maxMetrics}
|
|
||||||
itemRadius={board.itemRadius}
|
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,6 @@ 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,
|
||||||
@@ -154,31 +153,30 @@ export default function BeszelSystemStatsWidget({
|
|||||||
const systemStats = activeStats?.systemStats;
|
const systemStats = activeStats?.systemStats;
|
||||||
const containerStatsRaw = activeStats?.containerStats;
|
const containerStatsRaw = activeStats?.containerStats;
|
||||||
|
|
||||||
const timePeriod = options.timePeriod as BeszelTimePeriod;
|
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive);
|
||||||
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
|
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive);
|
||||||
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
|
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive);
|
||||||
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
|
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive);
|
||||||
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
|
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive);
|
||||||
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",
|
||||||
timePeriod,
|
isLive,
|
||||||
);
|
);
|
||||||
const dockerMemData = useDockerChartData(
|
const dockerMemData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"memory",
|
"memory",
|
||||||
timePeriod,
|
isLive,
|
||||||
);
|
);
|
||||||
const dockerNetData = useDockerChartData(
|
const dockerNetData = useDockerChartData(
|
||||||
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
||||||
containerNames,
|
containerNames,
|
||||||
"network",
|
"network",
|
||||||
timePeriod,
|
isLive,
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerSeries = useMemo(
|
const containerSeries = useMemo(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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";
|
||||||
@@ -28,7 +27,6 @@ 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 };
|
||||||
|
|
||||||
@@ -68,7 +66,6 @@ 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 },
|
||||||
@@ -258,11 +255,6 @@ 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 (
|
||||||
@@ -280,7 +272,6 @@ export default function BeszelSystemTableWidget({
|
|||||||
idAccessor="_key"
|
idAccessor="_key"
|
||||||
height="100%"
|
height="100%"
|
||||||
className="beszel-table"
|
className="beszel-table"
|
||||||
onRowClick={isEditMode ? undefined : handleRowClick}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,23 +6,14 @@
|
|||||||
.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,46 +8,14 @@ import dayjs from "dayjs";
|
|||||||
|
|
||||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||||
|
|
||||||
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
|
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
|
||||||
|
const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss");
|
||||||
|
|
||||||
const timeFormats: Record<BeszelTimePeriod, string> = {
|
function prepareRecords<T>(records: T[], live: boolean) {
|
||||||
"1m": "HH:mm:ss",
|
if (live) {
|
||||||
"1h": "HH:mm",
|
return { fmt: formatTimeLive, ordered: records };
|
||||||
"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, ordered: [...records].toReversed() };
|
return { fmt: formatTime, 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;
|
||||||
@@ -109,7 +77,7 @@ const BeszelAreaChart = memo(
|
|||||||
dataKey="time"
|
dataKey="time"
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
withGradient={false}
|
withGradient={false}
|
||||||
connectNulls={false}
|
connectNulls
|
||||||
withDots={false}
|
withDots={false}
|
||||||
type={type}
|
type={type}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
@@ -128,18 +96,16 @@ 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>,
|
||||||
timePeriod: BeszelTimePeriod = "1h",
|
live = false,
|
||||||
) =>
|
) =>
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!systemStats) return [];
|
if (!systemStats) return [];
|
||||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
const { fmt, ordered } = prepareRecords(systemStats, live);
|
||||||
const mapped = ordered.map((r) => ({
|
return ordered.map((r) => ({
|
||||||
time: fmt(r.created),
|
time: fmt(r.created),
|
||||||
rawTime: r.created,
|
|
||||||
...mapFn(r.stats),
|
...mapFn(r.stats),
|
||||||
}));
|
}));
|
||||||
return padTimeGrid(mapped, timePeriod);
|
}, [systemStats, mapFn, live]);
|
||||||
}, [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[]>([]);
|
||||||
@@ -178,20 +144,19 @@ export const useDockerChartData = (
|
|||||||
containerStats: BeszelContainerStatsRecord[] | undefined,
|
containerStats: BeszelContainerStatsRecord[] | undefined,
|
||||||
containerNames: string[],
|
containerNames: string[],
|
||||||
metric: "cpu" | "memory" | "network",
|
metric: "cpu" | "memory" | "network",
|
||||||
timePeriod: BeszelTimePeriod = "1h",
|
live = false,
|
||||||
) =>
|
) =>
|
||||||
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, timePeriod);
|
const { fmt, ordered } = prepareRecords(containerStats, live);
|
||||||
const mapped = ordered.map((record) => {
|
return ordered.map((record) => {
|
||||||
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
const point: Record<string, unknown> = { time: fmt(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;
|
||||||
});
|
});
|
||||||
return padTimeGrid(mapped, timePeriod);
|
}, [containerStats, containerNames, metric, live]);
|
||||||
}, [containerStats, containerNames, metric, timePeriod]);
|
|
||||||
@@ -31,10 +31,7 @@ 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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
"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,10 +3,6 @@
|
|||||||
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: {
|
||||||
@@ -44,7 +40,6 @@ export interface TooltipPayloadItem {
|
|||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
dataKey: string;
|
dataKey: string;
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortalTooltipProps {
|
interface PortalTooltipProps {
|
||||||
@@ -55,39 +50,16 @@ 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 [pos, setPos] = useState({ x: 0, y: 0 });
|
const [mouse, setMouse] = 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(() => {
|
||||||
const el = tooltipRef.current;
|
setMouse(mouseRef.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 });
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -112,22 +84,21 @@ 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: pos.x,
|
left: mouse.x + 14,
|
||||||
top: pos.y,
|
top: mouse.y,
|
||||||
|
transform: "translateY(-50%)",
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div ref={tooltipRef} style={portalStyle}>
|
<div style={portalStyle}>
|
||||||
<div style={styles.wrapper}>
|
<div style={styles.wrapper}>
|
||||||
<div style={styles.header}>{tooltipLabel}</div>
|
<div style={styles.header}>{label}</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