{
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 && (
-
+
{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 && (