fix: address widget review feedback
This commit is contained in:
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);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
.suppressTouchCallout {
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
+37
-7
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -2800,10 +2800,6 @@
|
||||
"running": "Running automation",
|
||||
"success": "Automation completed"
|
||||
},
|
||||
"status": {
|
||||
"running": "Running automation",
|
||||
"success": "Automation completed"
|
||||
},
|
||||
"error": {
|
||||
"executeFailed": "Unable to run automation"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {};
|
||||
},
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in new issue
Block a user