feat: complete automatic mobile board UX
This commit is contained in:
1 parent
cf83e0d849
commit
7959c20d88
87 files changed
+2850
-570
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user