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
18 changed files with 767 additions and 1159 deletions

No files matched your search

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