fix: address widget review feedback

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent b1b17ad69f
commit a38e731209
79 files changed
+2138 -244

No files matched your search

@@ -135,7 +135,7 @@
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
animation-duration: 1ms;
animation: none;
}
}
@@ -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";
@@ -14,6 +15,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(),
@@ -38,14 +47,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 (
-4
View File
@@ -2800,10 +2800,6 @@
"running": "Running automation",
"success": "Automation completed"
},
"status": {
"running": "Running automation",
"success": "Automation completed"
},
"error": {
"executeFailed": "Unable to run automation"
}
+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;
}
@@ -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({
+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);
});
});
+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
@@ -67,12 +67,15 @@ import * as customApi from "./custom-api";
import * as wud from "./wud";
export type {
NormalizedWidgetQuery,
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
WidgetQueryMatcher,
WidgetQueryMatcherScope,
} from "./definition";
export { getWidgetQueryKeys } from "./definition";
export { getWidgetQueryKeys, getWidgetRuntimeQueries } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -33,7 +33,9 @@ export default function IndexerManagerWidget({
const hasSmallWidth = !isAdvanced && width < 256;
const hasSmallHeight = !isAdvanced && height < 256;
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
const unavailableCount = allIndexers.filter((indexer) => !indexer.status || !indexer.enabled).length;
const unavailableCount = allIndexers.filter(
(indexer) => indexer.status === false || indexer.enabled === false,
).length;
useEffect(() => {
if (!widgetStateRef) return;
@@ -15,9 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa
disabled:
context.isEditMode ||
!context.canInteractWithSelectedIntegrations ||
typeof widgetStateRef.current?.testAllIndexers !== "function",
typeof widgetStateRef?.current?.testAllIndexers !== "function",
onClick: () => {
const action = widgetStateRef.current?.testAllIndexers;
const action = widgetStateRef?.current?.testAllIndexers;
if (typeof action === "function") action();
},
},
+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,
});
};
+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) => ({