fix: complete widget review cleanup
This commit is contained in:
20 files changed
+251
-115
No files matched your search
@@ -35,4 +35,4 @@ import streetDepartures from "./img/street-departures.png";
|
||||
|
||||
<WidgetConfig configuration={timetableWidget.configuration} />
|
||||
|
||||
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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -22,6 +22,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(),
|
||||
@@ -49,6 +51,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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<Ti
|
||||
});
|
||||
}
|
||||
|
||||
return addresses;
|
||||
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
|
||||
};
|
||||
|
||||
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Timetable> => {
|
||||
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({
|
||||
|
||||
@@ -1991,11 +1991,15 @@
|
||||
"noStation": "No station selected",
|
||||
"option": {
|
||||
"baseUrl": {
|
||||
"label": "Search.ch URL"
|
||||
"label": "Search.ch URL",
|
||||
"description": "Custom URLs must resolve to a public IP address. Private and local hosts are rejected."
|
||||
},
|
||||
"station": {
|
||||
"label": "Station"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"stationSearch": "Unable to search stations. Check the custom URL and your board access."
|
||||
}
|
||||
},
|
||||
"notebook": {
|
||||
@@ -3541,6 +3545,9 @@
|
||||
"showPosterImage": {
|
||||
"label": "Show poster image"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"allFeedsFailed": "Unable to load RSS feeds. Check the feed URLs and try again."
|
||||
}
|
||||
},
|
||||
"releases": {
|
||||
@@ -4284,6 +4291,8 @@
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
|
||||
"selectionSaveTitle": "Unable to save selection",
|
||||
"selectionSaveMessage": "Your system or time period selection could not be saved. Please try again.",
|
||||
"switchToHistorical": "Switch to historical view",
|
||||
"systemNotFound": "Selected system was not found. Choose a different system."
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -102,23 +102,26 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Group>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={4}>
|
||||
{status.items.map((item) => (
|
||||
<Card key={item.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.project ?? projectName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="xs" color={getStatusColor(item.status)}>
|
||||
{t(`status.${item.status}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
{status.items.map((item) => {
|
||||
const itemStatusKey = getStatusKey(item.status);
|
||||
return (
|
||||
<Card key={item.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.project ?? projectName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="xs" color={getStatusColor(itemStatusKey)}>
|
||||
{t(`status.${itemStatusKey}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,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";
|
||||
@@ -36,7 +37,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,
|
||||
|
||||
@@ -405,7 +405,7 @@ export default function BeszelSystemTableWidget({
|
||||
integrationIds: [activeIntegrationId],
|
||||
systemId: activeSystem.id,
|
||||
timePeriod: "1h",
|
||||
includeDocker: true,
|
||||
includeDocker: false,
|
||||
},
|
||||
"query",
|
||||
),
|
||||
@@ -472,9 +472,9 @@ export default function BeszelSystemTableWidget({
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDisk,
|
||||
network: options.showNet,
|
||||
dockerCpu: true,
|
||||
dockerMemory: true,
|
||||
dockerNetwork: true,
|
||||
dockerCpu: false,
|
||||
dockerMemory: false,
|
||||
dockerNetwork: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -134,6 +134,7 @@ export const setWidgetRuntimeQueries = (
|
||||
queryKeys: readonly QueryKey[],
|
||||
) => {
|
||||
if (!widgetStateRef) return;
|
||||
// This render-time ref write is idempotent and does not trigger a render.
|
||||
const state = widgetStateRef.current ?? {};
|
||||
state[runtimeQueriesStateKey] = queryKeys.flatMap((queryKey) => {
|
||||
const query = normalizeWidgetQuery(queryKey);
|
||||
|
||||
@@ -69,8 +69,10 @@ export const SystemHealthMonitoring = ({
|
||||
|
||||
if (healthData.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
const Container = withScrollArea ? ScrollArea : Box;
|
||||
|
||||
return (
|
||||
<ScrollArea h={withScrollArea ? "100%" : undefined}>
|
||||
<Container h={withScrollArea ? "100%" : undefined}>
|
||||
<Stack mih="100%" gap="sm" className="health-monitoring">
|
||||
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
|
||||
const filteredFileSystem = filterStorageVolumes(
|
||||
@@ -221,7 +223,7 @@ export const SystemHealthMonitoring = ({
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
||||
itemId?: string,
|
||||
boardId?: string,
|
||||
) => {
|
||||
error?: string;
|
||||
isPending: boolean;
|
||||
options: DynamicSelectOption[];
|
||||
};
|
||||
|
||||
@@ -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">) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function UmamiWidget({
|
||||
"query",
|
||||
),
|
||||
getQueryKey(clientApi.widget.umami.getActiveVisitors, { integrationId, websiteId: options.websiteId }, "query"),
|
||||
...(options.viewMode === "events"
|
||||
...(options.viewMode === "events" && options.eventNames.length > 0
|
||||
? [
|
||||
getQueryKey(
|
||||
clientApi.widget.umami.getMultiEventTimeSeries,
|
||||
|
||||
Reference in new issue
Block a user