From f38eb034da6106701571700660829194a0f905e3 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 13 Aug 2026 14:30:15 +0200 Subject: [PATCH] fix: address widget review findings --- .../widgets/archive-team-warrior/index.mdx | 2 +- .../docs/widgets/health-monitoring/index.mdx | 2 +- .../docs/widgets/whats-up-docker/index.mdx | 2 +- .../actions/items/children-action-item.tsx | 4 +- .../actions/items/group-action-item.tsx | 4 +- .../widgets/src/anchor-note/component.tsx | 68 ++++++----- .../src/archive-team-warrior/component.tsx | 16 ++- .../src/archive-team-warrior/layout.spec.ts | 8 ++ packages/widgets/src/bookmarks/component.tsx | 6 +- .../src/calendar/calendar-event-list.tsx | 37 ++++-- .../widgets/src/calendar/calendar-events.ts | 10 +- .../widgets/src/calendar/calendar.spec.ts | 9 ++ .../cluster/accordion-state.spec.ts | 12 +- .../cluster/accordion-state.ts | 9 ++ .../cluster/cluster-health.tsx | 37 +++--- .../src/immich/album-carousel/component.tsx | 56 ++++----- .../network-status/component.tsx | 109 ++++++++++++------ .../network-status/layout.spec.ts | 21 +++- .../network-status/layout.ts | 2 + packages/widgets/src/wud/component.tsx | 8 +- 20 files changed, 280 insertions(+), 142 deletions(-) diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.mdx b/apps/docs/docs/widgets/archive-team-warrior/index.mdx index c8cc97c42..b247b2d53 100644 --- a/apps/docs/docs/widgets/archive-team-warrior/index.mdx +++ b/apps/docs/docs/widgets/archive-team-warrior/index.mdx @@ -14,7 +14,7 @@ import { archiveTeamWarriorWidget } from "."; -Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list, job start times, project and bandwidth-session identifiers, session transfer totals, and exact update time. +Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and optional broadcast message visible. Advanced view adds the complete active-job list, job start times, project and bandwidth-session identifiers, session transfer totals, exact update time, and the full broadcast message whenever one is available. A failed refresh keeps the last snapshot visible with a stale-data warning. ### Supported integration diff --git a/apps/docs/docs/widgets/health-monitoring/index.mdx b/apps/docs/docs/widgets/health-monitoring/index.mdx index d5b685b16..312fd818f 100644 --- a/apps/docs/docs/widgets/health-monitoring/index.mdx +++ b/apps/docs/docs/widgets/health-monitoring/index.mdx @@ -23,7 +23,7 @@ import { glancesIntegration } from "@site/docs/integrations/glances"; This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more. -Advanced view is a complete health workspace: it shows every available CPU, temperature, memory, GPU, filesystem, update, reboot, and system-information section even when compact visibility switches hide some of them on the board. Storage-volume filters, units, and permissions remain in effect. +Advanced view is a complete health workspace: it shows every available CPU, temperature, memory, GPU, filesystem, update, reboot, and system-information section even when compact visibility switches hide some of them on the board. Proxmox clusters likewise show uptime, CPU, memory, and every node, VM, container, and storage section. Storage-volume filters, units, and permissions remain in effect. ### Screenshots diff --git a/apps/docs/docs/widgets/whats-up-docker/index.mdx b/apps/docs/docs/widgets/whats-up-docker/index.mdx index 0b3113ce0..63a9f21f3 100644 --- a/apps/docs/docs/widgets/whats-up-docker/index.mdx +++ b/apps/docs/docs/widgets/whats-up-docker/index.mdx @@ -29,7 +29,7 @@ This widget shows two stats from your WUD instance: the total number of monitore -In advanced view, the widget always shows its title, update ring, monitored-container summary, and the full list of available container updates. The configured horizontal or vertical layout still applies. Visibility options only affect the compact board view. +In advanced view, the widget always shows its title, update ring, monitored-container summary, and the full list of available container updates. The configured horizontal or vertical layout still applies. Visibility options only affect the compact board view. A failed refresh keeps the last WUD snapshot visible with a stale-data warning. ### Adding the widget diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx index f56e46a23..6d3e28a6b 100644 --- a/packages/spotlight/src/components/actions/items/children-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -1,6 +1,6 @@ import { Spotlight } from "@mantine/spotlight"; -import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; +import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common"; import { Link } from "@homarr/ui"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -16,7 +16,7 @@ interface ChildrenActionItemProps { export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => { const interaction = action.useInteraction(childrenOptions.option, query); - const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; + const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined; const renderRoot = interaction.type === "link" && safeHref ? (props: Record) => { diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index bc7cfaf41..5ec50ca81 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -1,8 +1,8 @@ import { Spotlight } from "@mantine/spotlight"; +import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common"; import type { TranslationObject } from "@homarr/translation"; import { Link } from "@homarr/ui"; -import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; import type { SearchGroup } from "../../../lib/group"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -31,7 +31,7 @@ export const SpotlightGroupActionItem = const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record; - const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; + const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined; const renderRoot = interaction.type === "link" && safeHref ? (props: Record) => { diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 9e786afa8..0d7625c91 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -19,6 +19,7 @@ import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client" import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import actionTargetClasses from "../common/action-target.module.css"; import "react-quill-new/dist/quill.snow.css"; @@ -307,9 +308,9 @@ const AnchorNoteWidgetContent = ({ {isEditing ? ( setDraftTitle(event.currentTarget.value)} size="sm" /> ) : ( - options.showTitle && {note.title || t("untitled")} + (isAdvanced || options.showTitle) && {note.title || t("untitled")} )} - {!isEditing && options.showUpdatedAt && ( + {!isEditing && (isAdvanced || options.showUpdatedAt) && ( {t("updatedAt", { date: updatedAtRelative })} @@ -341,37 +342,40 @@ const AnchorNoteWidgetContent = ({ )} - {(isEditing || canEdit) && ( - - {isEditing ? ( - <> - + + + ) : isAdvanced ? ( + - - - ) : isAdvanced ? ( - - ) : ( - - - - - - )} - - )} + ) : ( + + + + + + )} + + )} + {isEditing || isAdvanced ? (
{ const t = useScopedI18n("widget.archiveTeamWarrior"); const locale = useCurrentIntlLocale(); - const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId }); + const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId }); const status = data.status; const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected"); @@ -71,14 +72,17 @@ const ArchiveTeamWarriorWidgetContent = ({ - - {t(`status.${statusKey}`)} - + + + + {t(`status.${statusKey}`)} + + - {options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && ( + {(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && ( - + {status.broadcastMessage} diff --git a/packages/widgets/src/archive-team-warrior/layout.spec.ts b/packages/widgets/src/archive-team-warrior/layout.spec.ts index 213c5bd11..bfe9709a2 100644 --- a/packages/widgets/src/archive-team-warrior/layout.spec.ts +++ b/packages/widgets/src/archive-team-warrior/layout.spec.ts @@ -20,4 +20,12 @@ describe("archive team warrior compact layout", () => { showSecondaryCounts: true, }); }); + + test("keeps all display sections available in advanced mode", () => { + expect(getArchiveCompactLayout(180, 100, true)).toMatchObject({ + showBandwidth: true, + showBroadcast: true, + showSecondaryCounts: true, + }); + }); }); diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 13e211f53..d16e08ee4 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -111,7 +111,7 @@ export default function BookmarksWidget({ return ( {options.title.length > 0 && ( - + <Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}> {options.title} )} @@ -283,14 +283,14 @@ const AdvancedBookmarksLayout = ({ style={{ width: 40, height: 40, flex: "0 0 auto" }} /> - + {app.name} {getBookmarkHostname(app.href)} {app.description && ( - + {app.description} )} diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx index 95187b3e3..f6a1f65dc 100644 --- a/packages/widgets/src/calendar/calendar-event-list.tsx +++ b/packages/widgets/src/calendar/calendar-event-list.tsx @@ -24,8 +24,13 @@ import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-u import { formatLocalizedTime } from "../common/locale"; import classes from "./calendar-event-list.module.css"; +export type CalendarEventWithSource = CalendarEvent & { + source?: { integrationId: string; integrationName: string }; +}; + interface CalendarEventListProps { - events: CalendarEvent[]; + events: CalendarEventWithSource[]; + advanced?: boolean; fillHeight?: boolean; groupByDate?: boolean; locale?: string; @@ -33,6 +38,7 @@ interface CalendarEventListProps { export const CalendarEventList = ({ events, + advanced = false, fillHeight = false, groupByDate = false, locale, @@ -70,26 +76,34 @@ export const CalendarEventList = ({ {dateFormatter.format(groupedEvents[0]?.startDate)} - + ); }) ) : ( - + )} ); }; -const CalendarEventRows = ({ events, locale }: Pick & { locale: string }) => { +const CalendarEventRows = ({ + events, + locale, + advanced, +}: Pick & { locale: string }) => { const { colorScheme } = useMantineColorScheme(); const t = useI18n(); return ( <> {events.map((event, eventIndex) => ( - + {event.image !== null && ( {event.subTitle !== null && ( - + {event.subTitle} )} - + {event.title} + {advanced && event.source && ( + + {event.source.integrationName} + + )} {event.metadata?.type === "radarr" && ( @@ -159,14 +178,14 @@ const CalendarEventRows = ({ events, locale }: Pick - + {event.location} )} {!isNullOrWhitespace(event.description) && ( - + {event.description} )} diff --git a/packages/widgets/src/calendar/calendar-events.ts b/packages/widgets/src/calendar/calendar-events.ts index 19ddd3bd5..60b150b0c 100644 --- a/packages/widgets/src/calendar/calendar-events.ts +++ b/packages/widgets/src/calendar/calendar-events.ts @@ -10,11 +10,11 @@ export const moveCalendarMonth = (month: Date, amount: number): Date => const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) => event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType); -export const getCalendarAgendaEvents = ( - events: CalendarEvent[], +export const getCalendarAgendaEvents = ( + events: TEvent[], viewedMonth: Date, releaseTypes: readonly string[], -): CalendarEvent[] => { +): TEvent[] => { const monthStart = dayjs(viewedMonth).startOf("month"); const monthEnd = monthStart.add(1, "month"); @@ -31,8 +31,8 @@ export const getCalendarAgendaEvents = ( .toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()); }; -export const groupEventsByDate = (events: CalendarEvent[]): Map => { - const result = new Map(); +export const groupEventsByDate = (events: TEvent[]): Map => { + const result = new Map(); for (const event of events) { const key = toCalendarDateKey(event.startDate); const entries = result.get(key) ?? []; diff --git a/packages/widgets/src/calendar/calendar.spec.ts b/packages/widgets/src/calendar/calendar.spec.ts index d42d6f151..946ff8508 100644 --- a/packages/widgets/src/calendar/calendar.spec.ts +++ b/packages/widgets/src/calendar/calendar.spec.ts @@ -90,6 +90,15 @@ describe("getCalendarAgendaEvents", () => { expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]); }); + + test("preserves source ownership through advanced filtering", () => { + const event = { + ...createEvent(new Date(2025, 0, 5)), + source: { integrationId: "calendar-1", integrationName: "Family calendar" }, + }; + + expect(getCalendarAgendaEvents([event], new Date(2025, 0, 1), [])).toEqual([event]); + }); }); describe("moveCalendarMonth", () => { diff --git a/packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts b/packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts index 2004a4b01..4a55b91f3 100644 --- a/packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts +++ b/packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from "vitest"; -import { getClusterAccordionDefault } from "./accordion-state"; +import { clusterSections, getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state"; + +describe("getClusterVisibleSections", () => { + test("keeps the configured sections in compact mode", () => { + expect(getClusterVisibleSections("compact", ["node", "storage"])).toEqual(["node", "storage"]); + }); + + test("shows every cluster section in advanced mode", () => { + expect(getClusterVisibleSections("advanced", ["node"])).toEqual(clusterSections); + }); +}); describe("getClusterAccordionDefault", () => { test("opens only the first visible section in compact mode", () => { diff --git a/packages/widgets/src/health-monitoring/cluster/accordion-state.ts b/packages/widgets/src/health-monitoring/cluster/accordion-state.ts index 425e4431b..f7a292753 100644 --- a/packages/widgets/src/health-monitoring/cluster/accordion-state.ts +++ b/packages/widgets/src/health-monitoring/cluster/accordion-state.ts @@ -1,3 +1,12 @@ +export const clusterSections = ["node", "qemu", "lxc", "storage"] as const; + +type ClusterSection = (typeof clusterSections)[number]; + +export const getClusterVisibleSections = ( + displayMode: "compact" | "advanced" | undefined, + visibleSections: readonly ClusterSection[], +): ClusterSection[] => (displayMode === "advanced" ? [...clusterSections] : [...visibleSections]); + export const getClusterAccordionDefault = ( displayMode: "compact" | "advanced" | undefined, visibleSections: readonly string[], diff --git a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx index 08d053e60..e0fb16d81 100644 --- a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx +++ b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core"; +import { Accordion, Box, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core"; import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -8,9 +8,10 @@ import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; import { getUsableWidgetQueryData } from "../../common/query-state"; +import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator"; import type { WidgetComponentProps } from "../../definition"; import { formatUptime } from "../system-health"; -import { getClusterAccordionDefault } from "./accordion-state"; +import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state"; import { ResourceAccordionItem } from "./resource-accordion-item"; import { ResourceTable } from "./resource-table"; @@ -39,11 +40,11 @@ export const ClusterHealthMonitoring = ({ displayMode, }: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => { const t = useI18n(); - const healthData = getUsableWidgetQueryData( - clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ integrationId }), - ); - const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`; - const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections); + const healthQuery = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ integrationId }); + const healthData = getUsableWidgetQueryData(healthQuery); + const visibleSections = getClusterVisibleSections(displayMode, options.visibleClusterSections); + const accordionScope = `${displayMode}:${visibleSections.join(",")}`; + const accordionDefault = getClusterAccordionDefault(displayMode, visibleSections); const [accordionValues, setAccordionValues] = useState>({}); const accordionValue = accordionValues[accordionScope] ?? accordionDefault; @@ -65,10 +66,14 @@ export const ClusterHealthMonitoring = ({ const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0; const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0; + const isAdvanced = displayMode === "advanced"; const isTiny = displayMode !== "advanced" && width < 256; return ( - - {options.showUptime && !isTiny && ( + + + + + {(isAdvanced || options.showUptime) && !isTiny && ( {formatUptime(uptime, t)} @@ -78,15 +83,15 @@ export const ClusterHealthMonitoring = ({ - {options.visibleClusterSections.length >= 1 && ( + {visibleSections.length >= 1 && ( setAccordionValues((current) => ({ ...current, [accordionScope]: value }))} > - {options.visibleClusterSections.includes("node") && ( + {visibleSections.includes("node") && ( )} - {options.visibleClusterSections.includes("qemu") && ( + {visibleSections.includes("qemu") && ( )} - {options.visibleClusterSections.includes("lxc") && ( + {visibleSections.includes("lxc") && ( )} - {options.visibleClusterSections.includes("storage") && ( + {visibleSections.includes("storage") && ( ) { const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [paused, setPaused] = useState(false); + const t = useI18n(); - const album = getUsableWidgetQueryData( - clientApi.widget.immich.getAlbum.useQuery( - { - integrationId: integrationIds[0] ?? "", - albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined, - }, - { enabled: integrationIds.length > 0 }, - ), + const albumQuery = clientApi.widget.immich.getAlbum.useQuery( + { + integrationId: integrationIds[0] ?? "", + albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined, + }, + { enabled: integrationIds.length > 0 }, ); + const album = getUsableWidgetQueryData(albumQuery); const photoAssets = useMemo(() => { const assets = album?.assets.filter((asset) => asset.type === "IMAGE") ?? []; @@ -68,26 +69,27 @@ export default function ImmichAlbumCarouselWidget({ if (!album) return ; - if (album.assets.length === 0) { - return ; - } - - if (photoAssets.length === 0) { - return ; - } - return ( - + + + + + {album.assets.length === 0 || photoAssets.length === 0 ? ( + + ) : ( + + )} + ); } diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index 4c612b07a..99de34228 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -1,25 +1,19 @@ "use client"; import { useMemo } from "react"; -import { Box, Card, Center, SimpleGrid, Text } from "@mantine/core"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import objectSupport from "dayjs/plugin/objectSupport"; -import relativeTime from "dayjs/plugin/relativeTime"; +import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../../common/query-state"; +import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator"; import { getNetworkControllerStatusLayout } from "./layout"; import { WifiVariant } from "./variants/wifi-variant"; import { WiredVariant } from "./variants/wired-variant"; -dayjs.extend(objectSupport); -dayjs.extend(relativeTime); -dayjs.extend(duration); - export default function NetworkControllerNetworkStatusWidget({ options, integrationIds, @@ -27,40 +21,89 @@ export default function NetworkControllerNetworkStatusWidget({ width, height, }: WidgetComponentProps<"networkControllerStatus">) { - const { - data: summaries, - error, - isPending, - } = clientApi.widget.networkController.summary.useQuery({ + const summaryQuery = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); + const results = getUsableWidgetQueryData(summaryQuery) ?? []; + const summaries = useMemo( + () => + results.filter( + (result): result is typeof result & { summary: NonNullable } => result.summary !== null, + ), + [results], + ); const t = useScopedI18n("widget.networkControllerStatus"); const tCommon = useScopedI18n("common"); + const locale = useCurrentIntlLocale(); - const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => (summary ? [summary] : [])), [summaries]); - const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0); - const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0); - const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0); - const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0); + const countWifiGuests = summaries.reduce((sum, { summary }) => sum + summary.wifi.guests, 0); + const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0); + const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0); + const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0); const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content }); + const isAdvanced = displayMode === "advanced"; + const queryIndicators = ( + + + + + ); - if (error && summaries === undefined) throw error; - - if (isPending || data.length === 0) { + if (summaryQuery.isPending || summaries.length === 0) { return ( -
- - {isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")} - -
+ + {queryIndicators} +
+ + {summaryQuery.isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")} + +
+
+ ); + } + + if (isAdvanced) { + return ( + + {queryIndicators} + 1 ? layout.sourceColumns : 1} spacing="sm"> + {summaries.map(({ integration, summary, updatedAt }) => ( + + + + + + {integration.name} + + {updatedAt && ( + + {t("advanced.updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })} + + )} + + + {integration.kind} + + + + + + + + + + + + + ))} + + ); } return ( - - - - + + {queryIndicators} {layout.showWifi && ( @@ -83,6 +126,6 @@ export default function NetworkControllerNetworkStatusWidget({ )} - +
); } diff --git a/packages/widgets/src/network-controller/network-status/layout.spec.ts b/packages/widgets/src/network-controller/network-status/layout.spec.ts index 8e6376f8b..edc58f04d 100644 --- a/packages/widgets/src/network-controller/network-status/layout.spec.ts +++ b/packages/widgets/src/network-controller/network-status/layout.spec.ts @@ -22,12 +22,26 @@ describe("network controller status layout", () => { { name: "advanced below wide boundary", input: { width: 559, height: 320, displayMode: "advanced" as const, content: "wifi" as const }, - expected: { padding: "md", columns: 1, showWifi: true, showWired: true, horizontalStats: false }, + expected: { + padding: "md", + columns: 1, + sourceColumns: 1, + showWifi: true, + showWired: true, + horizontalStats: false, + }, }, { name: "wide advanced", input: { width: 560, height: 320, displayMode: "advanced" as const, content: "wired" as const }, - expected: { padding: "md", columns: 2, showWifi: true, showWired: true, horizontalStats: false }, + expected: { + padding: "md", + columns: 2, + sourceColumns: 1, + showWifi: true, + showWired: true, + horizontalStats: false, + }, }, { name: "advanced at 200 percent zoom", @@ -45,5 +59,8 @@ describe("network controller status layout", () => { expect( getNetworkControllerStatusLayout({ width: 199, height: 119, displayMode: "compact", content: "wifi" }), ).toMatchObject({ padding: "xs", horizontalStats: false }); + expect( + getNetworkControllerStatusLayout({ width: 960, height: 320, displayMode: "advanced", content: "wifi" }), + ).toMatchObject({ sourceColumns: 2 }); }); }); diff --git a/packages/widgets/src/network-controller/network-status/layout.ts b/packages/widgets/src/network-controller/network-status/layout.ts index 549ec1f62..9b869a511 100644 --- a/packages/widgets/src/network-controller/network-status/layout.ts +++ b/packages/widgets/src/network-controller/network-status/layout.ts @@ -13,6 +13,7 @@ interface NetworkControllerStatusLayoutInput { export interface NetworkControllerStatusLayout { padding: "xs" | "sm" | "md"; columns: 1 | 2; + sourceColumns: 1 | 2; showWifi: boolean; showWired: boolean; cardPadding: 0 | "md"; @@ -32,6 +33,7 @@ export const getNetworkControllerStatusLayout = ({ return { padding: isAdvanced ? "md" : height < 120 ? "xs" : "sm", columns: isAdvanced && width >= 560 ? 2 : 1, + sourceColumns: isAdvanced && width >= 960 ? 2 : 1, showWifi: isAdvanced || content === "wifi", showWired: isAdvanced || content === "wired", cardPadding: isAdvanced ? "md" : 0, diff --git a/packages/widgets/src/wud/component.tsx b/packages/widgets/src/wud/component.tsx index a2db93cb3..a1f74b097 100644 --- a/packages/widgets/src/wud/component.tsx +++ b/packages/widgets/src/wud/component.tsx @@ -12,6 +12,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; @@ -40,7 +41,7 @@ const WudWidgetContent = ({ isAdvanced: boolean; }) => { const t = useScopedI18n("widget.wud"); - const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId }); + const [data, statsQuery] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId }); const board = useRequiredBoard(); const isTiny = !isAdvanced && width < 256; @@ -114,6 +115,11 @@ const WudWidgetContent = ({ return ( + {Boolean(statsQuery.error) && ( + + + + )} {showTitle && !isTiny && (