Compare commits

..
Author SHA1 Message Date
Thomas Camlong 2db0a5d282 fix(docs): remove baseUrl from tsconfig for TypeScript 7
`baseUrl` has been removed in TypeScript 7. The Docusaurus tsconfig
extends from sets `baseUrl: "."` and `paths: { "@site/*": ["./*"] }`.

Drop the `@docusaurus/tsconfig` extension and inline the needed
options, redefining `paths` so the `@site/*` alias resolves
relative to the tsconfig file (the new TS 7 default for paths).
2026-06-25 11:38:20 +02:00
ajnart 6e3045029c feat(deps): upgrade TypeScript to v7 native compiler (tsgo)
- Upgrade typescript from ^6.0.3 to ^7.0.1-rc (native Go compiler)
- Fix type errors caught by TS7's stricter generic variance checking
  in RequestError instantiation (3 handler files)
- Update apps/docs/tsconfig.json: remove deprecated baseUrl,
  bump target from es5 to ES2022 (both removed in TS7)
- Add TS7 to minimumReleaseAgeExclude (rc release within cutoff)

Benchmark results (cold, 37 packages):
  TS6: 6m49s -> TS7 native: 1m35s (~4.3x faster)
  Incremental/warm: ~24x faster (17s for full project)
2026-06-23 18:04:42 +02:00
34 changed files with 2039 additions and 2349 deletions

No files matched your search

