fix: address widget review feedback

This commit is contained in:
Thomas Camlong committed 2026-08-01 23:44:17 +02:00
1 parent cce3e455fa
commit c754946089
94 files changed
+2332 -322

No files matched your search

+3 -3
View File
@@ -36,13 +36,13 @@ Underscores and hyphen-minus are allowed.
### Column count
Homarr has a complex drag and drop system that can be used from the browser.
Homarr has a complex drag-and-drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
### Public
This will enable public access for everyone - regardless of whether they are logged in or not.
This can be really useful if you want to have public boards in your company or for family and fiends.
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
@@ -106,7 +106,7 @@ Amount of columns for this layout. Defines how many items can be placed in a row
#### Breakpoint
Minimum width of the screen where the layout will be used.
Once the minmum of the next layout is reached the layout will change.
Once the minimum width of the next layout is reached, the layout will change.
#### Preview
+2 -2
View File
@@ -21,12 +21,12 @@ You can add links to your favorite applications, websites, or any other resource
import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from "./img/vertical-list-all.png";
import vericalListSimple from "./img/vertical-list-simple.png";
import verticalListSimple from "./img/vertical-list-simple.png";
<div className='flex gap-1'>
<div className='flex flex-col gap-1'>
<img src={gridIconsOnly} alt="bookmarks grid icons-only" width={128} height={128} />
<img src={vericalListSimple} alt="bookmarks vertical-list no-domain" width={128} height={128} />
<img src={verticalListSimple} alt="bookmarks vertical-list no-domain" width={128} height={128} />
</div>
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
@@ -1,6 +1,6 @@
---
title: "DNS Hole Controls"
description: "Control PiHole or AdGuard from your dashboard."
description: "Control Pi-hole, AdGuard Home, or Technitium DNS from your dashboard."
hide_title: true
---
+6 -7
View File
@@ -39,13 +39,14 @@ import youtubeStream from "./img/nasa-iss-stream.png";
Although you can integrate many websites, some may not work.
This is because the administrators of those sites disallow iframes
for security reasons, as users could easily be tricked
by embeding the original site on a malicious page.
by embedding the original site on a malicious page.
Some proxies disallow iframes by default for this reason.
You can use https://iframetest.com to check, if the site is allowing iframes.
You can use https://iframetest.com to check whether the site allows iframes.
If you manage all of your own applications, you can usually change that by configuring
the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes.
the `Content-Security-Policy` header and its `frame-ancestors` directive. The obsolete
`X-Frame-Options: ALLOW-FROM` directive is not supported by modern browsers.
For example, to allow your site to be embedded in iframes from specific domains:
@@ -53,14 +54,12 @@ For example, to allow your site to be embedded in iframes from specific domains:
```apache
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
```
**Nginx:**
```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com";
```
### Example URLs
@@ -86,11 +85,11 @@ To create a transparent iframe like below you can use the following html code an
![transparent iframe](./img/transparent-iframe.png)
### Third party content
### Third-party content
:::warning
The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository.
The content below is an overview of third-party projects. All support requests/problems must be redirected to the appropriate repository.
We cannot guarantee the functionality nor security of these tools.
:::
@@ -1,6 +1,6 @@
---
title: "Media releases"
description: "Display newly added medias or upcoming releases from different integrations"
description: "Display newly added media or upcoming releases from different integrations"
hide_title: true
---
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections.
Provide an overview of your media requests, including total requests, open requests, approvals, and rejections.
### Supported Integrations
@@ -21,8 +21,8 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget enables you to watch and view current active sessions over all your media servers.
It also displays useful information, that you can use when providing support to users or debugging a problem.
The media server widget shows active sessions across your media servers.
It also displays useful information for user support and troubleshooting.
### Screenshots
@@ -26,7 +26,7 @@ import bedrockItem from "./img/bedrock.png";
<div className="flex gap-1">
<img src={javaItem} alt="minecraft-server-status java-edition" width={128 * 1.5} height={128 * 1.5} />
<img src={bedrockItem} alt="minecraft-server-status bedrock-editon" width={128 * 1.5} height={128 * 1.5} />
<img src={bedrockItem} alt="minecraft-server-status bedrock-edition" width={128 * 1.5} height={128 * 1.5} />
</div>
<WidgetInteractionGuide />
@@ -42,4 +42,4 @@ import bedrockItem from "./img/bedrock.png";
### Data source
We use the Minecraft Server Status API to fetch the server status.
If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us`
If you need to whitelist the API, use the following URL: `https://api.mcsrvstat.us`.
@@ -34,7 +34,7 @@ import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import type { WidgetDefinition } from "@homarr/widgets";
import { widgetImports } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -96,8 +96,11 @@ export function TRPCReactProvider(props: PropsWithChildren) {
const def = definition as WidgetDefinition & { kind: string };
if (def.refetchInterval === undefined) continue;
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
const primaryQueryKey = def.queryKey ?? [["widget", def.kind]];
client.setQueryDefaults(primaryQueryKey, { refetchInterval: interval });
for (const queryKey of getWidgetQueryKeys(def)) {
const path = queryKey[0];
if (!Array.isArray(path) || path[0] !== "widget") continue;
client.setQueryDefaults(queryKey, { refetchInterval: interval });
}
}
return client;
});
@@ -135,7 +135,7 @@
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
animation-duration: 1ms;
animation: none;
}
}
@@ -14,7 +14,7 @@ import {
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
@@ -28,8 +28,13 @@ import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { getWidgetQueryKeys, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets";
import {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
reduceWidgetOptionsWithDefaultValues,
widgetImports,
} from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
@@ -38,6 +43,7 @@ import { useBoardPermissions } from "../permissions/client";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
@@ -70,13 +76,45 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: t("item.menu.notification.saveError.title"),
message: t("item.menu.notification.saveError.message"),
});
},
});
},
[t, updateAndPersistBoard],
);
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
const widgetQueryKeys = useMemo(
() => getWidgetQueryKeys(currentDefinition as Parameters<typeof getWidgetQueryKeys>[0]),
[currentDefinition],
);
const widgetQueryMatcher = (currentDefinition as WidgetDefinition).queryMatcher;
const matchesWidgetQuery = useCallback(
(queryKey: QueryKey) => widgetQueryKeys.some((candidate) => partialMatchKey(queryKey, candidate)),
[widgetQueryKeys],
(queryKey: QueryKey) =>
matchesWidgetItemQuery(
queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options,
runtimeQueries: getWidgetRuntimeQueries(widgetStateRef),
},
widgetQueryMatcher,
),
[board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetStateRef],
);
const isWidgetFetching =
useIsFetching({
@@ -84,17 +122,29 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
predicate: (query) => matchesWidgetQuery(query.queryKey),
}) > 0;
const handleRefetch = useCallback(() => {
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "active" })));
}, [queryClient, widgetQueryKeys]);
void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) });
}, [matchesWidgetQuery, queryClient]);
const canInteractWithSelectedIntegrations = useMemo(() => {
const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id));
return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id));
}, [integrationsWithInteractAccess, item.integrationIds]);
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
const setItemOptions = useCallback(
(partial: Record<string, unknown>) => {
if (isEditMode) {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
return;
}
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem,
),
}));
},
[isEditMode, item.id, options, persistBoard, updateItemOptions],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
@@ -110,9 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
setOptions: setItemOptions,
integrationIds: item.integrationIds,
context: {
isEditMode,
@@ -125,20 +173,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
})();
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: t("item.menu.notification.saveError.title"),
message: t("item.menu.notification.saveError.message"),
});
},
});
},
[t, updateAndPersistBoard],
);
const openEditModal = useCallback(() => {
openModal(
{
@@ -195,19 +229,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
if (isEditMode) {
updateItemOptions({ itemId: item.id, newOptions: { ...options, [key]: checked } });
} else {
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id !== item.id ? boardItem : { ...boardItem, options: { ...boardItem.options, [key]: checked } },
),
}));
}
},
[options, item.id, updateItemOptions, isEditMode, persistBoard],
(key: string) => (checked: boolean) => setItemOptions({ [key]: checked }),
[setItemOptions],
);
if (!session) return <>{children}</>;
@@ -306,7 +329,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKeys={widgetQueryKeys}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
@@ -350,15 +373,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
interface WidgetQueryStatusProps {
queryClient: QueryClient;
queryKeys: readonly QueryKey[];
matchesQuery: (queryKey: QueryKey) => boolean;
isFetching: boolean;
t: TranslationFunction;
}
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({
type: "active",
predicate: (query) => queryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
predicate: (query) => matchesQuery(query.queryKey),
});
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
@@ -0,0 +1,439 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import { matchesWidgetItemQuery } from "./widget-query-scope";
type WidgetKind = keyof typeof widgetImports;
const allPhotosAlbumId = "all";
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
itemId: "item-1",
boardId: "board-1",
integrationIds: ["integration-1"],
options: {},
runtimeQueries: [],
...overrides,
});
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
const queryKey: QueryKey = [path, { input, type: "query" }];
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
};
describe("matchesWidgetItemQuery", () => {
test("matches only the current Custom API definition", () => {
const scope = createScope({ options: { definitionId: "definition-1" } });
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-1" }, scope)).toBe(
true,
);
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-2" }, scope)).toBe(
false,
);
});
test("owns only the active compact or advanced Media Missing page size", () => {
const path = ["widget", "mediaOrganizer", "getData"];
const compactInput = { integrationIds: ["radarr-1"], pageSize: 20 };
const advancedInput = { integrationIds: ["radarr-1"], pageSize: 50 };
const compactScope = createScope({
integrationIds: ["radarr-1"],
runtimeQueries: [{ path, input: compactInput }],
});
const advancedScope = createScope({
integrationIds: ["radarr-1"],
runtimeQueries: [{ path, input: advancedInput }],
});
expect(matches("mediaMissing", path, compactInput, compactScope)).toBe(true);
expect(matches("mediaMissing", path, advancedInput, compactScope)).toBe(false);
expect(matches("mediaMissing", path, advancedInput, advancedScope)).toBe(true);
expect(matches("mediaMissing", path, compactInput, advancedScope)).toBe(false);
});
test("owns every Releases batch by item id", () => {
const scope = createScope({ itemId: "release-item" });
const path = ["widget", "releases", "getLatest"];
expect(matches("releases", path, { itemId: "release-item", repositories: [{ id: "one" }] }, scope)).toBe(true);
expect(matches("releases", path, { itemId: "sibling-item", repositories: [{ id: "one" }] }, scope)).toBe(false);
});
test("normalizes Immich's all-photos sentinel and separates Immich widget kinds", () => {
const carouselScope = createScope({
integrationIds: ["immich-1"],
options: { albumId: allPhotosAlbumId },
});
const serverStatsQuery: NormalizedWidgetQuery = {
path: ["widget", "immich", "getServerStats"],
input: { integrationId: "immich-1" },
};
const albumsQuery: NormalizedWidgetQuery = {
path: ["widget", "immich", "getAlbums"],
input: { integrationId: "immich-1" },
};
const compactServerScope = createScope({
integrationIds: ["immich-1"],
runtimeQueries: [serverStatsQuery],
});
const advancedServerScope = createScope({
integrationIds: ["immich-1"],
runtimeQueries: [serverStatsQuery, albumsQuery],
});
expect(
matches("immich-albumCarousel", ["widget", "immich", "getAlbum"], { integrationId: "immich-1" }, carouselScope),
).toBe(true);
expect(
matches(
"immich-albumCarousel",
["widget", "immich", "getAlbum"],
{ integrationId: "immich-1", albumId: "album-2" },
carouselScope,
),
).toBe(false);
expect(
matches(
"immich-albumCarousel",
["widget", "immich", "getServerStats"],
{ integrationId: "immich-1" },
carouselScope,
),
).toBe(false);
expect(matches("immich-serverStats", serverStatsQuery.path, serverStatsQuery.input, compactServerScope)).toBe(true);
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, compactServerScope)).toBe(false);
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, advancedServerScope)).toBe(true);
expect(
matches("immich-serverStats", ["widget", "immich", "getAlbum"], albumsQuery.input, advancedServerScope),
).toBe(false);
});
test("uses the resolved Beszel integration, system, and includeDocker input", () => {
const runtimeQuery: NormalizedWidgetQuery = {
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: ["beszel-2"],
systemId: "system-2",
timePeriod: "1h",
includeDocker: true,
},
};
const scope = createScope({
integrationIds: ["beszel-1", "beszel-2"],
options: { systemId: "beszel-2:system-2", timePeriod: "1h", showDockerCpu: true },
runtimeQueries: [runtimeQuery],
});
expect(matches("beszelSystemStats", runtimeQuery.path, runtimeQuery.input, scope)).toBe(true);
expect(
matches(
"beszelSystemStats",
runtimeQuery.path,
{ ...(runtimeQuery.input as Record<string, unknown>), includeDocker: false },
scope,
),
).toBe(false);
expect(
matches(
"beszelSystemStats",
runtimeQuery.path,
{ ...(runtimeQuery.input as Record<string, unknown>), systemId: "system-1" },
scope,
),
).toBe(false);
});
test("separates Beszel procedure families while retaining shared getSystems ownership", () => {
const scope = createScope({
integrationIds: ["beszel-1"],
options: { showHistory: true, maxHistoryItems: 10 },
});
const systemsInput = { integrationIds: ["beszel-1"] };
const alertsInput = { integrationIds: ["beszel-1"], includeHistory: true, maxHistoryItems: 10 };
expect(matches("beszelSystemGrid", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
expect(matches("beszelSystemTable", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
expect(matches("beszelAlerts", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(false);
expect(matches("beszelAlerts", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(true);
expect(matches("beszelSystemGrid", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(false);
});
test("owns only the active Beszel table detail query", () => {
const activeQuery: NormalizedWidgetQuery = {
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: ["beszel-1"],
systemId: "system-1",
timePeriod: "1h",
includeDocker: false,
},
};
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
expect(matches("beszelSystemTable", activeQuery.path, activeQuery.input, scope)).toBe(true);
expect(
matches(
"beszelSystemTable",
activeQuery.path,
{ ...(activeQuery.input as Record<string, unknown>), systemId: "system-2" },
scope,
),
).toBe(false);
expect(matches("beszelSystemGrid", activeQuery.path, activeQuery.input, scope)).toBe(false);
});
test("owns only Umami queries registered for the active view and layout", () => {
const eventQuery: NormalizedWidgetQuery = {
path: ["widget", "umami", "getMultiEventTimeSeries"],
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["signup", "view"] },
};
const topPagesQuery: NormalizedWidgetQuery = {
path: ["widget", "umami", "getTopPages"],
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", limit: 5 },
};
const eventsScope = createScope({ runtimeQueries: [eventQuery] });
const advancedScope = createScope({ runtimeQueries: [eventQuery, topPagesQuery] });
expect(matches("umami", eventQuery.path, eventQuery.input, eventsScope)).toBe(true);
expect(
matches(
"umami",
eventQuery.path,
{ integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["view", "signup"] },
eventsScope,
),
).toBe(false);
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, eventsScope)).toBe(false);
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, advancedScope)).toBe(true);
});
test("maps Media Request status filters and separates list from stats", () => {
const listScope = createScope({
integrationIds: ["request-1"],
options: { statusFilter: [], recentDays: 30, linksTargetNewTab: true },
});
const allStatuses = ["pending", "approved", "declined", "failed", "completed"];
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getLatestRequests"],
{ integrationIds: ["request-1"], statuses: allStatuses, recentDays: 30 },
listScope,
),
).toBe(true);
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getLatestRequests"],
{ integrationIds: ["request-1"], statuses: ["pending"], recentDays: 30 },
listScope,
),
).toBe(false);
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getStats"],
{ integrationIds: ["request-1"] },
listScope,
),
).toBe(false);
expect(
matches(
"mediaRequests-requestStats",
["widget", "mediaRequests", "getStats"],
{ integrationIds: ["request-1"] },
listScope,
),
).toBe(true);
});
test("separates Audio Stats stream details from Media Server filters", () => {
const statsQuery: NormalizedWidgetQuery = {
path: ["widget", "audioStats", "getStats"],
input: { integrationId: "audio-1" },
};
const streamsQuery: NormalizedWidgetQuery = {
path: ["widget", "mediaServer", "getCurrentStreams"],
input: { integrationIds: ["audio-1"], showOnlyPlaying: false },
};
const compactAudioScope = createScope({ integrationIds: ["audio-1"], runtimeQueries: [statsQuery] });
const advancedAudioScope = createScope({
integrationIds: ["audio-1"],
runtimeQueries: [statsQuery, streamsQuery],
});
const mediaScope = createScope({ integrationIds: ["audio-1"], options: { showOnlyPlaying: true } });
expect(matches("audioStats", statsQuery.path, statsQuery.input, compactAudioScope)).toBe(true);
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, compactAudioScope)).toBe(false);
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, advancedAudioScope)).toBe(true);
expect(
matches(
"audioStats",
streamsQuery.path,
{ integrationIds: ["audio-1"], showOnlyPlaying: true },
advancedAudioScope,
),
).toBe(false);
expect(
matches("mediaServer", streamsQuery.path, { integrationIds: ["audio-1"], showOnlyPlaying: true }, mediaScope),
).toBe(true);
expect(matches("mediaServer", streamsQuery.path, streamsQuery.input, mediaScope)).toBe(false);
});
test("uses each Calendar instance's registered month", () => {
const path = ["widget", "calendar", "findAllEvents"];
const januaryInput = {
integrationIds: ["calendar-1"],
month: 0,
year: 2026,
releaseType: [],
showUnmonitored: false,
};
const februaryInput = { ...januaryInput, month: 1 };
const januaryScope = createScope({ runtimeQueries: [{ path, input: januaryInput }] });
const februaryScope = createScope({ runtimeQueries: [{ path, input: februaryInput }] });
expect(matches("calendar", path, januaryInput, januaryScope)).toBe(true);
expect(matches("calendar", path, februaryInput, januaryScope)).toBe(false);
expect(matches("calendar", path, februaryInput, februaryScope)).toBe(true);
});
test("uses each Media Transcoding instance's registered page and geometry-derived page size", () => {
const path = ["widget", "mediaTranscoding", "getDataAsync"];
const firstPage = { integrationId: "transcode-1", pageSize: 8, page: 1 };
const secondPage = { integrationId: "transcode-1", pageSize: 12, page: 2 };
const firstScope = createScope({ runtimeQueries: [{ path, input: firstPage }] });
const secondScope = createScope({ runtimeQueries: [{ path, input: secondPage }] });
expect(matches("mediaTranscoding", path, firstPage, firstScope)).toBe(true);
expect(matches("mediaTranscoding", path, secondPage, firstScope)).toBe(false);
expect(matches("mediaTranscoding", path, secondPage, secondScope)).toBe(true);
});
test("matches only the Downloads query with the current integrations and limit", () => {
const scope = createScope({
integrationIds: ["sabnzbd-1", "qbittorrent-1"],
options: { limitPerIntegration: 25 },
});
const path = ["widget", "downloads", "getJobsAndStatuses"];
expect(
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 25 }, scope),
).toBe(true);
expect(matches("downloads", path, { integrationIds: ["sabnzbd-1"], limitPerIntegration: 25 }, scope)).toBe(false);
expect(
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 50 }, scope),
).toBe(false);
expect(
matches("downloads", ["widget", "downloads", "pause"], { integrationIds: ["sabnzbd-1", "qbittorrent-1"] }, scope),
).toBe(false);
});
test("matches only the active Stock Price symbol and time frame", () => {
const scope = createScope({ options: { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" } });
const path = ["widget", "stockPrice", "getPriceHistory"];
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(true);
expect(matches("stockPrice", path, { stock: "MSFT", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(false);
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "5d", timeInterval: "1d" }, scope)).toBe(false);
});
test("matches Weather by the coordinates sent to the location procedure", () => {
const scope = createScope({
options: { location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 } },
});
const path = ["widget", "weather", "atLocation"];
expect(matches("weather", path, { latitude: 48.85341, longitude: 2.3488 }, scope)).toBe(true);
expect(matches("weather", path, { latitude: 51.5072, longitude: -0.1276 }, scope)).toBe(false);
expect(
matches("weather", ["widget", "weather", "forecast"], { latitude: 48.85341, longitude: 2.3488 }, scope),
).toBe(false);
});
test("matches Clock weather only when enabled and at its configured coordinates", () => {
const path = ["widget", "weather", "atLocation"];
const location = { name: "Brisbane", latitude: -27.4698, longitude: 153.0251 };
const enabledScope = createScope({ options: { showWeather: true, weatherLocation: location } });
const disabledScope = createScope({ options: { showWeather: false, weatherLocation: location } });
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, enabledScope)).toBe(true);
expect(matches("clock", path, { latitude: 48.85341, longitude: 2.3488 }, enabledScope)).toBe(false);
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, disabledScope)).toBe(false);
});
test("matches Minecraft Server Status by the full input passed to useQuery", () => {
const scope = createScope({
options: { title: "Survival", domain: "play.example.com", isBedrockServer: false },
});
const path = ["widget", "minecraft", "getServerStatus"];
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Survival", domain: "play.example.com", isBedrockServer: false },
scope,
),
).toBe(true);
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Creative", domain: "play.example.com", isBedrockServer: false },
scope,
),
).toBe(false);
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Survival", domain: "bedrock.example.com", isBedrockServer: true },
scope,
),
).toBe(false);
});
test("matches Notifications by integrations and the full input passed to useQuery", () => {
const scope = createScope({ integrationIds: ["ntfy-1"], options: { hideLogos: true } });
const path = ["widget", "notifications", "getNotifications"];
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["ntfy-1"] }, scope)).toBe(true);
expect(matches("notifications", path, { hideLogos: false, integrationIds: ["ntfy-1"] }, scope)).toBe(false);
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["gotify-1"] }, scope)).toBe(false);
expect(
matches("notifications", ["widget", "notifications", "dismiss"], { integrationIds: ["ntfy-1"] }, scope),
).toBe(false);
});
test("keeps renamed RSS input fields item-specific", () => {
const scope = createScope({
integrationIds: [],
options: { feedUrls: ["https://feed.example/rss"], maximumAmountPosts: 100 },
});
const path = ["widget", "rssFeed", "getFeeds"];
expect(matches("rssFeed", path, { urls: ["https://feed.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(true);
expect(matches("rssFeed", path, { urls: ["https://other.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(
false,
);
});
test("generic fallback ignores transformed non-identity inputs", () => {
const scope = createScope({ integrationIds: ["integration-1"], options: { pageSize: 10 } });
const definitionKeys = [[["widget", "example"]]] as const;
expect(
matchesWidgetItemQuery(
[["widget", "example", "getData"], { input: { integrationIds: ["integration-1"], pageSize: 50 } }],
definitionKeys,
scope,
),
).toBe(true);
});
});
@@ -0,0 +1,94 @@
import type { QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { NormalizedWidgetQuery, WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets";
export const matchesWidgetItemQuery = (
queryKey: QueryKey,
widgetQueryKeys: readonly QueryKey[],
scope: WidgetQueryMatcherScope,
queryMatcher?: WidgetQueryMatcher,
) => {
const query = normalizeWidgetQuery(queryKey);
if (!query || !widgetQueryKeys.some((candidate) => matchesDefinitionPath(query.path, candidate))) return false;
if (queryMatcher) return queryMatcher(query, scope);
return matchesQueryInput(query.input, scope);
};
const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return { path, input };
};
const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => {
const candidatePath = candidate[0];
return (
Array.isArray(candidatePath) &&
candidatePath.every((part, index) => typeof part === "string" && path[index] === part)
);
};
const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => {
if (input == null) return true;
if (Array.isArray(input)) {
return isEqual(input, scope.integrationIds) || containsValue(scope.options, input);
}
if (!isRecord(input)) {
return (
input === scope.itemId ||
input === scope.boardId ||
scope.integrationIds.includes(String(input)) ||
containsValue(scope.options, input)
);
}
for (const [key, value] of Object.entries(input)) {
if (key === "itemId") {
if (value !== scope.itemId) return false;
continue;
}
if (key === "boardId") {
if (value !== scope.boardId) return false;
continue;
}
if (key === "integrationId") {
if (!scope.integrationIds.includes(String(value))) return false;
continue;
}
if (key === "integrationIds") {
if (!isEqual(value, scope.integrationIds)) return false;
continue;
}
if (isIdentityKey(key) && !matchesAnyScopedValue(value, scope)) return false;
}
return true;
};
const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) =>
value === scope.itemId ||
value === scope.boardId ||
scope.integrationIds.includes(String(value)) ||
containsValue(scope.options, value);
const isIdentityKey = (key: string) => key === "id" || key.endsWith("Id") || key.endsWith("Ids");
const containsValue = (container: unknown, expected: unknown, seen = new WeakSet<object>()): boolean => {
if (isEqual(container, expected)) return true;
if (container === null || typeof container !== "object" || seen.has(container)) return false;
seen.add(container);
return Object.values(container).some((value) => containsValue(value, expected, seen));
};
const isEqual = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
@@ -0,0 +1,4 @@
.suppressTouchCallout {
user-select: none;
-webkit-touch-callout: none;
}
@@ -17,6 +17,10 @@ import { ItemSelectModal } from "../../items/item-select-modal";
import { useBoardPermissions } from "../../permissions/client";
import type { BoardPlacementHint } from "../../items/actions/create-item";
import type { UseGridstackRefs } from "./use-gridstack";
import classes from "./board-empty-space-context-menu.module.css";
import { hasExceededLongPressMoveTolerance } from "./long-press";
const suppressTouchCalloutClass = classes.suppressTouchCallout as string;
interface MenuState {
x: number;
@@ -39,18 +43,25 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const [isEditMode] = useEditMode();
const [menu, setMenu] = useState<MenuState | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null);
const touchGuardElement = useRef<HTMLDivElement | null>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
const clearLongPress = () => {
if (longPressTimer.current === null) return;
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
if (longPressTimer.current !== null) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
touchGuardElement.current = null;
longPressPointer.current = null;
};
useEffect(
() => () => {
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
},
[],
);
@@ -89,12 +100,31 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
clearLongPress();
longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
touchGuardElement.current = event.currentTarget;
touchGuardElement.current.classList.add(suppressTouchCalloutClass);
longPressTimer.current = setTimeout(() => {
setMenu({ x: event.clientX, y: event.clientY, placement });
navigator.vibrate?.(20);
}, 500);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
const pointer = longPressPointer.current;
if (
!pointer ||
pointer.pointerId !== event.pointerId ||
!hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY })
) {
return;
}
clearLongPress();
};
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
!enabled ||
@@ -134,10 +164,10 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={clearLongPress}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
onPointerLeave={clearLongPress}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onPointerLeave={handlePointerEnd}
>
{children}
</Box>
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { hasExceededLongPressMoveTolerance } from "./long-press";
describe("hasExceededLongPressMoveTolerance", () => {
test("allows small touch jitter", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 106 })).toBe(false);
});
test("allows movement exactly at the threshold", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 108 })).toBe(false);
});
test("cancels a deliberate scroll gesture", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 100, y: 116 })).toBe(true);
});
});
@@ -0,0 +1,7 @@
export const longPressMoveTolerancePx = 10;
export const hasExceededLongPressMoveTolerance = (
start: { x: number; y: number },
current: { x: number; y: number },
tolerance = longPressMoveTolerancePx,
) => Math.hypot(current.x - start.x, current.y - start.y) > tolerance;
+3 -2
View File
@@ -76,6 +76,7 @@ describe("Board advanced interactions", () => {
await page.keyboard.down("Shift");
await widget.hover();
await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
await expect(advancedDialog).toHaveCSS("animation-name", "none");
const advancedBounds = await advancedDialog.boundingBox();
expect(advancedBounds?.width).toBeGreaterThanOrEqual(900);
await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance");
@@ -280,7 +281,7 @@ const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => {
const clientX = bounds.x + bounds.width - 16;
const clientY = bounds.y + Math.min(24, bounds.height / 2);
await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY });
await new Promise((resolve) => setTimeout(resolve, 550));
await new Promise((resolve) => setTimeout(resolve, 800));
await grid.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY });
};
@@ -310,7 +311,7 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
id: iframeItemId,
kind: "iframe",
boardId,
options: stringify({ embedUrl: "https://example.com", allowScrolling: true }),
options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
]);
@@ -0,0 +1,76 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { immichRouter } from "../../widgets/immich";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
});
const mocks = vi.hoisted(() => ({
albumsHandler: vi.fn(),
}));
vi.mock("@homarr/request-handler/immich", () => ({
immichAlbumRequestHandler: { handler: vi.fn() },
immichStatsRequestHandler: { handler: vi.fn() },
immichAlbumsRequestHandler: {
handler: (_integration: unknown, input: unknown) => {
mocks.albumsHandler(input);
return { getDataAsync: async () => ({ data: [], timestamp: new Date() }) };
},
},
}));
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCallerAsync = async () => {
const db = createDb();
const userId = createId();
const integrationId = createId();
await db.insert(users).values({ id: userId });
await db.insert(integrations).values({
id: integrationId,
kind: "immich",
name: "Immich",
url: "https://immich.example.com",
});
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
return {
integrationId,
caller: immichRouter.createCaller({ db: db as Database, deviceType: undefined, session: createSession(userId) }),
};
};
describe("immichRouter.getAlbums", () => {
beforeEach(() => vi.clearAllMocks());
test("passes a validated limit to the cached request handler", async () => {
const { caller, integrationId } = await createCallerAsync();
await caller.getAlbums({ integrationId, limit: 50 });
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: 50 });
await expect(caller.getAlbums({ integrationId, limit: 0 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
await expect(caller.getAlbums({ integrationId, limit: 501 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
test("keeps the limit optional for unbounded callers", async () => {
const { caller, integrationId } = await createCallerAsync();
await caller.getAlbums({ integrationId });
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: undefined });
});
});
@@ -1,5 +1,5 @@
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
@@ -17,7 +17,18 @@ import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import type { WidgetComponentProps } from "../../../../../widgets/src/definition";
import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed";
import { canAccessAllFeedsAsync, restrictUrlsAsync, rssFeedRouter } from "../../widgets/rssFeed";
const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, getFeed: vi.fn() }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => mocks.logger }));
vi.mock("@homarr/request-handler/rss-feeds", () => ({
rssFeedsRequestHandler: {
handler: (input: unknown) => ({ getDataAsync: async () => await mocks.getFeed(input) }),
},
}));
beforeEach(() => vi.clearAllMocks());
const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
user: {
@@ -29,6 +40,32 @@ const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
});
describe("rssFeedRouter", () => {
describe("getFeeds", () => {
test("redacts feed secrets from logs and returns a generic error when every feed fails", async () => {
const feedUrl = "https://user:password@example.com/feed?token=secret";
mocks.getFeed.mockRejectedValue(new Error(`Failed to fetch ${feedUrl}`));
const caller = rssFeedRouter.createCaller({
db: createDb(),
deviceType: undefined,
session: createSession(["board-create"]),
});
await expect(caller.getFeeds({ urls: [feedUrl], maximumAmountPosts: 5 })).rejects.toMatchObject({
code: "BAD_GATEWAY",
message: "All RSS feed requests failed",
});
expect(mocks.logger.warn).toHaveBeenCalledWith("RSS feed fetch failed", {
feedIndex: 0,
origin: "https://example.com",
errorType: "Error",
});
const logged = JSON.stringify(mocks.logger.warn.mock.calls);
expect(logged).not.toContain("password");
expect(logged).not.toContain("token");
expect(logged).not.toContain("secret");
});
});
describe("canAccessAllFeedsAsync", () => {
test("should return false for unauthenticated users", async () => {
// Arrange
@@ -1,15 +1,18 @@
import { stringify } from "superjson";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { timetableRouter } from "../../widgets/timetable";
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() }));
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup }));
vi.mock("@homarr/request-handler/timetable", () => ({
timetableGetTimetableRequestHandler: {
@@ -30,6 +33,11 @@ vi.mock("@homarr/request-handler/timetable", () => ({
},
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
});
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
@@ -66,6 +74,14 @@ describe("timetableRouter source authorization", () => {
});
});
it("reports invalid timetable URLs as bad requests", async () => {
const caller = createCaller(createDb(), null);
await expect(
caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("loads a persisted private endpoint only through a viewable timetable item", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
@@ -89,14 +105,161 @@ describe("timetableRouter source authorization", () => {
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("allows board modifiers to search an unsaved self-hosted endpoint", async () => {
it("allows board modifiers to search an unsaved public endpoint", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
const baseUrl = "https://timetable.example.com";
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true });
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
);
});
it.each([
"http://127.0.0.1:8080",
"http://timetable.internal:8080",
"http://[::1]:8080",
"http://[::ffff:127.0.0.1]:8080",
"http://[fd00::1]:8080",
"http://[64:ff9b::7f00:1]:8080",
"http://[2002:7f00:1::]:8080",
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks an RFC 2765 IPv4-translated loopback address", async () => {
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(
caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks an unsaved public hostname that resolves to a private address", async () => {
mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
const db = createDb();
const { boardId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch",
isPublic: false,
});
const caller = createCaller(db, createSession(ownerId));
await expect(
caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("allows the exact saved private endpoint when item and board IDs match", 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" });
expect(mocks.lookup).not.toHaveBeenCalled();
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: undefined }),
);
});
it("rejects an item ID that does not belong to the requested board", async () => {
const db = createDb();
const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
await expect(
createCaller(db, createSession(second.ownerId)).searchStations({
baseUrl: "http://timetable.internal:8080",
boardId: second.boardId,
itemId: first.itemId,
query: "Bern",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("blocks a changed private endpoint for an existing item", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "http://timetable.internal:8080",
isPublic: false,
});
await expect(
createCaller(db, createSession(ownerId)).searchStations({
baseUrl: "http://127.0.0.1:8080",
boardId,
itemId,
query: "Bern",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.searchStations).not.toHaveBeenCalled();
});
it("allows a changed public endpoint for an existing item and pins its address", async () => {
const db = createDb();
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
baseUrl: "http://timetable.internal:8080",
isPublic: false,
});
const baseUrl = "https://timetable.example.com";
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
expect(mocks.searchStations).toHaveBeenLastCalledWith(
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
);
});
it("reports malformed saved widget options as server data corruption", async () => {
const db = createDb();
const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" });
await db.update(items).set({ options: "{" }).where(eq(items.id, itemId));
await expect(
createCaller(db, null).getTimetable({
baseUrl: "https://search.ch",
itemId,
stationId: "station",
limit: 10,
}),
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
});
it("reports an invalid saved timetable URL as server data corruption", async () => {
const db = createDb();
const { itemId } = await createTimetableItemAsync(db, {
baseUrl: "https://search.ch?redirect=/admin",
});
await expect(
createCaller(db, null).getTimetable({
baseUrl: "https://search.ch",
itemId,
stationId: "station",
limit: 10,
}),
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
});
it("does not treat public board view access as configuration access", async () => {
@@ -37,6 +37,7 @@ const queryFirewall = <
summary: createFallback(),
error: error instanceof Error ? error.message : "Integration request failed",
}),
throwOnAllFailures: true,
},
),
);
+12 -5
View File
@@ -34,9 +34,16 @@ export const immichRouter = createTRPCRouter({
return data.data;
}),
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbums: publicProcedure
.input(
z.object({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
+22 -3
View File
@@ -1,3 +1,4 @@
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import { z } from "zod/v4";
@@ -13,6 +14,14 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "rssFeed" });
const safeFeedLogContext = (url: string, feedIndex: number) => {
try {
return { feedIndex, origin: new URL(url).origin };
} catch {
return { feedIndex };
}
};
const feedsInput = z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
@@ -35,14 +44,24 @@ export const rssFeedRouter = createTRPCRouter({
);
const failures = settled.filter((result) => result.status === "rejected");
settled.forEach((result, index) => {
if (result.status === "rejected") {
logger.warn("RSS feed fetch failed", {
...safeFeedLogContext(urls[index] ?? "", index),
errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason,
});
}
});
if (settled.length > 0 && failures.length === settled.length) {
throw failures[0]?.reason;
throw new TRPCError({
code: "BAD_GATEWAY",
message: "All RSS feed requests failed",
});
}
return settled
.flatMap((result, index) => {
.flatMap((result) => {
if (result.status === "fulfilled") return result.value.data.entries;
logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
return [];
})
.toSorted((entryA, entryB) => {
+171 -13
View File
@@ -1,3 +1,5 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod";
@@ -6,6 +8,7 @@ import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
@@ -21,28 +24,184 @@ const timetableSourceSchema = z.object({
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
const blockedTimetableIpv4Addresses = new BlockList();
const blockedTimetableIpv6Addresses = new BlockList();
const blockedTimetableSubnets = [
["0.0.0.0", 8, "ipv4"],
["10.0.0.0", 8, "ipv4"],
["100.64.0.0", 10, "ipv4"],
["127.0.0.0", 8, "ipv4"],
["169.254.0.0", 16, "ipv4"],
["172.16.0.0", 12, "ipv4"],
["192.0.0.0", 24, "ipv4"],
["192.0.2.0", 24, "ipv4"],
["192.168.0.0", 16, "ipv4"],
["198.18.0.0", 15, "ipv4"],
["198.51.100.0", 24, "ipv4"],
["203.0.113.0", 24, "ipv4"],
["224.0.0.0", 4, "ipv4"],
["240.0.0.0", 4, "ipv4"],
["::", 96, "ipv6"],
["::ffff:0:0", 96, "ipv6"],
["::ffff:0:0:0", 96, "ipv6"],
["64:ff9b::", 96, "ipv6"],
["64:ff9b:1::", 48, "ipv6"],
["100::", 64, "ipv6"],
["2001::", 32, "ipv6"],
["2001:2::", 48, "ipv6"],
["2001:10::", 28, "ipv6"],
["2001:db8::", 32, "ipv6"],
["2002::", 16, "ipv6"],
["fc00::", 7, "ipv6"],
["fe80::", 10, "ipv6"],
["fec0::", 10, "ipv6"],
["ff00::", 8, "ipv6"],
] as const;
for (const [network, prefix, family] of blockedTimetableSubnets) {
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
blockList.addSubnet(network, prefix, family);
}
const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
cause,
});
}
};
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Timetable widget configuration is invalid",
cause,
});
};
const parseSavedTimetableOptions = (serializedOptions: string) => {
try {
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeHostname = (hostname: string) => {
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
return withoutBrackets.toLowerCase().replace(/\.$/, "");
};
const isBlockedTimetableAddress = (address: string, family: number) => {
return family === 6
? blockedTimetableIpv6Addresses.check(address, "ipv6")
: blockedTimetableIpv4Addresses.check(address, "ipv4");
};
const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
const hostname = normalizeHostname(new URL(baseUrl).hostname);
const hostnameFamily = isIP(hostname);
const isInternalHostname =
hostnameFamily === 0 &&
(!hostname.includes(".") ||
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname.endsWith(".home.arpa"));
if (isInternalHostname) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
}
let addresses: TimetableResolvedAddress[];
try {
const resolvedAddresses =
hostnameFamily === 0
? await lookup(hostname, { all: true, verbatim: true })
: [{ address: hostname, family: hostnameFamily }];
addresses = resolvedAddresses.filter(
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL host could not be resolved",
cause,
});
}
if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL must not target a private or internal address",
});
}
return addresses;
};
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
const options = parseSavedTimetableOptions(serializedOptions);
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
interface ResolvedTimetableSource {
baseUrl: string;
pinnedAddresses?: TimetableResolvedAddress[];
}
const resolveTimetableBaseUrlAsync = async (
ctx: { db: Database; session: Session | null },
input: z.infer<typeof timetableSourceSchema>,
allowBoardConfiguration = false,
) => {
const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl);
): Promise<ResolvedTimetableSource> => {
const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
let savedOptions: string | undefined;
if (input.itemId) {
const item = await ctx.db.query.items.findFirst({
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;
}
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return requestedBaseUrl;
const savedBaseUrl = savedOptions === undefined ? undefined : getSavedTimetableBaseUrl(savedOptions);
if (requestedBaseUrl === savedBaseUrl || requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
return { baseUrl: requestedBaseUrl };
}
const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl);
return { baseUrl: requestedBaseUrl, pinnedAddresses };
}
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
}
return DEFAULT_TIMETABLE_BASE_URL;
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
if (!input.itemId) {
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" });
}
return DEFAULT_TIMETABLE_BASE_URL;
return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
const item = await ctx.db.query.items.findFirst({
@@ -51,14 +210,11 @@ const resolveTimetableBaseUrlAsync = async (
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = parse<Record<string, unknown>>(item.options);
const savedBaseUrl = normalizeTimetableBaseUrl(
typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL,
);
const savedBaseUrl = getSavedTimetableBaseUrl(item.options);
if (requestedBaseUrl !== savedBaseUrl) {
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
}
return savedBaseUrl;
return { baseUrl: savedBaseUrl };
};
export const timetableRouter = createTRPCRouter({
@@ -70,7 +226,7 @@ export const timetableRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input);
const { baseUrl } = await resolveTimetableBaseUrlAsync(ctx, input);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data;
@@ -82,9 +238,11 @@ export const timetableRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
const { data } = await timetableSearchStationsRequestHandler
.handler({ ...handlerInput, baseUrl, pinnedAddresses })
.getDataAsync();
return data;
}),
});
+7 -1
View File
@@ -1,3 +1,5 @@
import { TRPCError } from "@trpc/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -50,7 +52,11 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
errors.length > 0 &&
(results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
) {
throw errors[0];
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "All integration queries failed",
cause: errors[0],
});
}
return results;
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { settleIntegrationQueries } from "../settle-integrations";
@@ -19,6 +19,8 @@ const integrations = [
{ id: "second", name: "Second", kind: "jellyfin" },
];
beforeEach(() => vi.clearAllMocks());
describe("settleIntegrationQueries", () => {
test("returns an empty result when no integrations are configured", async () => {
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
@@ -35,9 +37,14 @@ describe("settleIntegrationQueries", () => {
});
test("rejects when every integration fails", async () => {
const error = new Error("Integration unavailable");
const error = new Error("Integration unavailable with token=secret");
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error);
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
message: "All integration queries failed",
cause: error,
});
expect(mocks.logger.warn).toHaveBeenCalledTimes(integrations.length);
});
test("uses configured fallbacks when every integration fails", async () => {
@@ -62,6 +69,10 @@ describe("settleIntegrationQueries", () => {
options,
),
).resolves.toEqual(["First", "Second"]);
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error);
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toMatchObject({
code: "INTERNAL_SERVER_ERROR",
message: "All integration queries failed",
cause: error,
});
});
});
@@ -1,8 +1,9 @@
import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import type { LookupFunction } from "node:net";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import type { EnvHttpProxyAgent as UndiciEnvHttpProxyAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -30,17 +31,25 @@ export const createCustomCheckServerIdentity = (
};
export const createCertificateAgentAsync = async (
override?: {
override?: Partial<{
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
lookup: LookupFunction;
}>,
agentOptions?: Pick<
UndiciEnvHttpProxyAgent.Options,
"autoSelectFamily" | "bodyTimeout" | "httpProxy" | "httpsProxy" | "noProxy"
>,
) => {
const ca = override?.ca ?? (await getAllTrustedCertificatesAsync());
const identityCheck =
override?.checkServerIdentity ?? createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync());
return new UndiciHttpAgent({
...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
connect: {
ca,
checkServerIdentity: identityCheck,
...(override?.lookup ? { lookup: override.lookup } : {}),
},
});
};
@@ -25,6 +25,15 @@ describe("Overseerr media request actions", () => {
])("rejects %s when Overseerr returns a non-success response", async (_action, run) => {
mockFetch.mockResolvedValue(new Response(null, { status: 500 }));
await expect(run()).rejects.toThrow("Response from integration did not indicate success");
await expect(run()).rejects.toMatchObject({
name: "IntegrationResponseError",
message: "Response from integration did not indicate success",
cause: {
name: "ResponseError",
message: "Response did not indicate success",
statusCode: 500,
},
});
expect(mockFetch).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { immichAlbumsRequestHandler } from "./immich";
const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
const integration = {
id: "immich-integration",
kind: "immich",
} as Parameters<typeof immichAlbumsRequestHandler.handler>[0];
const albums = [
{ id: "upstream-first", albumName: "Zebra", assetCount: 1 },
{ id: "second-b", albumName: "Beta", assetCount: 10 },
{ id: "second-a", albumName: "Alpha", assetCount: 10 },
];
describe("immichAlbumsRequestHandler", () => {
beforeEach(() => {
immichAlbumsRequestHandler.invalidateCache();
mocks.createIntegrationAsync.mockReset();
});
test("sorts deterministically before caching a limited response", async () => {
const getAlbumsAsync = vi.fn().mockResolvedValue(albums);
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
const first = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
const cached = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
expect(first.data.map(({ id }) => id)).toStrictEqual(["second-a", "second-b"]);
expect(cached.data).toStrictEqual(first.data);
expect(getAlbumsAsync).toHaveBeenCalledOnce();
});
test("preserves upstream order when no limit is requested", async () => {
const getAlbumsAsync = vi.fn().mockResolvedValue(albums);
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
const result = await immichAlbumsRequestHandler.handler(integration, {}).getDataAsync();
expect(result.data).toStrictEqual(albums);
});
});
+16 -3
View File
@@ -22,14 +22,27 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
assetCount: number;
}[],
IntegrationKindByCategory<"photoService">,
Record<string, never>
{ limit?: number }
>({
async requestAsync(integration) {
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumsAsync();
const albums = await integrationInstance.getAlbumsAsync();
if (input.limit === undefined) return albums;
return albums
.toSorted(
(left, right) =>
right.assetCount - left.assetCount ||
compareText(left.albumName, right.albumName) ||
compareText(left.id, right.id),
)
.slice(0, input.limit);
},
});
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
+64 -3
View File
@@ -1,16 +1,25 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
vi.mock("@homarr/core/infrastructure/http", () => ({
createCertificateAgentAsync: vi.fn(),
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
const mockCloseDispatcher = vi.fn();
const mockDispatcher = { close: mockCloseDispatcher };
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
mockCreateCertificateAgent.mockResolvedValue(mockDispatcher as never);
});
describe("normalizeTimetableBaseUrl", () => {
it("normalizes an HTTP endpoint", () => {
@@ -55,6 +64,58 @@ 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 },
];
await timetableSearchStationsRequestHandler
.handler({
baseUrl: "https://timetable.example.com",
query: "pinned-stations",
pinnedAddresses,
})
.getDataAsync();
expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
{ lookup: expect.any(Function) },
{
autoSelectFamily: true,
bodyTimeout: 10_000,
httpProxy: "",
httpsProxy: "",
noProxy: "*",
},
);
const requestUrl = mockFetch.mock.calls[0]?.[0];
expect(new URL(String(requestUrl)).hostname).toBe("timetable.example.com");
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
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");
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);
const unexpectedHostCallback = vi.fn();
pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback);
expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" });
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
});
it("never caches more departures than requested", async () => {
mockFetch.mockResolvedValue(
new Response(
+75 -15
View File
@@ -1,7 +1,8 @@
import type { LookupFunction } from "node:net";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createRequestHandler } from "./lib/request-handler";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
@@ -36,12 +37,17 @@ const MAX_STATION_RESULTS = 100;
const MAX_TIMETABLE_ENTRIES = 100;
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
export interface TimetableResolvedAddress {
address: string;
family: 4 | 6;
}
export const timetableSearchStationsRequestHandler = createRequestHandler<
Station[],
{ baseUrl: string; query: string }
{ baseUrl: string; query: string; pinnedAddresses?: TimetableResolvedAddress[] }
>({
async requestAsync(input) {
return await searchStationsAsync(input.baseUrl, input.query);
return await searchStationsAsync(input.baseUrl, input.query, input.pinnedAddresses);
},
cacheTtlMs: 24 * 60 * 60 * 1000,
});
@@ -64,19 +70,73 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<strin
return url.toString();
};
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
timetableFetchOptions,
);
if (!response.ok) throw new ResponseError(response);
const normalizeLookupHostname = (hostname: string) =>
hostname
.replace(/^\[(.*)\]$/, "$1")
.toLowerCase()
.replace(/\.$/, "");
const body = await readBoundedTimetableJsonAsync(response);
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
return data
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
.filter((item) => item !== null);
const createPinnedLookup = (baseUrl: string, addresses: TimetableResolvedAddress[]): LookupFunction => {
const expectedHostname = normalizeLookupHostname(new URL(baseUrl).hostname);
return (hostname, options, callback) => {
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = requestedFamily ? addresses.filter(({ family }) => family === requestedFamily) : addresses;
if (normalizeLookupHostname(hostname) !== expectedHostname || candidates.length === 0) {
const error = new Error("Pinned timetable address is unavailable") as NodeJS.ErrnoException;
error.code = "ENOTFOUND";
callback(error, []);
return;
}
if (options.all) {
callback(null, candidates);
return;
}
const selected = candidates[0];
if (!selected) return callback(new Error("Pinned timetable address is unavailable"), []);
callback(null, selected.address, selected.family);
};
};
const createPinnedTimetableDispatcherAsync = async (baseUrl: string, addresses: TimetableResolvedAddress[]) => {
// Proxies resolve CONNECT targets independently, which would defeat DNS pinning. Force this security-sensitive
// configuration request to connect directly. The original URL remains unchanged for the Host header and TLS SNI.
return await createCertificateAgentAsync(
{ lookup: createPinnedLookup(baseUrl, addresses) },
{
autoSelectFamily: addresses.length > 1,
bodyTimeout: timetableFetchOptions.bodyTimeout,
httpProxy: "",
httpsProxy: "",
noProxy: "*",
},
);
};
const searchStationsAsync = async (
baseUrl: string,
query: string,
pinnedAddresses?: TimetableResolvedAddress[],
): Promise<Station[]> => {
const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
try {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
{ ...timetableFetchOptions, dispatcher },
);
if (!response.ok) throw new ResponseError(response);
const body = await readBoundedTimetableJsonAsync(response);
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
return data
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
.filter((item) => item !== null);
} finally {
await dispatcher?.close();
}
};
const getTimetableAsync = async (
+3
View File
@@ -3712,6 +3712,9 @@
"error": {
"internalServerError": "Unable to get data from firewall"
},
"empty": {
"noInterfaces": "No network interface data yet"
},
"versionUnknown": "Unknown version",
"metricAccessible": "{metric}: {value}%, {status}",
"status": {
+1 -1
View File
@@ -69,7 +69,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode || isAdvanced}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
+1 -1
View File
@@ -15,7 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
queryKeys: [[["app", "byId"]], [["widget", "app"]]],
queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
@@ -5,7 +5,7 @@ import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -47,6 +47,7 @@ const ArchiveTeamWarriorWidgetContent = ({
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
const locale = useCurrentLocale();
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
@@ -96,7 +97,7 @@ const ArchiveTeamWarriorWidgetContent = ({
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
</Text>
<Text size="xs" c="dimmed">
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })}
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
</Text>
</Group>
<ScrollArea style={{ flex: 1 }}>
+12 -6
View File
@@ -8,6 +8,7 @@ import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({
@@ -16,14 +17,19 @@ export default function AudioStatsWidget({
width,
height,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
const statsInput = { integrationId: integrationIds[0] ?? "" };
const { data: response } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
const streamsInput = { integrationIds, showOnlyPlaying: false };
const streamsEnabled = displayMode === "advanced" && response?.kind === "navidrome";
const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(streamsInput, {
enabled: streamsEnabled,
});
const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
{ integrationIds, showOnlyPlaying: false },
{ enabled: displayMode === "advanced" && response?.kind === "navidrome" },
);
setWidgetRuntimeQueries(widgetStateRef, [
{ path: ["widget", "audioStats", "getStats"], input: statsInput },
...(streamsEnabled ? [{ path: ["widget", "mediaServer", "getCurrentStreams"], input: streamsInput }] : []),
]);
if (!response) return <WidgetEmptyState />;
+3 -2
View File
@@ -1,6 +1,6 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const hideUnlessNavidrome = {
@@ -13,7 +13,8 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]],
queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
queryMatcher: matchesWidgetRuntimeQuery,
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
+8 -2
View File
@@ -1,12 +1,18 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
queryKey: [["widget", "beszel"]],
queryKey: [["widget", "beszel", "getAlerts"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
includeHistory: scope.options.showHistory,
maxHistoryItems: scope.options.maxHistoryItems,
}),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -430,7 +430,9 @@ export default function BeszelSystemGridWidget({
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size);
const maxMetrics = advanced
? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size))
: getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
queryKey: [["widget", "beszel"]],
queryKey: [["widget", "beszel", "getSystems"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -13,6 +13,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
@@ -28,6 +29,7 @@ export default function BeszelSystemStatsWidget({
boardId,
itemId,
setOptions,
widgetStateRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -45,6 +47,25 @@ export default function BeszelSystemStatsWidget({
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
const includeDocker =
(displayMode === "advanced" || height >= 380) &&
(options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork);
setWidgetRuntimeQueries(
widgetStateRef,
selectedSystem && options.timePeriod !== "1m"
? [
{
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: [selectedSystem.integrationId],
systemId: selectedSystem.systemId,
timePeriod: options.timePeriod,
includeDocker,
},
},
]
: [],
);
const handleSelectSystem = useCallback(
(value: string) => {
@@ -178,7 +199,7 @@ export default function BeszelSystemStatsWidget({
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
onSwitchToHistorical={() => handleTimePeriod("1h")}
/>
</Stack>
</ScrollArea>
@@ -2,9 +2,9 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
import { createBeszelSystemChoices } from "./selection";
import { createBeszelSystemChoices, resolveStoredBeszelQuerySelection } from "./selection";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
@@ -17,7 +17,29 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
queryKey: [["widget", "beszel"]],
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher(query, scope) {
if (query.path.at(-1) === "getSystems") {
return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds });
}
const hasRuntimeStatsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getSystemStats");
if (hasRuntimeStatsQuery) return matchesWidgetRuntimeQuery(query, scope);
const selection = resolveStoredBeszelQuerySelection(String(scope.options.systemId ?? ""), scope.integrationIds);
if (!selection) return false;
const dockerEnabled = ["showDockerCpu", "showDockerMemory", "showDockerNetwork"].some(
(key) => scope.options[key] === true,
);
const expected = {
...selection,
timePeriod: scope.options.timePeriod,
};
return (
widgetQueryInputMatches(query.input, { ...expected, includeDocker: false }) ||
(dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true }))
);
},
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
import { createBeszelSystemChoices, resolveBeszelSystemChoice, resolveStoredBeszelQuerySelection } from "./selection";
const choices = createBeszelSystemChoices([
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
@@ -13,7 +13,34 @@ describe("Beszel system selection", () => {
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
});
it("keeps legacy plain system ids working", () => {
expect(resolveBeszelSystemChoice(choices, "same")?.integrationId).toBe("one");
it("keeps unique legacy plain system ids working", () => {
expect(resolveBeszelSystemChoice(choices.slice(0, 1), "same")?.integrationId).toBe("one");
});
it("defaults an empty selection to the first system", () => {
expect(resolveBeszelSystemChoice(choices, "")?.integrationId).toBe("one");
});
it("does not guess when a legacy plain system id is ambiguous", () => {
expect(resolveBeszelSystemChoice(choices, "same")).toBeUndefined();
});
it("does not silently replace an unresolved stored system", () => {
expect(resolveBeszelSystemChoice(choices, "missing")).toBeUndefined();
});
it("derives the queried integration and system from a composite selection", () => {
expect(resolveStoredBeszelQuerySelection("two:same", ["one", "two"])).toEqual({
integrationIds: ["two"],
systemId: "same",
});
});
it("supports a legacy plain system id only when the integration is unambiguous", () => {
expect(resolveStoredBeszelQuerySelection("same", ["one"])).toEqual({
integrationIds: ["one"],
systemId: "same",
});
expect(resolveStoredBeszelQuerySelection("same", ["one", "two"])).toBeNull();
});
});
@@ -24,9 +24,25 @@ export function createBeszelSystemChoices(results: BeszelSystemsResult[]): Besze
}
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
return (
choices.find((choice) => choice.value === storedValue) ??
choices.find((choice) => choice.systemId === storedValue) ??
choices[0]
);
const exactMatch = choices.find((choice) => choice.value === storedValue);
if (exactMatch) return exactMatch;
if (storedValue === "") return choices[0];
const legacyMatches = choices.filter((choice) => choice.systemId === storedValue);
return legacyMatches.length === 1 ? legacyMatches[0] : undefined;
}
export function resolveStoredBeszelQuerySelection(storedValue: string, integrationIds: readonly string[]) {
for (const integrationId of integrationIds) {
const prefix = `${integrationId}:`;
if (storedValue.startsWith(prefix)) {
return { integrationIds: [integrationId], systemId: storedValue.slice(prefix.length) };
}
}
if (storedValue !== "" && integrationIds.length === 1) {
return { integrationIds: [integrationIds[0] as string], systemId: storedValue };
}
return null;
}
@@ -24,6 +24,7 @@ import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import {
@@ -77,6 +78,7 @@ export default function BeszelSystemTableWidget({
isEditMode,
width,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
@@ -319,6 +321,25 @@ export default function BeszelSystemTableWidget({
openModal({ integrationId, systemId: record.id }, { title: record.name });
};
const activeSystem = selectedSystem ?? sortedSystems[0];
const activeIntegrationId = activeSystem?._key.split(":")[0] ?? "";
setWidgetRuntimeQueries(
widgetStateRef,
displayMode === "advanced" && activeSystem
? [
{
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: [activeIntegrationId],
systemId: activeSystem.id,
timePeriod: "1h",
includeDocker: false,
},
},
]
: [],
);
if (systemsError) throw systemsError;
if (isPending) {
@@ -355,8 +376,6 @@ export default function BeszelSystemTableWidget({
if (displayMode === "compact") return table;
const activeSystem = selectedSystem ?? sortedSystems[0];
const activeIntegrationId = activeSystem?._key.split(":")[0] ?? "";
return (
<div
style={{
@@ -378,14 +397,14 @@ export default function BeszelSystemTableWidget({
timePeriod="1h"
columns={1}
visibility={{
cpu: true,
memory: true,
disk: true,
diskIO: true,
network: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDisk,
network: options.showNet,
dockerCpu: false,
dockerMemory: false,
dockerNetwork: false,
}}
/>
</Stack>
@@ -1,6 +1,6 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
@@ -25,7 +25,11 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
queryKey: [["widget", "beszel"]],
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher: (query, scope) =>
query.path.at(-1) === "getSystems"
? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds })
: matchesWidgetRuntimeQuery(query, scope),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
+14 -2
View File
@@ -13,6 +13,7 @@ import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { CalendarDay } from "./calender-day";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
import { CalendarEventList } from "./calendar-event-list";
@@ -22,6 +23,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
const [month, setMonth] = useState(() => new Date());
if (props.integrationIds.length === 0) {
setWidgetRuntimeQueries(props.widgetStateRef, []);
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
}
@@ -33,7 +35,15 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
displayMode,
widgetStateRef,
}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -41,6 +51,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "calendar", "findAllEvents"], input }]);
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
@@ -75,6 +86,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const isSmall = width < 256;
const calendarHeight = displayMode === "advanced" ? height * 0.62 : height;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const visibleEvents = useMemo(
@@ -159,7 +171,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={height}
rootHeight={calendarHeight}
/>
);
}}
+3 -1
View File
@@ -4,11 +4,13 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
queryKey: [["widget", "calendar", "findAllEvents"]],
queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
+5 -1
View File
@@ -8,6 +8,8 @@ import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { clientApi } from "@homarr/api/client";
import { metricToImperial } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
@@ -77,6 +79,7 @@ interface ClockWeatherCornerProps {
}
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }: ClockWeatherCornerProps) => {
const tCommon = useScopedI18n("common");
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
if (!weather) return null;
@@ -93,7 +96,8 @@ const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }:
</Text>
{showDetails && (
<Text size="xs" c="dimmed">
{Math.round(weather.current.windspeed)} km/h
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
</Text>
)}
</Stack>
+18 -2
View File
@@ -1,12 +1,28 @@
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
queryKey: [["widget", "weather"]],
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.weatherLocation;
if (
scope.options.showWeather !== true ||
location === null ||
typeof location !== "object" ||
!("latitude" in location) ||
!("longitude" in location)
) {
return false;
}
return widgetQueryInputMatches(input, {
latitude: location.latitude,
longitude: location.longitude,
});
},
createOptions() {
return optionsBuilder.from(
(factory) => ({
+21 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -33,12 +33,27 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const visibleSections = useMemo(
() =>
[
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null),
[options.showApplications, options.showServers, options.showServices],
);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const previousVisibleSectionsRef = useRef(visibleSections);
useEffect(() => {
const newlyVisibleSections = visibleSections.filter(
(section) => !previousVisibleSectionsRef.current.includes(section),
);
previousVisibleSectionsRef.current = visibleSections;
if (newlyVisibleSections.length === 0) return;
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
}, [visibleSections]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -32,12 +32,27 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const visibleSections = [
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null);
const visibleSections = useMemo(
() =>
[
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null),
[options.showApplications, options.showServers, options.showServices],
);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const previousVisibleSectionsRef = useRef(visibleSections);
useEffect(() => {
const newlyVisibleSections = visibleSections.filter(
(section) => !previousVisibleSectionsRef.current.includes(section),
);
previousVisibleSectionsRef.current = visibleSections;
if (newlyVisibleSections.length === 0) return;
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
}, [visibleSections]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
+5 -3
View File
@@ -1,11 +1,13 @@
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi", "getData"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
contextActions({ options, widgetStateRef }) {
return [
{
@@ -13,9 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("customApi
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetStateRef.current?.togglePolling !== "function",
disabled: typeof widgetStateRef?.current?.togglePolling !== "function",
onClick: () => {
const action = widgetStateRef.current?.togglePolling;
const action = widgetStateRef?.current?.togglePolling;
if (typeof action === "function") action();
},
},
+17 -2
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "vitest";
import { getWidgetQueryKeys } from "./definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, setWidgetRuntimeQueries } from "./definition";
const togglePolling = () => undefined;
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
@@ -12,8 +14,21 @@ describe("getWidgetQueryKeys", () => {
});
test("preserves every explicit key for multi-root widgets", () => {
const queryKeys = [[["widget", "audioStats"]], [["widget", "mediaServer"]]] as const;
const queryKeys = [
[["widget", "audioStats", "getStats"]],
[["widget", "mediaServer", "getCurrentStreams"]],
] as const;
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
test("registers runtime queries without replacing imperative widget actions", () => {
const widgetStateRef = { current: { togglePolling } };
const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
});
});
+50
View File
@@ -1,6 +1,7 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -29,6 +30,21 @@ export interface WidgetContextMenuContext {
canInteractWithSelectedIntegrations: boolean;
}
export interface NormalizedWidgetQuery {
path: readonly string[];
input: unknown;
}
export interface WidgetQueryMatcherScope {
itemId: string;
boardId: string | undefined;
integrationIds: readonly string[];
options: Record<string, unknown>;
runtimeQueries: readonly NormalizedWidgetQuery[];
}
export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean;
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
@@ -72,6 +88,7 @@ export interface WidgetDefinition {
icon: TablerIcon;
queryKey?: QueryKey;
queryKeys?: readonly QueryKey[];
queryMatcher?: WidgetQueryMatcher;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
@@ -101,6 +118,39 @@ export const getWidgetQueryKeys = (definition: {
return [definition.queryKey ?? [["widget", definition.kind]]];
};
const runtimeQueriesStateKey = "__homarrRuntimeQueries";
export const setWidgetRuntimeQueries = (
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null> | undefined,
queries: readonly NormalizedWidgetQuery[],
) => {
if (!widgetStateRef) return;
const state = widgetStateRef.current ?? {};
state[runtimeQueriesStateKey] = queries;
widgetStateRef.current = state;
};
export const getWidgetRuntimeQueries = (
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>,
): readonly NormalizedWidgetQuery[] => {
const queries = widgetStateRef.current?.[runtimeQueriesStateKey];
return Array.isArray(queries) ? (queries as NormalizedWidgetQuery[]) : [];
};
export const matchesWidgetRuntimeQuery: WidgetQueryMatcher = (query, scope) =>
scope.runtimeQueries.some(
(runtimeQuery) =>
widgetQueryValueEquals(runtimeQuery.path, query.path) && widgetQueryValueEquals(runtimeQuery.input, query.input),
);
export const widgetQueryInputMatches = (input: unknown, expected: Record<string, unknown>) =>
isRecord(input) && Object.entries(expected).every(([key, value]) => widgetQueryValueEquals(input[key], value));
export const widgetQueryValueEquals = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
+7 -1
View File
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
const columnsList = [
@@ -42,6 +42,12 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
queryKey: [["widget", "downloads", "getJobsAndStatuses"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
limitPerIntegration: scope.options.limitPerIntegration,
}),
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
+69 -27
View File
@@ -127,7 +127,9 @@ export default function FirewallWidget({
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
const hasError = Boolean(
cpu?.error || memory?.error || version?.error || interfacesQuery.isError || interfaces?.error,
);
const metadata = firewallMetadata.get(firewallId);
return (
@@ -138,13 +140,18 @@ export default function FirewallWidget({
version={version?.error ? undefined : version?.summary.version}
cpu={cpu?.error ? undefined : cpu?.summary.total}
memory={memory?.error ? undefined : memory?.summary.percent}
interfaces={interfaces?.error ? undefined : interfaces?.summary}
interfaces={interfaces?.summary}
interfacesLoaded={interfaces !== undefined}
interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
noDataLabel={t("widget.firewall.error.internalServerError")}
errorLabel={t("widget.firewall.error.internalServerError")}
noDataLabel={t("widget.firewall.empty.noInterfaces")}
loadingLabel={t("common.action.loading")}
errorBadgeLabel={t("common.error")}
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
t={t}
/>
@@ -163,12 +170,17 @@ interface FirewallPanelProps {
cpu?: number;
memory?: number;
interfaces?: FirewallInterfacesSummary[];
interfacesLoaded: boolean;
interfacesError: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
noDataLabel: string;
loadingLabel: string;
errorBadgeLabel: string;
interfacesLabel: string;
t: TranslationFunction;
}
@@ -180,12 +192,17 @@ const FirewallPanel = ({
cpu,
memory,
interfaces,
interfacesLoaded,
interfacesError,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
errorLabel,
noDataLabel,
loadingLabel,
errorBadgeLabel,
interfacesLabel,
t,
}: FirewallPanelProps) => (
@@ -205,7 +222,7 @@ const FirewallPanel = ({
</Stack>
<Group gap={4} wrap="nowrap">
{hasError && (
<Tooltip label={noDataLabel}>
<Tooltip label={errorLabel}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
</Badge>
@@ -235,11 +252,16 @@ const FirewallPanel = ({
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
</Stack>
@@ -294,21 +316,31 @@ const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
interface InterfacesPanelProps {
summary: FirewallInterfacesSummary[];
hasResult: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
errorBadgeLabel: string;
noDataLabel: string;
loadingLabel: string;
label: string;
}
const InterfacesPanel = ({
summary,
hasResult,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
errorLabel,
errorBadgeLabel,
noDataLabel,
loadingLabel,
label,
}: InterfacesPanelProps) => {
const bandwidth = calculateBandwidth(summary).data;
@@ -321,35 +353,45 @@ const InterfacesPanel = ({
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
{hasError && (
<Badge color="red" variant="light" size="xs">
{errorBadgeLabel}
</Badge>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
{bandwidth.length > 0 ? (
bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
{name}
{bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
{name}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
{formatBitsPerSec(transmit, 2)}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
{formatBitsPerSec(transmit, 2)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
</Group>
))
) : (
<Text size="xs" c="dimmed">
{noDataLabel}
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
</Group>
))}
{hasError ? (
<Text size="xs" c="red">
{errorLabel}
</Text>
)}
) : bandwidth.length === 0 ? (
<Text size="xs" c="dimmed">
{hasResult ? noDataLabel : loadingLabel}
</Text>
) : null}
</Stack>
</Accordion.Panel>
</Accordion.Item>
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAllowedPermissions, getSandboxFlags, isSupportedProtocol } from "./component";
import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component";
const permissions = {
allowFullScreen: true,
@@ -23,4 +23,12 @@ describe("iframe policy", () => {
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
it("creates a display title without credentials, paths, or query parameters", () => {
expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com");
});
it("keeps a non-default port in the display title", () => {
expect(getFrameTitle("http://user:secret@example.com:8080/private?token=sensitive")).toBe("example.com:8080");
});
});
+3 -3
View File
@@ -28,7 +28,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
{getFrameTitle(embedUrl)}
</Text>
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
{embedUrl}
{t("widget.app.option.openInNewTab.label")}
</Anchor>
</Group>
)}
@@ -138,9 +138,9 @@ const permissionMapping: Partial<
allowPayment: "payment",
};
const getFrameTitle = (url: string) => {
export const getFrameTitle = (url: string) => {
try {
return new URL(url).hostname;
return new URL(url).host;
} catch {
return "iframe";
}
@@ -4,23 +4,31 @@ import z from "zod";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
queryKey: [["widget", "immich"]],
queryKey: [["widget", "immich", "getAlbum"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationId: scope.integrationIds[0] ?? "",
albumId:
typeof scope.options.albumId === "string" && scope.options.albumId !== ALL_PHOTOS_ALBUM_ID
? scope.options.albumId
: undefined,
}),
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions({ widgetStateRef }) {
const call = (key: string) => () => {
const action = widgetStateRef.current?.[key];
const action = widgetStateRef?.current?.[key];
if (typeof action === "function") action();
};
const disabled = (key: string) => typeof widgetStateRef.current?.[key] !== "function";
const disabled = (key: string) => typeof widgetStateRef?.current?.[key] !== "function";
return [
{
key: "previousPhoto",
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { WidgetComponentProps } from "../definition";
import ImmichServerStatsWidget from "./server-stats/component";
import { definition as albumCarouselDefinition } from "./album-carousel";
const mocks = vi.hoisted(() => ({
getAlbums: vi.fn(() => ({ data: [], isPending: false, isError: false })),
}));
vi.mock("@homarr/api/client", () => ({
clientApi: {
widget: {
immich: {
getAlbums: { useQuery: mocks.getAlbums },
getServerStats: { useQuery: () => ({ data: undefined }) },
},
},
},
}));
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
useScopedI18n: () => (key: string) => key,
}));
describe("Immich album queries", () => {
beforeEach(() => vi.clearAllMocks());
test("server stats requests a bounded album response", () => {
ImmichServerStatsWidget({
integrationIds: ["immich-id"],
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
displayMode: "advanced",
width: 800,
height: 600,
isEditMode: false,
boardId: "board-id",
itemId: "item-id",
setOptions: vi.fn(),
} satisfies WidgetComponentProps<"immich-serverStats">);
expect(mocks.getAlbums).toHaveBeenCalledWith(
{ integrationId: "immich-id", limit: 50 },
expect.objectContaining({ enabled: true }),
);
});
test("album carousel keeps requesting every album for its selector", () => {
const options = albumCarouselDefinition.createOptions();
options.albumId.useOptions(["immich-id"]);
expect(mocks.getAlbums).toHaveBeenCalledWith(
{ integrationId: "immich-id" },
expect.objectContaining({ enabled: true }),
);
});
});
@@ -10,30 +10,37 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { setWidgetRuntimeQueries } from "../../definition";
import classes from "./component.module.css";
const MAX_ADVANCED_ALBUMS = 50;
export default function ImmichServerStatsWidget({
integrationIds,
options,
displayMode = "compact",
width,
widgetStateRef,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
integrationId: integrationIds[0] ?? "",
const input = { integrationId: integrationIds[0] ?? "" };
const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS };
const isAdvanced = displayMode === "advanced";
const albumsEnabled = isAdvanced && integrationIds.length > 0;
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(input);
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
enabled: albumsEnabled,
staleTime: 15 * 60 * 1000,
});
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: displayMode === "advanced" && integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
setWidgetRuntimeQueries(widgetStateRef, [
{ path: ["widget", "immich", "getServerStats"], input },
...(albumsEnabled ? [{ path: ["widget", "immich", "getAlbums"], input: albumsInput }] : []),
]);
if (!stats) return <WidgetEmptyState />;
const statsContent = (
<SimpleGrid
cols={displayMode === "advanced" ? (width >= 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1}
spacing="sm"
>
<SimpleGrid cols={isAdvanced ? (width >= 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1} spacing="sm">
{options.showUsers && (
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
)}
@@ -61,7 +68,7 @@ export default function ImmichServerStatsWidget({
</SimpleGrid>
);
if (displayMode === "compact") {
if (!isAdvanced) {
return (
<Stack gap="md" h="100%" p="md" justify="center">
{statsContent}
@@ -69,7 +76,7 @@ export default function ImmichServerStatsWidget({
);
}
const sortedAlbums = [...albums].toSorted((left, right) => right.assetCount - left.assetCount);
const sortedAlbums = albums;
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return (
<Stack gap="lg" h="100%" p="lg">
@@ -1,11 +1,12 @@
import { IconGraphFilled } from "@tabler/icons-react";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../../definition";
import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled,
queryKey: [["widget", "immich"]],
queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
+4 -1
View File
@@ -66,12 +66,15 @@ import * as weather from "./weather";
import * as customApi from "./custom-api";
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();
},
},
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
import type { MediaMissingTab } from "./tabs";
@@ -37,14 +38,18 @@ export default function MediaMissingWidget({
width,
height,
displayMode,
widgetStateRef,
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
{ integrationIds, pageSize },
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
);
const input = { integrationIds, pageSize };
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaOrganizer", "getData"], input }]);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
+3 -2
View File
@@ -2,14 +2,15 @@ import { IconMovie } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
queryKey: [["widget", "mediaOrganizer"]],
queryKey: [["widget", "mediaOrganizer", "getData"]],
queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
@@ -4,14 +4,23 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
queryKey: [["widget", "mediaRequests"]],
queryKey: [["widget", "mediaRequests", "getLatestRequests"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
statuses:
Array.isArray(scope.options.statusFilter) && scope.options.statusFilter.length > 0
? scope.options.statusFilter
: mediaRequestStatusValues,
recentDays: scope.options.recentDays,
}),
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
@@ -7,7 +7,7 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
queryKey: [["widget", "mediaRequests"]],
queryKey: [["widget", "mediaRequests", "getStats"]],
createOptions() {
return {};
},
+7 -1
View File
@@ -2,11 +2,17 @@ import { IconVideo } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
queryKey: [["widget", "mediaServer", "getCurrentStreams"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
showOnlyPlaying: scope.options.showOnlyPlaying,
}),
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -23,6 +23,7 @@ import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel";
@@ -44,6 +45,7 @@ export default function MediaTranscodingWidget({
width,
height,
displayMode,
widgetStateRef,
}: WidgetComponentProps<"mediaTranscoding">) {
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
@@ -58,6 +60,7 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: requestPagination.page,
};
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaTranscoding", "getDataAsync"], input }]);
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState<View>(options.defaultView);
@@ -4,13 +4,15 @@ import { z } from "zod/v4";
import { capitalize } from "@homarr/common";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
queryKey: [["widget", "mediaTranscoding", "getDataAsync"]],
queryMatcher: matchesWidgetRuntimeQuery,
maxIntegrations: 1,
refetchInterval: null,
createOptions() {
@@ -1,12 +1,18 @@
import { IconBrandMinecraft } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft,
queryKey: [["widget", "minecraft"]],
queryKey: [["widget", "minecraft", "getServerStatus"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
title: scope.options.title,
domain: scope.options.domain,
isBedrockServer: scope.options.isBedrockServer,
}),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+19 -25
View File
@@ -53,7 +53,6 @@ import { Table } from "@tiptap/extension-table";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableRow } from "@tiptap/extension-table-row";
import { TaskItem } from "@tiptap/extension-task-item";
import { TaskList } from "@tiptap/extension-task-list";
import { TextAlign } from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
@@ -61,7 +60,6 @@ import type { Editor } from "@tiptap/react";
import { useEditor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import { StarterKit } from "@tiptap/starter-kit";
import type { Node } from "prosemirror-model";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
@@ -70,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css";
@@ -208,17 +207,20 @@ export function Notebook({
}),
TableHeader,
TableRow,
TaskItem.configure({
ReadOnlyTaskItem.configure({
nested: true,
onReadOnlyChecked: (node, checked) => {
onReadOnlyChecked: () => {
if (!allowReadOnlyCheckRef.current) return false;
if (!canChangeRef.current) return false;
return true;
},
onReadOnlyCheckedAtPosition: (position, checked) => {
if (!allowReadOnlyCheckRef.current) return;
if (!canChangeRef.current) return;
const event = new CustomEvent(readOnlyCheckEventName, {
detail: { node, checked },
detail: { position, checked },
});
dispatchEvent(event);
return true;
},
}),
TaskList.configure({ itemTypeName: "taskItem" }),
@@ -243,25 +245,17 @@ export function Notebook({
(event: Event) => {
if (!allowReadOnlyCheckRef.current) return;
if (!editor) return;
const { detail } = event as CustomEvent<{ node: Node; checked: boolean }>;
const { detail } = event as CustomEvent<{ position: number; checked: boolean }>;
const transaction = createReadOnlyTaskItemTransaction(editor.state, detail.position, detail.checked);
if (!transaction) return;
editor.state.doc.descendants((subnode, pos) => {
if (!detail) return;
if (!subnode.eq(detail.node)) return;
const { tr } = editor.state;
tr.setNodeMarkup(pos, undefined, {
...detail.node.attrs,
checked: detail.checked,
});
editor.view.dispatch(tr);
const nextContent = editor.getHTML();
setContent(nextContent);
void handleContentUpdate(nextContent).then((wasSaved) => {
if (wasSaved) return;
setContent(previousContentRef.current);
editor.commands.setContent(previousContentRef.current);
});
editor.view.dispatch(transaction);
const nextContent = editor.getHTML();
setContent(nextContent);
void handleContentUpdate(nextContent).then((wasSaved) => {
if (wasSaved) return;
setContent(previousContentRef.current);
editor.commands.setContent(previousContentRef.current);
});
},
[editor, handleContentUpdate],
@@ -0,0 +1,96 @@
import { Editor } from "@tiptap/core";
import { TaskList } from "@tiptap/extension-task-list";
import { Schema } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
const schema = new Schema({
nodes: {
doc: { content: "taskList" },
paragraph: { content: "text*" },
text: { inline: true },
taskList: { content: "taskItem+" },
taskItem: {
attrs: { checked: { default: false } },
content: "paragraph",
},
},
});
const createTaskItem = () =>
schema.node("taskItem", { checked: false }, [schema.node("paragraph", null, [schema.text("Duplicate task")])]);
describe("read-only task item updates", () => {
it("reports the exact position of the clicked duplicate task", () => {
const element = document.createElement("div");
let clickedPosition: number | undefined;
const editor = new Editor({
element,
editable: false,
extensions: [
StarterKit,
TaskList.configure({ itemTypeName: "taskItem" }),
ReadOnlyTaskItem.configure({
nested: true,
onReadOnlyChecked: () => true,
onReadOnlyCheckedAtPosition: (position) => {
clickedPosition = position;
},
}),
],
content: {
type: "doc",
content: [
{
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
{ type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
],
},
],
},
});
try {
const positions: number[] = [];
editor.state.doc.descendants((node, position) => {
if (node.type.name === "taskItem") positions.push(position);
});
const secondPosition = positions[1];
const secondCheckbox = element.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').item(1);
if (secondPosition === undefined || !secondCheckbox) throw new Error("Expected a second task item checkbox");
secondCheckbox.checked = true;
secondCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
expect(clickedPosition).toBe(secondPosition);
} finally {
editor.destroy();
}
});
it("updates the selected position when task items are structurally identical", () => {
const doc = schema.node("doc", null, [schema.node("taskList", null, [createTaskItem(), createTaskItem()])]);
const taskItemPositions: number[] = [];
doc.descendants((node, position) => {
if (node.type.name === "taskItem") taskItemPositions.push(position);
});
const secondTaskItemPosition = taskItemPositions[1];
if (secondTaskItemPosition === undefined) throw new Error("Expected a second task item");
const state = EditorState.create({ doc });
const transaction = createReadOnlyTaskItemTransaction(state, secondTaskItemPosition, true);
if (!transaction) throw new Error("Expected a task-item transaction");
const nextState = state.apply(transaction);
const checkedStates: boolean[] = [];
nextState.doc.descendants((node) => {
if (node.type.name === "taskItem") checkedStates.push(Boolean(node.attrs.checked));
});
expect(checkedStates).toEqual([false, true]);
});
});
@@ -0,0 +1,66 @@
import type { NodeViewRenderer } from "@tiptap/core";
import { TaskItem } from "@tiptap/extension-task-item";
import type { TaskItemOptions } from "@tiptap/extension-task-item";
import type { EditorState, Transaction } from "@tiptap/pm/state";
interface ReadOnlyTaskItemOptions extends TaskItemOptions {
onReadOnlyCheckedAtPosition?: (position: number, checked: boolean) => void;
}
export const ReadOnlyTaskItem = TaskItem.extend<ReadOnlyTaskItemOptions>({
addOptions() {
const parentOptions = this.parent?.();
return {
onReadOnlyChecked: parentOptions?.onReadOnlyChecked,
nested: parentOptions?.nested ?? false,
HTMLAttributes: parentOptions?.HTMLAttributes ?? {},
taskListTypeName: parentOptions?.taskListTypeName ?? "taskList",
a11y: parentOptions?.a11y,
onReadOnlyCheckedAtPosition: undefined,
};
},
addNodeView() {
const parentNodeView = this.parent?.() as NodeViewRenderer | undefined;
if (!parentNodeView) return null;
return (props) => {
const nodeView = parentNodeView(props);
const checkbox =
nodeView.dom instanceof HTMLElement
? nodeView.dom.querySelector<HTMLInputElement>('input[type="checkbox"]')
: null;
if (!checkbox) return nodeView;
const handleChange = () => {
if (props.editor.isEditable) return;
const position = props.getPos();
if (typeof position !== "number") return;
this.options.onReadOnlyCheckedAtPosition?.(position, checkbox.checked);
};
checkbox.addEventListener("change", handleChange);
const destroyParent = nodeView.destroy?.bind(nodeView);
return {
...nodeView,
destroy() {
checkbox.removeEventListener("change", handleChange);
destroyParent?.();
},
};
};
},
});
export const createReadOnlyTaskItemTransaction = (
state: EditorState,
position: number,
checked: boolean,
): Transaction | null => {
const taskItem = state.doc.nodeAt(position);
if (taskItem?.type.name !== "taskItem") return null;
return state.tr.setNodeMarkup(position, undefined, {
...taskItem.attrs,
checked,
});
};
+7 -1
View File
@@ -2,11 +2,17 @@ import { IconMessage } 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("notifications", {
icon: IconMessage,
queryKey: [["widget", "notifications", "getNotifications"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
hideLogos: scope.options.hideLogos,
integrationIds: scope.integrationIds,
}),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+3 -1
View File
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { releaseProviderKinds } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
const relativeDateSchema = z
@@ -14,6 +14,8 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
queryKey: [["widget", "releases", "getLatest"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
+7 -1
View File
@@ -1,7 +1,7 @@
import { IconRss } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
/**
@@ -12,6 +12,12 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
queryKey: [["widget", "rssFeed", "getFeeds"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
urls: scope.options.feedUrls,
maximumAmountPosts: scope.options.maximumAmountPosts,
}),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -7,7 +7,7 @@ import { IconBinaryTree } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
@@ -20,6 +20,7 @@ export default function SmartHomeEntityStateWidget({
displayMode,
}: WidgetComponentProps<"smartHome-entityState">) {
const t = useI18n();
const locale = useCurrentLocale();
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
@@ -123,7 +124,7 @@ export default function SmartHomeEntityStateWidget({
</Text>
<Text size="xs" c="dimmed">
{t("widget.smartHome-entityState.advanced.lastUpdated", {
date: new Date(entity.last_updated).toLocaleString(),
date: new Date(entity.last_updated).toLocaleString(locale),
})}
</Text>
</>
@@ -37,12 +37,16 @@ export default function SpeedtestTrackerWidget({
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
const hasChart = options.showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = options.showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={displayMode === "advanced" ? width / 2 : width} />
<LatestResultSection
result={combined.latestResult}
width={displayMode === "advanced" ? advancedSectionWidth : width}
/>
);
const averages = options.showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? width / 2 : width} />
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
);
if (displayMode === "advanced") {
@@ -69,6 +73,12 @@ export default function SpeedtestTrackerWidget({
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
</div>
)}
{noSectionsEnabled && (
<Text c="dimmed" ta="center" size="sm">
{t("noSectionsEnabled")}
</Text>
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
);
}
+8 -1
View File
@@ -1,6 +1,6 @@
import { IconBuildingBank } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const stockPriceTimeFrames = {
@@ -13,6 +13,13 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank,
queryKey: [["widget", "stockPrice", "getPriceHistory"]],
queryMatcher: ({ input }, scope) =>
widgetQueryInputMatches(input, {
stock: scope.options.stock,
timeRange: scope.options.timeRange,
timeInterval: scope.options.timeInterval,
}),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -136,7 +136,7 @@ const SystemCharts = ({
showTitle,
}: SystemChartsProps) => {
const showNetwork =
items.length > 0 && items.every((item) => item.network !== null) && options.visibleCharts.includes("network");
items.length > 0 && items[items.length - 1]?.network !== null && options.visibleCharts.includes("network");
const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
const chartHeight = isAdvanced
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getAdvancedColumnWidth } from "./component";
describe("Tracearr advanced layout", () => {
it("subtracts grid padding and the inter-column gap", () => {
expect(getAdvancedColumnWidth(800, true)).toBe(376);
expect(getAdvancedColumnWidth(801, true)).toBe(376.5);
});
it("subtracts only grid padding in a single-column layout", () => {
expect(getAdvancedColumnWidth(799, false)).toBe(767);
});
});
+18 -3
View File
@@ -14,6 +14,10 @@ import { StatsBar } from "./stats-section";
import { StreamsList } from "./streams-section";
import { ViolationsList } from "./violations-section";
const ADVANCED_GRID_BREAKPOINT = 800;
const ADVANCED_GRID_GAP_PX = 16;
const ADVANCED_GRID_PADDING_PX = 16;
export default function TracearrWidget({
options,
integrationIds,
@@ -122,14 +126,20 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
const recentActivity = combined.recentActivity?.data ?? [];
if (displayMode === "advanced") {
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
const columnWidth = getAdvancedColumnWidth(width, isTwoColumn);
return (
<ScrollArea h="100%">
<SimpleGrid cols={width >= 800 ? 2 : 1} spacing="md" p="md">
<SimpleGrid
cols={isTwoColumn ? 2 : 1}
spacing={`${ADVANCED_GRID_GAP_PX}px`}
p={`${ADVANCED_GRID_PADDING_PX}px`}
>
<Stack gap="sm">
{options.showStats && (
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={width / 2} />
<StatsBar stats={combined.stats} summary={combined.streams.summary} width={columnWidth} />
)}
{options.showStreams && <StreamsList streams={streams} width={width / 2} />}
{options.showStreams && <StreamsList streams={streams} width={columnWidth} />}
</Stack>
<Stack gap="sm">
{options.showViolations && <ViolationsList violations={violations} />}
@@ -156,3 +166,8 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
</ScrollArea>
);
}
export const getAdvancedColumnWidth = (width: number, isTwoColumn: boolean) => {
const contentWidth = Math.max(0, width - ADVANCED_GRID_PADDING_PX * 2);
return isTwoColumn ? Math.max(0, (contentWidth - ADVANCED_GRID_GAP_PX) / 2) : contentWidth;
};
+45
View File
@@ -5,6 +5,7 @@ import { Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { UmamiContent } from "./umami-content";
export default function UmamiWidget({
@@ -12,11 +13,55 @@ export default function UmamiWidget({
integrationIds,
width,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"umami">) {
if (!options.websiteId || options.websiteId.trim() === "") {
setWidgetRuntimeQueries(widgetStateRef, []);
return <NoWebsiteConfigured />;
}
const integrationId = integrationIds[0] ?? "";
const commonTopInput = {
integrationId,
websiteId: options.websiteId,
timeFrame: options.timeFrame,
limit: options.topCount,
};
setWidgetRuntimeQueries(widgetStateRef, [
{
path: ["widget", "umami", "getVisitorStats"],
input: {
integrationIds,
websiteId: options.websiteId,
timeFrame: options.timeFrame,
eventName: options.eventName || undefined,
},
},
{
path: ["widget", "umami", "getActiveVisitors"],
input: { integrationId, websiteId: options.websiteId },
},
...(options.viewMode === "events"
? [
{
path: ["widget", "umami", "getMultiEventTimeSeries"],
input: {
integrationId,
websiteId: options.websiteId,
timeFrame: options.timeFrame,
eventNames: [...options.eventNames].toSorted(),
},
},
]
: []),
...(displayMode === "advanced" || options.viewMode === "topPages"
? [{ path: ["widget", "umami", "getTopPages"], input: commonTopInput }]
: []),
...(displayMode === "advanced" || options.viewMode === "topReferrers"
? [{ path: ["widget", "umami", "getTopReferrers"], input: commonTopInput }]
: []),
]);
return (
<UmamiContent
integrationIds={integrationIds}
+9 -1
View File
@@ -1,13 +1,21 @@
import { IconChartBar, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth"] as const;
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
queryKeys: [
[["widget", "umami", "getVisitorStats"]],
[["widget", "umami", "getActiveVisitors"]],
[["widget", "umami", "getMultiEventTimeSeries"]],
[["widget", "umami", "getTopPages"]],
[["widget", "umami", "getTopReferrers"]],
],
queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["umami"],
integrationsRequired: true,
+17 -13
View File
@@ -216,19 +216,23 @@ export function UmamiContent({
{displayMode === "advanced" ? (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%">
<Box mih={260}>{selectedView}</Box>
<SimpleGrid cols={2} spacing="md" mih={260}>
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
{viewMode !== "topPages" && (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
{viewMode !== "topReferrers" && (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
</SimpleGrid>
</SimpleGrid>
) : (
@@ -201,7 +201,8 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
{monitor.name}
</Text>
<Badge size="xs" color="red">
{t("status.down")} · {formatNumber(monitor.uptimePercent24h ?? 0, 1)}%
{t("status.down")} ·{" "}
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
</Badge>
</Group>
))}
+1 -1
View File
@@ -235,7 +235,7 @@ function Forecast({
</Text>
{showDetails && (
<Text fz="xs" c="dimmed">
{getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, true)}
{getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
)}
</Stack>
+12 -1
View File
@@ -2,11 +2,22 @@ import { IconCloud } from "@tabler/icons-react";
import dayjs from "dayjs";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("weather", {
icon: IconCloud,
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.location;
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
return false;
}
return widgetQueryInputMatches(input, {
latitude: location.latitude,
longitude: location.longitude,
});
},
createOptions() {
return optionsBuilder.from(
(factory) => ({