fix: address widget review findings

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:09 +02:00
1 parent a3a89db727
commit f38eb034da
20 files changed
+280 -142

No files matched your search

@@ -14,7 +14,7 @@ import { archiveTeamWarriorWidget } from ".";
<WidgetHeader widget={archiveTeamWarriorWidget} categories={["Archiving", "Monitoring"]} />
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
@@ -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
@@ -29,7 +29,7 @@ This widget shows two stats from your WUD instance: the total number of monitore
<WidgetInteractionGuide />
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
@@ -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<string, unknown>) => {
@@ -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 = <TOption extends Record<string, unknown>
const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record<string, unknown>;
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<string, unknown>) => {
+36 -32
View File
@@ -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 ? (
<TextInput value={draftTitle} onChange={(event) => setDraftTitle(event.currentTarget.value)} size="sm" />
) : (
options.showTitle && <Text fw={600}>{note.title || t("untitled")}</Text>
(isAdvanced || options.showTitle) && <Text fw={600}>{note.title || t("untitled")}</Text>
)}
{!isEditing && options.showUpdatedAt && (
{!isEditing && (isAdvanced || options.showUpdatedAt) && (
<Text size="xs" c="dimmed">
{t("updatedAt", { date: updatedAtRelative })}
</Text>
@@ -341,37 +342,40 @@ const AnchorNoteWidgetContent = ({
</Text>
)}
</Stack>
{(isEditing || canEdit) && (
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
<Group gap="xs" wrap="nowrap">
<WidgetQueryErrorIndicator error={noteQuery.error} label={t("name")} />
{(isEditing || canEdit) && (
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
</Button>
</>
) : isAdvanced ? (
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
{t("edit")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
</Button>
</>
) : isAdvanced ? (
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
{t("edit")}
</Button>
) : (
<Tooltip label={t("edit")}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t("edit")}
size="md"
variant="light"
onClick={handleEdit}
disabled={isUpdating}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
) : (
<Tooltip label={t("edit")}>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t("edit")}
size="md"
variant="light"
onClick={handleEdit}
disabled={isUpdating}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</Group>
</Group>
{isEditing || isAdvanced ? (
<div
@@ -8,6 +8,7 @@ import { getIconUrl } from "@homarr/definitions";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
export default function ArchiveTeamWarriorWidget({
integrationIds,
@@ -48,7 +49,7 @@ const ArchiveTeamWarriorWidgetContent = ({
}) => {
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 = ({
</Text>
</Group>
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
<Group gap={4} wrap="nowrap">
<WidgetQueryErrorIndicator error={statusQuery.error} label={t("name")} />
<Badge size="sm" color={getStatusColor(statusKey)}>
{t(`status.${statusKey}`)}
</Badge>
</Group>
</Group>
{options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && (
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
<Card withBorder p="xs">
<Text size="xs" lineClamp={3}>
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
{status.broadcastMessage}
</Text>
</Card>
@@ -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,
});
});
});
+3 -3
View File
@@ -111,7 +111,7 @@ export default function BookmarksWidget({
return (
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
{options.title.length > 0 && (
<Title order={4} px="0.25rem" lineClamp={1}>
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
{options.title}
</Title>
)}
@@ -283,14 +283,14 @@ const AdvancedBookmarksLayout = ({
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
<Text fw={700} size="sm">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
<Text size="xs" c="dimmed">
{app.description}
</Text>
)}
@@ -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)}
</Text>
<Stack mt="xs">
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} />
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} advanced={advanced} />
</Stack>
</Box>
);
})
) : (
<CalendarEventRows events={events} locale={effectiveLocale} />
<CalendarEventRows events={events} locale={effectiveLocale} advanced={advanced} />
)}
</Stack>
</ScrollArea>
);
};
const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "events"> & { locale: string }) => {
const CalendarEventRows = ({
events,
locale,
advanced,
}: Pick<CalendarEventListProps, "events" | "advanced"> & { locale: string }) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return (
<>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
<Group
key={`${event.source?.integrationId ?? "event"}-${event.startDate.toISOString()}-${event.title}-${eventIndex}`}
align={"stretch"}
wrap="nowrap"
>
{event.image !== null && (
<Box pos="relative">
<Image
@@ -115,13 +129,18 @@ const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "eve
<Group justify="space-between" align="start" mb="xs" wrap="wrap">
<Stack gap={0} style={{ flex: "1 1 12rem", minWidth: 0 }}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
<Text lineClamp={advanced ? undefined : 1} size="sm">
{event.subTitle}
</Text>
)}
<Text fw={"bold"} lineClamp={1} size="sm">
<Text fw={"bold"} lineClamp={advanced ? undefined : 1} size="sm">
{event.title}
</Text>
{advanced && event.source && (
<Badge size="xs" variant="light" w="fit-content">
{event.source.integrationName}
</Badge>
)}
</Stack>
{event.metadata?.type === "radarr" && (
<Group wrap="nowrap">
@@ -159,14 +178,14 @@ const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "eve
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
<Text size={"xs"} c={"dimmed"} lineClamp={advanced ? undefined : 1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
<Text size={"xs"} c={"dimmed"} lineClamp={advanced ? undefined : 2}>
{event.description}
</Text>
)}
@@ -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 = <TEvent extends CalendarEvent>(
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<string, CalendarEvent[]> => {
const result = new Map<string, CalendarEvent[]>();
export const groupEventsByDate = <TEvent extends CalendarEvent>(events: TEvent[]): Map<string, TEvent[]> => {
const result = new Map<string, TEvent[]>();
for (const event of events) {
const key = toCalendarDateKey(event.startDate);
const entries = result.get(key) ?? [];
@@ -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", () => {
@@ -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", () => {
@@ -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[],
@@ -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<Record<string, string[]>>({});
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 (
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
{options.showUptime && !isTiny && (
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.healthMonitoring.name")} />
</Box>
{(isAdvanced || options.showUptime) && !isTiny && (
<Group justify="center" wrap="nowrap">
<Text fz="xs" fw={700} c="dimmed" ta="center">
{formatUptime(uptime, t)}
@@ -78,15 +83,15 @@ export const ClusterHealthMonitoring = ({
<SummaryHeader
cpu={{
value: cpuPercent,
hidden: !options.cpu,
hidden: !isAdvanced && !options.cpu,
}}
memory={{
value: memPercent,
hidden: !options.memory,
hidden: !isAdvanced && !options.memory,
}}
isTiny={isTiny}
/>
{options.visibleClusterSections.length >= 1 && (
{visibleSections.length >= 1 && (
<Accordion
variant="contained"
chevronPosition="right"
@@ -94,7 +99,7 @@ export const ClusterHealthMonitoring = ({
value={accordionValue}
onChange={(value) => setAccordionValues((current) => ({ ...current, [accordionScope]: value }))}
>
{options.visibleClusterSections.includes("node") && (
{visibleSections.includes("node") && (
<ResourceAccordionItem
value="node"
title={t("widget.healthMonitoring.cluster.resource.node.name")}
@@ -110,7 +115,7 @@ export const ClusterHealthMonitoring = ({
</ResourceAccordionItem>
)}
{options.visibleClusterSections.includes("qemu") && (
{visibleSections.includes("qemu") && (
<ResourceAccordionItem
value="qemu"
title={t("widget.healthMonitoring.cluster.resource.qemu.name")}
@@ -126,7 +131,7 @@ export const ClusterHealthMonitoring = ({
</ResourceAccordionItem>
)}
{options.visibleClusterSections.includes("lxc") && (
{visibleSections.includes("lxc") && (
<ResourceAccordionItem
value="lxc"
title={t("widget.healthMonitoring.cluster.resource.lxc.name")}
@@ -142,7 +147,7 @@ export const ClusterHealthMonitoring = ({
</ResourceAccordionItem>
)}
{options.visibleClusterSections.includes("storage") && (
{visibleSections.includes("storage") && (
<ResourceAccordionItem
value="storage"
title={t("widget.healthMonitoring.cluster.resource.storage.name")}
@@ -20,6 +20,7 @@ import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { useWidgetRuntimeActions } from "../../runtime-hooks";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
@@ -31,16 +32,16 @@ export default function ImmichAlbumCarouselWidget({
}: WidgetComponentProps<"immich-albumCarousel">) {
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 <WidgetEmptyState />;
if (album.assets.length === 0) {
return <NoPhotosInAlbum />;
}
if (photoAssets.length === 0) {
return <NoPhotosInAlbum />;
}
return (
<Carousel
assets={photoAssets}
currentIndex={currentPhotoIndex}
setCurrentIndex={setCurrentPhotoIndex}
rotationInterval={options.rotationIntervalSeconds}
showPhotoInfo={options.showPhotoInfo}
albumName={album.albumName}
advanced={displayMode === "advanced"}
paused={paused}
setPaused={setPaused}
/>
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 3 }}>
<WidgetQueryErrorIndicator error={albumQuery.error} label={t("widget.immich-albumCarousel.name")} />
</Box>
{album.assets.length === 0 || photoAssets.length === 0 ? (
<NoPhotosInAlbum />
) : (
<Carousel
assets={photoAssets}
currentIndex={currentPhotoIndex}
setCurrentIndex={setCurrentPhotoIndex}
rotationInterval={options.rotationIntervalSeconds}
showPhotoInfo={options.showPhotoInfo}
albumName={album.albumName}
advanced={displayMode === "advanced"}
paused={paused}
setPaused={setPaused}
/>
)}
</Box>
);
}
@@ -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<typeof result.summary> } => 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 = (
<Group gap={0} justify="flex-end">
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
</Group>
);
if (error && summaries === undefined) throw error;
if (isPending || data.length === 0) {
if (summaryQuery.isPending || summaries.length === 0) {
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")}
</Text>
</Center>
<Stack h="100%" gap={0}>
{queryIndicators}
<Center p="sm" style={{ flex: 1 }}>
<Text c="dimmed" size="sm" ta="center">
{summaryQuery.isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")}
</Text>
</Center>
</Stack>
);
}
if (isAdvanced) {
return (
<ScrollArea h="100%" p={layout.padding}>
{queryIndicators}
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} withBorder p="sm">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text fw={600} size="sm" truncate="end">
{integration.name}
</Text>
{updatedAt && (
<Text size="xs" c="dimmed">
{t("advanced.updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })}
</Text>
)}
</Stack>
<Badge size="xs" variant="light">
{integration.kind}
</Badge>
</Group>
<SimpleGrid cols={layout.columns} spacing="sm">
<Card p={layout.cardPadding} withBorder>
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} />
</Card>
<Card p={layout.cardPadding} withBorder>
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} />
</Card>
</SimpleGrid>
</Stack>
</Card>
))}
</SimpleGrid>
</ScrollArea>
);
}
return (
<Box p={layout.padding} h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={summaries ?? []} />
</Box>
<Stack p={layout.padding} h="100%" gap={0}>
{queryIndicators}
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
{layout.showWifi && (
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
@@ -83,6 +126,6 @@ export default function NetworkControllerNetworkStatusWidget({
</Card>
)}
</SimpleGrid>
</Box>
</Stack>
);
}
@@ -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 });
});
});
@@ -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,
+7 -1
View File
@@ -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 (
<Stack p="xs" gap="xs" h="100%">
{Boolean(statsQuery.error) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Group>
)}
{showTitle && !isTiny && (
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
<Group gap="xs" wrap="nowrap" miw={0}>