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 (