-14
View File
@@ -20,17 +20,6 @@ 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
@@ -75,9 +64,6 @@ 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"]
+5 -4
View File
@@ -1,9 +1,6 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"target": "es5",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@@ -12,10 +9,14 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@site/*": ["./*"]
},
},
"include": [
"**/*.ts",
-4
View File
@@ -31,8 +31,6 @@ 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 */
@@ -43,8 +41,6 @@ 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,20 +149,16 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
},
];
interface DockerTableProps {
initialData?: RouterOutputs["docker"]["getContainers"];
}
export function DockerTable({ initialData }: DockerTableProps) {
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp },
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
@@ -180,11 +176,8 @@ export function DockerTable({ initialData }: DockerTableProps) {
},
});
const containers = data?.containers ?? [];
const table = useTranslatedMantineReactTable({
data: containers,
state: { isLoading: isFetching },
data: data.containers,
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -197,7 +190,7 @@ export function DockerTable({ initialData }: DockerTableProps) {
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }),
placeholder: tDocker("table.search", { count: String(data.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 initialData={{ containers, timestamp }} />
<DockerTable containers={containers} timestamp={timestamp} />
</Stack>
</>
);
@@ -1,10 +0,0 @@
"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,7 +3,6 @@ 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";
@@ -12,10 +11,9 @@ 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} isDockerEnabled={isDockerEnabled}>
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
@@ -5,15 +5,7 @@ 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 {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -25,16 +17,14 @@ 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, isDockerEnabled }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -44,7 +34,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
await signOut({
@@ -91,11 +80,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
<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 />
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=11.7.0"
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.7.0"
"packageManager": "pnpm@11.6.0"
}
+2 -1
View File
@@ -43,6 +43,7 @@
"@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
@@ -62,7 +63,7 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=11.7.0"
"pnpm": ">=11.6.0"
},
"packageManager": "pnpm@11.7.0",
"dependencies": {
+9 -25
View File
@@ -15,36 +15,20 @@ 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 },
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];
columns: {
id: true,
name: true,
url: true,
},
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,
},
},
+5 -109
View File
@@ -2,7 +2,6 @@ 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 {
@@ -15,8 +14,6 @@ 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({
@@ -28,9 +25,7 @@ export const beszelRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
@@ -45,29 +40,6 @@ 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;
}),
@@ -111,14 +83,7 @@ export const beszelRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
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(
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
@@ -135,7 +100,6 @@ export const beszelRouter = createTRPCRouter({
}
return {
integrationId: integration.id,
integrationName: integration.name,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
@@ -143,30 +107,6 @@ 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;
}),
@@ -224,41 +164,13 @@ export const beszelRouter = createTRPCRouter({
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
logger.debug("getSystemStats called", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
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),
};
}
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
}),
subscribeSystemStats: publicProcedure
@@ -278,11 +190,6 @@ 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();
@@ -292,12 +199,6 @@ 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 {
@@ -309,11 +210,6 @@ export const beszelRouter = createTRPCRouter({
return () => {
abortController.abort();
logger.debug("subscribeSystemStats closed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
};
});
}),
@@ -25,7 +25,7 @@ export class AxiosHttpErrorHandler extends HttpErrorHandler {
return new RequestError(requestErrorInput, {
cause: error,
});
}) as AnyRequestError;
}
handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof AxiosError)) return undefined;
@@ -20,7 +20,7 @@ export class FetchHttpErrorHandler extends HttpErrorHandler {
const result = matchErrorCode(error.cause.code);
if (!result) return undefined;
return new RequestError(result, { cause: error });
return new RequestError(result, { cause: error }) as AnyRequestError;
}
/**
@@ -59,7 +59,7 @@ export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined =
type: key,
reason: found[0] as RequestErrorReason<typeof key>,
code: code as RequestErrorCode,
};
} as AnyRequestErrorInput;
}
return undefined;
@@ -30,7 +30,7 @@ export class NodeFetchHttpErrorHandler extends HttpErrorHandler {
return new RequestError(requestErrorInput, {
cause: error,
});
}) as AnyRequestError;
}
/**
@@ -1,11 +1,8 @@
"use client";
import type { FocusEventHandler } from "react";
import { startTransition, useEffect, useState } from "react";
import { startTransition } from "react";
import {
ActionIcon,
Box,
Button,
Card,
Combobox,
Flex,
@@ -21,7 +18,7 @@ import {
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -37,6 +34,7 @@ interface IconPickerProps {
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
withAsterisk?: boolean;
@@ -52,30 +50,29 @@ export const IconPicker = ({
label,
placeholder,
}: IconPickerProps) => {
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 [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 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) || "";
const [debouncedSearch] = useDebouncedValue(search, 100);
// 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 },
);
// 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,
});
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
@@ -93,15 +90,25 @@ export const IconPicker = ({
>
<UnstyledButton
onClick={() => {
const value = item.url;
startTransition(() => {
setValue(item.url);
setQuery("");
setPreviewUrl(value);
setSearch(value);
setValue(value);
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>
@@ -123,48 +130,6 @@ export const IconPicker = ({
);
});
// ponytail: show the typed query if non-empty, else fall back to the URL.
const inputValue = query || value || "";
// ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
// ponytail: prop override -> real count -> loading text.
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const renderDropdown = () => {
if (isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
</Stack>
);
};
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
@@ -172,11 +137,16 @@ export const IconPicker = ({
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={leftSection}
value={inputValue}
leftSection={
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
setQuery(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -186,21 +156,25 @@ export const IconPicker = ({
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setQuery("");
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
placeholder={placeholderText}
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
/>
{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);
});
}}
>
@@ -216,7 +190,17 @@ export const IconPicker = ({
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{renderDropdown()}
{totalOptions > 0 ? (
<Stack gap={4}>{groups}</Stack>
) : (
Array(15)
.fill(0)
.map((_, index: number) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
@@ -225,8 +209,8 @@ export const IconPicker = ({
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
// See https://github.com/homarr-labs/homarr/issues/3070
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => {
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
const shouldShowPreview = (value: string | null): value is string => {
if (!value) return false;
return !localizationPathRegex.test(value);
};
@@ -46,13 +46,10 @@ 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 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, {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -60,28 +57,23 @@ 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,
method: options.method ?? "GET",
headers: {
Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}),
@@ -90,42 +82,14 @@ 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);
}
@@ -293,7 +257,6 @@ 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 },
@@ -301,14 +264,8 @@ 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();
@@ -365,10 +322,6 @@ 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 {
+2 -12
View File
@@ -415,18 +415,8 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
});
},
publishAndUpdateLastStateAsync: async (data: TData) => {
// 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),
});
}
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
},
setAsync: async (data: TData) => {
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
-31
View File
@@ -1,6 +1,5 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
BeszelAlert,
@@ -14,8 +13,6 @@ import type {
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
export type { BeszelSystemRow } from "@homarr/integrations/types";
/**
@@ -59,7 +56,6 @@ 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(
@@ -68,16 +64,9 @@ 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",
});
@@ -92,21 +81,12 @@ 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",
});
@@ -130,26 +110,15 @@ 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,9 +18,6 @@ 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 = <
@@ -38,9 +35,6 @@ 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,16 +7,6 @@ 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;
@@ -27,70 +17,28 @@ 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 = (): 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);
}
}
const requestNewDataAsync = async () => {
try {
const data = await options.requestAsync(input);
await channel.publishAndUpdateLastStateAsync(data);
return {
data,
timestamp: new Date(),
};
} catch (error) {
if (options.fallbackToStaleOnError && channelData) {
logger.warn(
new ErrorWithMetadata(
@@ -98,21 +46,15 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
{
channel: channel.name,
queryKey: options.queryKey,
attempts: retryConfig.attempts,
},
{ cause: lastError },
{ cause: error },
),
);
return channelData;
}
throw lastError;
})().finally(() => {
inFlightFetches.delete(channel.name);
});
inFlightFetches.set(channel.name, promise as InFlightEntry);
return promise;
throw error;
}
};
if (forceUpdate) {
+7 -7
View File
@@ -3374,27 +3374,27 @@
"pausedCount": "Pausiert"
},
"error": {
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
"internalServerError": ""
}
},
"audioStats": {
"name": "Audiostatistiken",
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
"description": "",
"option": {
"showArtists": {
"label": "Künstler anzeigen",
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
"description": ""
},
"showAlbums": {
"label": "Alben anzeigen",
"label": "",
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
},
"showSongs": {
"label": "Songs anzeigen",
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
"description": ""
},
"showNowPlaying": {
"label": "\"Sie hören\" anzeigen",
"label": "",
"description": ""
},
"showNowPlayingList": {
@@ -3720,7 +3720,7 @@
},
"chart": {
"cpu": {
"title": "CPU Auslastung",
"title": "CPU-Auslastung",
"subtitle": "",
"series": "CPU"
},
+33 -33
View File
@@ -177,7 +177,7 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Password suggestions",
"suggestionsTitle": "",
"suggestion": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
@@ -687,28 +687,28 @@
"customWidget": {
"page": {
"list": {
"title": "Custom Widgets",
"noResults": "No custom widgets yet. Create your first one!",
"loading": "Loading..."
"title": "",
"noResults": "",
"loading": ""
},
"create": {
"title": "Create Custom Widget",
"subtitle": "Define an API URL, authentication, and how to display the response data."
"title": "",
"subtitle": ""
},
"edit": {
"title": "Edit Custom Widget"
"title": ""
}
},
"action": {
"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",
"create": "",
"import": "",
"save": "",
"delete": "",
"deleteConfirm": "",
"export": "",
"addMapping": "",
"addColumn": "",
"edit": "",
"removeMapping": "",
"removeColumn": "",
"addItem": "",
@@ -5599,71 +5599,71 @@
"label": "",
"children": {
"detail": {
"title": "Select your colour scheme"
"title": ""
}
}
},
"locale": {
"label": "Language",
"label": "",
"children": {
"detail": {
"title": "Select your preferred language"
"title": ""
}
}
},
"defaultSearchEngineId": {
"label": "Default search engine",
"label": "",
"children": {
"detail": {
"title": "Select your default search engine"
"title": ""
}
}
},
"openSearchInNewTab": {
"label": "Open search results in new tab",
"label": "",
"children": {
"detail": {
"title": "Open search results in new tab"
"title": ""
}
}
},
"ddgBangs": {
"label": "Enable DuckDuckGo bangs",
"label": "",
"children": {
"detail": {
"title": "Enable DuckDuckGo bangs"
"title": ""
}
}
},
"firstDayOfWeek": {
"label": "First day of the week",
"label": "",
"children": {
"detail": {
"title": "Select the first day of the week"
"title": ""
}
}
},
"homeBoardId": {
"label": "Home board",
"label": "",
"children": {
"detail": {
"title": "Select your home board"
"title": ""
}
}
},
"mobileHomeBoardId": {
"label": "Mobile home board",
"label": "",
"children": {
"detail": {
"title": "Select your mobile home board"
"title": ""
}
}
},
"pingIconsEnabled": {
"label": "Use icons for pings",
"label": "",
"children": {
"detail": {
"title": "Use icons for pings"
"title": ""
}
}
},
+2 -12
View File
@@ -1412,10 +1412,7 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"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."
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
},
"colorScheme": {
"options": {
@@ -1467,7 +1464,6 @@
"switchToDarkMode": "Switch to dark mode",
"switchToLightMode": "Switch to light mode",
"management": "Management",
"docker": "Docker",
"preferences": "Your preferences",
"logout": "Logout",
"login": "Login",
@@ -3643,9 +3639,6 @@
"error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
},
"empty": {
"noSystems": "No systems found"
},
"metric": {
"cpu": "CPU",
"memory": "Memory",
@@ -3719,9 +3712,6 @@
}
},
"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.",
@@ -4296,7 +4286,7 @@
"notStarted": "Not started"
},
"name": {
"management": "Management",
"management": "Management",
"dashboard": "Dashboard"
}
}
+2 -2
View File
@@ -4943,8 +4943,8 @@
"search": "Cerca {count} container",
"selected": "{selectCount} di {totalCount} containers selezionati",
"footer": "Totale container {count}",
"totalCpu": "CPU Totale: {cpu}%",
"totalMemory": "Mem Totale: {memory}"
"totalCpu": "",
"totalMemory": ""
},
"field": {
"name": {
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -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": "Безпаперовий ngx",
"name": "",
"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": "АрхівTeam Warrior",
"description": "Слідкуйте за станом, проєктами та активними архівними завданнями ArchiveTeam Warrior.",
"name": "",
"description": "",
"option": {
"showBroadcastMessage": {
"label": ""
@@ -1778,7 +1778,7 @@
}
},
"error": {
"internalServerError": "Не вдалося отримати резюме DNS Hole",
"internalServerError": "",
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
},
"data": {
@@ -1808,7 +1808,7 @@
}
},
"showToggleAllButtons": {
"label": "Показати всі кнопки"
"label": ""
}
},
"error": {
+98 -116
View File
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { Badge, Box, Divider, Group, 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,7 +12,6 @@ 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,
@@ -41,11 +40,7 @@ export default function BeszelAlertsWidget({
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
);
const {
data: results = [],
error: alertsError,
isPending,
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
const { data: results = [], error: alertsError } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
staleTime: 15_000,
refetchInterval: 15_000,
retry: false,
@@ -98,126 +93,113 @@ export default function BeszelAlertsWidget({
if (alertsError) throw alertsError;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
return (
<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")}
<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})
</Text>
</Stack>
)}
</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>
)}
{triggeredAlerts.length > 0 && (
{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}>
<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>
{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)" />
<IconHistory size={14} opacity={0.5} />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
{t("status.ok")} ({okAlerts.length})
{t("history")}
</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()}
{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>
</>
)}
</Stack>
</ScrollArea>
</Box>
<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>
);
}
@@ -1,7 +1,7 @@
"use client";
import type { MantineSize } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
import {
Activity,
Battery,
@@ -15,7 +15,6 @@ import {
Thermometer,
Wifi,
} from "lucide-react";
import { IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -29,7 +28,6 @@ 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 {
@@ -365,11 +363,7 @@ export default function BeszelSystemGridWidget({
const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
);
@@ -379,30 +373,6 @@ 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;
@@ -413,37 +383,35 @@ export default function BeszelSystemGridWidget({
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<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
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) => {
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>
);
}
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useMemo } from "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 { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -30,7 +30,6 @@ 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";
@@ -99,18 +98,13 @@ export default function BeszelSystemStatsWidget({
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{
staleTime: 30 * 1000,
refetchInterval: isLive ? false : 5_000,
enabled: !isLive && systemReady && selectedSystem !== "",
retry: false,
},
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "", retry: false },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
let activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] }
| null
| undefined = statsResult;
if (isLive) {
@@ -197,30 +191,6 @@ 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%">
@@ -258,186 +228,164 @@ export default function BeszelSystemStatsWidget({
}
return (
<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}
<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)}
>
{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>
)}
{s.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
<Center h={CHART_HEIGHT}>
<Loader size="sm" />
</Center>
)}
{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 && "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.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,
}}
/>
)}
{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.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,
}}
/>
)}
{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.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.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,
}}
/>
)}
{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.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,
}}
/>
)}
{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.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.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,
}}
/>
)}
{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>
{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>
);
}
@@ -3,7 +3,7 @@
import "./styles.css";
import { useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import { Group, Indicator, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
@@ -28,7 +28,6 @@ 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 };
@@ -70,11 +69,7 @@ export default function BeszelSystemTableWidget({
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery(
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
);
@@ -270,35 +265,22 @@ export default function BeszelSystemTableWidget({
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
return (
<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>
<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}
/>
);
}
@@ -1,25 +0,0 @@
"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>
);
}
+853 -672
View File
File diff suppressed because it is too large. Load diff
+25 -22
View File
@@ -14,7 +14,7 @@ catalog:
"@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0
"@extractus/feed-extractor": 7.2.1
'@gfazioli/mantine-onboarding-tour': ^4.0.4
"@gfazioli/mantine-onboarding-tour": ^4.0.4
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0
@@ -52,22 +52,22 @@ catalog:
"@testcontainers/mysql": ^12.0.2
"@testcontainers/postgresql": ^12.0.2
"@testcontainers/redis": ^12.0.2
"@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
"@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
"@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.18.0
posthog-node: ^4.17.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.20.0
lucide-react: ^1.18.0
"mantine-contextmenu": ^9.3.0
mantine-datatable: ^9.3.0
mantine-react-table: npm:mantine-react-table-open@9.0.3
@@ -176,11 +176,11 @@ catalog:
tsdav: ^2.2.2
tsx: 4.20.4
turbo: ^2.9.18
typescript: ^6.0.3
typescript: ^7.0.1-rc
vite: ^8.0.16
undici: 7.27.2
use-deep-compare-effect: ^1.8.1
video.js: ^8.23.8
video.js: ^8.23.7
vite-tsconfig-paths: ^6.1.1
vitest: ^4.1.8
winston: 3.19.0
@@ -190,6 +190,7 @@ catalog:
zod: ^4.4.3
zod-form-data: ^3.0.2
zod-validation-error: ^5.0.0
catalogMode: strict
nodeLinker: hoisted
strictPeerDependencies: false
@@ -199,6 +200,8 @@ minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- next
- "@next/*"
- typescript@7.0.1-rc
- "@typescript/*"
# Renovate security update: turbo@2.9.14
- turbo@2.9.14
# Renovate security update: form-data@4.0.6
@@ -206,7 +209,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.18.0"
"axios@>=1.0.0 <1.8.2": ">=1.17.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"