perf: reduce widget and runtime overhead

This commit is contained in:
ajnart committed 2026-08-13 19:57:13 +02:00
1 parent 5a7607ddf7
commit 14c7e58e17
33 files changed
+779 -339

No files matched your search

+5 -114
View File
@@ -1,14 +1,9 @@
"use client";
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
@@ -21,102 +16,11 @@ import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import actionTargetClasses from "../common/action-target.module.css";
import { storedContentToPlainText } from "./content";
import "react-quill-new/dist/quill.snow.css";
import "./anchor-note.css";
type ReactQuillProps = ComponentProps<typeof ReactQuillComponent>;
type ReactQuillOnChange = NonNullable<ReactQuillProps["onChange"]>;
const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });
const quillDeltaOperationSchema = z.object({
insert: z.unknown().optional(),
attributes: z.record(z.string(), z.unknown()).optional(),
});
const quillDeltaSchema = z.object({
ops: z.array(quillDeltaOperationSchema),
});
type QuillDelta = z.infer<typeof quillDeltaSchema>;
const DeltaConstructor = Quill.import("delta") as new (
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
) => DeltaStatic;
const quillModules = {
toolbar: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, 4, false] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ indent: "-1" }, { indent: "+1" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
history: {
delay: 1000,
maxStack: 200,
userOnly: true,
},
};
const readOnlyModules = {
toolbar: false,
};
const quillFormats = [
"bold",
"italic",
"underline",
"strike",
"header",
"align",
"list",
"indent",
"blockquote",
"code-block",
"link",
];
const emptyDelta = (): DeltaStatic => new DeltaConstructor([{ insert: "\n" }]);
const toPlainTextDelta = (value: string): DeltaStatic => {
const normalized = value.endsWith("\n") ? value : `${value}\n`;
return new DeltaConstructor([{ insert: normalized }]);
};
const parseStoredContent = (content?: string | null): DeltaStatic => {
if (!content) return emptyDelta();
try {
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
if (parsedDelta.success) {
return new DeltaConstructor(parsedDelta.data.ops);
}
} catch {
return toPlainTextDelta(content);
}
return toPlainTextDelta(content);
};
const stringifyDelta = (delta: unknown): string => {
const parsedDelta = quillDeltaSchema.safeParse(delta);
if (parsedDelta.success) {
return JSON.stringify(parsedDelta.data);
}
return JSON.stringify(emptyDelta());
};
export const storedContentToPlainText = (content?: string | null): string => {
return parseStoredContent(content)
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
};
const AnchorNoteEditor = dynamic(() => import("./editor"), { ssr: false });
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
@@ -217,17 +121,7 @@ const AnchorNoteWidgetContent = ({
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
}, [draftContent, draftTitle, note]);
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
const next = stringifyDelta(editor.getContents());
setDraftContent(next);
},
[isEditing],
);
const handleEdit = useCallback(() => {
if (!canEdit || !note) return;
@@ -382,13 +276,10 @@ const AnchorNoteWidgetContent = ({
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
style={{ flex: 1, minHeight: 0 }}
>
<ReactQuill
theme="snow"
<AnchorNoteEditor
content={isEditing ? draftContent : note.content}
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
onChange={setDraftContent}
placeholder={t("emptyContent")}
/>
</div>
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { parseStoredOperations, storedContentToPlainText, stringifyDelta } from "./content";
describe("Anchor Note stored content", () => {
it("preserves valid Delta operations and extracts their text", () => {
const content = JSON.stringify({
ops: [{ insert: "Hello ", attributes: { bold: true } }, { insert: { image: "ignored" } }, { insert: "world\n" }],
});
expect(parseStoredOperations(content)).toEqual([
{ insert: "Hello ", attributes: { bold: true } },
{ insert: { image: "ignored" } },
{ insert: "world\n" },
]);
expect(storedContentToPlainText(content)).toBe("Hello world");
});
it("keeps legacy plain text readable and normalizes an editor newline", () => {
expect(parseStoredOperations("legacy note")).toEqual([{ insert: "legacy note\n" }]);
expect(storedContentToPlainText("legacy note")).toBe("legacy note");
});
it("falls back to an empty Delta when editor output is invalid", () => {
expect(stringifyDelta({ invalid: true })).toBe('{"ops":[{"insert":"\\n"}]}');
});
});
@@ -0,0 +1,40 @@
import { z } from "zod/v4";
const quillDeltaOperationSchema = z.object({
insert: z.unknown().optional(),
attributes: z.record(z.string(), z.unknown()).optional(),
});
export const quillDeltaSchema = z.object({
ops: z.array(quillDeltaOperationSchema),
});
export type QuillDelta = z.infer<typeof quillDeltaSchema>;
const toPlainTextOperations = (value: string): QuillDelta["ops"] => [
{ insert: value.endsWith("\n") ? value : `${value}\n` },
];
export const parseStoredOperations = (content?: string | null): QuillDelta["ops"] => {
if (!content) return [{ insert: "\n" }];
try {
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
if (parsedDelta.success) return parsedDelta.data.ops;
} catch {
return toPlainTextOperations(content);
}
return toPlainTextOperations(content);
};
export const stringifyDelta = (delta: unknown): string => {
const parsedDelta = quillDeltaSchema.safeParse(delta);
return JSON.stringify(parsedDelta.success ? parsedDelta.data : { ops: [{ insert: "\n" }] });
};
export const storedContentToPlainText = (content?: string | null): string =>
parseStoredOperations(content)
.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
@@ -0,0 +1,83 @@
"use client";
import { useCallback, useMemo } from "react";
import type { ComponentProps } from "react";
import ReactQuill, { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type { QuillDelta } from "./content";
import { parseStoredOperations, stringifyDelta } from "./content";
import "react-quill-new/dist/quill.snow.css";
type ReactQuillOnChange = NonNullable<ComponentProps<typeof ReactQuill>["onChange"]>;
const DeltaConstructor = Quill.import("delta") as new (
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
) => DeltaStatic;
const quillModules = {
toolbar: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, 4, false] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ indent: "-1" }, { indent: "+1" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
history: {
delay: 1000,
maxStack: 200,
userOnly: true,
},
};
const readOnlyModules = {
toolbar: false,
};
const quillFormats = [
"bold",
"italic",
"underline",
"strike",
"header",
"align",
"list",
"indent",
"blockquote",
"code-block",
"link",
];
interface AnchorNoteEditorProps {
content?: string | null;
readOnly: boolean;
placeholder: string;
onChange: (content: string) => void;
}
export default function AnchorNoteEditor({ content, readOnly, placeholder, onChange }: AnchorNoteEditorProps) {
const value = useMemo(() => new DeltaConstructor(parseStoredOperations(content)), [content]);
const handleChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (readOnly || source !== "user") return;
onChange(stringifyDelta(editor.getContents()));
},
[onChange, readOnly],
);
return (
<ReactQuill
theme="snow"
readOnly={readOnly}
value={value}
onChange={handleChange}
modules={readOnly ? readOnlyModules : quillModules}
formats={quillFormats}
placeholder={placeholder}
/>
);
}
@@ -89,6 +89,22 @@ const getSizeConfig = (width: number): SizeConfig => {
};
};
const PercentCell = ({ value, size }: { value: number; size: SizeConfig }) => {
return (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
};
export default function BeszelSystemTableWidget({
options,
integrationIds,
@@ -165,20 +181,6 @@ export default function BeszelSystemTableWidget({
});
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
@@ -211,7 +213,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
render: (record) => <PercentCell value={record.cpu} size={size} />,
},
visibleMetricKeys.has("showMemory") && {
accessor: "memory",
@@ -223,7 +225,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
render: (record) => <PercentCell value={record.memory} size={size} />,
},
visibleMetricKeys.has("showDisk") && {
accessor: "disk",
@@ -255,7 +257,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
render: (record) => <PercentCell value={record.gpu} size={size} />,
},
visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
+25 -22
View File
@@ -22,6 +22,30 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256;
const cell = (
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
</Container>
);
if (disabled) return cell;
return (
<HoverCard
position="bottom"
@@ -32,29 +56,8 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
transitionProps={{ transition: "pop" }}
openDelay={350}
closeDelay={400}
disabled={disabled}
>
<HoverCard.Target>
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
</Container>
</HoverCard.Target>
<HoverCard.Target>{cell}</HoverCard.Target>
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
<CalendarEventList events={events} />
</HoverCard.Dropdown>
@@ -11,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
@@ -11,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
supportsAdvancedFocus: false,
icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
+1 -1
View File
@@ -49,7 +49,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
integrationIds: scope.integrationIds,
limitPerIntegration: scope.options.limitPerIntegration,
}),
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from(
(factory) => ({
+1 -1
View File
@@ -8,7 +8,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
supportsAdvancedFocus: true,
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from(() => ({}));
},
@@ -10,7 +10,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
icon: IconHeartRateMonitor,
supportsAdvancedFocus: true,
queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]],
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -237,6 +237,7 @@ function Carousel({
<Image
src={asset.publicLink}
alt=""
loading="lazy"
w={64}
h={44}
radius="sm"
+19
View File
@@ -148,4 +148,23 @@ describe("widget manifest promise stability", () => {
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
);
});
it("does not poll integration handlers faster than their cache can refresh", async () => {
const definitions = await loadAllWidgetDefinitions();
const cachedWidgetKinds = [
"dnsHoleControls",
"dnsHoleSummary",
"downloads",
"firewall",
"healthMonitoring",
"mediaServer",
"systemDisks",
"systemResources",
"tracearr",
] as const;
for (const kind of cachedWidgetKinds) {
expect(definitions.get(kind)?.refetchInterval).toBe(10);
}
});
});
@@ -17,6 +17,11 @@
}
}
.item[data-defer-rendering] {
content-visibility: auto;
contain-intrinsic-size: auto 88px;
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
@@ -117,6 +117,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
>
<UnstyledButton
className={classes.item}
data-defer-rendering={isAdvanced ? undefined : true}
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
@@ -159,6 +160,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
src={item.imageUrls.poster}
alt=""
radius="xs"
loading="lazy"
style={{ flexShrink: 0 }}
/>
)}
+1 -1
View File
@@ -14,7 +14,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
integrationIds: scope.integrationIds,
showOnlyPlaying: scope.options.showOnlyPlaying,
}),
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
+19 -13
View File
@@ -7,24 +7,30 @@
*/
export const widgetQueryRefetchIntervals = [
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads"]], intervalSeconds: 5 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer"]], intervalSeconds: 5 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
{ queryKey: [["widget", "immich"]], intervalSeconds: null },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 10 },
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 10 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 10 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft", "getServerStatus"]], intervalSeconds: null },
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
{ queryKey: [["widget", "releases"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed"]], intervalSeconds: null },
{ queryKey: [["widget", "releases", "getLatest"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed", "getFeeds"]], intervalSeconds: null },
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice", "getPriceHistory"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getVisitorStats"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getActiveVisitors"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null },
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
] as const;
+1 -1
View File
@@ -8,7 +8,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemDis
supportsAdvancedFocus: true,
icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
refetchInterval: 10,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() {
return optionsBuilder.from(
@@ -16,7 +16,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes
icon: IconGraphFilled,
supportsAdvancedFocus: true,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
refetchInterval: 10,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
+1 -1
View File
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat,
supportsAdvancedFocus: true,
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
showStreams: factory.switch({
+19 -38
View File
@@ -11,49 +11,36 @@ import type { WudContainerUpdate } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function WudWidget({ integrationIds, options, width, displayMode }: WidgetComponentProps<"wud">) {
export default function WudWidget({ integrationIds, options, width }: WidgetComponentProps<"wud">) {
const integrationId = integrationIds[0];
if (!integrationId) return null;
return (
<WudWidgetContent
integrationId={integrationId}
options={options}
width={width}
isAdvanced={displayMode === "advanced"}
/>
);
return <WudWidgetContent integrationId={integrationId} options={options} width={width} />;
}
const WudWidgetContent = ({
integrationId,
options,
width,
isAdvanced,
}: {
integrationId: string;
options: WidgetComponentProps<"wud">["options"];
width: number;
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.wud");
const [data, statsQuery] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const board = useRequiredBoard();
const isTiny = !isAdvanced && width < 256;
const isTiny = width < 256;
const stats = data.stats;
if (stats.totalContainers === 0) return <WidgetEmptyState />;
const updatePercentage = Math.round((stats.updatesAvailable / stats.totalContainers) * 100);
const badgeColor = stats.updatesAvailable === 0 ? "green" : progressColor(updatePercentage);
const showTitle = isAdvanced || options.showTitle;
const showRing = isAdvanced || options.showRing;
const showUpdateList = (isAdvanced || options.showUpdateList) && stats.updates.length > 0;
const showUpdateList = options.showUpdateList && stats.updates.length > 0;
const ring = (
<RingProgress
@@ -86,7 +73,7 @@ const WudWidgetContent = ({
</Stack>
);
const tinyContent = showRing ? (
const tinyContent = options.showRing ? (
<Tooltip
label={
<Stack gap={2}>
@@ -115,12 +102,7 @@ const WudWidgetContent = ({
return (
<Stack p="xs" gap="xs" h="100%">
{Boolean(statsQuery.error) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Group>
)}
{showTitle && !isTiny && (
{options.showTitle && !isTiny && (
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size={20} radius="sm" src={getIconUrl("wud")} />
@@ -140,12 +122,12 @@ const WudWidgetContent = ({
tinyContent
) : options.layout === "horizontal" ? (
<Group justify="center" wrap="nowrap" gap="md">
{showRing && ring}
{options.showRing && ring}
{summary}
</Group>
) : (
<Stack align="center" gap="xs">
{showRing && ring}
{options.showRing && ring}
{summary}
</Stack>
)}
@@ -199,12 +181,17 @@ const UpdateCard = ({
className: string | undefined;
}) => {
const t = useScopedI18n("widget.wud");
const href = getSafeApplicationUrl(update.link);
const isDigestUpdate = isDigestVersion(update.newVersion);
const fullVersionText = buildVersionText(update.currentVersion, update.newVersion);
const versionText = isDigestUpdate
? t("updateAvailable")
: buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion));
const versionBadge = versionText ? (
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
{versionText}
</Badge>
) : null;
const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText;
return (
<Card className={combineClasses(className)} radius={radius} p="xs" style={{ overflow: "visible" }}>
@@ -213,19 +200,13 @@ const UpdateCard = ({
{update.name}
</Text>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{versionText && (
<Tooltip label={fullVersionText} disabled={isDigestUpdate || versionText === fullVersionText}>
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
{versionText}
</Badge>
</Tooltip>
)}
{href && (
{showVersionTooltip ? <Tooltip label={fullVersionText}>{versionBadge}</Tooltip> : versionBadge}
{update.link && (
<ActionIcon
component="a"
href={href}
href={update.link}
target="_blank"
rel={SAFE_NEW_TAB_REL}
rel="noreferrer noopener"
variant="subtle"
color="gray"
size="sm"