diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx index e53f29920..75a7221e4 100644 --- a/apps/docs/docs/widgets/timetable/index.mdx +++ b/apps/docs/docs/widgets/timetable/index.mdx @@ -35,4 +35,4 @@ import streetDepartures from "./img/street-departures.png"; -For security, new or changed custom base URLs must resolve to a public IP address. Private and local hosts are rejected. +For security, all custom base URLs must resolve to a public IP address whenever Homarr uses them. Private and local hosts are rejected. diff --git a/packages/api/src/router/test/widgets/rssFeed.spec.ts b/packages/api/src/router/test/widgets/rssFeed.spec.ts index 9e445a910..74b2d318b 100644 --- a/packages/api/src/router/test/widgets/rssFeed.spec.ts +++ b/packages/api/src/router/test/widgets/rssFeed.spec.ts @@ -58,6 +58,7 @@ describe("rssFeedRouter", () => { feedIndex: 0, origin: "https://example.com", errorType: "Error", + errorMessage: "Failed to fetch [redacted URL]", }); const logged = JSON.stringify(mocks.logger.warn.mock.calls); expect(logged).not.toContain("password"); diff --git a/packages/api/src/router/test/widgets/timetable.spec.ts b/packages/api/src/router/test/widgets/timetable.spec.ts index aad001e9b..d3b31a046 100644 --- a/packages/api/src/router/test/widgets/timetable.spec.ts +++ b/packages/api/src/router/test/widgets/timetable.spec.ts @@ -86,14 +86,16 @@ describe("timetableRouter source authorization", () => { ).rejects.toMatchObject({ code: "BAD_REQUEST" }); }); - it("loads a persisted private endpoint only through a viewable timetable item", async () => { + it("rejects a persisted private endpoint at runtime even through a viewable timetable item", async () => { const db = createDb(); const baseUrl = "http://timetable.internal:8080"; const { itemId } = await createTimetableItemAsync(db, { baseUrl }); const caller = createCaller(db, null); - await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }); - expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl })); + await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mocks.getTimetable).not.toHaveBeenCalled(); await expect( caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }), ).rejects.toMatchObject({ code: "FORBIDDEN" }); @@ -128,6 +130,7 @@ describe("timetableRouter source authorization", () => { "http://[::1]:8080", "http://[::ffff:127.0.0.1]:8080", "https://[fd00::1]:8443", + "https://192.88.99.1", "http://[64:ff9b::7f00:1]:8080", "http://[2002:7f00:1::]:8080", ])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => { @@ -173,17 +176,54 @@ describe("timetableRouter source authorization", () => { expect(mocks.searchStations).not.toHaveBeenCalled(); }); - it("allows the exact saved private endpoint when item and board IDs match", async () => { + it("rejects the exact saved private endpoint at runtime", async () => { const db = createDb(); const baseUrl = "http://timetable.internal:8080"; const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); - await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }); + await expect( + createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); expect(mocks.lookup).not.toHaveBeenCalled(); - expect(mocks.searchStations).toHaveBeenLastCalledWith( - expect.objectContaining({ baseUrl, pinnedAddresses: undefined }), - ); + expect(mocks.searchStations).not.toHaveBeenCalled(); + }); + + it("re-resolves and pins a saved public endpoint for searches and timetable requests", async () => { + const db = createDb(); + const baseUrl = "https://timetable.example.com"; + const pinnedAddresses = [ + { address: "93.184.216.34", family: 4 }, + { address: "93.184.216.35", family: 4 }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, + ]; + mocks.lookup.mockResolvedValue([pinnedAddresses[2], pinnedAddresses[1], pinnedAddresses[0]]); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + const caller = createCaller(db, createSession(ownerId)); + + await caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" }); + await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }); + + expect(mocks.lookup).toHaveBeenCalledTimes(2); + expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses })); + expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses })); + }); + + it("rejects a saved public hostname when it resolves to a private address at request time", async () => { + const db = createDb(); + const baseUrl = "https://timetable.example.com"; + mocks.lookup.mockResolvedValue([{ address: "10.0.0.8", family: 4 }]); + const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + const caller = createCaller(db, createSession(ownerId)); + + await expect(caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + expect(mocks.searchStations).not.toHaveBeenCalled(); + expect(mocks.getTimetable).not.toHaveBeenCalled(); }); it("blocks changing a saved timetable to a private endpoint through item options", async () => { diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 2b49e1ba0..1dfb170df 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -23,6 +23,8 @@ const safeFeedLogContext = (url: string, feedIndex: number) => { } }; +const redactFeedFailureMessage = (message: string) => message.replace(/https?:\/\/\S+/gi, "[redacted URL]"); + const feedsInput = z.object({ urls: z.array(z.string()).max(100), maximumAmountPosts: z.number(), @@ -52,6 +54,7 @@ export const rssFeedRouter = createTRPCRouter({ logger.warn("RSS feed fetch failed", { ...safeFeedLogContext(urls[index] ?? "", index), errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason, + errorMessage: result.reason instanceof Error ? redactFeedFailureMessage(result.reason.message) : undefined, }); } }); diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index 0daf7a4d0..ced16645e 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -37,6 +37,7 @@ const blockedTimetableSubnets = [ ["172.16.0.0", 12, "ipv4"], ["192.0.0.0", 24, "ipv4"], ["192.0.2.0", 24, "ipv4"], + ["192.88.99.0", 24, "ipv4"], ["192.168.0.0", 16, "ipv4"], ["198.18.0.0", 15, "ipv4"], ["198.51.100.0", 24, "ipv4"], @@ -153,7 +154,7 @@ const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise left.family - right.family || left.address.localeCompare(right.address)); }; const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { @@ -203,19 +204,19 @@ const resolveTimetableBaseUrlAsync = async ( if (allowBoardConfiguration) { if (input.boardId) { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); - let savedOptions: string | undefined; if (input.itemId) { const item = await ctx.db.query.items.findFirst({ + columns: { id: true }, where: and(eq(items.id, input.itemId), eq(items.kind, "timetable"), eq(items.boardId, input.boardId)), }); if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" }); - savedOptions = item.options; } - const savedBaseUrl = savedOptions === undefined ? undefined : getSavedTimetableBaseUrl(savedOptions); - if (requestedBaseUrl === savedBaseUrl || requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) { + if (requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) { return { baseUrl: requestedBaseUrl }; } + // Resolve immediately before every custom request. A URL that was public when saved can later resolve to a + // private address, so matching the persisted value is not sufficient protection against DNS rebinding. const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl); return { baseUrl: requestedBaseUrl, pinnedAddresses }; } @@ -241,7 +242,10 @@ const resolveTimetableBaseUrlAsync = async ( if (requestedBaseUrl !== savedBaseUrl) { throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" }); } - return { baseUrl: savedBaseUrl }; + if (savedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return { baseUrl: savedBaseUrl }; + + const pinnedAddresses = await resolvePublicTimetableAddressesAsync(savedBaseUrl); + return { baseUrl: savedBaseUrl, pinnedAddresses }; }; export const timetableRouter = createTRPCRouter({ @@ -253,9 +257,11 @@ export const timetableRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const { baseUrl } = await resolveTimetableBaseUrlAsync(ctx, input); + const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input); const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input; - const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync(); + const { data } = await timetableGetTimetableRequestHandler + .handler({ ...handlerInput, baseUrl, pinnedAddresses }) + .getDataAsync(); return data; }), searchStations: publicProcedure diff --git a/packages/request-handler/src/immich.spec.ts b/packages/request-handler/src/immich.spec.ts index a8739587f..199341933 100644 --- a/packages/request-handler/src/immich.spec.ts +++ b/packages/request-handler/src/immich.spec.ts @@ -28,7 +28,7 @@ describe("immichAlbumsRequestHandler", () => { }); test("sorts deterministically before caching a limited response", async () => { - const getAlbumsAsync = vi.fn().mockResolvedValue(albums); + const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]); mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync }); const first = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync(); @@ -40,7 +40,7 @@ describe("immichAlbumsRequestHandler", () => { }); test("preserves upstream order when no limit is requested", async () => { - const getAlbumsAsync = vi.fn().mockResolvedValue(albums); + const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]); mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync }); const result = await immichAlbumsRequestHandler.handler(integration, {}).getDataAsync(); diff --git a/packages/request-handler/src/timetable.spec.ts b/packages/request-handler/src/timetable.spec.ts index b0185b775..54bd68645 100644 --- a/packages/request-handler/src/timetable.spec.ts +++ b/packages/request-handler/src/timetable.spec.ts @@ -65,13 +65,30 @@ describe("timetable response bounds", () => { }); it("pins the vetted address into a direct connection without changing the request hostname", async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }])), - ); const pinnedAddresses = [ { address: "93.184.216.34", family: 4 as const }, { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 as const }, ]; + mockFetch.mockImplementation(async () => { + const pinnedLookup = mockCreateCertificateAgent.mock.calls[0]?.[0]?.lookup; + expect(pinnedLookup).toBeTypeOf("function"); + if (!pinnedLookup) throw new Error("Expected a pinned lookup function"); + + const lookupCallback = vi.fn(); + pinnedLookup("timetable.example.com", { all: true }, lookupCallback); + expect(lookupCallback).toHaveBeenCalledWith(null, pinnedAddresses); + + const repeatedLookupCallback = vi.fn(); + pinnedLookup("timetable.example.com", { all: true }, repeatedLookupCallback); + expect(repeatedLookupCallback).toHaveBeenCalledWith(null, pinnedAddresses); + expect(mockCloseDispatcher).not.toHaveBeenCalled(); + + const unexpectedHostCallback = vi.fn(); + pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback); + expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" }); + + return new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }])); + }); await timetableSearchStationsRequestHandler .handler({ @@ -98,21 +115,44 @@ describe("timetable response bounds", () => { expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }), ); - const pinnedLookup = mockCreateCertificateAgent.mock.calls[0]?.[0]?.lookup; - expect(pinnedLookup).toBeTypeOf("function"); - if (!pinnedLookup) throw new Error("Expected a pinned lookup function"); + expect(mockCloseDispatcher).toHaveBeenCalledOnce(); + }); - const lookupCallback = vi.fn(); - pinnedLookup("timetable.example.com", { all: true }, lookupCallback); - expect(lookupCallback).toHaveBeenCalledWith(null, pinnedAddresses); + it("pins timetable requests and closes their direct dispatcher", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + connections: [ + { + time: "2026-01-01T00:00:00Z", + line: "1", + color: "ffffff", + terminal: { name: "Stop" }, + dep_delay: "0", + }, + ], + }), + ), + ); + const pinnedAddresses = [{ address: "93.184.216.34", family: 4 as const }]; - const repeatedLookupCallback = vi.fn(); - pinnedLookup("timetable.example.com", { all: true }, repeatedLookupCallback); - expect(repeatedLookupCallback).toHaveBeenCalledWith(null, pinnedAddresses); + await timetableGetTimetableRequestHandler + .handler({ + baseUrl: "https://timetable.example.com", + stationId: "pinned-departures", + limit: 1, + pinnedAddresses, + }) + .getDataAsync(); - const unexpectedHostCallback = vi.fn(); - pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback); - expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" }); + expect(mockCreateCertificateAgent).toHaveBeenCalledWith( + { lookup: expect.any(Function) }, + expect.objectContaining({ noProxy: "*" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }), + ); expect(mockCloseDispatcher).toHaveBeenCalledOnce(); }); diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts index e8144d435..2ae4eb449 100644 --- a/packages/request-handler/src/timetable.ts +++ b/packages/request-handler/src/timetable.ts @@ -54,10 +54,14 @@ export const timetableSearchStationsRequestHandler = createRequestHandler< export const timetableGetTimetableRequestHandler = createRequestHandler< Timetable, - { baseUrl: string; stationId: string; limit: number } + { baseUrl: string; stationId: string; limit: number; pinnedAddresses?: TimetableResolvedAddress[] } >({ async requestAsync(input) { - return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit }); + return await getTimetableAsync( + input.baseUrl, + { stationId: input.stationId, limit: input.limit }, + input.pinnedAddresses, + ); }, cacheTtlMs: 60_000, }); @@ -142,6 +146,7 @@ const searchStationsAsync = async ( const getTimetableAsync = async ( baseUrl: string, options: { stationId: string; limit: number }, + pinnedAddresses?: TimetableResolvedAddress[], ): Promise => { const now = new Date(); const date = new Intl.DateTimeFormat("en-US", { @@ -156,48 +161,53 @@ const getTimetableAsync = async ( minute: "2-digit", hour12: false, }).format(now); - const response = await fetchWithTrustedCertificatesAsync( - buildUrl(baseUrl, "/timetable/api/stationboard.json", { - stop: options.stationId, - limit: options.limit, - show_delays: 1, - show_tracks: 1, - date, - time, - }), - timetableFetchOptions, - ); - if (!response.ok) throw new ResponseError(response); + const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined; + try { + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, "/timetable/api/stationboard.json", { + stop: options.stationId, + limit: options.limit, + show_delays: 1, + show_tracks: 1, + date, + time, + }), + { ...timetableFetchOptions, dispatcher }, + ); + if (!response.ok) throw new ResponseError(response); - const body = await readBoundedTimetableJsonAsync(response); - const envelope = timetableEnvelopeSchema.parse(body); - const data = await timetableSchema.parseAsync({ - connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)), - }); - return { - stationId: options.stationId, - timestamp: now, - entries: data.connections.map((connection) => { - const color = connection.color.split("~")[0]; - return { - timestamp: connection.time, - line: connection.line - ? { - name: connection.line, - color: color && color.length >= 1 ? `#${color}` : null, - } - : null, - location: connection.terminal.name, - delay: connection.dep_delay, - platform: connection.track - ? { - name: connection.track.replace("!", ""), - hasChanged: connection.track.includes("!"), - } - : null, - }; - }), - }; + const body = await readBoundedTimetableJsonAsync(response); + const envelope = timetableEnvelopeSchema.parse(body); + const data = await timetableSchema.parseAsync({ + connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)), + }); + return { + stationId: options.stationId, + timestamp: now, + entries: data.connections.map((connection) => { + const color = connection.color.split("~")[0]; + return { + timestamp: connection.time, + line: connection.line + ? { + name: connection.line, + color: color && color.length >= 1 ? `#${color}` : null, + } + : null, + location: connection.terminal.name, + delay: connection.dep_delay, + platform: connection.track + ? { + name: connection.track.replace("!", ""), + hasChanged: connection.track.includes("!"), + } + : null, + }; + }), + }; + } finally { + await dispatcher?.close(); + } }; const timetableSchema = z.object({ diff --git a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx index 532fd04f8..17a340421 100644 --- a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx @@ -30,13 +30,11 @@ export const WidgetDynamicSelectInput = ({ const inputProps = form.getInputProps(`options.${property}`); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 300); - const { isPending, options: selectOptions } = options.useOptions( - debouncedSearch, - form.values.integrationIds, - form.values.options, - itemId, - boardId, - ); + const { + error: optionsError, + isPending, + options: selectOptions, + } = options.useOptions(debouncedSearch, form.values.integrationIds, form.values.options, itemId, boardId); const currentOption = inputProps.value as DynamicSelectOption | null; const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void; @@ -98,6 +96,7 @@ export const WidgetDynamicSelectInput = ({ description={options.withDescription ? tWidget("description") : undefined} searchable {...inputProps} + error={inputProps.error ?? optionsError} value={currentOption === null ? null : currentOption.value} onChange={(selectedValue: string | null) => { if (selectedValue === null) { diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index 5dff480dd..e6b4f878d 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -102,23 +102,26 @@ const ArchiveTeamWarriorWidgetContent = ({ - {status.items.map((item) => ( - - - - - {item.name} - - - {item.project ?? projectName} - - - - {t(`status.${item.status}`)} - - - - ))} + {status.items.map((item) => { + const itemStatusKey = getStatusKey(item.status); + return ( + + + + + {item.name} + + + {item.project ?? projectName} + + + + {t(`status.${itemStatusKey}`)} + + + + ); + })} diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index e064cea5c..a3144e5b3 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { useOptionalBoard } from "@homarr/boards/context"; +import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import classes from "./component.module.css"; @@ -35,7 +36,13 @@ export default function BeszelSystemStatsWidget({ const board = useOptionalBoard(); const { data: session } = useSession(); const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; - const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation(); + const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ + onError: () => + showErrorNotification({ + title: t("error.selectionSaveTitle"), + message: t("error.selectionSaveMessage"), + }), + }); const { data: systemsResult = [], isPending: systemsPending, diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 25a08ce8a..317f97f5a 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -69,8 +69,10 @@ export const SystemHealthMonitoring = ({ if (healthData.length === 0) return ; + const Container = withScrollArea ? ScrollArea : Box; + return ( - + {healthData.map(({ integrationId, integrationName, healthInfo }) => { const filteredFileSystem = filterStorageVolumes( @@ -221,7 +223,7 @@ export const SystemHealthMonitoring = ({ ); })} - + ); }; diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 8ca3c06e2..bdcb715be 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -59,6 +59,7 @@ interface DynamicSelectInput extends CommonInput { itemId?: string, boardId?: string, ) => { + error?: string; isPending: boolean; options: DynamicSelectOption[]; }; diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 1785c0dc1..1a30e0352 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -12,10 +12,10 @@ import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.scss"; const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { - const { data: feedEntries = [], error } = clientApi.widget.rssFeed.getFeeds.useQuery(input); - if (error) throw error; + const { data: feedEntries, error } = clientApi.widget.rssFeed.getFeeds.useQuery(input); + if (error && feedEntries === undefined) throw error; - return feedEntries; + return feedEntries ?? []; }; export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) { diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts index dcef56cb4..a00124fc5 100644 --- a/packages/widgets/src/rssFeed/index.ts +++ b/packages/widgets/src/rssFeed/index.ts @@ -1,4 +1,4 @@ -import { IconRss } from "@tabler/icons-react"; +import { IconRss, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; @@ -19,6 +19,12 @@ export const { definition, componentLoader } = createWidgetDefinition("rssFeed", maximumAmountPosts: scope.options.maximumAmountPosts, }), refetchInterval: null, + errors: { + BAD_GATEWAY: { + icon: IconServerOff, + message: (t) => t("widget.rssFeed.error.allFeedsFailed"), + }, + }, createOptions() { return optionsBuilder.from((factory) => ({ feedUrls: factory.multiText({ diff --git a/packages/widgets/src/timetable/index.ts b/packages/widgets/src/timetable/index.ts index 634ad20c4..212e2746b 100644 --- a/packages/widgets/src/timetable/index.ts +++ b/packages/widgets/src/timetable/index.ts @@ -2,6 +2,7 @@ import { IconBusStop } from "@tabler/icons-react"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -13,10 +14,16 @@ export const { componentLoader, definition } = createWidgetDefinition("timetable baseUrl: factory.text({ defaultValue: "https://search.ch", validate: z.string().url(), + withDescription: true, }), station: factory.dynamicSelect({ useOptions(query, _integrationIds, options, itemId, boardId) { - const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery({ + const t = useScopedI18n("widget.timetable"); + const { + data: stations, + error, + isPending, + } = clientApi.widget.timetable.searchStations.useQuery({ baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch", itemId, boardId, @@ -24,6 +31,7 @@ export const { componentLoader, definition } = createWidgetDefinition("timetable }); return { + error: error ? t("error.stationSearch") : undefined, isPending, options: stations?.map((station) => ({ diff --git a/packages/widgets/src/umami/component.tsx b/packages/widgets/src/umami/component.tsx index 8364c828e..e4fbbe52e 100644 --- a/packages/widgets/src/umami/component.tsx +++ b/packages/widgets/src/umami/component.tsx @@ -1,11 +1,13 @@ "use client"; import { Stack, Text } from "@mantine/core"; +import { getQueryKey } from "@trpc/react-query"; +import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -import { setWidgetRuntimeQueries } from "../definition"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { UmamiContent } from "./umami-content"; export default function UmamiWidget({ @@ -14,13 +16,8 @@ export default function UmamiWidget({ width, height, displayMode = "compact", - widgetStateRef, + widgetRuntimeRef, }: WidgetComponentProps<"umami">) { - if (!options.websiteId || options.websiteId.trim() === "") { - setWidgetRuntimeQueries(widgetStateRef, []); - return ; - } - const integrationId = integrationIds[0] ?? ""; const commonTopInput = { integrationId, @@ -28,40 +25,51 @@ export default function UmamiWidget({ 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" + const hasWebsite = Boolean(options.websiteId?.trim()); + useWidgetRuntimeQueries( + widgetRuntimeRef, + hasWebsite ? [ - { - path: ["widget", "umami", "getMultiEventTimeSeries"], - input: { - integrationId, + getQueryKey( + clientApi.widget.umami.getVisitorStats, + { + integrationIds, websiteId: options.websiteId, timeFrame: options.timeFrame, - eventNames: [...options.eventNames].toSorted(), + eventName: options.eventName || undefined, }, - }, + "query", + ), + getQueryKey( + clientApi.widget.umami.getActiveVisitors, + { integrationId, websiteId: options.websiteId }, + "query", + ), + ...(options.viewMode === "events" && options.eventNames.length > 0 + ? [ + getQueryKey( + clientApi.widget.umami.getMultiEventTimeSeries, + { + integrationId, + websiteId: options.websiteId, + timeFrame: options.timeFrame, + eventNames: [...options.eventNames].toSorted(), + }, + "query", + ), + ] + : []), + ...(displayMode === "advanced" || options.viewMode === "topPages" + ? [getQueryKey(clientApi.widget.umami.getTopPages, commonTopInput, "query")] + : []), + ...(displayMode === "advanced" || options.viewMode === "topReferrers" + ? [getQueryKey(clientApi.widget.umami.getTopReferrers, commonTopInput, "query")] + : []), ] - : []), - ...(displayMode === "advanced" || options.viewMode === "topPages" - ? [{ path: ["widget", "umami", "getTopPages"], input: commonTopInput }] - : []), - ...(displayMode === "advanced" || options.viewMode === "topReferrers" - ? [{ path: ["widget", "umami", "getTopReferrers"], input: commonTopInput }] - : []), - ]); + : [], + ); + + if (!hasWebsite) return ; return (