chore: reconcile widget layer after stack rebase

This commit is contained in:
ajnart committed 2026-08-13 20:00:10 +02:00
1 parent 3079eb1767
commit 1f3e7afd5e
175 files changed
+9048 -15628

No files matched your search

+3 -1
View File
@@ -15,9 +15,10 @@
".": "./index.ts",
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
"./catalog": "./src/catalog.ts",
"./definition": "./src/definition.ts",
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
"./errors": "./src/errors/component.tsx",
"./manifest": "./src/manifest.ts",
"./options": "./src/options.ts",
@@ -80,6 +81,7 @@
"@tiptap/extension-underline": "catalog:",
"@tiptap/react": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@trpc/react-query": "catalog:",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"clsx": "catalog:",
@@ -3,6 +3,7 @@
import { useCallback, useMemo, useRef } from "react";
import { Box, Button, Center, Group, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -68,15 +69,16 @@ export default function BeszelSystemStatsWidget({
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
? [
{
path: ["widget", "beszel", "getSystemStats"],
input: {
getQueryKey(
clientApi.widget.beszel.getSystemStats,
{
integrationIds: [selectedSystem.integrationId],
systemId: selectedSystem.systemId,
timePeriod: options.timePeriod,
timePeriod: options.timePeriod as BeszelTimePeriod,
includeDocker,
},
},
"query",
),
]
: [],
);
@@ -20,22 +20,17 @@ import {
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import type { BeszelTooltipLayer } from "./tooltip";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const makeTooltipSet = (layer: BeszelTooltipLayer) => ({
percent: makeTooltipProps(formatPercent, false, layer),
gigabytes: makeTooltipProps(formatGB, true, layer),
rate: makeTooltipProps(formatByteRate, true, layer),
percentTotal: makeTooltipProps(formatPercent, true, layer),
bytesTotal: makeTooltipProps(formatStorageBytes, true, layer),
});
const tooltipSets = {
background: makeTooltipSet("background"),
modal: makeTooltipSet("modal"),
const tooltips = {
percent: makeTooltipProps(formatPercent),
gigabytes: makeTooltipProps(formatGB, true),
rate: makeTooltipProps(formatByteRate, true),
percentTotal: makeTooltipProps(formatPercent, true),
bytesTotal: makeTooltipProps(formatStorageBytes, true),
};
const ChartSkeleton = () => (
@@ -66,7 +61,6 @@ interface BeszelStatsViewProps {
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
tooltipLayer?: BeszelTooltipLayer;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
@@ -78,14 +72,11 @@ export function BeszelStatsView({
visibility,
columns,
onSwitchToHistorical,
tooltipLayer = "background",
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const tooltips = tooltipSets[tooltipLayer];
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
@@ -50,7 +50,6 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
tooltipLayer="modal"
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
-12
View File
@@ -40,15 +40,3 @@ export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] {
return [...new Set(items.map(({ state }) => state))];
}
export function columnWidthsToRecord(columnsWidth: readonly Record<string, string | number>[]): Record<string, number> {
const result: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const width = entry[key];
if (typeof width === "number") result[key] = width;
else if (typeof width === "string" && width.endsWith("px")) result[key] = parseInt(width, 10);
}
return result;
}
@@ -46,7 +46,6 @@ describe("WidgetError", () => {
MantineProvider,
null,
createElement(WidgetError, {
kind: "app",
error: new Error(secret),
resetErrorBoundary: vi.fn(),
}),
+24 -9
View File
@@ -37,7 +37,7 @@ interface FirewallQueryState {
isPending: boolean;
isFetching: boolean;
isError: boolean;
data?: readonly { error?: string }[];
data?: readonly { error?: string; integration?: { id: string } }[];
}
export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[]) =>
@@ -48,6 +48,13 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
(query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
);
export const hasFirewallPartialFailure = (firewallId: string, queries: readonly FirewallQueryState[]) =>
queries.some(
(query) =>
query.isError ||
query.data?.some(({ error, integration }) => integration?.id === firewallId && Boolean(error)) === true,
);
export default function FirewallWidget({
integrationIds,
width,
@@ -101,6 +108,7 @@ export default function FirewallWidget({
const initialSelectedFirewall = firewallIds[0] ?? "";
const activeFirewall = firewallMetadata.has(selectedFirewall) ? selectedFirewall : initialSelectedFirewall;
const displayedFirewallIds = isAdvanced ? firewallIds : activeFirewall ? [activeFirewall] : [];
const firewallHasError = (firewallId: string) => hasFirewallPartialFailure(firewallId, queries);
const dropdownItems = firewallIds.map((firewallId) => ({
label: firewallMetadata.get(firewallId)?.name ?? firewallId,
value: firewallId,
@@ -128,11 +136,20 @@ export default function FirewallWidget({
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
<FirewallVersion
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
<Group gap={4} wrap="nowrap">
{activeFirewall && firewallHasError(activeFirewall) && (
<Tooltip label={t("widget.firewall.error.internalServerError")}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
</Badge>
</Tooltip>
)}
<FirewallVersion
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
</Group>
</Group>
)}
@@ -142,9 +159,7 @@ 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 || interfacesQuery.isError || interfaces?.error,
);
const hasError = firewallHasError(firewallId);
const metadata = firewallMetadata.get(firewallId);
return (
@@ -13,12 +13,19 @@ const mocks = vi.hoisted(() => ({
getAlbums: vi.fn(() => ({ data: [], isPending: false, isError: false })),
}));
vi.mock("@trpc/react-query", () => ({
getQueryKey: (procedure: { path: string[] }, input: unknown) => [procedure.path, { input, type: "query" }],
}));
vi.mock("@homarr/api/client", () => ({
clientApi: {
widget: {
immich: {
getAlbums: { useQuery: mocks.getAlbums },
getServerStats: { useQuery: () => ({ data: undefined }) },
getAlbums: { path: ["widget", "immich", "getAlbums"], useQuery: mocks.getAlbums },
getServerStats: {
path: ["widget", "immich", "getServerStats"],
useQuery: () => ({ data: undefined }),
},
},
},
},
+15 -13
View File
@@ -15,6 +15,7 @@ import {
} from "./manifest";
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
import { widgetCatalogIcons } from "./catalog";
import { getWidgetQueryKeys } from "./definition";
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
@@ -111,11 +112,11 @@ describe("widget manifest promise stability", () => {
const definitions = await loadAllWidgetDefinitions();
const expectedQueryKeys = new Map([
["anchorNote", [["widget", "anchorNotes"]]],
["beszelAlerts", [["widget", "beszel"]]],
["clock", [["widget", "weather"]]],
["mediaMissing", [["widget", "mediaOrganizer"]]],
["mediaRequests-requestList", [["widget", "mediaRequests"]]],
["mediaRequests-requestStats", [["widget", "mediaRequests"]]],
["beszelAlerts", [["widget", "beszel", "getAlerts"]]],
["clock", [["widget", "weather", "atLocation"]]],
["mediaMissing", [["widget", "mediaOrganizer", "getData"]]],
["mediaRequests-requestList", [["widget", "mediaRequests", "getLatestRequests"]]],
["mediaRequests-requestStats", [["widget", "mediaRequests", "getStats"]]],
["smartHome-entityState", [["widget", "smartHome"]]],
] as const);
@@ -133,14 +134,15 @@ describe("widget manifest promise stability", () => {
const definition = definitions.get(kind);
if (definition?.refetchInterval === undefined) continue;
const entry = {
queryKey: definition.queryKey ?? [["widget", kind]],
intervalSeconds: definition.refetchInterval,
} as const;
const serializedQueryKey = JSON.stringify(entry.queryKey);
const existing = expectedByQueryKey.get(serializedQueryKey);
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
expectedByQueryKey.set(serializedQueryKey, entry);
for (const queryKey of getWidgetQueryKeys(definition, kind)) {
const path = queryKey[0];
if (!Array.isArray(path) || (path[0] !== "widget" && path[0] !== "docker")) continue;
const entry = { queryKey, intervalSeconds: definition.refetchInterval } as const;
const serializedQueryKey = JSON.stringify(entry.queryKey);
const existing = expectedByQueryKey.get(serializedQueryKey);
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
expectedByQueryKey.set(serializedQueryKey, entry);
}
}
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
+15 -9
View File
@@ -8,23 +8,29 @@
export const widgetQueryRefetchIntervals = [
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 5 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 5 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
{ queryKey: [["widget", "immich"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft", "getServerStatus"]], intervalSeconds: null },
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
{ queryKey: [["widget", "releases"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed"]], intervalSeconds: null },
{ queryKey: [["widget", "releases", "getLatest"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed", "getFeeds"]], intervalSeconds: null },
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice", "getPriceHistory"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getVisitorStats"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getActiveVisitors"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null },
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
] as const;
+28 -19
View File
@@ -5,38 +5,47 @@ import { createLanguageMapping } from "@homarr/translation";
import { loadAllWidgetDefinitions } from "../manifest";
const doesOptionRenderTranslatedCopy = (option: unknown) => {
if (typeof option !== "object" || option === null || !("type" in option)) return false;
return option.type !== "internal" && option.type !== "customWidgetConfiguration";
};
const widgetDefinitions = await loadAllWidgetDefinitions();
const enTranslation = await createLanguageMapping().en();
describe("Widget properties with description should have matching translations", () => {
for (const [key, definition] of widgetDefinitions) {
Object.entries(definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
Object.entries(definition.createOptions({} as SettingsContextProps))
.filter(([, option]) => doesOptionRenderTranslatedCopy(option))
.forEach(([optionKey, optionValue_]) => {
const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
expect("description" in value).toBe(optionValue.withDescription);
expect("description" in value).toBe(optionValue.withDescription);
});
});
});
}
});
describe("Widget properties should have matching name translations", () => {
for (const [key, definition] of widgetDefinitions) {
Object.keys(definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
Object.entries(definition.createOptions({} as SettingsContextProps))
.filter(([, option]) => doesOptionRenderTranslatedCopy(option))
.forEach(([optionKey]) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
expect("label" in value).toBe(true);
expect("label" in value).toBe(true);
});
});
});
}
});