perf: reduce widget render overhead

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent 63dca7d1b6
commit b2fbd10965
10 files changed
+133 -113

No files matched your search

@@ -39,12 +39,21 @@ interface ItemProps {
}
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
if (item.type === "section") return <ContainerSectionContentItem item={item} />;
return (
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
<FixedGridItem item={item}>
{item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
<BoardItemContent item={item} />
</FixedGridItem>
);
};
const ContainerSectionContentItem = ({ item }: { item: ContainerSectionItem }) => {
const { minWidth, minHeight } = useMinSize(item);
return (
<FixedGridItem item={item} minWidth={minWidth} minHeight={minHeight}>
<BoardContainerSection section={item} />
</FixedGridItem>
);
};
@@ -52,14 +61,18 @@ export const SectionContentItem = ({ item, integrations }: ItemProps) => {
/**
* Calculates a container's minimum width or height from its direct content.
*/
export const useMinSize = (item: ContainerSectionItem | SectionItem, direction: "x" | "y") => {
export const useMinSize = (item: ContainerSectionItem) => {
const { items, innerSections } = useSectionItems(item.id);
if (item.type === "item") return undefined;
const size = direction === "x" ? "width" : "height";
return Math.max(
1,
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
);
return {
minWidth: Math.max(
1,
...items.map((child) => child.xOffset + child.width),
...innerSections.map((child) => child.xOffset + child.width),
),
minHeight: Math.max(
1,
...items.map((child) => child.yOffset + child.height),
...innerSections.map((child) => child.yOffset + child.height),
),
};
};
@@ -34,8 +34,6 @@ import {
IconTriangleFilled,
IconZoomScan,
} from "@tabler/icons-react";
import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
@@ -63,6 +61,8 @@ interface FormValidation {
errors: FormErrors;
}
const escapeForRegEx = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
+27 -12
View File
@@ -59,17 +59,9 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
return (
<Box h="100%" w="100%" pos="relative">
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
<AppDescriptionTooltip
description={app.description}
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
>
<Flex
p={isTiny ? 4 : "sm"}
@@ -123,7 +115,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
}}
/>
</Flex>
</Tooltip.Floating>
</AppDescriptionTooltip>
{options.pingEnabled &&
!settings.forceDisableStatus &&
!board.disableStatus &&
@@ -148,6 +140,29 @@ interface AppLinkProps {
enabled: boolean;
}
const AppDescriptionTooltip = ({
description,
enabled,
children,
}: PropsWithChildren<{ description?: string | null; enabled: boolean }>) =>
enabled ? (
<Tooltip.Floating
label={description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
styles={{ tooltip: { maxWidth: 300 } }}
>
{children}
</Tooltip.Floating>
) : (
children
);
const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<AppLinkProps>) =>
enabled ? (
<UnstyledButton
+21 -14
View File
@@ -45,8 +45,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
const runningApps = instance.instanceInfo.applications.filter(
(a) => parseStatus(a.status ?? "") === "running",
@@ -140,6 +138,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
variant="filled"
chevronPosition="right"
multiple
keepMounted={false}
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
@@ -168,19 +167,27 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Accordion>
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed">
v{instance.instanceInfo.version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
)}
</Card>
);
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useScopedI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed">
v{version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
);
};
@@ -50,8 +50,6 @@ export function SingleInstanceLayout({
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
return (
<ScrollArea h="100%">
<Stack gap={0}>
@@ -87,6 +85,7 @@ export function SingleInstanceLayout({
variant="contained"
chevronPosition="right"
multiple
keepMounted={false}
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
@@ -115,23 +114,31 @@ export function SingleInstanceLayout({
</Accordion>
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{instance.instanceInfo.version}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
)}
</Stack>
</ScrollArea>
);
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useScopedI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{version}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
);
};
@@ -168,6 +168,7 @@ export default function MediaMissingWidget({
return (
<Tabs
value={activeTab}
keepMounted={false}
onChange={(value) => {
if (value === "missing" || value === "queued") setSelectedTab(value);
}}
@@ -193,12 +194,12 @@ export default function MediaMissingWidget({
{showMissing && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(missing, t("empty.missing"))}
{activeTab === "missing" && renderPanel(missing, t("empty.missing"))}
</Tabs.Panel>
)}
{showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(queued, t("empty.queued"))}
{activeTab === "queued" && renderPanel(queued, t("empty.queued"))}
</Tabs.Panel>
)}
</Tabs>
+18 -29
View File
@@ -874,27 +874,30 @@ function TaskListToggle() {
);
}
function ListIndentIncrease() {
function useActiveListItemType() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
useEffect(() => {
if (!editor) return;
const updateItemType = () => setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
updateItemType();
editor.on("selectionUpdate", updateItemType);
return () => {
editor.off("selectionUpdate", updateItemType);
};
}, [editor]);
return { editor, itemType };
}
function ListIndentIncrease() {
const { editor, itemType } = useActiveListItemType();
const tControls = useScopedI18n("widget.notebook.controls");
const handleIncreaseIndent = useCallback(() => {
editor?.chain().focus().sinkListItem(itemType).run();
}, [editor, itemType]);
useEffect(() => {
if (!editor) return;
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
};
editor.on("selectionUpdate", handleSelectionUpdate);
return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
};
}, [editor]);
return (
<RichTextEditor.Control
title={tControls("increaseIndent")}
@@ -907,27 +910,13 @@ function ListIndentIncrease() {
}
function ListIndentDecrease() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
const { editor, itemType } = useActiveListItemType();
const tControls = useScopedI18n("widget.notebook.controls");
const handleDecreaseIndent = useCallback(() => {
editor?.chain().focus().liftListItem(itemType).run();
}, [editor, itemType]);
useEffect(() => {
if (!editor) return;
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
};
editor.on("selectionUpdate", handleSelectionUpdate);
return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
};
}, [editor]);
return (
<RichTextEditor.Control
title={tControls("decreaseIndent")}
+2 -1
View File
@@ -131,11 +131,12 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
const isInitialLoading = queryResults.some(isInitialWidgetQueryPending);
const repositories = useMemo(() => {
const resultById = new Map(results.flat().map((result) => [result.id, result]));
const formattedResults = normalizedRepositories
.map((repository) => {
if (!repository.provider) return { ...repository, error: { code: "noProviderSelected" } };
const repositoryResult = results.flat().find(({ id }) => id === repository.id);
const repositoryResult = resultById.get(repository.id);
if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } };
if (!repositoryResult.success) return { ...repository, error: repositoryResult.error };
+3 -4
View File
@@ -63,7 +63,7 @@ export function UmamiContent({
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
);
const multiEventSeries = multiEventQuery.data;
const multiEventSeries = getUsableWidgetQueryData(multiEventQuery);
const multiEventTotal = multiEventSeries
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
@@ -106,10 +106,9 @@ export function UmamiContent({
const selectedView =
viewMode === "events" ? (
<UmamiEventsContent
integrationIds={integrationIds}
websiteId={websiteId}
series={multiEventSeries ?? []}
timeFrame={timeFrame}
eventNames={eventNames}
hasSelectedEvents={eventNames.length > 0}
chartType={chartType}
showXAxis={showXAxis}
/>
@@ -3,27 +3,23 @@
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UmamiEventSeries } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils";
import { getUsableWidgetQueryData } from "../common/query-state";
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
interface UmamiEventsContentProps {
integrationIds: string[];
websiteId: string;
series: UmamiEventSeries[];
timeFrame: string;
eventNames: string[];
hasSelectedEvents: boolean;
chartType: string;
showXAxis: boolean;
}
export function UmamiEventsContent({
integrationIds,
websiteId,
series,
timeFrame,
eventNames,
hasSelectedEvents,
chartType,
showXAxis,
}: UmamiEventsContentProps) {
@@ -31,15 +27,7 @@ export function UmamiEventsContent({
const locale = useCurrentIntlLocale();
const tickColor = "var(--mantine-color-dimmed)";
const series =
getUsableWidgetQueryData(
clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
umamiQueryOptions,
),
) ?? [];
if (eventNames.length === 0) {
if (!hasSelectedEvents) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">