Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b8aae9fc | ||
|
|
58cb65cb3a | ||
|
|
89380f81dd | ||
|
|
e01571b221 | ||
|
|
49c266374d | ||
|
|
577950d20e | ||
|
|
82bf71b073 | ||
|
|
e26883acfa | ||
|
|
8ee747cde8 | ||
|
|
faa7e55b1b | ||
|
|
ce021a89ac | ||
|
|
55fb227305 |
No files matched your search
+14
@@ -20,6 +20,17 @@ ARG DISABLE_REDIS_LOGS='true'
|
||||
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
|
||||
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
|
||||
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
|
||||
# See https://github.com/homarr-labs/homarr/issues/5891
|
||||
RUN cd apps/nextjs/.next/server && \
|
||||
find . -name '*.map' | while IFS= read -r map; do \
|
||||
dest="../standalone/apps/nextjs/.next/server/$map"; \
|
||||
mkdir -p "$(dirname "$dest")"; \
|
||||
cp "$map" "$dest"; \
|
||||
done
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -64,6 +75,9 @@ ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
|
||||
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
ENV NODE_OPTIONS='--enable-source-maps'
|
||||
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -31,6 +31,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
productionBrowserSourceMaps: true,
|
||||
// react compiler breaks mantine-react-table, so disabled for now
|
||||
//reactCompiler: true,
|
||||
/** We already do typechecking as separate tasks in CI */
|
||||
@@ -41,6 +43,8 @@ const nextConfig: NextConfig = {
|
||||
*/
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
serverSourceMaps: true,
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
|
||||
@@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
interface DockerTableProps {
|
||||
initialData?: RouterOutputs["docker"]["getContainers"];
|
||||
}
|
||||
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData: { containers, timestamp },
|
||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
@@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
},
|
||||
});
|
||||
|
||||
const containers = data?.containers ?? [];
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
data: containers,
|
||||
state: { isLoading: isFetching },
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
enableBottomToolbar: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
style: { minWidth: 300 },
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
<DockerTable initialData={{ containers, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createModal } from "@homarr/modals";
|
||||
|
||||
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
|
||||
|
||||
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
|
||||
defaultTitle: (t) => t("docker.title"),
|
||||
size: 1400,
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
|
||||
export const UserButton = async () => {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
isDockerEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal } = useModalAction(LogoutModal);
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
await signOut({
|
||||
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{t("docker")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"pnpm": ">=11.7.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0"
|
||||
"packageManager": "pnpm@11.7.0"
|
||||
}
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"pnpm": ">=11.7.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.7.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import {
|
||||
@@ -14,6 +15,8 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "beszelRouter" });
|
||||
|
||||
export const beszelRouter = createTRPCRouter({
|
||||
getSystems: publicProcedure
|
||||
.meta({
|
||||
@@ -25,7 +28,9 @@ export const beszelRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
@@ -40,6 +45,29 @@ export const beszelRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getSystems integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
integrationUrl: integration?.url ?? "",
|
||||
systems: [],
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getSystems completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
resultCount: results.length,
|
||||
errorCount: results.filter((r) => "error" in r).length,
|
||||
});
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -83,7 +111,14 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getAlerts called", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
@@ -100,6 +135,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
alerts: alertsResult.data.alerts,
|
||||
history: alertsResult.data.history,
|
||||
systemNameMap,
|
||||
@@ -107,6 +143,30 @@ export const beszelRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getAlerts integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
alerts: [],
|
||||
history: [],
|
||||
systemNameMap: {},
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getAlerts completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
resultCount: results.length,
|
||||
errorCount: results.filter((r) => "error" in r).length,
|
||||
});
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -164,13 +224,41 @@ export const beszelRouter = createTRPCRouter({
|
||||
if (!integration) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||
}
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
logger.debug("getSystemStats called", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
try {
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
logger.debug("getSystemStats completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
} catch (error) {
|
||||
logger.warn("getSystemStats failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
systemStats: [],
|
||||
containerStats: [],
|
||||
updatedAt: new Date(0),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
@@ -190,6 +278,11 @@ export const beszelRouter = createTRPCRouter({
|
||||
emit.error(new Error("No Beszel integration configured"));
|
||||
return () => {};
|
||||
}
|
||||
logger.debug("subscribeSystemStats opened", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -199,6 +292,12 @@ export const beszelRouter = createTRPCRouter({
|
||||
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
logger.warn("subscribeSystemStats error", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
if (error instanceof Error) {
|
||||
emit.error(error);
|
||||
} else {
|
||||
@@ -210,6 +309,11 @@ export const beszelRouter = createTRPCRouter({
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
logger.debug("subscribeSystemStats closed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -46,10 +46,13 @@ export class BeszelIntegration extends Integration {
|
||||
private async authenticateAsync(): Promise<BeszelSession> {
|
||||
const existingSession = await this.sessionStore.getAsync();
|
||||
if (existingSession) {
|
||||
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||
const authUrl = this.url("/api/collections/users/auth-with-password");
|
||||
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
|
||||
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -57,23 +60,28 @@ export class BeszelIntegration extends Integration {
|
||||
password: this.getSecretValue("password"),
|
||||
}),
|
||||
});
|
||||
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse;
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||
await this.sessionStore.setAsync(session);
|
||||
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
|
||||
return session;
|
||||
}
|
||||
|
||||
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
||||
const method = options.method ?? "GET";
|
||||
const start = performance.now();
|
||||
let session = await this.authenticateAsync();
|
||||
|
||||
const doFetch = (token: string) =>
|
||||
fetchWithTrustedCertificatesAsync(url, {
|
||||
method: options.method ?? "GET",
|
||||
method,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
@@ -82,14 +90,42 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
|
||||
let response = await doFetch(session.token);
|
||||
logger.debug("Beszel response", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
attempt: 1,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
logger.warn("Beszel 401, clearing session and retrying", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
});
|
||||
await this.sessionStore.clearAsync();
|
||||
session = await this.authenticateAsync();
|
||||
response = await doFetch(session.token);
|
||||
logger.debug("Beszel response (after re-auth)", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
attempt: 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Beszel request failed", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
});
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
@@ -257,6 +293,7 @@ export class BeszelIntegration extends Integration {
|
||||
): Promise<void> {
|
||||
const session = await this.authenticateAsync();
|
||||
const sseUrl = this.url("/api/realtime");
|
||||
logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
|
||||
|
||||
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
|
||||
headers: { Authorization: session.token },
|
||||
@@ -264,8 +301,14 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
|
||||
if (!sseResponse.ok || !sseResponse.body) {
|
||||
logger.warn("Beszel SSE connection failed", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
status: sseResponse.status,
|
||||
});
|
||||
throw new ResponseError(sseResponse);
|
||||
}
|
||||
logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
|
||||
|
||||
const reader = sseResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -322,6 +365,10 @@ export class BeszelIntegration extends Integration {
|
||||
}
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
logger.warn("Beszel SSE stream ended unexpectedly", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
});
|
||||
throw new Error("Beszel SSE stream ended unexpectedly");
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -415,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
|
||||
});
|
||||
},
|
||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
// Set first, then publish. If publish fails the storage is still consistent
|
||||
// and subscribers will catch up on the next event.
|
||||
const payload = superjson.stringify({ data, timestamp: new Date() });
|
||||
await getSetClient.set(channelName, payload);
|
||||
try {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
} catch (error) {
|
||||
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
|
||||
channel: channelName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
setAsync: async (data: TData) => {
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type {
|
||||
BeszelAlert,
|
||||
@@ -13,6 +14,8 @@ import type {
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||
|
||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systems = await instance.getSystemsAsync();
|
||||
const enriched = await Promise.all(
|
||||
@@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
return mapToSystemRow(system, details);
|
||||
}),
|
||||
);
|
||||
logger.debug("beszelSystems fetch completed", {
|
||||
integrationId: integration.id,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
count: enriched.length,
|
||||
});
|
||||
return enriched;
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelSystems",
|
||||
});
|
||||
|
||||
@@ -81,12 +92,21 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ includeHistory: boolean; maxHistoryItems: number }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const alerts = await instance.getAlertsAsync();
|
||||
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
||||
logger.debug("beszelAlerts fetch completed", {
|
||||
integrationId: integration.id,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
alertsCount: alerts.length,
|
||||
historyCount: history.length,
|
||||
});
|
||||
return { alerts, history };
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelAlerts",
|
||||
});
|
||||
|
||||
@@ -95,8 +115,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 {
|
||||
@@ -110,15 +130,26 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const start = performance.now();
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||
const containerStats = input.includeDocker
|
||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
||||
: [];
|
||||
logger.debug("beszelStats fetch completed", {
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
systemStatsCount: systemStats.length,
|
||||
containerStatsCount: containerStats.length,
|
||||
});
|
||||
return { systemStats, containerStats };
|
||||
},
|
||||
cacheDuration: dayjs.duration(60, "seconds"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelStats",
|
||||
cacheDurationForInput(input) {
|
||||
const config = timePeriodConfig[input.timePeriod];
|
||||
|
||||
@@ -18,6 +18,9 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheDuration: Duration;
|
||||
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
export const createCachedIntegrationRequestHandler = <
|
||||
@@ -35,6 +38,9 @@ export const createCachedIntegrationRequestHandler = <
|
||||
return await options.requestAsync(input.integration, input.options);
|
||||
},
|
||||
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
retry: options.retry,
|
||||
isValid: options.isValid,
|
||||
createRedisChannel(input, handlerOptions) {
|
||||
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,16 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "cachedRequestHandler" });
|
||||
|
||||
type FetchResult<TData> = { data: TData; timestamp: Date };
|
||||
type InFlightEntry = Promise<FetchResult<unknown>>;
|
||||
|
||||
const inFlightFetches = new Map<string, InFlightEntry>();
|
||||
|
||||
const isAbortedError = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.name === "AbortError" || error.name === "TimeoutError";
|
||||
};
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
@@ -17,28 +27,70 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||
cacheDuration: Duration;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
// Retry the upstream call on failure. attempts=1 means no retry (default).
|
||||
// AbortError / TimeoutError are never retried.
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
// Predicate to decide whether a fetched response is worth caching.
|
||||
// Return false to skip the cache write (next call will refetch).
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
const defaultRetry = { attempts: 1, delayMs: 0 };
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (input: TInput) => {
|
||||
const channel = options.createRedisChannel(input, options);
|
||||
const retryConfig = { ...defaultRetry, ...options.retry };
|
||||
|
||||
return {
|
||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||
const channelData = await channel.getAsync();
|
||||
|
||||
const requestNewDataAsync = async () => {
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
|
||||
const inflight = inFlightFetches.get(channel.name);
|
||||
if (inflight) {
|
||||
logger.debug("Cached request handler joining in-flight fetch", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
});
|
||||
return inflight as Promise<FetchResult<TData>>;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<FetchResult<TData>> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
if (options.isValid && !options.isValid(data)) {
|
||||
logger.warn("Cached request handler received invalid data, not caching", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
});
|
||||
return { data, timestamp: new Date() };
|
||||
}
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return { data, timestamp: new Date() };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isAbortedError(error) || attempt === retryConfig.attempts) {
|
||||
break;
|
||||
}
|
||||
logger.warn("Cached request handler fetch failed, retrying", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
nextDelayMs: retryConfig.delayMs,
|
||||
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
||||
});
|
||||
await sleep(retryConfig.delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.fallbackToStaleOnError && channelData) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
@@ -46,15 +98,21 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
|
||||
{
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempts: retryConfig.attempts,
|
||||
},
|
||||
{ cause: error },
|
||||
{ cause: lastError },
|
||||
),
|
||||
);
|
||||
return channelData;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
throw lastError;
|
||||
})().finally(() => {
|
||||
inFlightFetches.delete(channel.name);
|
||||
});
|
||||
|
||||
inFlightFetches.set(channel.name, promise as InFlightEntry);
|
||||
return promise;
|
||||
};
|
||||
|
||||
if (forceUpdate) {
|
||||
|
||||
@@ -3374,27 +3374,27 @@
|
||||
"pausedCount": "Pausiert"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
|
||||
}
|
||||
},
|
||||
"audioStats": {
|
||||
"name": "Audiostatistiken",
|
||||
"description": "",
|
||||
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
|
||||
"option": {
|
||||
"showArtists": {
|
||||
"label": "Künstler anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
|
||||
},
|
||||
"showAlbums": {
|
||||
"label": "",
|
||||
"label": "Alben anzeigen",
|
||||
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showSongs": {
|
||||
"label": "Songs anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"label": "\"Sie hören\" anzeigen",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
@@ -3720,7 +3720,7 @@
|
||||
},
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "CPU-Auslastung",
|
||||
"title": "CPU Auslastung",
|
||||
"subtitle": "",
|
||||
"series": "CPU"
|
||||
},
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestionsTitle": "",
|
||||
"suggestionsTitle": "Password suggestions",
|
||||
"suggestion": {
|
||||
"length": "Includes a minimum of 8 characters",
|
||||
"lowercase": "Includes a lower-case letter",
|
||||
@@ -687,28 +687,28 @@
|
||||
"customWidget": {
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "",
|
||||
"noResults": "",
|
||||
"loading": ""
|
||||
"title": "Custom Widgets",
|
||||
"noResults": "No custom widgets yet. Create your first one!",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"create": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Create Custom Widget",
|
||||
"subtitle": "Define an API URL, authentication, and how to display the response data."
|
||||
},
|
||||
"edit": {
|
||||
"title": ""
|
||||
"title": "Edit Custom Widget"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "",
|
||||
"import": "",
|
||||
"save": "",
|
||||
"delete": "",
|
||||
"deleteConfirm": "",
|
||||
"export": "",
|
||||
"addMapping": "",
|
||||
"addColumn": "",
|
||||
"edit": "",
|
||||
"create": "Create",
|
||||
"import": "Import",
|
||||
"save": "Save",
|
||||
"delete": "Delete Custom Widget",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"export": "Export",
|
||||
"addMapping": "Add Mapping",
|
||||
"addColumn": "Add Column",
|
||||
"edit": "Edit",
|
||||
"removeMapping": "",
|
||||
"removeColumn": "",
|
||||
"addItem": "",
|
||||
@@ -5599,71 +5599,71 @@
|
||||
"label": "",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your colour scheme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale": {
|
||||
"label": "",
|
||||
"label": "Language",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your preferred language"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"label": "",
|
||||
"label": "Default search engine",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your default search engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"openSearchInNewTab": {
|
||||
"label": "",
|
||||
"label": "Open search results in new tab",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Open search results in new tab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ddgBangs": {
|
||||
"label": "",
|
||||
"label": "Enable DuckDuckGo bangs",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Enable DuckDuckGo bangs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firstDayOfWeek": {
|
||||
"label": "",
|
||||
"label": "First day of the week",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select the first day of the week"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homeBoardId": {
|
||||
"label": "",
|
||||
"label": "Home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobileHomeBoardId": {
|
||||
"label": "",
|
||||
"label": "Mobile home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your mobile home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": "",
|
||||
"label": "Use icons for pings",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Use icons for pings"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
@@ -1464,6 +1467,7 @@
|
||||
"switchToDarkMode": "Switch to dark mode",
|
||||
"switchToLightMode": "Switch to light mode",
|
||||
"management": "Management",
|
||||
"docker": "Docker",
|
||||
"preferences": "Your preferences",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
@@ -3639,6 +3643,9 @@
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": "No systems found"
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
@@ -3712,6 +3719,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "Select system",
|
||||
"empty": {
|
||||
"noSystems": "No systems found"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
|
||||
@@ -4286,7 +4296,7 @@
|
||||
"notStarted": "Not started"
|
||||
},
|
||||
"name": {
|
||||
"management": "Management",
|
||||
"management": "Management",
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4943,8 +4943,8 @@
|
||||
"search": "Cerca {count} container",
|
||||
"selected": "{selectCount} di {totalCount} containers selezionati",
|
||||
"footer": "Totale container {count}",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
"totalCpu": "CPU Totale: {cpu}%",
|
||||
"totalMemory": "Mem Totale: {memory}"
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -687,7 +687,7 @@
|
||||
"customWidget": {
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "",
|
||||
"title": "Власні віджети",
|
||||
"noResults": "",
|
||||
"loading": ""
|
||||
},
|
||||
@@ -1021,7 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"mediaLibrary": "",
|
||||
"mediaLibrary": "Медіа бібліотека",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -1139,7 +1139,7 @@
|
||||
"description": {
|
||||
"connection": {
|
||||
"hostUnreachable": "",
|
||||
"networkUnreachable": "",
|
||||
"networkUnreachable": "Мережа недоступна. Перевірте підключення до Інтернету або налаштування мережі.",
|
||||
"refused": "",
|
||||
"reset": ""
|
||||
},
|
||||
@@ -1328,7 +1328,7 @@
|
||||
}
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"name": "Безпаперовий ngx",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
@@ -1679,9 +1679,9 @@
|
||||
"label": "",
|
||||
"option": {
|
||||
"row": "",
|
||||
"row-reverse": "",
|
||||
"row-reverse": "Горизонтально (зворотнє)",
|
||||
"column": "",
|
||||
"column-reverse": ""
|
||||
"column-reverse": "Вертикальна (перевернута)"
|
||||
}
|
||||
},
|
||||
"descriptionDisplayMode": {
|
||||
@@ -1690,7 +1690,7 @@
|
||||
"option": {
|
||||
"normal": "",
|
||||
"tooltip": "",
|
||||
"hidden": ""
|
||||
"hidden": "Приховати"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1703,7 +1703,7 @@
|
||||
},
|
||||
"bookmarks": {
|
||||
"name": "Закладки",
|
||||
"description": "",
|
||||
"description": "Показує декілька посилань додатка",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": "Назва"
|
||||
@@ -1747,8 +1747,8 @@
|
||||
}
|
||||
},
|
||||
"archiveTeamWarrior": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "АрхівTeam Warrior",
|
||||
"description": "Слідкуйте за станом, проєктами та активними архівними завданнями ArchiveTeam Warrior.",
|
||||
"option": {
|
||||
"showBroadcastMessage": {
|
||||
"label": ""
|
||||
@@ -1778,7 +1778,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"internalServerError": "Не вдалося отримати резюме DNS Hole",
|
||||
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
|
||||
},
|
||||
"data": {
|
||||
@@ -1808,7 +1808,7 @@
|
||||
}
|
||||
},
|
||||
"showToggleAllButtons": {
|
||||
"label": ""
|
||||
"label": "Показати всі кнопки"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
@@ -12,6 +12,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
@@ -40,7 +41,11 @@ export default function BeszelAlertsWidget({
|
||||
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
||||
[integrationIds, options.showHistory, options.maxHistoryItems],
|
||||
);
|
||||
const { data: results = [], error: alertsError } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
||||
const {
|
||||
data: results = [],
|
||||
error: alertsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 15_000,
|
||||
retry: false,
|
||||
@@ -93,113 +98,126 @@ export default function BeszelAlertsWidget({
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{alerts.length === 0 && (
|
||||
<Stack align="center" justify="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconBellOff size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
</Box>
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{alerts.length === 0 && (
|
||||
<Stack align="center" justify="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconBellOff size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
</Group>
|
||||
{triggeredAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{okAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("history")}
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{history.map((entry) => {
|
||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||
const isResolved = !!entry.resolved;
|
||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||
return (
|
||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={3}
|
||||
h={24}
|
||||
style={{ borderRadius: 2, flexShrink: 0 }}
|
||||
bg={isResolved ? "green.6" : "red.6"}
|
||||
/>
|
||||
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{systemName}
|
||||
{triggeredAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{okAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("history")}
|
||||
</Text>
|
||||
</Group>
|
||||
{history.map((entry) => {
|
||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||
const isResolved = !!entry.resolved;
|
||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||
return (
|
||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={3}
|
||||
h={24}
|
||||
style={{ borderRadius: 2, flexShrink: 0 }}
|
||||
bg={isResolved ? "green.6" : "red.6"}
|
||||
/>
|
||||
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
||||
{isResolved ? t("resolved") : t("status.triggered")}
|
||||
</Badge>
|
||||
<Text size="10px" c="dimmed">
|
||||
{dayjs(entry.created).fromNow()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
||||
{isResolved ? t("resolved") : t("status.triggered")}
|
||||
</Badge>
|
||||
<Text size="10px" c="dimmed">
|
||||
{dayjs(entry.created).fromNow()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
@@ -15,16 +15,22 @@ import {
|
||||
Thermometer,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
import { IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
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 { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
@@ -154,6 +160,7 @@ interface SystemCardProps {
|
||||
size: SizeConfig;
|
||||
maxMetrics: number;
|
||||
itemRadius: MantineSize;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const metricRenderers = [
|
||||
@@ -310,7 +317,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 +326,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,8 +363,13 @@ 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(
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||
{ integrationIds },
|
||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
||||
);
|
||||
@@ -364,6 +379,30 @@ export default function BeszelSystemGridWidget({
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredSystems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty.noSystems")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const cols = getColCount(width, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
@@ -374,28 +413,37 @@ export default function BeszelSystemGridWidget({
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{filteredSystems.map((system) => (
|
||||
<SystemCard
|
||||
key={system._key}
|
||||
system={system}
|
||||
options={options}
|
||||
t={t}
|
||||
size={size}
|
||||
maxMetrics={maxMetrics}
|
||||
itemRadius={board.itemRadius}
|
||||
/>
|
||||
))}
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react";
|
||||
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -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,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
formatPercent,
|
||||
formatStorageBytes,
|
||||
} from "../beszel/_shared/format";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||
import { useLiveStats } from "../beszel/_shared/use-live-stats";
|
||||
|
||||
@@ -97,13 +99,18 @@ export default function BeszelSystemStatsWidget({
|
||||
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
|
||||
includeDocker: showDocker,
|
||||
},
|
||||
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "", retry: false },
|
||||
{
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: isLive ? false : 5_000,
|
||||
enabled: !isLive && systemReady && selectedSystem !== "",
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
|
||||
|
||||
let activeStats:
|
||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] }
|
||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
|
||||
| null
|
||||
| undefined = statsResult;
|
||||
if (isLive) {
|
||||
@@ -153,30 +160,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(
|
||||
@@ -189,6 +197,30 @@ export default function BeszelSystemStatsWidget({
|
||||
if (systemsError) throw systemsError;
|
||||
if (!isLive && statsError) throw statsError;
|
||||
|
||||
if (systemsPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (systems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty.noSystems")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemsPending && !systemExists && systems.length > 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -226,164 +258,186 @@ export default function BeszelSystemStatsWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
className={classes.beszelStatsContainer}
|
||||
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
||||
>
|
||||
<Stack gap="md" p="sm">
|
||||
{!isEditMode && systems.length > 1 && (
|
||||
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size={14} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
>
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((s) => (
|
||||
<Menu.Item
|
||||
key={s.value}
|
||||
fz="xs"
|
||||
fw={s.value === selectedSystem ? 600 : 400}
|
||||
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(s.value)}
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
</Box>
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
className={classes.beszelStatsContainer}
|
||||
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
||||
>
|
||||
<Stack gap="md" p="sm">
|
||||
{!isEditMode && systems.length > 1 && (
|
||||
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size={14} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
>
|
||||
{s.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((s) => (
|
||||
<Menu.Item
|
||||
key={s.value}
|
||||
fz="xs"
|
||||
fw={s.value === selectedSystem ? 600 : 400}
|
||||
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{activeStats && (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{options.showCpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{options.showMemory && memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{activeStats.error}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{options.showDisk && diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
series: series.disk,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{options.showCpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDiskIO && diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showMemory && memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showNetwork && networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showDisk && diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
series: series.disk,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
tooltipProps: tooltipPercentTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showDiskIO && diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerMemData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showNetwork && networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerNetData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
tooltipProps: tooltipPercentTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerMemData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerNetData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import "./styles.css";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Group, Indicator, Progress, Text } from "@mantine/core";
|
||||
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
import {
|
||||
@@ -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,8 @@ 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 { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
|
||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||
|
||||
@@ -66,7 +69,12 @@ export default function BeszelSystemTableWidget({
|
||||
width,
|
||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||
const t = useScopedI18n("widget.beszelSystemTable");
|
||||
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||
{ integrationIds },
|
||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
||||
);
|
||||
@@ -255,23 +263,42 @@ 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;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
fz={size.fontSize}
|
||||
records={sortedSystems}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
height="100%"
|
||||
className="beszel-table"
|
||||
/>
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
</div>
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
fz={size.fontSize}
|
||||
records={sortedSystems}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
height="100%"
|
||||
className="beszel-table"
|
||||
onRowClick={isEditMode ? undefined : handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
interface IntegrationResult {
|
||||
integrationId: string;
|
||||
integrationName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BeszelIntegrationErrorIndicatorProps {
|
||||
results: IntegrationResult[];
|
||||
}
|
||||
|
||||
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
|
||||
const failed = results.filter((r) => "error" in r && r.error);
|
||||
if (failed.length === 0) return null;
|
||||
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
Generated
+335
-315
File diff suppressed because it is too large.
Load diff
+20
-20
@@ -52,22 +52,22 @@ catalog:
|
||||
"@testcontainers/mysql": ^12.0.2
|
||||
"@testcontainers/postgresql": ^12.0.2
|
||||
"@testcontainers/redis": ^12.0.2
|
||||
"@tiptap/extension-color": 3.26.1
|
||||
"@tiptap/extension-highlight": 3.26.1
|
||||
"@tiptap/extension-image": 3.26.1
|
||||
"@tiptap/extension-link": ^3.26.1
|
||||
"@tiptap/extension-placeholder": ^3.26.1
|
||||
"@tiptap/extension-table": 3.26.1
|
||||
"@tiptap/extension-table-cell": 3.26.1
|
||||
"@tiptap/extension-table-header": 3.26.1
|
||||
"@tiptap/extension-table-row": 3.26.1
|
||||
"@tiptap/extension-task-item": 3.26.1
|
||||
"@tiptap/extension-task-list": 3.26.1
|
||||
"@tiptap/extension-text-align": 3.26.1
|
||||
"@tiptap/extension-text-style": 3.26.1
|
||||
"@tiptap/extension-underline": 3.26.1
|
||||
"@tiptap/react": ^3.26.1
|
||||
"@tiptap/starter-kit": ^3.26.1
|
||||
"@tiptap/extension-color": 3.27.0
|
||||
"@tiptap/extension-highlight": 3.27.0
|
||||
"@tiptap/extension-image": 3.27.0
|
||||
"@tiptap/extension-link": ^3.27.0
|
||||
"@tiptap/extension-placeholder": ^3.27.0
|
||||
"@tiptap/extension-table": 3.27.0
|
||||
"@tiptap/extension-table-cell": 3.27.0
|
||||
"@tiptap/extension-table-header": 3.27.0
|
||||
"@tiptap/extension-table-row": 3.27.0
|
||||
"@tiptap/extension-task-item": 3.27.0
|
||||
"@tiptap/extension-task-list": 3.27.0
|
||||
"@tiptap/extension-text-align": 3.27.0
|
||||
"@tiptap/extension-text-style": 3.27.0
|
||||
"@tiptap/extension-underline": 3.27.0
|
||||
"@tiptap/react": ^3.27.0
|
||||
"@tiptap/starter-kit": ^3.27.0
|
||||
"@trpc/client": ^11.17.0
|
||||
"@trpc/next": ^11.17.0
|
||||
"@trpc/react-query": ^11.17.0
|
||||
@@ -93,7 +93,7 @@ catalog:
|
||||
"@types/ws": ^8.18.1
|
||||
"@types/xml2js": ^0.4.14
|
||||
posthog-js: ^1.255.0
|
||||
posthog-node: ^4.17.0
|
||||
posthog-node: ^4.18.0
|
||||
"@vitejs/plugin-react": ^6.0.2
|
||||
"@vitest/coverage-v8": ^4.1.8
|
||||
"@vitest/ui": ^4.1.8
|
||||
@@ -138,7 +138,7 @@ catalog:
|
||||
jsonpath-plus: ^10.4.0
|
||||
ldapts: 8.1.8
|
||||
lodash.clonedeep: ^4.5.0
|
||||
lucide-react: ^1.18.0
|
||||
lucide-react: ^1.20.0
|
||||
"mantine-contextmenu": ^9.3.0
|
||||
mantine-datatable: ^9.3.0
|
||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||
@@ -180,7 +180,7 @@ catalog:
|
||||
vite: ^8.0.16
|
||||
undici: 7.27.2
|
||||
use-deep-compare-effect: ^1.8.1
|
||||
video.js: ^8.23.7
|
||||
video.js: ^8.23.8
|
||||
vite-tsconfig-paths: ^6.1.1
|
||||
vitest: ^4.1.8
|
||||
winston: 3.19.0
|
||||
@@ -206,7 +206,7 @@ minimumReleaseAgeExclude:
|
||||
overrides:
|
||||
"@babel/helpers@<7.26.10": ">=7.29.7"
|
||||
"@babel/runtime@<7.26.10": ">=7.29.7"
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.17.0"
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.18.0"
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6"
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
|
||||
"esbuild@<=0.24.2": ">=0.28.0"
|
||||
|
||||
Reference in new issue
Block a user