fix: address widget review feedback

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent b1b17ad69f
commit a38e731209
79 files changed
+2138 -244

No files matched your search

+1 -1
View File
@@ -69,7 +69,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode || isAdvanced}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
+1 -1
View File
@@ -15,7 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
queryKeys: [[["app", "byId"]], [["widget", "app"]]],
queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
@@ -5,7 +5,7 @@ import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -47,6 +47,7 @@ const ArchiveTeamWarriorWidgetContent = ({
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
const locale = useCurrentLocale();
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
@@ -96,7 +97,7 @@ const ArchiveTeamWarriorWidgetContent = ({
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
</Text>
<Text size="xs" c="dimmed">
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })}
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
</Text>
</Group>
<ScrollArea style={{ flex: 1 }}>
+12 -6
View File
@@ -8,6 +8,7 @@ import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({
@@ -16,14 +17,19 @@ export default function AudioStatsWidget({
width,
height,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
const statsInput = { integrationId: integrationIds[0] ?? "" };
const { data: response } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
const streamsInput = { integrationIds, showOnlyPlaying: false };
const streamsEnabled = displayMode === "advanced" && response?.kind === "navidrome";
const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(streamsInput, {
enabled: streamsEnabled,
});
const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
{ integrationIds, showOnlyPlaying: false },
{ enabled: displayMode === "advanced" && response?.kind === "navidrome" },
);
setWidgetRuntimeQueries(widgetStateRef, [
{ path: ["widget", "audioStats", "getStats"], input: statsInput },
...(streamsEnabled ? [{ path: ["widget", "mediaServer", "getCurrentStreams"], input: streamsInput }] : []),
]);
if (!response) return <WidgetEmptyState />;
+3 -2
View File
@@ -1,6 +1,6 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const hideUnlessNavidrome = {
@@ -13,7 +13,8 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]],
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
queryMatcher: matchesWidgetRuntimeQuery,
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
+8 -2
View File
@@ -1,12 +1,18 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
queryKey: [["widget", "beszel"]],
queryKey: [["widget", "beszel", "getAlerts"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
includeHistory: scope.options.showHistory,
maxHistoryItems: scope.options.maxHistoryItems,
}),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -430,7 +430,9 @@ export default function BeszelSystemGridWidget({
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size);
const maxMetrics = advanced
? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size))
: getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
queryKey: [["widget", "beszel", "getSystems"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -13,6 +13,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
@@ -28,6 +29,7 @@ export default function BeszelSystemStatsWidget({
boardId,
itemId,
setOptions,
widgetStateRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -45,6 +47,25 @@ export default function BeszelSystemStatsWidget({
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
const includeDocker =
(displayMode === "advanced" || height >= 380) &&
(options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork);
setWidgetRuntimeQueries(
widgetStateRef,
selectedSystem && options.timePeriod !== "1m"
? [
{
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: [selectedSystem.integrationId],
systemId: selectedSystem.systemId,
timePeriod: options.timePeriod,
includeDocker,
},
},
]
: [],
);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -178,7 +199,7 @@ export default function BeszelSystemStatsWidget({
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
onSwitchToHistorical={() => handleTimePeriod("1h")}
/>
</Stack>
</ScrollArea>
@@ -2,9 +2,9 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
import { createBeszelSystemChoices } from "./selection";
import { createBeszelSystemChoices, resolveStoredBeszelQuerySelection } from "./selection";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
@@ -17,7 +17,29 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher(query, scope) {
if (query.path.at(-1) === "getSystems") {
return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds });
}
const hasRuntimeStatsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getSystemStats");
if (hasRuntimeStatsQuery) return matchesWidgetRuntimeQuery(query, scope);
const selection = resolveStoredBeszelQuerySelection(String(scope.options.systemId ?? ""), scope.integrationIds);
if (!selection) return false;
const dockerEnabled = ["showDockerCpu", "showDockerMemory", "showDockerNetwork"].some(
(key) => scope.options[key] === true,
);
const expected = {
...selection,
timePeriod: scope.options.timePeriod,
};
return (
widgetQueryInputMatches(query.input, { ...expected, includeDocker: false }) ||
(dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true }))
);
},
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
import { createBeszelSystemChoices, resolveBeszelSystemChoice, resolveStoredBeszelQuerySelection } from "./selection";
const choices = createBeszelSystemChoices([
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
@@ -13,7 +13,34 @@ describe("Beszel system selection", () => {
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
});
it("keeps legacy plain system ids working", () => {
expect(resolveBeszelSystemChoice(choices, "same")?.integrationId).toBe("one");
it("keeps unique legacy plain system ids working", () => {
expect(resolveBeszelSystemChoice(choices.slice(0, 1), "same")?.integrationId).toBe("one");
});
it("defaults an empty selection to the first system", () => {
expect(resolveBeszelSystemChoice(choices, "")?.integrationId).toBe("one");
});
it("does not guess when a legacy plain system id is ambiguous", () => {
expect(resolveBeszelSystemChoice(choices, "same")).toBeUndefined();
});
it("does not silently replace an unresolved stored system", () => {
expect(resolveBeszelSystemChoice(choices, "missing")).toBeUndefined();
});
it("derives the queried integration and system from a composite selection", () => {
expect(resolveStoredBeszelQuerySelection("two:same", ["one", "two"])).toEqual({
integrationIds: ["two"],
systemId: "same",
});
});
it("supports a legacy plain system id only when the integration is unambiguous", () => {
expect(resolveStoredBeszelQuerySelection("same", ["one"])).toEqual({
integrationIds: ["one"],
systemId: "same",
});
expect(resolveStoredBeszelQuerySelection("same", ["one", "two"])).toBeNull();
});
});
@@ -24,9 +24,25 @@ export function createBeszelSystemChoices(results: BeszelSystemsResult[]): Besze
}
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
return (
choices.find((choice) => choice.value === storedValue) ??
choices.find((choice) => choice.systemId === storedValue) ??
choices[0]
);
const exactMatch = choices.find((choice) => choice.value === storedValue);
if (exactMatch) return exactMatch;
if (storedValue === "") return choices[0];
const legacyMatches = choices.filter((choice) => choice.systemId === storedValue);
return legacyMatches.length === 1 ? legacyMatches[0] : undefined;
}
export function resolveStoredBeszelQuerySelection(storedValue: string, integrationIds: readonly string[]) {
for (const integrationId of integrationIds) {
const prefix = `${integrationId}:`;
if (storedValue.startsWith(prefix)) {
return { integrationIds: [integrationId], systemId: storedValue.slice(prefix.length) };
}
}
if (storedValue !== "" && integrationIds.length === 1) {
return { integrationIds: [integrationIds[0] as string], systemId: storedValue };
}
return null;
}
@@ -1,6 +1,6 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
@@ -25,7 +25,11 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher: (query, scope) =>
query.path.at(-1) === "getSystems"
? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds })
: matchesWidgetRuntimeQuery(query, scope),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
+14 -2
View File
@@ -13,6 +13,7 @@ import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { CalendarDay } from "./calender-day";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
import { CalendarEventList } from "./calendar-event-list";
@@ -22,6 +23,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
const [month, setMonth] = useState(() => new Date());
if (props.integrationIds.length === 0) {
setWidgetRuntimeQueries(props.widgetStateRef, []);
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
}
@@ -33,7 +35,15 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
displayMode,
widgetStateRef,
}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -41,6 +51,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "calendar", "findAllEvents"], input }]);
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
@@ -75,6 +86,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const isSmall = width < 256;
const calendarHeight = displayMode === "advanced" ? height * 0.62 : height;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const visibleEvents = useMemo(
@@ -159,7 +171,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
rootHeight={calendarHeight}
/>
);
}}
+3 -1
View File
@@ -4,11 +4,13 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
queryKey: [["widget", "calendar", "findAllEvents"]],
queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
+18 -2
View File
@@ -1,12 +1,28 @@
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
queryKey: [["widget", "weather"]],
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.weatherLocation;
if (
scope.options.showWeather !== true ||
location === null ||
typeof location !== "object" ||
!("latitude" in location) ||
!("longitude" in location)
) {
return false;
}
return widgetQueryInputMatches(input, {
latitude: location.latitude,
longitude: location.longitude,
});
},
createOptions() {
return optionsBuilder.from(
(factory) => ({
+21 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -33,12 +33,27 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const visibleSections = useMemo(
() =>
[
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null),
[options.showApplications, options.showServers, options.showServices],
);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const previousVisibleSectionsRef = useRef(visibleSections);
useEffect(() => {
const newlyVisibleSections = visibleSections.filter(
(section) => !previousVisibleSectionsRef.current.includes(section),
);
previousVisibleSectionsRef.current = visibleSections;
if (newlyVisibleSections.length === 0) return;
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
}, [visibleSections]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -32,12 +32,27 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const visibleSections = useMemo(
() =>
[
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null),
[options.showApplications, options.showServers, options.showServices],
);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const previousVisibleSectionsRef = useRef(visibleSections);
useEffect(() => {
const newlyVisibleSections = visibleSections.filter(
(section) => !previousVisibleSectionsRef.current.includes(section),
);
previousVisibleSectionsRef.current = visibleSections;
if (newlyVisibleSections.length === 0) return;
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
}, [visibleSections]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
+5 -3
View File
@@ -1,11 +1,13 @@
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi", "getData"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
contextActions({ options, widgetStateRef }) {
return [
{
@@ -13,9 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("customApi
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetStateRef.current?.togglePolling !== "function",
disabled: typeof widgetStateRef?.current?.togglePolling !== "function",
onClick: () => {
const action = widgetStateRef.current?.togglePolling;
const action = widgetStateRef?.current?.togglePolling;
if (typeof action === "function") action();
},
},
+17 -2
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "vitest";
import { getWidgetQueryKeys } from "./definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, setWidgetRuntimeQueries } from "./definition";
const togglePolling = () => undefined;
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
@@ -12,8 +14,21 @@ describe("getWidgetQueryKeys", () => {
});
test("preserves every explicit key for multi-root widgets", () => {
const queryKeys = [[["widget", "audioStats"]], [["widget", "mediaServer"]]] as const;
const queryKeys = [
[["widget", "audioStats", "getStats"]],
[["widget", "mediaServer", "getCurrentStreams"]],
] as const;
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
test("registers runtime queries without replacing imperative widget actions", () => {
const widgetStateRef = { current: { togglePolling } };
const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
});
});
+7 -1
View File
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
const columnsList = [
@@ -42,6 +42,12 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
queryKey: [["widget", "downloads", "getJobsAndStatuses"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
limitPerIntegration: scope.options.limitPerIntegration,
}),
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
+69 -27
View File
@@ -127,7 +127,9 @@ export default function FirewallWidget({
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
const hasError = Boolean(
cpu?.error || memory?.error || version?.error || interfacesQuery.isError || interfaces?.error,
);
const metadata = firewallMetadata.get(firewallId);
return (
@@ -138,13 +140,18 @@ export default function FirewallWidget({
version={version?.error ? undefined : version?.summary.version}
cpu={cpu?.error ? undefined : cpu?.summary.total}
memory={memory?.error ? undefined : memory?.summary.percent}
interfaces={interfaces?.error ? undefined : interfaces?.summary}
interfaces={interfaces?.summary}
interfacesLoaded={interfaces !== undefined}
interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={t("widget.firewall.error.internalServerError")}
errorLabel={t("widget.firewall.error.internalServerError")}
noDataLabel={t("widget.firewall.empty.noInterfaces")}
loadingLabel={t("common.action.loading")}
errorBadgeLabel={t("common.error")}
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
t={t}
/>
@@ -163,12 +170,17 @@ interface FirewallPanelProps {
cpu?: number;
memory?: number;
interfaces?: FirewallInterfacesSummary[];
interfacesLoaded: boolean;
interfacesError: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
noDataLabel: string;
loadingLabel: string;
errorBadgeLabel: string;
interfacesLabel: string;
t: TranslationFunction;
}
@@ -180,12 +192,17 @@ const FirewallPanel = ({
cpu,
memory,
interfaces,
interfacesLoaded,
interfacesError,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
errorLabel,
noDataLabel,
loadingLabel,
errorBadgeLabel,
interfacesLabel,
t,
}: FirewallPanelProps) => (
@@ -205,7 +222,7 @@ const FirewallPanel = ({
</Stack>
<Group gap={4} wrap="nowrap">
{hasError && (
<Tooltip label={noDataLabel}>
<Tooltip label={errorLabel}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
</Badge>
@@ -235,11 +252,16 @@ const FirewallPanel = ({
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
</Stack>
@@ -294,21 +316,31 @@ const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
interface InterfacesPanelProps {
summary: FirewallInterfacesSummary[];
hasResult: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
errorBadgeLabel: string;
noDataLabel: string;
loadingLabel: string;
label: string;
}
const InterfacesPanel = ({
summary,
hasResult,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
errorLabel,
errorBadgeLabel,
noDataLabel,
loadingLabel,
label,
}: InterfacesPanelProps) => {
const bandwidth = calculateBandwidth(summary).data;
@@ -321,35 +353,45 @@ const InterfacesPanel = ({
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
{hasError && (
<Badge color="red" variant="light" size="xs">
{errorBadgeLabel}
</Badge>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
{bandwidth.length > 0 ? (
bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
{name}
{bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
{name}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
{formatBitsPerSec(transmit, 2)}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
{formatBitsPerSec(transmit, 2)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
</Group>
))
) : (
<Text size="xs" c="dimmed">
{noDataLabel}
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
</Group>
))}
{hasError ? (
<Text size="xs" c="red">
{errorLabel}
</Text>
)}
) : bandwidth.length === 0 ? (
<Text size="xs" c="dimmed">
{hasResult ? noDataLabel : loadingLabel}
</Text>
) : null}
</Stack>
</Accordion.Panel>
</Accordion.Item>
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAllowedPermissions, getSandboxFlags, isSupportedProtocol } from "./component";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component";
const permissions = {
allowFullScreen: true,
@@ -23,4 +23,12 @@ describe("iframe policy", () => {
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
it("creates a display title without credentials, paths, or query parameters", () => {
expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com");
});
it("keeps a non-default port in the display title", () => {
expect(getFrameTitle("http://user:secret@example.com:8080/private?token=sensitive")).toBe("example.com:8080");
});
});
+3 -3
View File
@@ -28,7 +28,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
{getFrameTitle(embedUrl)}
</Text>
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
{embedUrl}
{t("widget.app.option.openInNewTab.label")}
</Anchor>
</Group>
)}
@@ -138,9 +138,9 @@ const permissionMapping: Partial<
allowPayment: "payment",
};
const getFrameTitle = (url: string) => {
export const getFrameTitle = (url: string) => {
try {
return new URL(url).hostname;
return new URL(url).host;
} catch {
return "iframe";
}
@@ -4,23 +4,31 @@ import z from "zod";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
queryKey: [["widget", "immich"]],
queryKey: [["widget", "immich", "getAlbum"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationId: scope.integrationIds[0] ?? "",
albumId:
typeof scope.options.albumId === "string" && scope.options.albumId !== ALL_PHOTOS_ALBUM_ID
? scope.options.albumId
: undefined,
}),
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions({ widgetStateRef }) {
const call = (key: string) => () => {
const action = widgetStateRef.current?.[key];
const action = widgetStateRef?.current?.[key];
if (typeof action === "function") action();
};
const disabled = (key: string) => typeof widgetStateRef.current?.[key] !== "function";
const disabled = (key: string) => typeof widgetStateRef?.current?.[key] !== "function";
return [
{
key: "previousPhoto",
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { WidgetComponentProps } from "../definition";
import ImmichServerStatsWidget from "./server-stats/component";
import { definition as albumCarouselDefinition } from "./album-carousel";
const mocks = vi.hoisted(() => ({
getAlbums: vi.fn(() => ({ data: [], isPending: false, isError: false })),
}));
vi.mock("@homarr/api/client", () => ({
clientApi: {
widget: {
immich: {
getAlbums: { useQuery: mocks.getAlbums },
getServerStats: { useQuery: () => ({ data: undefined }) },
},
},
},
}));
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
useScopedI18n: () => (key: string) => key,
}));
describe("Immich album queries", () => {
beforeEach(() => vi.clearAllMocks());
test("server stats requests a bounded album response", () => {
ImmichServerStatsWidget({
integrationIds: ["immich-id"],
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
displayMode: "advanced",
width: 800,
height: 600,
isEditMode: false,
boardId: "board-id",
itemId: "item-id",
setOptions: vi.fn(),
} satisfies WidgetComponentProps<"immich-serverStats">);
expect(mocks.getAlbums).toHaveBeenCalledWith(
{ integrationId: "immich-id", limit: 50 },
expect.objectContaining({ enabled: true }),
);
});
test("album carousel keeps requesting every album for its selector", () => {
const options = albumCarouselDefinition.createOptions();
options.albumId.useOptions(["immich-id"]);
expect(mocks.getAlbums).toHaveBeenCalledWith(
{ integrationId: "immich-id" },
expect.objectContaining({ enabled: true }),
);
});
});
@@ -10,30 +10,37 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { setWidgetRuntimeQueries } from "../../definition";
import classes from "./component.module.css";
const MAX_ADVANCED_ALBUMS = 50;
export default function ImmichServerStatsWidget({
integrationIds,
options,
displayMode = "compact",
width,
widgetStateRef,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
integrationId: integrationIds[0] ?? "",
const input = { integrationId: integrationIds[0] ?? "" };
const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS };
const isAdvanced = displayMode === "advanced";
const albumsEnabled = isAdvanced && integrationIds.length > 0;
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(input);
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
enabled: albumsEnabled,
staleTime: 15 * 60 * 1000,
});
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: displayMode === "advanced" && integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
setWidgetRuntimeQueries(widgetStateRef, [
{ path: ["widget", "immich", "getServerStats"], input },
...(albumsEnabled ? [{ path: ["widget", "immich", "getAlbums"], input: albumsInput }] : []),
]);
if (!stats) return <WidgetEmptyState />;
const statsContent = (
<SimpleGrid
cols={displayMode === "advanced" ? (width >= 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1}
spacing="sm"
>
<SimpleGrid cols={isAdvanced ? (width >= 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1} spacing="sm">
{options.showUsers && (
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
)}
@@ -61,7 +68,7 @@ export default function ImmichServerStatsWidget({
</SimpleGrid>
);
if (displayMode === "compact") {
if (!isAdvanced) {
return (
<Stack gap="md" h="100%" p="md" justify="center">
{statsContent}
@@ -69,7 +76,7 @@ export default function ImmichServerStatsWidget({
);
}
const sortedAlbums = [...albums].toSorted((left, right) => right.assetCount - left.assetCount);
const sortedAlbums = albums;
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return (
<Stack gap="lg" h="100%" p="lg">
@@ -1,11 +1,12 @@
import { IconGraphFilled } from "@tabler/icons-react";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../../definition";
import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled,
queryKey: [["widget", "immich"]],
queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
+4 -1
View File
@@ -67,12 +67,15 @@ import * as customApi from "./custom-api";
import * as wud from "./wud";
export type {
NormalizedWidgetQuery,
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
WidgetQueryMatcher,
WidgetQueryMatcherScope,
} from "./definition";
export { getWidgetQueryKeys } from "./definition";
export { getWidgetQueryKeys, getWidgetRuntimeQueries } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -33,7 +33,9 @@ export default function IndexerManagerWidget({
const hasSmallWidth = !isAdvanced && width < 256;
const hasSmallHeight = !isAdvanced && height < 256;
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
const unavailableCount = allIndexers.filter((indexer) => !indexer.status || !indexer.enabled).length;
const unavailableCount = allIndexers.filter(
(indexer) => indexer.status === false || indexer.enabled === false,
).length;
useEffect(() => {
if (!widgetStateRef) return;
@@ -15,9 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa
disabled:
context.isEditMode ||
!context.canInteractWithSelectedIntegrations ||
typeof widgetStateRef.current?.testAllIndexers !== "function",
typeof widgetStateRef?.current?.testAllIndexers !== "function",
onClick: () => {
const action = widgetStateRef.current?.testAllIndexers;
const action = widgetStateRef?.current?.testAllIndexers;
if (typeof action === "function") action();
},
},
+3 -2
View File
@@ -2,14 +2,15 @@ import { IconMovie } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
queryKey: [["widget", "mediaOrganizer"]],
queryKey: [["widget", "mediaOrganizer", "getData"]],
queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
@@ -4,14 +4,23 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
queryKey: [["widget", "mediaRequests"]],
queryKey: [["widget", "mediaRequests", "getLatestRequests"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
statuses:
Array.isArray(scope.options.statusFilter) && scope.options.statusFilter.length > 0
? scope.options.statusFilter
: mediaRequestStatusValues,
recentDays: scope.options.recentDays,
}),
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
@@ -7,7 +7,7 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
queryKey: [["widget", "mediaRequests"]],
queryKey: [["widget", "mediaRequests", "getStats"]],
createOptions() {
return {};
},
+7 -1
View File
@@ -2,11 +2,17 @@ import { IconVideo } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
queryKey: [["widget", "mediaServer", "getCurrentStreams"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
showOnlyPlaying: scope.options.showOnlyPlaying,
}),
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -23,6 +23,7 @@ import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel";
@@ -44,6 +45,7 @@ export default function MediaTranscodingWidget({
width,
height,
displayMode,
widgetStateRef,
}: WidgetComponentProps<"mediaTranscoding">) {
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
@@ -58,6 +60,7 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: requestPagination.page,
};
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaTranscoding", "getDataAsync"], input }]);
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState<View>(options.defaultView);
@@ -4,13 +4,15 @@ import { z } from "zod/v4";
import { capitalize } from "@homarr/common";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
queryKey: [["widget", "mediaTranscoding", "getDataAsync"]],
queryMatcher: matchesWidgetRuntimeQuery,
maxIntegrations: 1,
refetchInterval: null,
createOptions() {
@@ -1,12 +1,18 @@
import { IconBrandMinecraft } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft,
queryKey: [["widget", "minecraft"]],
queryKey: [["widget", "minecraft", "getServerStatus"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
title: scope.options.title,
domain: scope.options.domain,
isBedrockServer: scope.options.isBedrockServer,
}),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+19 -25
View File
@@ -53,7 +53,6 @@ import { Table } from "@tiptap/extension-table";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableRow } from "@tiptap/extension-table-row";
import { TaskItem } from "@tiptap/extension-task-item";
import { TaskList } from "@tiptap/extension-task-list";
import { TextAlign } from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
@@ -61,7 +60,6 @@ import type { Editor } from "@tiptap/react";
import { useEditor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import { StarterKit } from "@tiptap/starter-kit";
import type { Node } from "prosemirror-model";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
@@ -70,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css";
@@ -208,17 +207,20 @@ export function Notebook({
}),
TableHeader,
TableRow,
TaskItem.configure({
ReadOnlyTaskItem.configure({
nested: true,
onReadOnlyChecked: (node, checked) => {
onReadOnlyChecked: () => {
if (!allowReadOnlyCheckRef.current) return false;
if (!canChangeRef.current) return false;
return true;
},
onReadOnlyCheckedAtPosition: (position, checked) => {
if (!allowReadOnlyCheckRef.current) return;
if (!canChangeRef.current) return;
const event = new CustomEvent(readOnlyCheckEventName, {
detail: { node, checked },
detail: { position, checked },
});
dispatchEvent(event);
return true;
},
}),
TaskList.configure({ itemTypeName: "taskItem" }),
@@ -243,25 +245,17 @@ export function Notebook({
(event: Event) => {
if (!allowReadOnlyCheckRef.current) return;
if (!editor) return;
const { detail } = event as CustomEvent<{ node: Node; checked: boolean }>;
const { detail } = event as CustomEvent<{ position: number; checked: boolean }>;
const transaction = createReadOnlyTaskItemTransaction(editor.state, detail.position, detail.checked);
if (!transaction) return;
editor.state.doc.descendants((subnode, pos) => {
if (!detail) return;
if (!subnode.eq(detail.node)) return;
const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, {
...detail.node.attrs,
checked: detail.checked,
});
editor.view.dispatch(tr);
const nextContent = editor.getHTML();
setContent(nextContent);
void handleContentUpdate(nextContent).then((wasSaved) => {
if (wasSaved) return;
setContent(previousContentRef.current);
editor.commands.setContent(previousContentRef.current);
});
editor.view.dispatch(transaction);
const nextContent = editor.getHTML();
setContent(nextContent);
void handleContentUpdate(nextContent).then((wasSaved) => {
if (wasSaved) return;
setContent(previousContentRef.current);
editor.commands.setContent(previousContentRef.current);
});
},
[editor, handleContentUpdate],
@@ -0,0 +1,96 @@
import { Editor } from "@tiptap/core";
import { TaskList } from "@tiptap/extension-task-list";
import { Schema } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
const schema = new Schema({
nodes: {
doc: { content: "taskList" },
paragraph: { content: "text*" },
text: { inline: true },
taskList: { content: "taskItem+" },
taskItem: {
attrs: { checked: { default: false } },
content: "paragraph",
},
},
});
const createTaskItem = () =>
schema.node("taskItem", { checked: false }, [schema.node("paragraph", null, [schema.text("Duplicate task")])]);
describe("read-only task item updates", () => {
it("reports the exact position of the clicked duplicate task", () => {
const element = document.createElement("div");
let clickedPosition: number | undefined;
const editor = new Editor({
element,
editable: false,
extensions: [
StarterKit,
TaskList.configure({ itemTypeName: "taskItem" }),
ReadOnlyTaskItem.configure({
nested: true,
onReadOnlyChecked: () => true,
onReadOnlyCheckedAtPosition: (position) => {
clickedPosition = position;
},
}),
],
content: {
type: "doc",
content: [
{
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
{ type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
],
},
],
},
});
try {
const positions: number[] = [];
editor.state.doc.descendants((node, position) => {
if (node.type.name === "taskItem") positions.push(position);
});
const secondPosition = positions[1];
const secondCheckbox = element.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').item(1);
if (secondPosition === undefined || !secondCheckbox) throw new Error("Expected a second task item checkbox");
secondCheckbox.checked = true;
secondCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
expect(clickedPosition).toBe(secondPosition);
} finally {
editor.destroy();
}
});
it("updates the selected position when task items are structurally identical", () => {
const doc = schema.node("doc", null, [schema.node("taskList", null, [createTaskItem(), createTaskItem()])]);
const taskItemPositions: number[] = [];
doc.descendants((node, position) => {
if (node.type.name === "taskItem") taskItemPositions.push(position);
});
const secondTaskItemPosition = taskItemPositions[1];
if (secondTaskItemPosition === undefined) throw new Error("Expected a second task item");
const state = EditorState.create({ doc });
const transaction = createReadOnlyTaskItemTransaction(state, secondTaskItemPosition, true);
if (!transaction) throw new Error("Expected a task-item transaction");
const nextState = state.apply(transaction);
const checkedStates: boolean[] = [];
nextState.doc.descendants((node) => {
if (node.type.name === "taskItem") checkedStates.push(Boolean(node.attrs.checked));
});
expect(checkedStates).toEqual([false, true]);
});
});
@@ -0,0 +1,66 @@
import type { NodeViewRenderer } from "@tiptap/core";
import { TaskItem } from "@tiptap/extension-task-item";
import type { TaskItemOptions } from "@tiptap/extension-task-item";
import type { EditorState, Transaction } from "@tiptap/pm/state";
interface ReadOnlyTaskItemOptions extends TaskItemOptions {
onReadOnlyCheckedAtPosition?: (position: number, checked: boolean) => void;
}
export const ReadOnlyTaskItem = TaskItem.extend<ReadOnlyTaskItemOptions>({
addOptions() {
const parentOptions = this.parent?.();
return {
onReadOnlyChecked: parentOptions?.onReadOnlyChecked,
nested: parentOptions?.nested ?? false,
HTMLAttributes: parentOptions?.HTMLAttributes ?? {},
taskListTypeName: parentOptions?.taskListTypeName ?? "taskList",
a11y: parentOptions?.a11y,
onReadOnlyCheckedAtPosition: undefined,
};
},
addNodeView() {
const parentNodeView = this.parent?.() as NodeViewRenderer | undefined;
if (!parentNodeView) return null;
return (props) => {
const nodeView = parentNodeView(props);
const checkbox =
nodeView.dom instanceof HTMLElement
? nodeView.dom.querySelector<HTMLInputElement>('input[type="checkbox"]')
: null;
if (!checkbox) return nodeView;
const handleChange = () => {
if (props.editor.isEditable) return;
const position = props.getPos();
if (typeof position !== "number") return;
this.options.onReadOnlyCheckedAtPosition?.(position, checkbox.checked);
};
checkbox.addEventListener("change", handleChange);
const destroyParent = nodeView.destroy?.bind(nodeView);
return {
...nodeView,
destroy() {
checkbox.removeEventListener("change", handleChange);
destroyParent?.();
},
};
};
},
});
export const createReadOnlyTaskItemTransaction = (
state: EditorState,
position: number,
checked: boolean,
): Transaction | null => {
const taskItem = state.doc.nodeAt(position);
if (taskItem?.type.name !== "taskItem") return null;
return state.tr.setNodeMarkup(position, undefined, {
...taskItem.attrs,
checked,
});
};
+3 -1
View File
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { releaseProviderKinds } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
const relativeDateSchema = z
@@ -14,6 +14,8 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
queryKey: [["widget", "releases", "getLatest"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+7 -1
View File
@@ -1,7 +1,7 @@
import { IconRss } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
/**
@@ -12,6 +12,12 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
queryKey: [["widget", "rssFeed", "getFeeds"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
urls: scope.options.feedUrls,
maximumAmountPosts: scope.options.maximumAmountPosts,
}),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -7,7 +7,7 @@ import { IconBinaryTree } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
@@ -20,6 +20,7 @@ export default function SmartHomeEntityStateWidget({
displayMode,
}: WidgetComponentProps<"smartHome-entityState">) {
const t = useI18n();
const locale = useCurrentLocale();
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
@@ -123,7 +124,7 @@ export default function SmartHomeEntityStateWidget({
</Text>
<Text size="xs" c="dimmed">
{t("widget.smartHome-entityState.advanced.lastUpdated", {
date: new Date(entity.last_updated).toLocaleString(),
date: new Date(entity.last_updated).toLocaleString(locale),
})}
</Text>
</>
@@ -37,12 +37,16 @@ export default function SpeedtestTrackerWidget({
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
const hasChart = options.showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = options.showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={displayMode === "advanced" ? width / 2 : width} />
<LatestResultSection
result={combined.latestResult}
width={displayMode === "advanced" ? advancedSectionWidth : width}
/>
);
const averages = options.showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? width / 2 : width} />
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
);
if (displayMode === "advanced") {
@@ -69,6 +73,12 @@ export default function SpeedtestTrackerWidget({
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
</div>
)}
{noSectionsEnabled && (
<Text c="dimmed" ta="center" size="sm">
{t("noSectionsEnabled")}
</Text>
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
);
}
+8 -1
View File
@@ -1,6 +1,6 @@
import { IconBuildingBank } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const stockPriceTimeFrames = {
@@ -13,6 +13,13 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank,
queryKey: [["widget", "stockPrice", "getPriceHistory"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
stock: scope.options.stock,
timeRange: scope.options.timeRange,
timeInterval: scope.options.timeInterval,
}),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -136,7 +136,7 @@ const SystemCharts = ({
showTitle,
}: SystemChartsProps) => {
const showNetwork =
items.length > 0 && items.every((item) => item.network !== null) && options.visibleCharts.includes("network");
items.length > 0 && items[items.length - 1]?.network !== null && options.visibleCharts.includes("network");
const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
const chartHeight = isAdvanced
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getAdvancedColumnWidth } from "./component";
describe("Tracearr advanced layout", () => {
it("subtracts grid padding and the inter-column gap", () => {
expect(getAdvancedColumnWidth(800, true)).toBe(376);
expect(getAdvancedColumnWidth(801, true)).toBe(376.5);
});
it("subtracts only grid padding in a single-column layout", () => {
expect(getAdvancedColumnWidth(799, false)).toBe(767);
});
});
+18 -3
View File
@@ -14,6 +14,10 @@ import { StatsBar } from "./stats-section";
import { StreamsList } from "./streams-section";
import { ViolationsList } from "./violations-section";
const ADVANCED_GRID_BREAKPOINT = 800;
const ADVANCED_GRID_GAP_PX = 16;
const ADVANCED_GRID_PADDING_PX = 16;
export default function TracearrWidget({
options,
integrationIds,
@@ -122,14 +126,20 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
const recentActivity = combined.recentActivity?.data ?? [];
if (displayMode === "advanced") {
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
const columnWidth = getAdvancedColumnWidth(width, isTwoColumn);
return (
<ScrollArea h="100%">
<SimpleGrid cols={width >= 800 ? 2 : 1} spacing="md" p="md">
<SimpleGrid
cols={isTwoColumn ? 2 : 1}
spacing={`${ADVANCED_GRID_GAP_PX}px`}
p={`${ADVANCED_GRID_PADDING_PX}px`}
>
<Stack gap="sm">
{options.showStats && (
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={width / 2} />
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} />
)}
{options.showStreams && <StreamsList streams={streams} width={width / 2} />}
{options.showStreams && <StreamsList streams={streams} width={columnWidth} />}
</Stack>
<Stack gap="sm">
{options.showViolations && <ViolationsList violations={violations} />}
@@ -156,3 +166,8 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
</ScrollArea>
);
}
export const getAdvancedColumnWidth = (width: number, isTwoColumn: boolean) => {
const contentWidth = Math.max(0, width - ADVANCED_GRID_PADDING_PX * 2);
return isTwoColumn ? Math.max(0, (contentWidth - ADVANCED_GRID_GAP_PX) / 2) : contentWidth;
};
+45
View File
@@ -5,6 +5,7 @@ import { Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { UmamiContent } from "./umami-content";
export default function UmamiWidget({
@@ -12,11 +13,55 @@ export default function UmamiWidget({
integrationIds,
width,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"umami">) {
if (!options.websiteId || options.websiteId.trim() === "") {
setWidgetRuntimeQueries(widgetStateRef, []);
return <NoWebsiteConfigured />;
}
const integrationId = integrationIds[0] ?? "";
const commonTopInput = {
integrationId,
websiteId: options.websiteId,
timeFrame: options.timeFrame,
limit: options.topCount,
};
setWidgetRuntimeQueries(widgetStateRef, [
{
path: ["widget", "umami", "getVisitorStats"],
input: {
integrationIds,
websiteId: options.websiteId,
timeFrame: options.timeFrame,
eventName: options.eventName || undefined,
},
},
{
path: ["widget", "umami", "getActiveVisitors"],
input: { integrationId, websiteId: options.websiteId },
},
...(options.viewMode === "events"
? [
{
path: ["widget", "umami", "getMultiEventTimeSeries"],
input: {
integrationId,
websiteId: options.websiteId,
timeFrame: options.timeFrame,
eventNames: [...options.eventNames].toSorted(),
},
},
]
: []),
...(displayMode === "advanced" || options.viewMode === "topPages"
? [{ path: ["widget", "umami", "getTopPages"], input: commonTopInput }]
: []),
...(displayMode === "advanced" || options.viewMode === "topReferrers"
? [{ path: ["widget", "umami", "getTopReferrers"], input: commonTopInput }]
: []),
]);
return (
<UmamiContent
integrationIds={integrationIds}
+9 -1
View File
@@ -1,13 +1,21 @@
import { IconChartBar, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth"] as const;
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
queryKeys: [
[["widget", "umami", "getVisitorStats"]],
[["widget", "umami", "getActiveVisitors"]],
[["widget", "umami", "getMultiEventTimeSeries"]],
[["widget", "umami", "getTopPages"]],
[["widget", "umami", "getTopReferrers"]],
],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["umami"],
integrationsRequired: true,
+17 -13
View File
@@ -216,19 +216,23 @@ export function UmamiContent({
{displayMode === "advanced" ? (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%">
<Box mih={260}>{selectedView}</Box>
<SimpleGrid cols={2} spacing="md" mih={260}>
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
{viewMode !== "topPages" && (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
{viewMode !== "topReferrers" && (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
</SimpleGrid>
</SimpleGrid>
) : (
@@ -201,7 +201,8 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
{monitor.name}
</Text>
<Badge size="xs" color="red">
{t("status.down")} · {formatNumber(monitor.uptimePercent24h ?? 0, 1)}%
{t("status.down")} ·{" "}
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
</Badge>
</Group>
))}
+1 -1
View File
@@ -235,7 +235,7 @@ function Forecast({
</Text>
{showDetails && (
<Text fz="xs" c="dimmed">
{getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, true)}
{getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
)}
</Stack>
+12 -1
View File
@@ -2,11 +2,22 @@ import { IconCloud } from "@tabler/icons-react";
import dayjs from "dayjs";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("weather", {
icon: IconCloud,
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.location;
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
return false;
}
return widgetQueryInputMatches(input, {
latitude: location.latitude,
longitude: location.longitude,
});
},
createOptions() {
return optionsBuilder.from(
(factory) => ({