feat: complete automatic mobile board UX

This commit is contained in:
ajnart committed 2026-07-28 19:39:13 +02:00
1 parent cf83e0d849
commit 7959c20d88
87 files changed
+2850 -570

No files matched your search

+19 -1
View File
@@ -7,13 +7,16 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
export default function BookmarksWidget({ options, itemId, displayMode }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const t = useI18n();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(data) {
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
@@ -41,6 +44,21 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
[data],
);
if (displayMode === "mobileSummary") {
return (
<WidgetMobileSummary
value={data.length}
label={options.title || t("widget.bookmarks.name")}
description={
data
.map((app) => app.name)
.slice(0, 2)
.join(" · ") || t("board.mobile.details")
}
/>
);
}
return (
<Stack h="100%" gap="sm" p="sm">
{options.title.length > 0 && (
+7 -2
View File
@@ -10,8 +10,13 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
mobile: { supportsDetailView: true },
queryKey: [["app"]],
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["app", "byIds"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
+10 -4
View File
@@ -14,6 +14,7 @@ import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
@@ -44,8 +45,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !data) throw error;
if (isPending) return <WidgetMobileLoading />;
throwOnInitialQueryError(error, data !== undefined);
return (
<CalendarBase
@@ -55,7 +56,11 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
setMonth={setMonth}
options={options}
displayMode={displayMode}
isStale={Boolean(error) || (data?.length ?? 0) < integrationIds.length}
isStale={hasWidgetDataWarning({
error,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: data?.length,
})}
/>
);
};
@@ -125,7 +130,7 @@ const CalendarBase = ({
);
}
return (
const calendar = (
<Calendar
defaultDate={new Date()}
onPreviousMonth={(month) => setMonth(new Date(month))}
@@ -205,6 +210,7 @@ const CalendarBase = ({
}}
/>
);
return <WidgetDataState hasWarning={isStale}>{calendar}</WidgetDataState>;
};
/**
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { hasWidgetDataWarning, throwOnInitialQueryError } from "./query-state";
describe("widget query state", () => {
test.each([
{ error: new Error("refresh failed") },
{ failedIntegrationCount: 1 },
{ staleIntegrationCount: 1 },
{ expectedIntegrationCount: 2, receivedIntegrationCount: 1 },
])("warns when data provenance is incomplete or stale", (provenance) => {
expect(hasWidgetDataWarning(provenance)).toBe(true);
});
test("does not warn for complete current data", () => {
expect(
hasWidgetDataWarning({
failedIntegrationCount: 0,
staleIntegrationCount: 0,
expectedIntegrationCount: 2,
receivedIntegrationCount: 2,
}),
).toBe(false);
});
test("throws an initial query error", () => {
const error = new Error("initial request failed");
expect(() => throwOnInitialQueryError(error, false)).toThrow(error);
});
test("keeps cached data visible after a refresh error", () => {
expect(() => throwOnInitialQueryError(new Error("refresh failed"), true)).not.toThrow();
});
});
@@ -0,0 +1,73 @@
"use client";
import type { ReactNode } from "react";
import { Box, Center, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
interface WidgetQueryProvenance {
error?: unknown;
failedIntegrationCount?: number;
staleIntegrationCount?: number;
expectedIntegrationCount?: number;
receivedIntegrationCount?: number;
}
export const hasWidgetDataWarning = ({
error,
failedIntegrationCount = 0,
staleIntegrationCount = 0,
expectedIntegrationCount,
receivedIntegrationCount,
}: WidgetQueryProvenance) =>
Boolean(error) ||
failedIntegrationCount > 0 ||
staleIntegrationCount > 0 ||
(expectedIntegrationCount !== undefined &&
receivedIntegrationCount !== undefined &&
receivedIntegrationCount < expectedIntegrationCount);
export const throwOnInitialQueryError = (error: unknown, hasData: boolean) => {
if (error && !hasData) throw error;
};
interface WidgetDataStateProps {
children: ReactNode;
hasWarning: boolean;
}
export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps) {
const t = useI18n();
const label = t("board.mobile.dataWarning");
return (
<Box pos="relative" h="100%" w="100%">
{hasWarning && (
<Tooltip label={label} position="left" withArrow>
<Center
component="output"
aria-label={label}
pos="absolute"
bottom={4}
w={28}
h={28}
style={{
insetInlineStart: 4,
zIndex: 4,
borderRadius: "var(--mantine-radius-sm)",
background: "light-dark(var(--mantine-color-yellow-1), var(--mantine-color-dark-6))",
}}
>
<IconAlertTriangle
size={16}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
aria-hidden
/>
</Center>
</Tooltip>
)}
{children}
</Box>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
mobile: { supportsDetailView: true },
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
queryKey: [["widget", "customApi"]],
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -9,7 +9,7 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
mobile: { supportsDetailView: true },
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
@@ -9,6 +9,7 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd,
mobile: { unmountWhenOffscreen: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
+23 -7
View File
@@ -50,6 +50,7 @@ import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/i
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(relativeTime);
@@ -129,7 +130,7 @@ const DownloadClientsMobileSummary = ({
});
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
throwOnInitialQueryError(error, data !== undefined);
const currentItems = data ?? [];
const selectedClients = currentItems.filter(({ integration }) => integrationIds.includes(integration.id));
@@ -148,7 +149,11 @@ const DownloadClientsMobileSummary = ({
value={visibleItemCount}
label={t("name")}
description={`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`}
isStale={Boolean(error) || currentItems.length < integrationIds.length}
isStale={hasWidgetDataWarning({
error,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: currentItems.length,
})}
/>
);
};
@@ -163,10 +168,11 @@ const DownloadClientsTable = ({
integrationIds.includes(id) ? [id] : [],
);
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
const { data: queryData, error: queryError } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
});
const currentItems = queryData ?? [];
const t = useScopedI18n("widget.downloads");
const tCommon = useScopedI18n("common");
@@ -694,15 +700,24 @@ const DownloadClientsTable = ({
{ up: 0, down: 0 },
);
throwOnInitialQueryError(queryError, queryData !== undefined);
const hasWarning = hasWidgetDataWarning({
error: queryError,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: currentItems.length,
});
if (options.columns.length === 0)
return (
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
<WidgetDataState hasWarning={hasWarning}>
<Center h="100%">
<Text>{t("errors.noColumns")}</Text>
</Center>
</WidgetDataState>
);
//The actual widget
return (
const content = (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
<Group
@@ -728,6 +743,7 @@ const DownloadClientsTable = ({
<ItemInfoModal items={data} currentIndex={clickedIndex} opened={opened} onClose={close} />
</Stack>
);
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
};
interface ItemInfoModalProps {
+1 -1
View File
@@ -7,7 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
mobile: { supportsDetailView: true },
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
@@ -16,6 +16,7 @@ import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
export default function MediaReleasesWidget({
@@ -23,29 +24,33 @@ export default function MediaReleasesWidget({
integrationIds,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleasesWithProvenance.useQuery({
integrationIds,
});
const t = useI18n("widget.mediaReleases");
if (displayMode === "mobileSummary") {
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
if (isPending) return <WidgetMobileLoading />;
throwOnInitialQueryError(error, data !== undefined);
const releases = data ?? [];
const releases = data?.items ?? [];
const hasWarning = hasWidgetDataWarning({
error,
failedIntegrationCount: data?.failedIntegrationCount,
staleIntegrationCount: data?.staleIntegrationCount,
});
if (displayMode === "mobileSummary") {
return (
<WidgetMobileSummary
value={releases.length}
label={t("name")}
description={releases[0]?.title}
isStale={Boolean(error)}
isStale={hasWarning}
/>
);
}
const releases = data ?? [];
return (
const content = (
<Stack p="xs" gap="sm">
{releases.map((item, index) => (
<Fragment key={item.id}>
@@ -55,10 +60,11 @@ export default function MediaReleasesWidget({
))}
</Stack>
);
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
}
interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleasesWithProvenance"]["items"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
}
@@ -27,6 +27,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
@@ -39,11 +40,7 @@ export default function MediaServerWidget({
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList");
const {
data: mediaRequests,
error,
isPending,
} = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
const { data, error, isPending } = clientApi.widget.mediaRequests.getLatestRequestsWithProvenance.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
@@ -52,23 +49,38 @@ export default function MediaServerWidget({
recentDays: options.recentDays,
});
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !mediaRequests) throw error;
if (!mediaRequests) return <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
if (isPending) return <WidgetMobileLoading />;
throwOnInitialQueryError(error, data !== undefined);
if (!data) return <WidgetEmptyState />;
const hasWarning = hasWidgetDataWarning({
error,
failedIntegrationCount: data.failedIntegrationCount,
staleIntegrationCount: data.staleIntegrationCount,
});
if (data.items.length === 0 && !hasWarning) throw new NoIntegrationDataError();
if (displayMode === "mobileSummary") {
return (
<WidgetMobileSummary
value={mediaRequests.length}
value={data.items.length}
label={t("name")}
description={mediaRequests[0]?.name}
isStale={Boolean(error)}
description={data.items[0]?.name}
isStale={hasWarning}
/>
);
}
return (
if (data.items.length === 0) {
return (
<WidgetDataState hasWarning={hasWarning}>
<WidgetEmptyState />
</WidgetDataState>
);
}
const content = (
<Box className={classes.searchRoot}>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
<ScrollArea
@@ -77,7 +89,7 @@ export default function MediaServerWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
{mediaRequests.map((mediaRequest) => (
{data.items.map((mediaRequest) => (
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
@@ -89,6 +101,7 @@ export default function MediaServerWidget({
</ScrollArea>
</Box>
);
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
}
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
@@ -110,7 +123,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
};
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequestsWithProvenance"]["items"][number];
isTiny: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
@@ -22,6 +22,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
@@ -34,16 +36,30 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
width,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({
const {
data: requestStats,
error,
isPending,
} = clientApi.widget.mediaRequests.getStatsWithProvenance.useQuery({
integrationIds,
});
const board = useRequiredBoard();
if (isPending) return <WidgetMobileLoading />;
throwOnInitialQueryError(error, requestStats !== undefined);
if (!requestStats) return <WidgetEmptyState />;
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
const hasWarning = hasWidgetDataWarning({
error,
failedIntegrationCount: requestStats.failedIntegrationCount,
staleIntegrationCount: requestStats.staleIntegrationCount,
});
if (requestStats.users.length === 0 && requestStats.stats.length === 0 && !hasWarning) {
throw new NoIntegrationDataError();
}
const data = [
{
@@ -88,9 +104,23 @@ export default function MediaServerWidget({
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
if (displayMode === "mobileSummary") {
const total = data.find((stat) => stat.name === "total")?.number ?? 0;
const pending = data.find((stat) => stat.name === "pending")?.number ?? 0;
return (
<WidgetMobileSummary
value={total}
label={t("titles.stats.total")}
description={`${pending} ${t("titles.stats.pending")}`}
isStale={hasWarning}
/>
);
}
const isTiny = width < 256;
return (
const content = (
<Box className={searchClasses.searchRoot}>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
<Stack
@@ -173,6 +203,7 @@ export default function MediaServerWidget({
</Stack>
</Box>
);
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
}
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
@@ -7,7 +7,12 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
mobile: { supportsDetailView: true },
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
},
queryKey: [["widget", "mediaRequests"]],
createOptions() {
return {};
@@ -25,6 +25,7 @@ import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
export default function MediaServerWidget({ displayMode, ...props }: WidgetComponentProps<"mediaServer">) {
@@ -43,10 +44,9 @@ const MediaServerMobileSummary = ({
});
if (isPending) return <WidgetMobileLoading />;
if (error && !data) throw error;
throwOnInitialQueryError(error, data !== undefined);
const currentStreams = data ?? [];
const hasPartialFailure = currentStreams.length < integrationIds.length;
const playingSessions = currentStreams
.flatMap((stream) => stream.sessions)
.filter((session) => session.currentlyPlaying !== null);
@@ -56,7 +56,11 @@ const MediaServerMobileSummary = ({
value={playingSessions.length}
label={t("items.currentlyPlaying")}
description={playingSessions[0]?.currentlyPlaying?.name}
isStale={Boolean(error) || hasPartialFailure}
isStale={hasWidgetDataWarning({
error,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: currentStreams.length,
})}
/>
);
};
@@ -65,10 +69,11 @@ const MediaServerTable = ({
options,
integrationIds,
}: Pick<WidgetComponentProps<"mediaServer">, "options" | "integrationIds">) => {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
const { data: queryData, error } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
});
const currentStreams = queryData ?? [];
const t = useScopedI18n("widget.mediaServer");
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
@@ -292,8 +297,14 @@ const MediaServerTable = ({
integrationName: session?.integrationName,
};
});
throwOnInitialQueryError(error, queryData !== undefined);
const hasWarning = hasWidgetDataWarning({
error,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: currentStreams.length,
});
return (
const content = (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
<Group
@@ -317,6 +328,7 @@ const MediaServerTable = ({
</Group>
</Stack>
);
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
};
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
@@ -10,6 +10,7 @@ import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
export default function NotificationsWidget({
@@ -38,17 +39,20 @@ export default function NotificationsWidget({
[notificationIntegrations],
);
if (displayMode === "mobileSummary") {
if (isPending) return <WidgetMobileLoading />;
if (error && !notificationIntegrations) throw error;
if (isPending) return <WidgetMobileLoading />;
throwOnInitialQueryError(error, notificationIntegrations !== undefined);
const hasWarning = hasWidgetDataWarning({
error,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: notificationIntegrations?.length,
});
const notifications = notificationIntegrations ?? [];
if (displayMode === "mobileSummary") {
const latestNotification = sortedNotifications[0];
const isStale = Boolean(error) || notifications.length < integrationIds.length;
const summary = latestNotification ? (
<NotificationMobileSummary notification={latestNotification} isStale={isStale} />
<NotificationMobileSummary notification={latestNotification} isStale={hasWarning} />
) : (
<WidgetMobileSummary value={0} label={t("name")} description={t("noItems")} isStale={isStale} />
<WidgetMobileSummary value={0} label={t("name")} description={t("noItems")} isStale={hasWarning} />
);
if (!latestNotification?.href) return summary;
@@ -68,7 +72,7 @@ export default function NotificationsWidget({
);
}
return (
const content = (
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
<Stack w={"100%"} gap="sm">
{sortedNotifications.length > 0 ? (
@@ -117,6 +121,7 @@ export default function NotificationsWidget({
</Stack>
</ScrollArea>
);
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
}
const NotificationMobileSummary = ({
+1 -1
View File
@@ -5,7 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat,
mobile: { supportsDetailView: true },
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
queryKey: [["widget", "tracearr"]],
refetchInterval: 5,
createOptions() {
+1 -1
View File
@@ -8,7 +8,7 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
mobile: { supportsDetailView: true },
mobile: { supportsDetailView: true, unmountWhenOffscreen: true },
queryKey: [["widget", "umami"]],
refetchInterval: null,
supportedIntegrations: ["umami"],
+13 -7
View File
@@ -11,6 +11,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
@@ -117,11 +118,15 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti
} = clientApi.widget.uptimeKuma.getDashboard.useQuery({
integrationIds,
});
const hasPartialFailure = (dashboardData?.length ?? 0) < integrationIds.length;
if (displayMode === "mobileSummary" && isPending) return <WidgetMobileLoading />;
if (displayMode === "mobileSummary" && error && !dashboardData) throw error;
if (isPending) return <WidgetMobileLoading />;
throwOnInitialQueryError(error, dashboardData !== undefined);
if (!dashboardData) return <WidgetEmptyState />;
const hasWarning = hasWidgetDataWarning({
error,
expectedIntegrationCount: integrationIds.length,
receivedIntegrationCount: dashboardData.length,
});
const combined = dashboardData.reduce<UptimeKumaDashboardData>(
(acc, item) => ({
@@ -163,13 +168,13 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti
value={formatNumber(combined[primaryStat], 0)}
label={t(`stats.${primaryStat}`)}
description={supportingDescription}
isStale={Boolean(error) || hasPartialFailure}
isStale={hasWarning}
/>
);
}
if (!hasContent) {
return <WidgetMobileSummary value="—" label={t("name")} isStale={Boolean(error) || hasPartialFailure} />;
return <WidgetMobileSummary value="—" label={t("name")} isStale={hasWarning} />;
}
return (
@@ -177,7 +182,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti
value={`${formatNumber(uptimeValue, 1)}%`}
label={t("averageUptime")}
description={supportingDescription}
isStale={Boolean(error) || hasPartialFailure}
isStale={hasWarning}
/>
);
}
@@ -186,7 +191,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing];
return (
const content = (
<div className={classes.root}>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
@@ -241,6 +246,7 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti
)}
</div>
);
return <WidgetDataState hasWarning={hasWarning}>{content}</WidgetDataState>;
}
function clampPercent(value: number): number {