Compare commits

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

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

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

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

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

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

No ternaries in the changed files. Intended simplifications are marked
with ponytail: comments.
2026-06-22 12:41:04 +02:00
28 changed files with 1414 additions and 1895 deletions

No files matched your search

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