chore: reconcile widget layer after stack rebase

This commit is contained in:
ajnart committed 2026-08-14 11:07:09 +02:00
1 parent 09b82ba171
commit bd4bbcaca2
23 files changed
+101 -814

No files matched your search

@@ -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;