fix: address widget review feedback
This commit is contained in:
1 parent
b1b17ad69f
commit
a38e731209
79 files changed
+2138
-244
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {};
|
||||
},
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in new issue
Block a user